diff --git a/e2e/settings-search.spec.ts b/e2e/settings-search.spec.ts new file mode 100644 index 000000000..d2a87f847 --- /dev/null +++ b/e2e/settings-search.spec.ts @@ -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(); + }); +}); diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 47ece05e1..c085e8479 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -33,6 +33,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { ModalWide } from '../../../styles/Modal.css'; import { validBlurHash } from '../../../utils/blurHash'; import { useNearViewport } from '../../../hooks/useNearViewport'; +import { whenMediaAuthReady } from '../../../../sw-session'; type RenderViewerProps = { src: string; @@ -115,13 +116,24 @@ export const ImageContent = as<'div', ImageContentProps>( const handleLoad = () => { 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 = () => { setLoad(false); + if (!autoRetried.current && !encInfo) { + autoRetried.current = true; + whenMediaAuthReady().then(() => setAttempt((n) => n + 1)); + return; + } setError(true); }; const handleRetry = () => { setError(false); + setAttempt((n) => n + 1); loadSrc(); }; @@ -182,7 +194,7 @@ export const ImageContent = as<'div', ImageContentProps>( )} {srcState.status === AsyncStatus.Success && ( - + {renderImage({ alt: body, title: body, diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 07b90d317..8c665f514 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Avatar, Box, @@ -13,6 +13,7 @@ import { OverlayBackdrop, OverlayCenter, Text, + color, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { @@ -43,6 +44,8 @@ import { UseStateProvider } from '../../components/UseStateProvider'; import { stopPropagation } from '../../utils/keyboard'; import { LogoutDialog } from '../../components/LogoutDialog'; import { SettingsPages } from './pages'; +import { SettingsSearch } from './search/SettingsSearch'; +import type { SettingsEntry } from './search/settingsIndex'; export { SettingsPages }; @@ -94,6 +97,49 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage; }); 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(); + 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('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 = () => { if (screenSize === ScreenSize.Mobile) { @@ -134,8 +180,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { + -