Files
native/src/lib/settings-search.ts
T
Linus Rath 6f515e15f7 feat: rework settings into the picked list, About and Storage & backup pages
The settings list now has the account card on top, title-case group
labels and 56 dp rows that show each pane's current value. About and
"About & Data" were one placeholder page with a letter logo, a fixed
name and a store link that pointed at GitHub releases.

- About: the real logo, the app name from app.config.js, the update
  state, a source link that a build can set with SOURCE_CODE_URL, and
  rows for the server, update checks (Android), open-source licences,
  privacy and a prefilled GitHub issue. iPhone updates link TestFlight.
- Storage & backup: usage card, offline mail, settings export/import
  and reset, and a Troubleshooting page for refresh and debug logging.
- Sender favicons moved to Appearance, like the webmail.
- The S/MIME and Plugins stub pages are gone; both are webmail plugins.

The licence list is generated by `scripts/generate-licences.mjs`.
2026-10-15 14:49:37 +02:00

457 lines
18 KiB
TypeScript

// 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.
import { supportsDeviceSync } from './platform-capabilities';
export type SettingsTabId =
| 'account' | 'language' | 'notifications'
| 'appearance' | 'layout'
| 'reading' | 'composing' | 'identities' | 'vacation'
| 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads'
| 'security' | 'content_senders'
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
// about_data is Storage & backup (the id predates the split from About);
// debug, licences and privacy are pages opened from inside another one.
| 'about_data' | 'about' | 'licences' | 'privacy' | 'themes' | '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',
'settings.advanced.sender_favicons',
],
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',
],
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',
// Android only: calendars in the phone's Calendar app (#34).
...(supportsDeviceSync ? ['calendar.settings.device_sync_title'] : []),
],
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',
// Android only: address books in the phone's Contacts app (#34).
...(supportsDeviceSync ? ['settings.contacts.device_sync_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.offline.title',
'settings.offline.enabled',
'settings.offline.keep_from',
'settings.offline.use_at_most',
'settings.backup.title',
'settings.backup.export_import',
'settings.advanced.reset_settings',
'settings.troubleshooting.title',
],
about: [
'settings.about.server',
'settings.about.licences',
'settings.about.privacy',
'settings.about.report_problem',
],
licences: [],
privacy: [],
// The theme cards are added by the caller (built-in theme names).
themes: [],
updates: [
'updates.current_version',
'updates.latest_version',
'updates.last_checked',
'updates.check_now',
'updates.auto_check',
'updates.advisory',
'updates.release_page',
],
debug: [
'settings.advanced.refresh_cache',
'settings.advanced.debug_mode',
],
};
// 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',
content_senders: 'block sender remote images privacy tracking',
calendar: `event schedule appointment meeting timezone${supportsDeviceSync ? ' android phone device sync' : ''}`,
contacts: `address book contact${supportsDeviceSync ? ' android phone device sync' : ''}`,
files: 'attachments cloud drive storage upload',
sidebar_apps: 'apps webview iframe',
about_data: 'export import backup offline cache storage reset',
about: 'version licence license privacy bug issue report source github',
licences: 'licence license open source',
privacy: 'privacy data relay push',
themes: 'theme color colour palette skin appearance',
updates: 'update upgrade version apk install release',
debug: 'troubleshooting refresh 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);
}