CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m44s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 16s
CI / Playwright smoke (e2e) (pull_request) Failing after 23m12s
A search box at the top of the settings sidebar (Ctrl/Cmd+F while settings are open). Results replace the page list while typing, showing the page and section; picking one (or Enter for the top result) opens that page, scrolls to the setting and briefly outlines it. Esc clears. The index is built from the settings source itself (SettingTile titles and descriptions, section headers), loaded as raw text in a lazy chunk on first search, so options added later are found without a hand-kept list. Desktop-only options only show in the desktop app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Button,
|
|
config,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
IconSrc,
|
|
MenuItem,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
Text,
|
|
color,
|
|
} from 'folds';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import {
|
|
AdvancedDisplayOptions,
|
|
AppearancePage,
|
|
CallsPage,
|
|
DataStoragePage,
|
|
DesktopPage,
|
|
MessagesPage,
|
|
Privacy,
|
|
} from './general';
|
|
import { isTauri as isTauriEnv } from '../../hooks/useTauri';
|
|
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
|
import { Account } from './account';
|
|
import { useUserProfile } from '../../hooks/useUserProfile';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { UserAvatar } from '../../components/user-avatar';
|
|
import { nameInitials } from '../../utils/common';
|
|
import { Notifications } from './notifications';
|
|
import { Devices } from './devices';
|
|
import { EmojisStickers } from './emojis-stickers';
|
|
import { DeveloperTools } from './developer-tools';
|
|
import { About } from './about';
|
|
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 };
|
|
|
|
type SettingsMenuItem = {
|
|
page: SettingsPages;
|
|
name: string;
|
|
icon: IconSrc;
|
|
};
|
|
|
|
const useSettingsMenuItems = (): SettingsMenuItem[] =>
|
|
useMemo(
|
|
() =>
|
|
[
|
|
{ page: SettingsPages.AccountPage, name: 'Account', icon: Icons.User },
|
|
{ page: SettingsPages.AppearancePage, name: 'Appearance', icon: Icons.Sun },
|
|
{ page: SettingsPages.MessagesPage, name: 'Messages', icon: Icons.Message },
|
|
{ page: SettingsPages.NotificationPage, name: 'Notifications', icon: Icons.Bell },
|
|
{ page: SettingsPages.CallsPage, name: 'Calls & audio', icon: Icons.Phone },
|
|
{ page: SettingsPages.DevicesPage, name: 'Privacy & security', icon: Icons.ShieldUser },
|
|
{ page: SettingsPages.DataStoragePage, name: 'Data & storage', icon: Icons.Download },
|
|
// [Gitea #459] Desktop-only options: nothing to show in a browser.
|
|
...(isTauriEnv()
|
|
? [{ page: SettingsPages.DesktopPage, name: 'Desktop', icon: Icons.Monitor }]
|
|
: []),
|
|
{ page: SettingsPages.EmojisStickersPage, name: 'Emojis & stickers', icon: Icons.Smile },
|
|
{ page: SettingsPages.DeveloperToolsPage, name: 'Advanced', icon: Icons.Terminal },
|
|
{ page: SettingsPages.AboutPage, name: 'About', icon: Icons.Info },
|
|
] as SettingsMenuItem[],
|
|
[],
|
|
);
|
|
|
|
type SettingsProps = {
|
|
initialPage?: SettingsPages;
|
|
requestClose: () => void;
|
|
};
|
|
export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const userId = mx.getUserId()!;
|
|
const profile = useUserProfile(userId);
|
|
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
|
const avatarUrl = profile.avatarUrl
|
|
? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
|
|
: undefined;
|
|
|
|
const screenSize = useScreenSizeContext();
|
|
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
|
|
if (initialPage) return initialPage;
|
|
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<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 = () => {
|
|
if (screenSize === ScreenSize.Mobile) {
|
|
setActivePage(undefined);
|
|
return;
|
|
}
|
|
requestClose();
|
|
};
|
|
|
|
return (
|
|
<PageRoot
|
|
nav={
|
|
screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
|
|
<PageNav size="300">
|
|
<PageNavHeader outlined={false}>
|
|
<Box grow="Yes" gap="200">
|
|
<Avatar size="200" radii="300">
|
|
<UserAvatar
|
|
userId={userId}
|
|
src={avatarUrl}
|
|
renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>}
|
|
/>
|
|
</Avatar>
|
|
<Text size="H4" truncate>
|
|
Settings
|
|
</Text>
|
|
</Box>
|
|
<Box shrink="No">
|
|
{screenSize === ScreenSize.Mobile && (
|
|
<IconButton
|
|
onClick={requestClose}
|
|
variant="Background"
|
|
aria-label="Close settings"
|
|
>
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
</PageNavHeader>
|
|
<Box grow="Yes" direction="Column">
|
|
<SettingsSearch
|
|
pageNames={pageNames}
|
|
onActiveChange={setSearching}
|
|
onPick={pickResult}
|
|
/>
|
|
<PageNavContent>
|
|
<nav aria-label="Settings sections" style={{ flexGrow: 1 }} hidden={searching}>
|
|
{menuItems.map((item) => (
|
|
<MenuItem
|
|
key={item.name}
|
|
variant="Background"
|
|
radii="400"
|
|
aria-pressed={activePage === item.page}
|
|
before={<Icon src={item.icon} size="100" filled={activePage === item.page} />}
|
|
onClick={() => setActivePage(item.page)}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontWeight: activePage === item.page ? config.fontWeight.W600 : undefined,
|
|
}}
|
|
size="T300"
|
|
truncate
|
|
>
|
|
{item.name}
|
|
</Text>
|
|
</MenuItem>
|
|
))}
|
|
</nav>
|
|
</PageNavContent>
|
|
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
|
|
<UseStateProvider initial={false}>
|
|
{(logout, setLogout) => (
|
|
<>
|
|
<Button
|
|
size="300"
|
|
variant="Critical"
|
|
fill="None"
|
|
radii="Pill"
|
|
before={<Icon src={Icons.Power} size="100" />}
|
|
onClick={() => setLogout(true)}
|
|
>
|
|
<Text size="B400">Logout</Text>
|
|
</Button>
|
|
{logout && (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
onDeactivate: () => setLogout(false),
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<LogoutDialog handleClose={() => setLogout(false)} />
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
)}
|
|
</>
|
|
)}
|
|
</UseStateProvider>
|
|
</Box>
|
|
</Box>
|
|
</PageNav>
|
|
)
|
|
}
|
|
>
|
|
{activePage === SettingsPages.AppearancePage && (
|
|
<AppearancePage requestClose={handlePageRequestClose} />
|
|
)}
|
|
{activePage === SettingsPages.MessagesPage && (
|
|
<MessagesPage requestClose={handlePageRequestClose} />
|
|
)}
|
|
{activePage === SettingsPages.CallsPage && (
|
|
<CallsPage requestClose={handlePageRequestClose} />
|
|
)}
|
|
{activePage === SettingsPages.DataStoragePage && (
|
|
<DataStoragePage requestClose={handlePageRequestClose} />
|
|
)}
|
|
{activePage === SettingsPages.DesktopPage && (
|
|
<DesktopPage requestClose={handlePageRequestClose} />
|
|
)}
|
|
{activePage === SettingsPages.AccountPage && (
|
|
<Account requestClose={handlePageRequestClose} />
|
|
)}
|
|
{activePage === SettingsPages.NotificationPage && (
|
|
<Notifications requestClose={handlePageRequestClose} />
|
|
)}
|
|
{activePage === SettingsPages.DevicesPage && (
|
|
<Devices
|
|
title="Privacy & security"
|
|
before={<Privacy />}
|
|
requestClose={handlePageRequestClose}
|
|
/>
|
|
)}
|
|
{activePage === SettingsPages.EmojisStickersPage && (
|
|
<EmojisStickers requestClose={handlePageRequestClose} />
|
|
)}
|
|
{activePage === SettingsPages.DeveloperToolsPage && (
|
|
<DeveloperTools
|
|
title="Advanced"
|
|
before={<AdvancedDisplayOptions />}
|
|
requestClose={handlePageRequestClose}
|
|
/>
|
|
)}
|
|
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
|
|
</PageRoot>
|
|
);
|
|
}
|