From 46e285b3c99600187bfb283586cecd826bf24ea9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:35:20 +0200 Subject: [PATCH] feat: search settings from the Settings screen The webmail's Settings screen has a search field; the app had none, so finding a setting meant opening panes one by one. Settings now has a search field above the pane list that narrows the list to the panes that match and lists up to six matching settings under each. The index follows the webmail's `lib/settings-search.ts`: per pane, the translation keys it renders plus a few English keywords, walked for label/description pairs. The paths list what the native panes show rather than the webmail's, the walker prefers the `*_mobile` variants the panes render, and every label goes through t() for the current language. A test checks that each result is a key its pane renders, so results do not point at webmail-only settings. Tapping a result opens the pane, scrolls to the matching row and tints it briefly. Back clears the search before leaving Settings. --- src/components/settings/search-highlight.ts | 41 ++ src/components/settings/settings-section.tsx | 19 +- src/lib/__tests__/settings-search.test.ts | 175 ++++++++ src/lib/settings-search.ts | 439 +++++++++++++++++++ src/screens/SettingsScreen.tsx | 186 +++++++- 5 files changed, 834 insertions(+), 26 deletions(-) create mode 100644 src/components/settings/search-highlight.ts create mode 100644 src/lib/__tests__/settings-search.test.ts create mode 100644 src/lib/settings-search.ts diff --git a/src/components/settings/search-highlight.ts b/src/components/settings/search-highlight.ts new file mode 100644 index 00000000..a3b76b54 --- /dev/null +++ b/src/components/settings/search-highlight.ts @@ -0,0 +1,41 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import type { LayoutChangeEvent, View } from 'react-native'; + +/** + * A settings search result the user tapped: the pane that just opened shows + * the row whose label matches, scrolled into view and briefly highlighted + * (webmail: settings-search-highlight). + */ +export interface SearchHighlight { + label: string; + /** Scroll `view` into view; called once, by the first row that matches. */ + reveal: (view: View) => void; +} + +export const SearchHighlightContext = createContext(null); + +const HIGHLIGHT_MS = 1800; + +/** + * For a settings row with `label`: attach `ref` and `onLayout` to its outer + * View and paint the highlight while `highlighted` is true. + */ +export function useSearchHighlight(label: string) { + const target = useContext(SearchHighlightContext); + const ref = useRef(null); + const [highlighted, setHighlighted] = useState(false); + + const onLayout = useCallback((_e: LayoutChangeEvent) => { + if (!target || target.label.trim() !== label.trim() || !ref.current) return; + target.reveal(ref.current); + setHighlighted(true); + }, [target, label]); + + useEffect(() => { + if (!highlighted) return undefined; + const timer = setTimeout(() => setHighlighted(false), HIGHLIGHT_MS); + return () => clearTimeout(timer); + }, [highlighted]); + + return { ref, onLayout, highlighted }; +} diff --git a/src/components/settings/settings-section.tsx b/src/components/settings/settings-section.tsx index 82f0f756..c998cd79 100644 --- a/src/components/settings/settings-section.tsx +++ b/src/components/settings/settings-section.tsx @@ -13,6 +13,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import ToggleSwitchComponent from '../ToggleSwitch'; import { useLocaleStore } from '../../stores/locale-store'; +import { useSearchHighlight } from './search-highlight'; /** * Mirrors webmail settings-section.tsx: @@ -41,6 +42,7 @@ export function SettingsSection({ const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); + const highlight = useSearchHighlight(title); return ( {experimental && ( @@ -54,7 +56,7 @@ export function SettingsSection({ )} - + {title} {description && {description}} @@ -80,8 +82,13 @@ export function SettingItem({ }: SettingItemProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const highlight = useSearchHighlight(label); return ( - + {label} @@ -282,6 +289,14 @@ function makeStyles(c: ThemePalette) { settingRight: { flexShrink: 0, }, + // A row opened from Settings search. The negative margin keeps the + // content where it was while the tint gets some room around it. + searchHighlight: { + backgroundColor: c.selection, + borderRadius: radius.sm, + marginHorizontal: -spacing.sm, + paddingHorizontal: spacing.sm, + }, radioGroup: { flexDirection: 'row', flexWrap: 'wrap', diff --git a/src/lib/__tests__/settings-search.test.ts b/src/lib/__tests__/settings-search.test.ts new file mode 100644 index 00000000..d2ceb492 --- /dev/null +++ b/src/lib/__tests__/settings-search.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getDictionary, translate } from '../../i18n'; +import { + type SettingsTabId, + type SubResultKeys, + SETTINGS_SEARCH_PATHS, + buildSettingsSearchIndex, + collectSubResultKeys, + collectTabKeys, + getByPath, + normalizeSettingsQuery, + subResultsForQuery, + tabMatchesQuery, +} from '../settings-search'; + +const en = getDictionary('en'); +const tEn = (key: string) => translate('en', key); + +function labelsFor(tab: SettingsTabId): string[] { + return (buildSettingsSearchIndex(en, tEn).subResults[tab] ?? []).map((r) => r.label); +} + +describe('collectSubResultKeys', () => { + it('emits label/description objects, *_label keys and foo/foo_desc pairs', () => { + const list: SubResultKeys[] = []; + collectSubResultKeys( + { + name_label: 'Name', + free_scroll: 'Free scrolling', + free_scroll_desc: 'Scroll continuously', + hover_preview_off: 'Disabled', + nested: { label: 'Theme', description: 'Pick one', dark: 'Dark' }, + }, + 'x', + list, + ); + expect(list).toEqual([ + { label: 'x.name_label', description: undefined }, + { label: 'x.free_scroll', description: 'x.free_scroll_desc' }, + { label: 'x.nested.label', description: 'x.nested.description' }, + ]); + }); + + it('prefers the mobile label and description the native panes render', () => { + const list: SubResultKeys[] = []; + collectSubResultKeys( + { + swipe: { label: 'Swipe (mobile)', label_mobile: 'Swipe', description: 'Web', description_mobile: 'Phone' }, + show_avatars: 'Show avatars', + show_avatars_description: 'Load logos', + week_numbers: 'Week numbers', + week_numbers_desc: 'Web', + week_numbers_mobile_desc: 'Phone', + }, + 'x', + list, + ); + expect(list).toEqual([ + { label: 'x.show_avatars', description: 'x.show_avatars_description' }, + { label: 'x.week_numbers', description: 'x.week_numbers_mobile_desc' }, + { label: 'x.swipe.label_mobile', description: 'x.swipe.description_mobile' }, + ]); + }); +}); + +describe('settings search index (English catalog)', () => { + it('every pane path resolves to a translation', () => { + const missing = Object.values(SETTINGS_SEARCH_PATHS) + .flat() + .filter((path) => getByPath(en, path) === undefined); + expect(missing).toEqual([]); + }); + + // Panes whose sub-result labels must be the keys the pane renders, so a + // result never points at a setting the phone does not have. Panes that + // still render literal English are left out. + const PANE_FILES: Partial> = { + account: 'AccountSettings.tsx', + language: 'LanguageSettings.tsx', + notifications: 'NotificationSettings.tsx', + appearance: 'AppearanceSettings.tsx', + layout: 'LayoutSettings.tsx', + reading: 'ReadingSettings.tsx', + composing: 'ComposingSettings.tsx', + identities: 'IdentitySettings.tsx', + vacation: 'VacationSettings.tsx', + filters: 'FilterSettings.tsx', + templates: 'TemplateSettings.tsx', + folders: 'FolderSettings.tsx', + keywords: 'KeywordSettings.tsx', + downloads: 'DownloadsSettings.tsx', + security: 'AccountSecuritySettings.tsx', + calendar: 'CalendarSettings.tsx', + files: 'FilesSettings.tsx', + sidebar_apps: 'SidebarAppsSettings.tsx', + about_data: 'AboutDataSettings.tsx', + updates: 'UpdatesSettings.tsx', + }; + + for (const [tab, file] of Object.entries(PANE_FILES) as [SettingsTabId, string][]) { + it(`${tab}: every result is a setting ${file} renders`, () => { + const source = readFileSync(join(__dirname, '..', '..', 'components', 'settings', file), 'utf8'); + const notRendered = collectTabKeys(en, tab).subResults + .map((r) => r.label) + .filter((key) => !source.includes(`'${key}'`)); + expect(notRendered).toEqual([]); + }); + } + + it('lists settings where the native app shows them', () => { + expect(labelsFor('appearance')).toContain('Font Size'); + expect(labelsFor('layout')).toContain('Message list order'); + expect(labelsFor('notifications')).toContain('Parse email invitations'); + expect(labelsFor('calendar')).toContain('Show week numbers'); + expect(labelsFor('reading')).toContain('Clear search when switching folders'); + expect(labelsFor('notifications')).toContain('Unread count on app icon'); + // Webmail-only settings are not offered. + expect(labelsFor('calendar')).not.toContain('Free scrolling'); + expect(labelsFor('notifications')).not.toContain('Notification sound'); + }); +}); + +describe('buildSettingsSearchIndex', () => { + it('translates labels through t() for the current locale', () => { + const index = buildSettingsSearchIndex(en, (key) => translate('de', key)); + const fontSize = translate('de', 'settings.appearance.font_size.label'); + expect(index.subResults.appearance?.map((r) => r.label)).toContain(fontSize); + expect(index.haystacks.appearance).toContain(fontSize.toLowerCase()); + }); + + it('adds extra sub-results to the pane and its haystack', () => { + const index = buildSettingsSearchIndex(en, tEn, { + themes: [{ label: 'Nord', description: 'Arctic palette' }], + }); + expect(index.subResults.themes).toEqual([{ label: 'Nord', description: 'Arctic palette' }]); + expect(index.haystacks.themes).toContain('arctic'); + }); + + it('drops duplicate labels within a pane', () => { + const index = buildSettingsSearchIndex(en, () => 'Same'); + expect(index.subResults.appearance).toEqual([{ label: 'Same', description: 'Same' }]); + }); +}); + +describe('matching', () => { + const index = buildSettingsSearchIndex(en, tEn); + + it('normalizes the query', () => { + expect(normalizeSettingsQuery(' Font SIZE ')).toBe('font size'); + }); + + it('matches a pane by name, rendered text or keyword', () => { + expect(tabMatchesQuery(index, 'appearance', 'Appearance', '')).toBe(true); + expect(tabMatchesQuery(index, 'appearance', 'Appearance', 'appear')).toBe(true); + expect(tabMatchesQuery(index, 'appearance', 'Appearance', 'font size')).toBe(true); + expect(tabMatchesQuery(index, 'filters', 'Filters & Rules', 'sieve')).toBe(true); + expect(tabMatchesQuery(index, 'vacation', 'Vacation Responder', 'out of office')).toBe(true); + expect(tabMatchesQuery(index, 'appearance', 'Appearance', 'sieve')).toBe(false); + }); + + it('returns the matching settings of a pane, by label or description', () => { + expect(subResultsForQuery(index, 'appearance', 'font')).toEqual([ + { label: 'Font Size', description: 'Adjust text size for better readability' }, + ]); + expect(subResultsForQuery(index, 'appearance', 'readability').map((r) => r.label)).toEqual(['Font Size']); + expect(subResultsForQuery(index, 'appearance', '')).toEqual([]); + }); + + it('caps the settings listed under one pane', () => { + expect(subResultsForQuery(index, 'reading', 'e')).toHaveLength(6); + expect(subResultsForQuery(index, 'reading', 'e', 2)).toHaveLength(2); + }); +}); diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts new file mode 100644 index 00000000..c8bdb69a --- /dev/null +++ b/src/lib/settings-search.ts @@ -0,0 +1,439 @@ +// Settings search index: which translation keys each Settings pane renders, +// extra keywords per pane, and the walker that turns those keys into tappable +// sub-results. Ported from the webmail's lib/settings-search.ts, adapted to +// the native panes: +// - the paths list what the RN panes show, which is not always what the +// webmail panes show (the settings live in different panes, and many +// webmail-only settings don't exist here); +// - the walker prefers the `*_mobile` label/description variants the RN +// panes render; +// - it collects translation keys rather than strings, so every label goes +// through t() for the current locale, with the English fallback. +// Kept free of React so it can be unit-tested. + +export type SettingsTabId = + | 'account' | 'language' | 'notifications' + | 'appearance' | 'layout' + | 'reading' | 'composing' | 'identities' | 'vacation' + | 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads' + | 'security' | 'encryption' | 'content_senders' + | 'calendar' | 'contacts' | 'files' | 'sidebar_apps' + | 'about_data' | 'themes' | 'plugins' | 'updates' | 'debug'; + +// Translation paths per pane. A path may point at a whole namespace, at one +// setting's object ({ label, description, ...options }) or at a single label +// string. Everything below a path feeds the pane's full-text haystack. +export const SETTINGS_SEARCH_PATHS: Record = { + account: [ + 'settings.account.name_label', + 'settings.account.email', + 'settings.account.username_label', + 'settings.account.auth_method_label', + 'settings.account.server', + 'settings.account.storage', + 'settings.account.accounts', + 'settings.account.shared_accounts', + ], + language: [ + 'settings.appearance.language', + 'settings.language_region.date_format', + 'settings.language_region.time_format', + ], + notifications: [ + 'settings.notifications.push.title', + 'settings.notifications.push.enable', + 'settings.notifications.push.transport_label', + 'settings.notifications.push.up_distributor_label', + 'settings.notifications.push.relay_label', + 'settings.notifications.push.devices_title', + 'settings.notifications.email.title', + 'settings.notifications.email.enabled', + 'settings.notifications.email.app_icon_badge', + 'settings.notifications.calendar.title', + 'settings.notifications.calendar.enabled', + 'settings.notifications.calendar.invitation_parsing', + ], + appearance: [ + 'settings.appearance.theme', + 'settings.appearance.font_size', + 'settings.appearance.list_density', + 'settings.appearance.toolbar_labels', + 'settings.appearance.animations', + ], + layout: [ + 'settings.layout.quick_actions', + 'settings.email_behavior.swipe_left_action', + 'settings.email_behavior.swipe_right_action', + 'settings.layout.swipe_mode', + 'settings.message_list_order', + 'settings.layout.show_folder_total_count', + 'settings.layout.show_avatars_in_junk', + 'settings.layout.unified_cross_account', + ], + reading: [ + 'settings.email_behavior.mark_read', + 'settings.email_behavior.delete_action', + 'settings.email_behavior.archive_mode', + 'settings.email_behavior.permanently_delete_junk', + 'settings.email_behavior.show_preview', + 'settings.email_behavior.disable_threading', + 'settings.appearance.unified_mailbox.include_group', + 'settings.email_behavior.message_spacing', + 'settings.email_behavior.plain_text_font', + 'settings.email_behavior.read_receipt_response', + 'settings.email_behavior.attachment_click_action', + 'settings.email_behavior.attachment_position', + 'settings.email_behavior.hide_inline_image_attachments', + 'settings.email_behavior.emails_per_page', + 'settings.email_behavior.clear_search_on_folder_change', + ], + composing: [ + 'settings.email_behavior.auto_select_reply_identity', + 'settings.email_behavior.reply_identity_match', + 'settings.email_behavior.plain_text_mode', + 'settings.email_behavior.request_read_receipt', + 'settings.email_behavior.empty_subject_warning', + 'settings.email_behavior.signature_separator', + 'settings.email_behavior.signature_position', + 'settings.email_behavior.send_delay', + 'settings.composer.autosave', + 'settings.email_behavior.sub_address_delimiter.label', + 'settings.email_behavior.attachment_reminder', + ], + identities: [ + 'settings.identities.title', + 'settings.identities.display_name', + 'settings.identities.email_address', + 'identities.form.reply_to_label', + 'identities.form.bcc_label', + 'identities.form.text_signature_label', + 'identities.form.html_signature_label', + ], + vacation: ['settings.vacation'], + filters: [ + 'settings.filters.title', + 'settings.filters.add_rule', + 'settings.filters.expanded_view', + 'settings.filters.vacation_active', + 'settings.filters.open_sieve_editor', + ], + templates: ['settings.templates.title', 'settings.templates.export_import'], + folders: ['settings.folders.title', 'settings.folders.new_folder', 'settings.folders.role'], + keywords: [ + 'settings.keywords.title', + 'settings.keywords.add_keyword', + 'settings.keywords.discover.scan', + 'settings.keywords.reset_defaults', + ], + downloads: [ + 'settings.downloads.email_template', + 'settings.downloads.attachment_template', + 'settings.downloads.transform_title', + 'settings.downloads.spaces', + 'settings.downloads.lowercase', + 'settings.downloads.strip_diacritics', + 'settings.downloads.after_export', + ], + security: [ + 'settings.security.client_cert', + 'settings.security.password', + 'settings.security.display_name', + 'settings.security.two_factor.section_title', + 'settings.security.two_factor', + 'settings.security.app_passwords.title', + 'settings.security.app_passwords.name_label', + 'settings.security.api_keys', + 'settings.security.email_client', + 'settings.security.public_keys', + 'settings.security.encryption.section_title', + 'settings.security.encryption.algorithm_label', + 'settings.security.encryption.encrypt_on_append', + 'settings.security.encryption.allow_spam_training', + ], + encryption: ['settings.smime'], + content_senders: [ + 'settings.email_behavior.external_content', + 'settings.email_behavior.always_light_mode', + 'settings.email_behavior.trusted_senders', + ], + calendar: [ + 'calendar.settings.default_view', + 'calendar.settings.week_starts_on', + 'calendar.settings.time_format', + 'calendar.settings.time_zone', + 'calendar.settings.show_time_in_month_view', + 'calendar.settings.show_week_numbers', + 'calendar.settings.show_birthday_calendar', + 'calendar.settings.enable_tasks', + 'calendar.settings.show_tasks_on_calendar', + ], + contacts: [ + 'settings.contacts.group_by_letter_label', + 'settings.contacts.sort_by_last_name_label', + 'settings.contacts.import_label', + 'settings.contacts.export_label', + 'settings.contacts.manage_title', + ], + files: [ + 'files.settings_display', + 'files.settings_default_view', + 'files.settings_default_sort', + 'files.settings_sort_direction', + 'files.settings_icons', + 'files.settings_show_icons', + 'files.settings_colored_icons', + 'files.settings_show_thumbnails', + 'files.settings_behavior', + 'files.settings_show_hidden', + ], + sidebar_apps: [ + 'settings.sidebar_apps.keep_loaded', + 'settings.sidebar_apps.manage_title', + 'settings.sidebar_apps.add', + ], + about_data: [ + 'settings.advanced.debug_mode', + 'settings.advanced.sender_favicons', + 'settings.advanced.refresh_cache', + 'settings.advanced.export_settings', + 'settings.advanced.import_settings', + 'settings.advanced.reset_settings', + 'settings.offline.title', + 'settings.offline.enabled', + 'settings.offline.window', + 'settings.offline.max_size', + ], + // The theme cards are added by the caller (built-in theme names). + themes: [], + plugins: [], + updates: [ + 'updates.current_version', + 'updates.latest_version', + 'updates.last_checked', + 'updates.check_now', + 'updates.auto_check', + 'updates.advisory', + 'updates.release_page', + ], + debug: [], +}; + +// Extra English keywords per pane so common search terms hit even when the +// translation doesn't contain the literal word. +export const SETTINGS_SEARCH_KEYWORDS: Record = { + account: 'profile email password user signin signout reorder switch default multi-account shared storage quota', + language: 'locale region date time format translation', + notifications: 'alert push badge unifiedpush relay device reminder', + appearance: 'theme dark light font size text color animation density', + layout: 'swipe gesture quick actions toolbar order sort unified folder count avatar', + reading: 'mark read preview thread conversation archive delete attachment open monospace mono font plain text receipt', + composing: 'editor signature plain text reply forward draft compose autosave undo send delay', + identities: 'from address signature email', + vacation: 'auto reply away out of office holiday responder', + filters: 'sieve rules block junk forward', + templates: 'snippet quick reply', + folders: 'mailbox rename role', + keywords: 'tags labels colors', + downloads: 'download filename template eml attachment save export', + security: 'password 2fa two-factor totp app password mfa api key certificate encryption', + encryption: 's/mime smime certificate sign encrypt', + content_senders: 'block sender remote images privacy tracking', + calendar: 'event schedule appointment meeting timezone', + contacts: 'address book contact', + files: 'attachments cloud drive storage upload', + sidebar_apps: 'apps webview iframe', + about_data: 'export import backup offline cache debug logs reset version', + themes: 'theme color colour palette skin appearance', + plugins: 'extensions addons', + updates: 'update upgrade version apk install release', + debug: 'logs developer console diagnostic', +}; + +export interface SubResult { + label: string; + description?: string; +} + +// A sub-result as translation keys, before t(). +export interface SubResultKeys { + label: string; + description?: string; +} + +type Dictionary = Record; + +// Label/description fields of a setting object, most specific first: the RN +// panes render the `*_mobile` variants where the catalog has them. +const LABEL_FIELDS = ['label_mobile', 'label', 'title']; +const DESCRIPTION_FIELDS = ['description_mobile', 'description']; +// Flat `foo` + `foo_desc` style pairs (calendar.settings, files, settings.layout). +const DESCRIPTION_SUFFIXES = ['_mobile_desc', '_description_mobile', '_desc', '_description']; + +export function getByPath(obj: unknown, path: string): unknown { + let cur: unknown = obj; + for (const key of path.split('.')) { + if (cur && typeof cur === 'object' && key in (cur as Dictionary)) { + cur = (cur as Dictionary)[key]; + } else { + return undefined; + } + } + return cur; +} + +// Every string leaf key below `path`. +export function collectStringKeys(node: unknown, path: string, sink: string[]): void { + if (typeof node === 'string') { + sink.push(path); + return; + } + if (!node || typeof node !== 'object' || Array.isArray(node)) return; + for (const [key, value] of Object.entries(node as Dictionary)) { + collectStringKeys(value, `${path}.${key}`, sink); + } +} + +function descriptionFor(obj: Dictionary, base: string): string | undefined { + return DESCRIPTION_SUFFIXES.map((s) => base + s).find((k) => typeof obj[k] === 'string'); +} + +// Walk a translation subtree and emit sub-results (as keys) for renderable +// settings. Picks up: +// - objects with a label or title field (the standard pattern) +// - flat `*_label` string keys at any object level (e.g. `name_label`) +// - flat `foo` / `foo_desc` string pairs (the calendar.settings pattern) +export function collectSubResultKeys(node: unknown, path: string, sink: SubResultKeys[]): void { + if (!node || typeof node !== 'object' || Array.isArray(node)) return; + const obj = node as Dictionary; + const labelField = LABEL_FIELDS.find((f) => typeof obj[f] === 'string'); + if (labelField) { + const descField = DESCRIPTION_FIELDS.find((f) => typeof obj[f] === 'string'); + sink.push({ label: `${path}.${labelField}`, description: descField && `${path}.${descField}` }); + } + for (const [key, value] of Object.entries(obj)) { + if (typeof value !== 'string' || LABEL_FIELDS.includes(key) || DESCRIPTION_FIELDS.includes(key)) continue; + if (key.endsWith('_label')) { + const desc = descriptionFor(obj, key.slice(0, -'_label'.length)); + sink.push({ label: `${path}.${key}`, description: desc && `${path}.${desc}` }); + continue; + } + const desc = descriptionFor(obj, key); + if (desc) sink.push({ label: `${path}.${key}`, description: `${path}.${desc}` }); + } + for (const [key, value] of Object.entries(obj)) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + collectSubResultKeys(value, `${path}.${key}`, sink); + } + } +} + +// Sub-result for a path that points straight at a label string, with the +// description it sits next to (`foo` + `foo_desc`, `foo_title` + +// `foo_description`, `title` + `description`). +function leafSubResultKeys(dict: Dictionary, path: string): SubResultKeys { + const dot = path.lastIndexOf('.'); + const parentPath = path.slice(0, dot); + const key = path.slice(dot + 1); + const parent = getByPath(dict, parentPath) as Dictionary; + const desc = LABEL_FIELDS.includes(key) || key === 'section_title' + ? DESCRIPTION_FIELDS.find((f) => typeof parent[f] === 'string') + : descriptionFor(parent, key.replace(/_(label|title)$/, '')); + return { label: path, description: desc && `${parentPath}.${desc}` }; +} + +// The translation keys a pane is searched by: every string below its paths +// (`keys`) and the settings among them (`subResults`). +export function collectTabKeys( + dict: Dictionary, + tab: SettingsTabId, +): { keys: string[]; subResults: SubResultKeys[] } { + const keys: string[] = []; + const subResults: SubResultKeys[] = []; + for (const path of SETTINGS_SEARCH_PATHS[tab]) { + const node = getByPath(dict, path); + if (typeof node === 'string') { + const sub = leafSubResultKeys(dict, path); + subResults.push(sub); + keys.push(path); + if (sub.description) keys.push(sub.description); + } else { + collectStringKeys(node, path, keys); + collectSubResultKeys(node, path, subResults); + } + } + return { keys, subResults }; +} + +export interface SettingsSearchIndex { + haystacks: Partial>; + subResults: Partial>; +} + +/** + * Build the per-pane haystack and sub-result list. + * `dict` is the English catalog, which gives the key structure; `t` resolves + * each key in the current locale. `extra` adds dynamic sub-results (e.g. the + * built-in theme names). + */ +export function buildSettingsSearchIndex( + dict: Dictionary, + t: (key: string) => string, + extra: Partial> = {}, +): SettingsSearchIndex { + const haystacks: SettingsSearchIndex['haystacks'] = {}; + const subResults: SettingsSearchIndex['subResults'] = {}; + for (const tab of Object.keys(SETTINGS_SEARCH_PATHS) as SettingsTabId[]) { + const { keys, subResults: subKeys } = collectTabKeys(dict, tab); + const list: SubResult[] = [ + ...subKeys.map((k) => ({ label: t(k.label), description: k.description ? t(k.description) : undefined })), + ...(extra[tab] ?? []), + ]; + const seen = new Set(); + subResults[tab] = list.filter((r) => { + if (!r.label || seen.has(r.label)) return false; + seen.add(r.label); + return true; + }); + const strings = [ + tab.replace(/_/g, ' '), + SETTINGS_SEARCH_KEYWORDS[tab], + ...keys.map(t), + ...(extra[tab] ?? []).flatMap((r) => [r.label, r.description ?? '']), + ]; + haystacks[tab] = strings.join(' ').toLowerCase(); + } + return { haystacks, subResults }; +} + +export function normalizeSettingsQuery(query: string): string { + return query.trim().toLowerCase(); +} + +// Whether a pane matches a normalized query: its (translated) name or +// anything it renders. +export function tabMatchesQuery( + index: SettingsSearchIndex, + tab: SettingsTabId, + tabLabel: string, + query: string, +): boolean { + if (!query) return true; + if (tabLabel.toLowerCase().includes(query)) return true; + return index.haystacks[tab]?.includes(query) ?? false; +} + +// The pane's settings whose label or description match, for the result rows +// under the pane. +export function subResultsForQuery( + index: SettingsSearchIndex, + tab: SettingsTabId, + query: string, + limit = 6, +): SubResult[] { + if (!query) return []; + return (index.subResults[tab] ?? []) + .filter((r) => + r.label.toLowerCase().includes(query) + || (r.description?.toLowerCase().includes(query) ?? false)) + .slice(0, limit); +} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index e625057a..fff7a3bb 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native'; +import { View, Text, StyleSheet, ScrollView, Pressable, TextInput, Keyboard } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { - ArrowLeft, LogOut, Settings, ChevronRight, + ArrowLeft, LogOut, Settings, ChevronRight, Search, X, Palette, User, Shield, UserPen, Palmtree, Calendar, Filter, FileText, FolderOpen, Tags, HardDrive, BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw, @@ -45,15 +45,15 @@ import { import { supportsSideloadUpdates } from '../lib/platform-capabilities'; import { usePendingSettingsTab } from '../navigation/pending-settings-tab'; import { useBackWhileFocused } from '../lib/use-back-while-focused'; +import { + buildSettingsSearchIndex, normalizeSettingsQuery, subResultsForQuery, tabMatchesQuery, + type SettingsTabId, type SubResult, +} from '../lib/settings-search'; +import { SearchHighlightContext, type SearchHighlight } from '../components/settings/search-highlight'; +import { BUILTIN_THEMES } from '../theme/builtin-themes'; +import { getDictionary } from '../i18n'; -type Tab = - | 'account' | 'language' | 'notifications' - | 'appearance' | 'layout' - | 'reading' | 'composing' | 'identities' | 'vacation' - | 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads' - | 'security' | 'encryption' | 'content_senders' - | 'calendar' | 'contacts' | 'files' | 'sidebar_apps' - | 'about_data' | 'themes' | 'plugins' | 'updates' | 'debug'; +type Tab = SettingsTabId; type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced'; @@ -173,6 +173,11 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const [selectedTab, setSelectedTab] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + // A tapped search result: its pane scrolls to the setting and flashes it. + const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string } | null>(null); + const paneScrollRef = React.useRef(null); + const paneContentRef = React.useRef(null); // Subscribe to locale so labels re-render when the user picks a different language. const locale = useLocaleStore((s) => s.locale); const t = useLocaleStore((s) => s.t); @@ -233,13 +238,60 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin })); }, [locale, t, scopedTabs]); + // Settings search (webmail: lib/settings-search). The index is built on the + // first keystroke and again when the language changes. + const query = normalizeSettingsQuery(searchQuery); + const searching = query.length > 0; + const searchIndex = React.useMemo(() => { + if (!searching) return null; + void locale; // dependency: re-translate on locale change + return buildSettingsSearchIndex(getDictionary('en'), t, { + themes: [ + { + label: t('settings.themes.default_name', 'Bulwark'), + description: t('settings.themes.default_description', 'The default light and dark palettes.'), + }, + ...BUILTIN_THEMES.map((theme) => ({ label: theme.name, description: theme.description })), + ], + }); + }, [searching, locale, t]); + const visibleGroups = searchIndex + ? groupedTabs + .map((g) => ({ ...g, items: g.items.filter((tab) => tabMatchesQuery(searchIndex, tab.id, tab.label, query)) })) + .filter((g) => g.items.length > 0) + : groupedTabs; + + const closePane = React.useCallback(() => { + setSelectedTab(null); + setPendingHighlight(null); + }, []); + // Settings is a tab and stays mounted behind the other tabs, so hardware // back is only claimed while it is the focused one. const handleBack = React.useCallback(() => { - if (selectedTab) setSelectedTab(null); + if (selectedTab) closePane(); + else if (searchQuery) setSearchQuery(''); else leaveScope(); - }, [selectedTab, leaveScope]); - useBackWhileFocused(!!selectedTab || !!managedAccount, handleBack); + }, [selectedTab, searchQuery, closePane, leaveScope]); + useBackWhileFocused(!!selectedTab || !!searchQuery || !!managedAccount, handleBack); + + // The first row of the open pane whose label matches the tapped result + // calls this once it is laid out. + const revealHighlight = React.useCallback((view: View) => { + const content = paneContentRef.current; + if (content) { + view.measureLayout(content, (_x, y) => { + paneScrollRef.current?.scrollTo({ y: Math.max(0, y - spacing.lg), animated: true }); + }, () => undefined); + } + setPendingHighlight(null); + }, []); + const searchHighlight = React.useMemo( + () => (pendingHighlight && pendingHighlight.tab === selectedTab + ? { label: pendingHighlight.label, reveal: revealHighlight } + : null), + [pendingHighlight, selectedTab, revealHighlight], + ); const scopeBanner = managedAccount ? ( { if (!tab.implemented) return; if (unavailableTabs.has(tab.id)) return; + Keyboard.dismiss(); setSelectedTab(tab.id); + setPendingHighlight(null); onTabSelect?.(tab.id); }; + const handleSubResultPress = (tab: TabDef, sub: SubResult) => { + handleTabPress(tab); + setPendingHighlight({ tab: tab.id, label: sub.label }); + }; + if (selectedTab) { const tabDef = TABS.find((tab) => tab.id === selectedTab)!; const Component = TAB_COMPONENTS[selectedTab]; @@ -272,7 +331,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin setSelectedTab(null)} + onPress={closePane} accessibilityRole="button" accessibilityLabel={t('common.back', 'Back')} style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]} @@ -283,13 +342,17 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin {tabLabel} - - {scopeBanner} - {selectedTab === 'filters' ? ( - setSelectedTab('vacation') : undefined} - /> - ) : Component ? : null} + + + + {scopeBanner} + {selectedTab === 'filters' ? ( + setSelectedTab('vacation') : undefined} + /> + ) : Component ? : null} + + ); @@ -318,10 +381,41 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin {/* Tab list - matches webmail mobile: flat grouped list, no cards */} - + {scopeBanner && {scopeBanner}} + + + + {searchQuery.length > 0 && ( + setSearchQuery('')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('settings.search_clear', 'Clear search')} + > + + + )} + - {groupedTabs.map((group, groupIndex) => ( + {visibleGroups.length === 0 && ( + {t('settings.search_no_results', 'No matching settings')} + )} + {visibleGroups.map((group, groupIndex) => ( {groupIndex > 0 && } @@ -338,7 +432,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin : unavailable ? t('settings.badges.unavailable', 'Unavailable') : null; - return ( + const row = ( handleTabPress(tab)} @@ -374,6 +468,24 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin )} ); + // The pane's matching settings, one tap from opening it there. + const subs = searchIndex && !disabled ? subResultsForQuery(searchIndex, tab.id, query) : []; + if (subs.length === 0) return row; + return ( + + {row} + {subs.map((sub) => ( + handleSubResultPress(tab, sub)} + accessibilityRole="button" + style={({ pressed }) => [styles.subResult, pressed && styles.tabItemPressed]} + > + {sub.label} + + ))} + + ); })} ))} @@ -421,6 +533,32 @@ function makeStyles(c: ThemePalette) { tabList: { paddingVertical: spacing.sm }, + // Search - matches webmail mobile: full-width input above the list, + // matching settings as indented rows under their pane. + searchBar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginHorizontal: spacing.lg, + marginTop: spacing.md, + paddingHorizontal: spacing.md, + height: componentSizes.inputHeight, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.background, + }, + searchInput: { flex: 1, ...typography.body, color: c.text, paddingVertical: 0 }, + searchEmpty: { + ...typography.body, + color: c.mutedForeground, + textAlign: 'center', + paddingHorizontal: 20, + paddingVertical: spacing.xxl, + }, + subResult: { paddingLeft: 48, paddingRight: 20, paddingVertical: spacing.sm }, + subResultText: { ...typography.caption, color: c.mutedForeground }, + scopeBanner: { flexDirection: 'row', alignItems: 'center',