diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index dbe6814d..9c47bcbc 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -143,15 +143,20 @@ const tabIcons: Record = { const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced']; -// Translation paths to flatten for fulltext search per tab. Multiple tabs may -// share a namespace (e.g. email_behavior is split across reading/composing/ -// content_senders); a query that hits a shared namespace will surface all of -// them, which is acceptable since the user picks the right one. +// Translation paths per tab. Tabs that share a namespace (email_behavior, +// appearance) explicitly list the subkeys they actually render so sub-results +// are attributed to the correct tab. Tabs with their own namespace just point +// at the namespace root. const tabSearchPaths: Record = { account: ['settings.account'], - language: ['settings.language_region', 'settings.appearance.language'], + language: ['settings.appearance.language', 'settings.language_region'], notifications: ['settings.notifications'], - appearance: ['settings.appearance'], + appearance: [ + 'settings.appearance.theme', + 'settings.appearance.font_size', + 'settings.appearance.list_density', + 'settings.appearance.animations', + ], layout: [ 'settings.appearance.toolbar_position', 'settings.appearance.toolbar_labels', @@ -161,8 +166,27 @@ const tabSearchPaths: Record = { 'settings.appearance.colorful_sidebar_icons', 'settings.email_behavior.mail_layout', ], - reading: ['settings.email_behavior'], - composing: ['settings.email_behavior'], + reading: [ + 'settings.email_behavior.mark_read', + 'settings.email_behavior.archive_mode', + 'settings.email_behavior.delete_action', + 'settings.email_behavior.attachment_click_action', + 'settings.email_behavior.attachment_image_previews', + 'settings.email_behavior.attachment_position', + 'settings.email_behavior.disable_threading', + 'settings.email_behavior.emails_per_page', + 'settings.email_behavior.hide_inline_image_attachments', + 'settings.email_behavior.hover_actions', + 'settings.email_behavior.permanently_delete_junk', + 'settings.email_behavior.show_preview', + 'settings.email_behavior.plain_text_mode', + ], + composing: [ + 'settings.email_behavior.attachment_reminder', + 'settings.email_behavior.auto_select_reply_identity', + 'settings.email_behavior.default_mail_program', + 'settings.email_behavior.sub_address_delimiter', + ], identities: ['settings.identities'], vacation: ['settings.vacation'], filters: ['settings.filters'], @@ -171,7 +195,11 @@ const tabSearchPaths: Record = { keywords: ['settings.keywords'], security: ['settings.security'], encryption: ['smime'], - content_senders: ['settings.email_behavior'], + content_senders: [ + 'settings.email_behavior.always_light_mode', + 'settings.email_behavior.external_content', + 'settings.email_behavior.trusted_senders', + ], calendar: ['calendar.settings', 'calendar.management'], contacts: ['settings.contacts', 'contacts'], files: ['settings.files'], @@ -225,6 +253,31 @@ function flattenStrings(node: unknown, sink: string[]): void { } } +interface SubResult { + label: string; + description?: string; +} + +// Walk a translation subtree and emit one sub-result per object that has a +// `label` (or, at the root, a `title`) string. Each emitted setting is then +// shown as a clickable sub-row under its tab in the search results. +function collectSubResults(node: unknown, sink: SubResult[]): void { + if (!node || typeof node !== 'object' || Array.isArray(node)) return; + const obj = node as Record; + const label = typeof obj.label === 'string' ? obj.label : (typeof obj.title === 'string' ? obj.title : undefined); + if (label) { + sink.push({ + label, + description: typeof obj.description === 'string' ? obj.description : undefined, + }); + } + for (const value of Object.values(obj)) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + collectSubResults(value, sink); + } + } +} + function getByPath(obj: unknown, path: string): unknown { let cur: unknown = obj; for (const key of path.split('.')) { @@ -271,6 +324,7 @@ export default function SettingsPage() { const [activeTab, setActiveTab] = useState(readPersistedTab); const [mobileShowContent, setMobileShowContent] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string } | null>(null); const isDesktop = useIsDesktop(); const messages = useMessages() as Record; @@ -278,32 +332,56 @@ export default function SettingsPage() { const installedThemes = useThemeStore((s) => s.installedThemes); const sidebarAppsList = useSettingsStore((s) => s.sidebarApps); - // Build a per-tab haystack for fulltext search: tab id + curated English - // keywords + flattened translation strings under each tab's namespaces + - // dynamic content (installed plugins/themes/sidebar apps). - const tabSearchHaystacks = useMemo(() => { - const result: Partial> = {}; + // Build a per-tab haystack for fulltext search and a list of sub-results + // (individual settings) per tab. Sub-results come from translation entries + // that have a `label`/`title` field, plus dynamic content (installed + // plugins/themes/sidebar apps). + const { tabSearchHaystacks, tabSubResults } = useMemo(() => { + const haystacks: Partial> = {}; + const subs: Partial> = {}; const tabIds = Object.keys(tabSearchPaths) as Tab[]; for (const tabId of tabIds) { const strings: string[] = [tabId.replace(/_/g, ' '), tabKeywords[tabId] ?? '']; + const list: SubResult[] = []; for (const path of tabSearchPaths[tabId]) { - flattenStrings(getByPath(messages, path), strings); + const node = getByPath(messages, path); + flattenStrings(node, strings); + collectSubResults(node, list); } - result[tabId] = strings.join(' ').toLowerCase(); + // Dedupe sub-results by label + const seen = new Set(); + subs[tabId] = list.filter((r) => { + if (seen.has(r.label)) return false; + seen.add(r.label); + return true; + }); + haystacks[tabId] = strings.join(' ').toLowerCase(); } if (installedPlugins.length) { const text = installedPlugins.map((p) => `${p.name} ${p.description} ${p.author}`).join(' '); - result.plugins = `${result.plugins ?? ''} ${text}`.toLowerCase(); + haystacks.plugins = `${haystacks.plugins ?? ''} ${text}`.toLowerCase(); + subs.plugins = [ + ...(subs.plugins ?? []), + ...installedPlugins.map((p) => ({ label: p.name, description: p.description })), + ]; } if (installedThemes.length) { const text = installedThemes.map((th) => `${th.name} ${th.description} ${th.author}`).join(' '); - result.themes = `${result.themes ?? ''} ${text}`.toLowerCase(); + haystacks.themes = `${haystacks.themes ?? ''} ${text}`.toLowerCase(); + subs.themes = [ + ...(subs.themes ?? []), + ...installedThemes.map((th) => ({ label: th.name, description: th.description })), + ]; } if (sidebarAppsList.length) { const text = sidebarAppsList.map((a) => `${a.name} ${a.url}`).join(' '); - result.sidebar_apps = `${result.sidebar_apps ?? ''} ${text}`.toLowerCase(); + haystacks.sidebar_apps = `${haystacks.sidebar_apps ?? ''} ${text}`.toLowerCase(); + subs.sidebar_apps = [ + ...(subs.sidebar_apps ?? []), + ...sidebarAppsList.map((a) => ({ label: a.name, description: a.url })), + ]; } - return result; + return { tabSearchHaystacks: haystacks, tabSubResults: subs }; }, [messages, installedPlugins, installedThemes, sidebarAppsList]); // Sidebar resize state @@ -363,6 +441,43 @@ export default function SettingsPage() { return () => window.removeEventListener('popstate', handlePop); }, [isDesktop, mobileShowContent]); + // After clicking a search sub-result, scroll the matching setting into view + // and add a temporary highlight class. Two RAFs to wait for the tab content + // to mount and lay out before querying the DOM. + useEffect(() => { + if (!pendingHighlight) return; + if (pendingHighlight.tab !== activeTab) return; + if (typeof window === 'undefined') return; + + let cancelled = false; + let cleanupTimer: ReturnType | undefined; + let highlightedEl: HTMLElement | null = null; + + const r1 = window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + if (cancelled) return; + const escaped = pendingHighlight.label.replace(/"/g, '\\"'); + const el = document.querySelector(`[data-search-label="${escaped}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('settings-search-highlight'); + highlightedEl = el; + cleanupTimer = setTimeout(() => { + el.classList.remove('settings-search-highlight'); + }, 2000); + } + setPendingHighlight(null); + }); + }); + + return () => { + cancelled = true; + window.cancelAnimationFrame(r1); + if (cleanupTimer) clearTimeout(cleanupTimer); + if (highlightedEl) highlightedEl.classList.remove('settings-search-highlight'); + }; + }, [pendingHighlight, activeTab]); + if (!isAuthenticated) { return null; } @@ -426,6 +541,17 @@ export default function SettingsPage() { return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false; }; + const subResultsForTab = (tabId: Tab): SubResult[] => { + if (!trimmedQuery) return []; + const list = tabSubResults[tabId] ?? []; + return list + .filter((r) => + r.label.toLowerCase().includes(trimmedQuery) || + (r.description?.toLowerCase().includes(trimmedQuery) ?? false) + ) + .slice(0, 6); + }; + const filteredGroupedTabs = trimmedQuery ? groupedTabs .map((g) => ({ ...g, items: g.items.filter(matchesQuery) })) @@ -444,6 +570,11 @@ export default function SettingsPage() { } }; + const handleSubResultSelect = (tabId: Tab, label: string) => { + handleTabSelect(tabId); + setPendingHighlight({ tab: tabId, label }); + }; + const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? ''; const renderTabContent = () => ( @@ -565,23 +696,34 @@ export default function SettingsPage() { {group.items.map((tab) => { const Icon = tab.icon; + const subs = subResultsForTab(tab.id); return ( - +
+ + {subs.map((sub) => ( + + ))} +
); })} @@ -691,28 +833,39 @@ export default function SettingsPage() { {group.items.map((tab) => { const Icon = tab.icon; + const subs = subResultsForTab(tab.id); return ( - +
+ + {subs.map((sub) => ( + + ))} +
); })} diff --git a/app/globals.css b/app/globals.css index ddc60eb0..1f43d7da 100644 --- a/app/globals.css +++ b/app/globals.css @@ -765,3 +765,10 @@ body { .tiptap.resize-cursor { cursor: col-resize; } + +/* Brief flash applied to a setting row when the user clicks a sub-result in + the settings search. The page removes the class after ~2s. */ +.settings-search-highlight { + background-color: color-mix(in srgb, var(--color-primary) 18%, transparent); + box-shadow: 0 0 0 2px var(--color-primary); +} diff --git a/components/settings/settings-section.tsx b/components/settings/settings-section.tsx index bd9b4cf7..23b9018a 100644 --- a/components/settings/settings-section.tsx +++ b/components/settings/settings-section.tsx @@ -44,7 +44,10 @@ interface SettingItemProps { export function SettingItem({ label, description, children, locked }: SettingItemProps) { return ( -
+