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',