feat(settings): search across all settings (#453 phase 3) #467
@@ -0,0 +1,51 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { ensureUser, hsReachable, loginUI, uniq } from './localHs';
|
||||||
|
|
||||||
|
// [Gitea #463] Search across all settings.
|
||||||
|
test.describe('settings search (#463)', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.skip(!(await hsReachable()), 'local homeserver not running');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a setting, opens its page and shows it', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const user = await ensureUser(uniq('ss'));
|
||||||
|
await loginUI(page, user);
|
||||||
|
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||||
|
const dialog = page.getByRole('dialog').first();
|
||||||
|
const box = dialog.getByRole('textbox', { name: 'Search settings' });
|
||||||
|
|
||||||
|
// Ctrl+F focuses the box while settings are open (after the dialog has
|
||||||
|
// placed its own initial focus).
|
||||||
|
await expect(box).toBeVisible();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.keyboard.press('ControlOrMeta+f');
|
||||||
|
await expect(box).toBeFocused();
|
||||||
|
|
||||||
|
await box.fill('deafen');
|
||||||
|
const results = dialog.getByRole('list', { name: 'Matching settings' });
|
||||||
|
const first = results.getByRole('listitem').first();
|
||||||
|
await expect(first).toContainText('Push to deafen');
|
||||||
|
await expect(first).toContainText('Calls & audio');
|
||||||
|
await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeHidden();
|
||||||
|
await first.click();
|
||||||
|
await expect(dialog.getByText('Calls & audio', { exact: true }).last()).toBeVisible();
|
||||||
|
await expect(dialog.getByText('Push to deafen', { exact: true })).toBeInViewport();
|
||||||
|
await expect(dialog.getByRole('navigation', { name: 'Settings sections' })).toBeVisible();
|
||||||
|
|
||||||
|
// A description word finds it too; Enter picks the top result.
|
||||||
|
await box.fill('utm');
|
||||||
|
await expect(results.getByRole('listitem').first()).toContainText(
|
||||||
|
'Strip tracking parameters from links',
|
||||||
|
);
|
||||||
|
await box.press('Enter');
|
||||||
|
await expect(dialog.getByText('Strip tracking parameters from links')).toBeInViewport();
|
||||||
|
|
||||||
|
// No match, then Esc clears.
|
||||||
|
await box.fill('zzzz-nothing');
|
||||||
|
await expect(dialog.getByText(/No settings match/)).toBeVisible();
|
||||||
|
await box.press('Escape');
|
||||||
|
await expect(box).toHaveValue('');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { useNearViewport } from '../../../hooks/useNearViewport';
|
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||||
|
import { whenMediaAuthReady } from '../../../../sw-session';
|
||||||
|
|
||||||
type RenderViewerProps = {
|
type RenderViewerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -115,13 +116,24 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
setLoad(true);
|
setLoad(true);
|
||||||
};
|
};
|
||||||
|
// An image requested before the service worker controls the page goes out
|
||||||
|
// without the access token and fails (first visit, just after an update).
|
||||||
|
// Retry once by itself when that's ready; only then offer "Retry".
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const autoRetried = useRef(false);
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
setLoad(false);
|
setLoad(false);
|
||||||
|
if (!autoRetried.current && !encInfo) {
|
||||||
|
autoRetried.current = true;
|
||||||
|
whenMediaAuthReady().then(() => setAttempt((n) => n + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(true);
|
setError(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
setError(false);
|
setError(false);
|
||||||
|
setAttempt((n) => n + 1);
|
||||||
loadSrc();
|
loadSrc();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,7 +194,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
<Box key={attempt} className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||||
{renderImage({
|
{renderImage({
|
||||||
alt: body,
|
alt: body,
|
||||||
title: body,
|
title: body,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
OverlayCenter,
|
OverlayCenter,
|
||||||
Text,
|
Text,
|
||||||
|
color,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,8 @@ import { UseStateProvider } from '../../components/UseStateProvider';
|
|||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||||
import { SettingsPages } from './pages';
|
import { SettingsPages } from './pages';
|
||||||
|
import { SettingsSearch } from './search/SettingsSearch';
|
||||||
|
import type { SettingsEntry } from './search/settingsIndex';
|
||||||
|
|
||||||
export { SettingsPages };
|
export { SettingsPages };
|
||||||
|
|
||||||
@@ -94,6 +97,49 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage;
|
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage;
|
||||||
});
|
});
|
||||||
const menuItems = useSettingsMenuItems();
|
const menuItems = useSettingsMenuItems();
|
||||||
|
// [Gitea #463] Settings search: results replace the page list while typing;
|
||||||
|
// picking one opens its page and briefly highlights the setting.
|
||||||
|
const pageNames = useMemo(
|
||||||
|
() => new Map(menuItems.map((item) => [item.page, item.name])),
|
||||||
|
[menuItems],
|
||||||
|
);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [highlight, setHighlight] = useState<string>();
|
||||||
|
const pickResult = useCallback((entry: SettingsEntry) => {
|
||||||
|
setActivePage(entry.page);
|
||||||
|
setHighlight(entry.title);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlight) return undefined;
|
||||||
|
let tries = 0;
|
||||||
|
let flashTimer: number | undefined;
|
||||||
|
const find = () => {
|
||||||
|
const match = [...document.querySelectorAll<HTMLElement>('p, span, label, h1, h2, h3')].find(
|
||||||
|
(el) =>
|
||||||
|
!el.closest('nav') && el.textContent?.trim() === highlight && el.offsetParent !== null,
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
tries += 1;
|
||||||
|
if (tries < 20) poll = window.setTimeout(find, 100);
|
||||||
|
else setHighlight(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = (match.closest('[class*="SequenceCard"]') as HTMLElement | null) ?? match;
|
||||||
|
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
const prev = target.style.boxShadow;
|
||||||
|
target.style.boxShadow = `0 0 0 2px ${color.Primary.Main}`;
|
||||||
|
target.style.transition = 'box-shadow 0.3s';
|
||||||
|
flashTimer = window.setTimeout(() => {
|
||||||
|
target.style.boxShadow = prev;
|
||||||
|
setHighlight(undefined);
|
||||||
|
}, 1800);
|
||||||
|
};
|
||||||
|
let poll = window.setTimeout(find, 50);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(poll);
|
||||||
|
window.clearTimeout(flashTimer);
|
||||||
|
};
|
||||||
|
}, [highlight, activePage]);
|
||||||
|
|
||||||
const handlePageRequestClose = () => {
|
const handlePageRequestClose = () => {
|
||||||
if (screenSize === ScreenSize.Mobile) {
|
if (screenSize === ScreenSize.Mobile) {
|
||||||
@@ -134,8 +180,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</PageNavHeader>
|
</PageNavHeader>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
<SettingsSearch
|
||||||
|
pageNames={pageNames}
|
||||||
|
onActiveChange={setSearching}
|
||||||
|
onPick={pickResult}
|
||||||
|
/>
|
||||||
<PageNavContent>
|
<PageNavContent>
|
||||||
<nav aria-label="Settings sections" style={{ flexGrow: 1 }}>
|
<nav aria-label="Settings sections" style={{ flexGrow: 1 }} hidden={searching}>
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={item.name}
|
key={item.name}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Box, Icon, IconButton, Icons, Input, MenuItem, Text, config } from 'folds';
|
||||||
|
import { SettingsPages } from '../pages';
|
||||||
|
import { searchSettings, SettingsEntry } from './settingsIndex';
|
||||||
|
import { loadSettingsIndex } from './loadSettingsIndex';
|
||||||
|
import { isTauri } from '../../../hooks/useTauri';
|
||||||
|
|
||||||
|
type SettingsSearchProps = {
|
||||||
|
pageNames: Map<SettingsPages, string>;
|
||||||
|
/** Whether a query is active (the page list is hidden meanwhile). */
|
||||||
|
onActiveChange: (active: boolean) => void;
|
||||||
|
onPick: (entry: SettingsEntry) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #463] Search box at the top of the settings sidebar. Ctrl/Cmd+F
|
||||||
|
* focuses it while settings are open; Esc clears it.
|
||||||
|
*/
|
||||||
|
export function SettingsSearch({ pageNames, onActiveChange, onPick }: SettingsSearchProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [entries, setEntries] = useState<SettingsEntry[]>();
|
||||||
|
const active = query.trim().length > 0;
|
||||||
|
|
||||||
|
useEffect(() => onActiveChange(active), [active, onActiveChange]);
|
||||||
|
|
||||||
|
const ensureIndex = () => {
|
||||||
|
if (!entries) loadSettingsIndex().then(setEntries, () => setEntries([]));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
|
||||||
|
// Capture phase + stop: settings is on top, so this beats the app's
|
||||||
|
// own Ctrl/Cmd+F (message search) underneath.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Only desktop-app options in the desktop app; only pages that exist here.
|
||||||
|
const results = entries
|
||||||
|
? searchSettings(entries, query, { desktop: isTauri() }).filter((e) => pageNames.has(e.page))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="200" style={{ padding: `0 ${config.space.S200}` }}>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
size="400"
|
||||||
|
variant="Background"
|
||||||
|
radii="400"
|
||||||
|
placeholder="Search settings"
|
||||||
|
aria-label="Search settings"
|
||||||
|
value={query}
|
||||||
|
onFocus={ensureIndex}
|
||||||
|
onChange={(e) => {
|
||||||
|
ensureIndex();
|
||||||
|
setQuery(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape' && query) {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQuery('');
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && results[0]) {
|
||||||
|
onPick(results[0]);
|
||||||
|
setQuery('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
before={<Icon size="100" src={Icons.Search} />}
|
||||||
|
after={
|
||||||
|
query ? (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Background"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{active && (
|
||||||
|
<Box direction="Column" role="list" aria-label="Matching settings">
|
||||||
|
{entries === undefined && (
|
||||||
|
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||||
|
Searching…
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{entries !== undefined && results.length === 0 && (
|
||||||
|
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||||
|
No settings match “{query.trim()}”.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{results.map((r) => (
|
||||||
|
<MenuItem
|
||||||
|
key={`${r.page}-${r.title}`}
|
||||||
|
role="listitem"
|
||||||
|
variant="Background"
|
||||||
|
radii="400"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(r);
|
||||||
|
setQuery('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box direction="Column" style={{ padding: `${config.space.S100} 0`, minWidth: 0 }}>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{r.title}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300" truncate>
|
||||||
|
{pageNames.get(r.page)}
|
||||||
|
{r.section && r.section !== r.title ? ` · ${r.section}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { parseSettingsSource, SettingsEntry } from './settingsIndex';
|
||||||
|
|
||||||
|
// [Gitea #463] The settings source, as text, in its own lazily loaded chunk:
|
||||||
|
// fetched the first time someone searches, never at startup.
|
||||||
|
const sources = import.meta.glob(['../**/*.tsx', '!../**/*.test.tsx'], {
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
}) as Record<string, () => Promise<string>>;
|
||||||
|
|
||||||
|
let index: Promise<SettingsEntry[]> | undefined;
|
||||||
|
|
||||||
|
export const loadSettingsIndex = (): Promise<SettingsEntry[]> => {
|
||||||
|
index ??= Promise.all(
|
||||||
|
Object.entries(sources).map(async ([path, load]) =>
|
||||||
|
// '../general/General.tsx' -> '/general/General.tsx' (what the parser matches)
|
||||||
|
parseSettingsSource(path.replace(/^\.\./, ''), await load()),
|
||||||
|
),
|
||||||
|
).then((all) => all.flat());
|
||||||
|
return index;
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { parseSettingsSource, searchSettings } from './settingsIndex';
|
||||||
|
import { SettingsPages } from '../pages';
|
||||||
|
|
||||||
|
const general = fs.readFileSync('src/app/features/settings/general/General.tsx', 'utf8');
|
||||||
|
const devices = fs.readFileSync('src/app/features/settings/devices/Devices.tsx', 'utf8');
|
||||||
|
const sys = fs.readFileSync(
|
||||||
|
'src/app/features/settings/notifications/SystemNotification.tsx',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const entries = [
|
||||||
|
...parseSettingsSource('src/app/features/settings/general/General.tsx', general),
|
||||||
|
...parseSettingsSource('src/app/features/settings/devices/Devices.tsx', devices),
|
||||||
|
...parseSettingsSource('src/app/features/settings/notifications/SystemNotification.tsx', sys),
|
||||||
|
];
|
||||||
|
const find = (t: string) => entries.find((e) => e.title === t);
|
||||||
|
|
||||||
|
test('[#463] indexes the real settings source with the right pages', () => {
|
||||||
|
assert.equal(find('Push to deafen')?.page, SettingsPages.CallsPage);
|
||||||
|
assert.equal(find('Strip tracking parameters from links')?.page, SettingsPages.DevicesPage);
|
||||||
|
assert.equal(find('Page zoom')?.page, SettingsPages.AppearancePage);
|
||||||
|
assert.equal(find('Enter for a new line')?.page, SettingsPages.MessagesPage);
|
||||||
|
assert.equal(find('Show hidden events')?.page, SettingsPages.DeveloperToolsPage);
|
||||||
|
assert.equal(find('Device verification')?.page, SettingsPages.DevicesPage);
|
||||||
|
assert.equal(find('Quiet hours')?.page, SettingsPages.NotificationPage);
|
||||||
|
assert.equal(find('Launch on login')?.desktopOnly, true);
|
||||||
|
assert.match(find('Strip tracking parameters from links')?.description ?? '', /utm_/);
|
||||||
|
assert.ok(entries.length > 70, `indexed ${entries.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[#463] search: words anywhere, title first, desktop-only hidden in a browser', () => {
|
||||||
|
assert.equal(searchSettings(entries, 'deafen')[0]?.title, 'Push to deafen');
|
||||||
|
assert.equal(
|
||||||
|
searchSettings(entries, 'tracking')[0]?.title,
|
||||||
|
'Strip tracking parameters from links',
|
||||||
|
);
|
||||||
|
assert.equal(searchSettings(entries, 'zoom')[0]?.title, 'Page zoom');
|
||||||
|
assert.ok(searchSettings(entries, 'utm').some((e) => e.title.startsWith('Strip tracking')));
|
||||||
|
assert.equal(searchSettings(entries, 'launch on login').length, 0);
|
||||||
|
assert.equal(
|
||||||
|
searchSettings(entries, 'launch on login', { desktop: true })[0]?.title,
|
||||||
|
'Launch on login',
|
||||||
|
);
|
||||||
|
assert.deepEqual(searchSettings(entries, ' '), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[#463] parser handles the attribute forms and skips dynamic titles', () => {
|
||||||
|
const src = `
|
||||||
|
<Text size="L400">Things</Text>
|
||||||
|
<SettingTile title="Plain" description="A & B" after={<Switch value={x} />} />
|
||||||
|
<SettingTile title={'Quoted'} description={\`tick\`} />
|
||||||
|
<SettingTile title={\`Dynamic \${name}\`} />
|
||||||
|
<SettingTile title={someVar} />`;
|
||||||
|
const got = parseSettingsSource('src/app/features/settings/about/About.tsx', src);
|
||||||
|
assert.deepEqual(
|
||||||
|
got.map((e) => [e.title, e.description, e.section]),
|
||||||
|
[
|
||||||
|
['Plain', 'A & B', 'Things'],
|
||||||
|
['Quoted', 'tick', 'Things'],
|
||||||
|
['Things', '', undefined],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { SettingsPages } from '../pages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #463] Search across all settings. The index is built from the
|
||||||
|
* settings source itself (each <SettingTile title=… description=…> and each
|
||||||
|
* section header), so options added later are found without a hand-kept list.
|
||||||
|
* Pure: path + source text in, entries out.
|
||||||
|
*/
|
||||||
|
export type SettingsEntry = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
page: SettingsPages;
|
||||||
|
/** Section header the setting sits under, when known. */
|
||||||
|
section?: string;
|
||||||
|
/** Desktop-only (hidden in a browser). */
|
||||||
|
desktopOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// General.tsx: which page each section (or helper used by a section) is on.
|
||||||
|
const GENERAL_SECTIONS: Record<string, SettingsPages> = {
|
||||||
|
Appearance: SettingsPages.AppearancePage,
|
||||||
|
SystemThemePreferences: SettingsPages.AppearancePage,
|
||||||
|
SelectTheme: SettingsPages.AppearancePage,
|
||||||
|
PageZoomInput: SettingsPages.AppearancePage,
|
||||||
|
CollapsiblePicker: SettingsPages.AppearancePage,
|
||||||
|
SeasonalBgGrid: SettingsPages.AppearancePage,
|
||||||
|
ChatBgGrid: SettingsPages.AppearancePage,
|
||||||
|
Messages: SettingsPages.MessagesPage,
|
||||||
|
SelectMessageLayout: SettingsPages.MessagesPage,
|
||||||
|
SelectMessageSpacing: SettingsPages.MessagesPage,
|
||||||
|
Editor: SettingsPages.MessagesPage,
|
||||||
|
ComposerToolbarReorder: SettingsPages.MessagesPage,
|
||||||
|
ComposerToolbarButtonRow: SettingsPages.MessagesPage,
|
||||||
|
DateAndTime: SettingsPages.MessagesPage,
|
||||||
|
SelectDateFormat: SettingsPages.MessagesPage,
|
||||||
|
DateHint: SettingsPages.MessagesPage,
|
||||||
|
CustomDateFormat: SettingsPages.MessagesPage,
|
||||||
|
PresetDateFormat: SettingsPages.MessagesPage,
|
||||||
|
Calls: SettingsPages.CallsPage,
|
||||||
|
Privacy: SettingsPages.DevicesPage,
|
||||||
|
SettingsSyncSection: SettingsPages.DataStoragePage,
|
||||||
|
AdvancedDisplayOptions: SettingsPages.DeveloperToolsPage,
|
||||||
|
DesktopChromeSetting: SettingsPages.DesktopPage,
|
||||||
|
AutostartSetting: SettingsPages.DesktopPage,
|
||||||
|
KeychainMirrorSetting: SettingsPages.DesktopPage,
|
||||||
|
DesktopSettings: SettingsPages.DesktopPage,
|
||||||
|
PackageUpdate: SettingsPages.DesktopPage,
|
||||||
|
AppUpdates: SettingsPages.DesktopPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FOLDER_PAGES: [RegExp, SettingsPages][] = [
|
||||||
|
[/\/account\//, SettingsPages.AccountPage],
|
||||||
|
[/\/notifications\//, SettingsPages.NotificationPage],
|
||||||
|
[/\/devices\//, SettingsPages.DevicesPage],
|
||||||
|
[/\/developer(-tools)?\//, SettingsPages.DeveloperToolsPage],
|
||||||
|
[/\/emojis-stickers\//, SettingsPages.EmojisStickersPage],
|
||||||
|
[/\/about\//, SettingsPages.AboutPage],
|
||||||
|
[/\/general\/(StorageUsage|YourData)\.tsx$/, SettingsPages.DataStoragePage],
|
||||||
|
];
|
||||||
|
|
||||||
|
const decode = (s: string): string =>
|
||||||
|
s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/'|'/g, "'")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/\\'/g, "'")
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
/** A JSX attribute's static text: "…", {'…'}, {"…"} or {`…`} without ${}. */
|
||||||
|
const attr = (tag: string, name: string): string | undefined => {
|
||||||
|
const m = new RegExp(
|
||||||
|
`\\b${name}=(?:"([^"]*)"|\\{\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*\\}|\\{\\s*"([^"]*)"\\s*\\}|\\{\\s*\`([^\`$]*)\`\\s*\\})`,
|
||||||
|
).exec(tag);
|
||||||
|
const v = m && (m[1] ?? m[2] ?? m[3] ?? m[4]);
|
||||||
|
return v ? decode(v) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The opening tag starting at `start` (up to the matching '>' outside braces/strings). */
|
||||||
|
const openingTag = (src: string, start: number): string => {
|
||||||
|
let depth = 0;
|
||||||
|
let quote: string | null = null;
|
||||||
|
for (let i = start; i < src.length; i += 1) {
|
||||||
|
const c = src[i];
|
||||||
|
if (quote) {
|
||||||
|
if (c === quote && src[i - 1] !== '\\') quote = null;
|
||||||
|
} else if (c === '"' || c === "'" || c === '`') quote = c;
|
||||||
|
else if (c === '{') depth += 1;
|
||||||
|
else if (c === '}') depth -= 1;
|
||||||
|
else if (c === '>' && depth === 0) return src.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
return src.slice(start);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseSettingsSource = (path: string, src: string): SettingsEntry[] => {
|
||||||
|
const isGeneral = /\/general\/General\.tsx$/.test(path);
|
||||||
|
const folderPage = FOLDER_PAGES.find(([re]) => re.test(path))?.[1];
|
||||||
|
if (!isGeneral && folderPage === undefined) return [];
|
||||||
|
|
||||||
|
// For General.tsx: positions of each top-level function, to know the section.
|
||||||
|
const fns = isGeneral
|
||||||
|
? [...src.matchAll(/\n(?:export )?function (\w+)\(/g)].map((m) => ({
|
||||||
|
at: m.index ?? 0,
|
||||||
|
name: m[1],
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const pageAt = (pos: number): SettingsPages | undefined => {
|
||||||
|
if (!isGeneral) return folderPage;
|
||||||
|
let name: string | undefined;
|
||||||
|
fns.forEach((f) => {
|
||||||
|
if (f.at < pos) name = f.name;
|
||||||
|
});
|
||||||
|
return name ? GENERAL_SECTIONS[name] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const out: SettingsEntry[] = [];
|
||||||
|
const headers = [...src.matchAll(/<Text size="L400">([^<{]+)<\/Text>/g)].map((m) => ({
|
||||||
|
at: m.index ?? 0,
|
||||||
|
text: decode(m[1]),
|
||||||
|
}));
|
||||||
|
const sectionAt = (pos: number): string | undefined => {
|
||||||
|
let s: string | undefined;
|
||||||
|
headers.forEach((h) => {
|
||||||
|
if (h.at < pos) s = h.text;
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const m of src.matchAll(/<SettingTile\b/g)) {
|
||||||
|
const at = m.index ?? 0;
|
||||||
|
const tag = openingTag(src, at);
|
||||||
|
const title = attr(tag, 'title');
|
||||||
|
const page = pageAt(at);
|
||||||
|
if (title && page !== undefined) {
|
||||||
|
out.push({
|
||||||
|
title,
|
||||||
|
description: attr(tag, 'description') ?? '',
|
||||||
|
page,
|
||||||
|
section: sectionAt(at),
|
||||||
|
desktopOnly: page === SettingsPages.DesktopPage || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers.forEach((h) => {
|
||||||
|
const page = pageAt(h.at);
|
||||||
|
if (page !== undefined && !out.some((e) => e.title === h.text && e.page === page)) {
|
||||||
|
out.push({
|
||||||
|
title: h.text,
|
||||||
|
description: '',
|
||||||
|
page,
|
||||||
|
desktopOnly: page === SettingsPages.DesktopPage || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const norm = (s: string): string => s.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure: rank entries for a query. Every word must appear in the title,
|
||||||
|
* description or section; title matches rank first.
|
||||||
|
*/
|
||||||
|
export const searchSettings = (
|
||||||
|
entries: SettingsEntry[],
|
||||||
|
query: string,
|
||||||
|
{ desktop = false, limit = 30 }: { desktop?: boolean; limit?: number } = {},
|
||||||
|
): SettingsEntry[] => {
|
||||||
|
const words = norm(query).split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) return [];
|
||||||
|
const scored: { e: SettingsEntry; score: number }[] = [];
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.desktopOnly && !desktop) return;
|
||||||
|
const title = norm(e.title);
|
||||||
|
const rest = norm(`${e.description} ${e.section ?? ''}`);
|
||||||
|
let score = 0;
|
||||||
|
const all = words.every((w) => {
|
||||||
|
if (title.startsWith(w)) score += 3;
|
||||||
|
else if (title.includes(w)) score += 2;
|
||||||
|
else if (rest.includes(w)) score += 1;
|
||||||
|
else return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (all) scored.push({ e, score });
|
||||||
|
});
|
||||||
|
return scored
|
||||||
|
.sort((a, b) => b.score - a.score || a.e.title.localeCompare(b.e.title))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((s) => s.e);
|
||||||
|
};
|
||||||
@@ -9,3 +9,25 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?:
|
|||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves once media requests can carry the session: immediately-ish when a
|
||||||
|
* service worker already controls the page, otherwise when one takes control
|
||||||
|
* (first visit / just updated) or after `timeoutMs`. Used to retry a media
|
||||||
|
* load that raced the worker's activation and went out without a token.
|
||||||
|
*/
|
||||||
|
export function whenMediaAuthReady(timeoutMs = 3000): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sw = 'serviceWorker' in navigator ? navigator.serviceWorker : undefined;
|
||||||
|
if (!sw || sw.controller) {
|
||||||
|
window.setTimeout(resolve, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const done = () => {
|
||||||
|
sw.removeEventListener('controllerchange', done);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
sw.addEventListener('controllerchange', done);
|
||||||
|
window.setTimeout(done, timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user