Files
cinny/src/app/features/settings/Settings.tsx
T

292 lines
11 KiB
TypeScript
Raw Normal View History

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>
);
}