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.
This commit is contained in:
Linus Rath
2026-09-24 19:40:08 +02:00
parent 4c9efbc44d
commit 46e285b3c9
5 changed files with 834 additions and 26 deletions
@@ -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<SearchHighlight | null>(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<View>(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 };
}
+17 -2
View File
@@ -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 (
<View style={styles.section}>
{experimental && (
@@ -54,7 +56,7 @@ export function SettingsSection({
</View>
</View>
)}
<View>
<View ref={highlight.ref} onLayout={highlight.onLayout} style={highlight.highlighted && styles.searchHighlight}>
<Text style={styles.sectionTitle}>{title}</Text>
{description && <Text style={styles.sectionDescription}>{description}</Text>}
</View>
@@ -80,8 +82,13 @@ export function SettingItem({
}: SettingItemProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const highlight = useSearchHighlight(label);
return (
<View style={[styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked]}>
<View
ref={highlight.ref}
onLayout={highlight.onLayout}
style={[styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight]}
>
<View style={styles.settingContent}>
<View style={styles.settingLabelRow}>
<Text style={styles.settingLabel}>{label}</Text>
@@ -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',
+175
View File
@@ -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<Record<SettingsTabId, string>> = {
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);
});
});
+439
View File
@@ -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<SettingsTabId, string[]> = {
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<SettingsTabId, string> = {
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<string, unknown>;
// 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<Record<SettingsTabId, string>>;
subResults: Partial<Record<SettingsTabId, SubResult[]>>;
}
/**
* 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<Record<SettingsTabId, SubResult[]>> = {},
): 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<string>();
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);
}
+162 -24
View File
@@ -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<Tab | null>(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<ScrollView>(null);
const paneContentRef = React.useRef<View>(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<SearchHighlight | null>(
() => (pendingHighlight && pendingHighlight.tab === selectedTab
? { label: pendingHighlight.label, reveal: revealHighlight }
: null),
[pendingHighlight, selectedTab, revealHighlight],
);
const scopeBanner = managedAccount ? (
<Pressable
@@ -258,10 +310,17 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
const handleTabPress = (tab: TabDef) => {
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
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable
onPress={() => 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
<Text style={styles.headerTitle}>{tabLabel}</Text>
</View>
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
{scopeBanner}
{selectedTab === 'filters' ? (
<FilterSettings
onOpenVacation={hasVacation ? () => setSelectedTab('vacation') : undefined}
/>
) : Component ? <Component /> : null}
<ScrollView ref={paneScrollRef} style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
<View ref={paneContentRef}>
<SearchHighlightContext.Provider value={searchHighlight}>
{scopeBanner}
{selectedTab === 'filters' ? (
<FilterSettings
onOpenVacation={hasVacation ? () => setSelectedTab('vacation') : undefined}
/>
) : Component ? <Component /> : null}
</SearchHighlightContext.Provider>
</View>
</ScrollView>
</SafeAreaView>
);
@@ -318,10 +381,41 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
</View>
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
<ScrollView
style={styles.scrollArea}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
{scopeBanner && <View style={styles.scopeBannerList}>{scopeBanner}</View>}
<View style={styles.searchBar}>
<Search size={16} color={c.mutedForeground} />
<TextInput
style={styles.searchInput}
value={searchQuery}
onChangeText={setSearchQuery}
placeholder={t('settings.search_placeholder', 'Search settings')}
placeholderTextColor={c.mutedForeground}
accessibilityLabel={t('settings.search_placeholder', 'Search settings')}
autoCorrect={false}
autoCapitalize="none"
returnKeyType="search"
/>
{searchQuery.length > 0 && (
<Pressable
onPress={() => setSearchQuery('')}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('settings.search_clear', 'Clear search')}
>
<X size={16} color={c.mutedForeground} />
</Pressable>
)}
</View>
<View style={styles.tabList}>
{groupedTabs.map((group, groupIndex) => (
{visibleGroups.length === 0 && (
<Text style={styles.searchEmpty}>{t('settings.search_no_results', 'No matching settings')}</Text>
)}
{visibleGroups.map((group, groupIndex) => (
<View key={group.group}>
{groupIndex > 0 && <View style={styles.groupDivider} />}
@@ -338,7 +432,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
: unavailable
? t('settings.badges.unavailable', 'Unavailable')
: null;
return (
const row = (
<Pressable
key={tab.id}
onPress={() => handleTabPress(tab)}
@@ -374,6 +468,24 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
)}
</Pressable>
);
// 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 (
<View key={tab.id}>
{row}
{subs.map((sub) => (
<Pressable
key={sub.label}
onPress={() => handleSubResultPress(tab, sub)}
accessibilityRole="button"
style={({ pressed }) => [styles.subResult, pressed && styles.tabItemPressed]}
>
<Text style={styles.subResultText} numberOfLines={1}>{sub.label}</Text>
</Pressable>
))}
</View>
);
})}
</View>
))}
@@ -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',