mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user