hidden shit, and online status bars, search settings

This commit is contained in:
2026-07-12 07:49:17 +10:00
parent 5374c10c61
commit 02d96a9758
31 changed files with 1323 additions and 265 deletions

View File

@@ -1,5 +1,17 @@
import React, { useMemo, useState } from 'react';
import { Avatar, Box, Button, config, IconButton, MenuItem, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Avatar,
Box,
Button,
config,
IconButton,
Input,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import { Icon, Icons, IconSrc } from '../../components/icons';
import FocusTrap from 'focus-trap-react';
import { General } from './general';
@@ -22,18 +34,17 @@ import { Plugins } from './plugins';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
import { fuzzyFilter } from '../../utils/fuzzy';
import { SETTINGS_SEARCH_CATALOG } from './settingsSearchCatalog';
import { SettingsPages } from './SettingsPages';
import { SettingsSearchResult } from './styles.css';
import { BreakWord } from '../../styles/Text.css';
import {
SettingsFocusProvider,
settingAnchorId,
} from '../../components/setting-tile';
export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
AudioPage,
DevicesPage,
EmojisStickersPage,
PluginsPage,
DeveloperToolsPage,
AboutPage,
}
export { SettingsPages };
type SettingsMenuItem = {
page: SettingsPages;
@@ -112,7 +123,37 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
if (initialPage) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
});
const [searchQuery, setSearchQuery] = useState('');
const [focusAnchorId, setFocusAnchorId] = useState<string | undefined>();
const menuItems = useSettingsMenuItems();
const pageNameByPage = useMemo(() => {
const map = new Map<SettingsPages, string>();
menuItems.forEach((item) => map.set(item.page, item.name));
return map;
}, [menuItems]);
const iconByPage = useMemo(() => {
const map = new Map<SettingsPages, IconSrc>();
menuItems.forEach((item) => map.set(item.page, item.icon));
return map;
}, [menuItems]);
const searchResults = useMemo(() => {
const q = searchQuery.trim();
if (!q) return [];
return fuzzyFilter(SETTINGS_SEARCH_CATALOG, q, (entry) => [
entry.title,
pageNameByPage.get(entry.page),
...(entry.keywords ?? []),
]).slice(0, 40);
}, [pageNameByPage, searchQuery]);
const settingsFocus = useMemo(
() => ({
anchorId: focusAnchorId,
setAnchorId: setFocusAnchorId,
}),
[focusAnchorId]
);
const handlePageRequestClose = () => {
if (screenSize === ScreenSize.Mobile) {
@@ -122,120 +163,244 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
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">
<Icon src={Icons.Cross} />
</IconButton>
)}
</Box>
</PageNavHeader>
<Box grow="Yes" direction="Column">
<PageNavContent>
<div style={{ flexGrow: 1 }}>
{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>
))}
</div>
</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>
)
const openSearchResult = useCallback(
(page: SettingsPages, title: string) => {
const pageName = pageNameByPage.get(page);
// Page-level entries just open the page; tile entries scroll into view.
if (title !== pageName) {
setFocusAnchorId(settingAnchorId(title));
} else {
setFocusAnchorId(undefined);
}
>
{activePage === SettingsPages.GeneralPage && (
<General requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AccountPage && (
<Account requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.NotificationPage && (
<Notifications requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AudioPage && (
<Audio requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DevicesPage && (
<Devices requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.PluginsPage && (
<Plugins requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
</PageRoot>
setActivePage(page);
setSearchQuery('');
},
[pageNameByPage]
);
// Fallback scroll if the page just mounted and tiles need a moment to render.
useEffect(() => {
if (!focusAnchorId) return;
let cancelled = false;
let attempts = 0;
const tryScroll = () => {
if (cancelled) return;
const el = document.getElementById(focusAnchorId);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
if (attempts < 40) {
attempts += 1;
window.requestAnimationFrame(tryScroll);
} else {
setFocusAnchorId(undefined);
}
};
const timer = window.setTimeout(tryScroll, 50);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [focusAnchorId, activePage]);
const isSearching = searchQuery.trim().length > 0;
return (
<SettingsFocusProvider value={settingsFocus}>
<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">
<Icon src={Icons.Cross} />
</IconButton>
)}
</Box>
</PageNavHeader>
<Box grow="Yes" direction="Column">
<Box
style={{ padding: `${config.space.S100} ${config.space.S200}`, width: '100%' }}
shrink="No"
>
<Input
style={{ width: '100%' }}
variant="Background"
size="400"
placeholder="Search"
value={searchQuery}
onChange={(evt) => setSearchQuery(evt.target.value)}
before={<Icon size="100" src={Icons.Search} />}
after={
searchQuery ? (
<IconButton
size="300"
variant="Background"
radii="300"
onClick={() => setSearchQuery('')}
aria-label="Clear search"
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
) : undefined
}
/>
</Box>
<PageNavContent>
<div style={{ flexGrow: 1 }}>
{isSearching ? (
searchResults.length > 0 ? (
searchResults.map(({ item }) => (
<button
key={`${item.page}-${item.title}`}
type="button"
className={SettingsSearchResult}
aria-pressed={activePage === item.page}
onClick={() => openSearchResult(item.page, item.title)}
>
<Box shrink="No" style={{ paddingTop: config.space.S100 }}>
<Icon
src={iconByPage.get(item.page) ?? Icons.Setting}
size="100"
filled={activePage === item.page}
/>
</Box>
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0 }}>
<Text
className={BreakWord}
size="T300"
style={{
fontWeight:
activePage === item.page ? config.fontWeight.W600 : undefined,
}}
>
{item.title}
</Text>
<Text size="T200" priority="300">
{pageNameByPage.get(item.page)}
</Text>
</Box>
</button>
))
) : (
<Box
direction="Column"
alignItems="Center"
style={{ padding: config.space.S400 }}
>
<Text size="T300" priority="300" className={BreakWord}>
No settings match {searchQuery.trim()}
</Text>
</Box>
)
) : (
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>
))
)}
</div>
</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.GeneralPage && (
<General requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AccountPage && (
<Account requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.NotificationPage && (
<Notifications requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AudioPage && (
<Audio requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DevicesPage && (
<Devices requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.PluginsPage && (
<Plugins requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
</PageRoot>
</SettingsFocusProvider>
);
}

View File

@@ -0,0 +1,11 @@
export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
AudioPage,
DevicesPage,
EmojisStickersPage,
PluginsPage,
DeveloperToolsPage,
AboutPage,
}

View File

@@ -10,6 +10,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css';
import { pluginMarketplaceManager, pluginRegistry, SettingDefinition } from './PluginAPI';
import { stopPropagation } from '../../../utils/keyboard';
import { fuzzyFilter } from '../../../utils/fuzzy';
/**
* Renders settings UI for a plugin based on its settings schema
@@ -320,13 +321,6 @@ export function Plugins({ requestClose }: PluginsProps) {
[installedPlugins]
);
const fuzzySearch = (query: string, text: string): boolean => {
if (!query) return true;
const searchLower = query.toLowerCase();
const textLower = text.toLowerCase();
return textLower.includes(searchLower);
};
const PluginAvatar = ({ thumbnail, name }: { thumbnail: string; name: string }) => {
const [imageError, setImageError] = useState(false);
@@ -365,22 +359,22 @@ export function Plugins({ requestClose }: PluginsProps) {
};
const filteredMarketplacePlugins = useMemo(() => {
if (!searchQuery) return marketplacePlugins;
return marketplacePlugins.filter((plugin) =>
fuzzySearch(searchQuery, plugin.name) ||
fuzzySearch(searchQuery, plugin.description) ||
fuzzySearch(searchQuery, plugin.author) ||
plugin.tags.some((tag) => fuzzySearch(searchQuery, tag))
);
if (!searchQuery.trim()) return marketplacePlugins;
return fuzzyFilter(marketplacePlugins, searchQuery, (plugin) => [
plugin.name,
plugin.description,
plugin.author,
...plugin.tags,
]).map(({ item }) => item);
}, [marketplacePlugins, searchQuery]);
const filteredInstalledPlugins = useMemo(() => {
if (!searchQuery) return installedPlugins;
return installedPlugins.filter((plugin) =>
fuzzySearch(searchQuery, plugin.name) ||
fuzzySearch(searchQuery, plugin.description) ||
fuzzySearch(searchQuery, plugin.author)
);
if (!searchQuery.trim()) return installedPlugins;
return fuzzyFilter(installedPlugins, searchQuery, (plugin) => [
plugin.name,
plugin.description,
plugin.author,
]).map(({ item }) => item);
}, [installedPlugins, searchQuery]);
return (

View File

@@ -0,0 +1,137 @@
import { SettingsPages } from './SettingsPages';
export type SettingsSearchEntry = {
page: SettingsPages;
/** Primary label shown in results. */
title: string;
/** Extra terms for fuzzy matching (aliases, related words). */
keywords?: string[];
};
/**
* Catalog of settings pages and individual tiles for fuzzy search.
* Keep titles aligned with SettingTile labels in each page.
*/
export const SETTINGS_SEARCH_CATALOG: SettingsSearchEntry[] = [
// Pages
{ page: SettingsPages.GeneralPage, title: 'General', keywords: ['appearance', 'theme', 'messages', 'editor'] },
{ page: SettingsPages.AccountPage, title: 'Account', keywords: ['profile', 'email', 'mxid', 'ignore'] },
{
page: SettingsPages.NotificationPage,
title: 'Notifications',
keywords: ['alerts', 'push', 'desktop', 'sound', 'mentions'],
},
{ page: SettingsPages.AudioPage, title: 'Audio & Video', keywords: ['microphone', 'speaker', 'call', 'camera'] },
{ page: SettingsPages.DevicesPage, title: 'Devices', keywords: ['sessions', 'verification', 'backup', 'export'] },
{
page: SettingsPages.EmojisStickersPage,
title: 'Emojis & Stickers',
keywords: ['emoticons', 'stickers', 'telegram', 'custom emoji'],
},
{ page: SettingsPages.PluginsPage, title: 'Plugins', keywords: ['extensions', 'addons', 'marketplace'] },
{
page: SettingsPages.DeveloperToolsPage,
title: 'Developer Tools',
keywords: ['debug', 'token', 'account data'],
},
{ page: SettingsPages.AboutPage, title: 'About', keywords: ['version', 'cache', 'protocol', 'paarrot'] },
// General
{
page: SettingsPages.GeneralPage,
title: 'System Theme',
keywords: ['appearance', 'dark', 'light', 'auto'],
},
{ page: SettingsPages.GeneralPage, title: 'Theme', keywords: ['appearance', 'color scheme'] },
{ page: SettingsPages.GeneralPage, title: 'Monochrome Mode', keywords: ['grayscale', 'appearance'] },
{ page: SettingsPages.GeneralPage, title: 'Emoji Style', keywords: ['appearance', 'twemoji', 'native'] },
{ page: SettingsPages.GeneralPage, title: 'Page Zoom', keywords: ['scale', 'accessibility', 'size'] },
{ page: SettingsPages.GeneralPage, title: 'Date Format', keywords: ['time', 'calendar'] },
{ page: SettingsPages.GeneralPage, title: '24-Hour Time Format', keywords: ['clock', 'time'] },
{ page: SettingsPages.GeneralPage, title: 'ENTER for Newline', keywords: ['editor', 'keyboard', 'shift enter'] },
{ page: SettingsPages.GeneralPage, title: 'Markdown Formatting', keywords: ['editor', 'formatting'] },
{
page: SettingsPages.GeneralPage,
title: 'Hide Typing & Read Receipts',
keywords: ['privacy', 'activity', 'typing'],
},
{ page: SettingsPages.GeneralPage, title: 'Auto-Join Space Rooms', keywords: ['spaces', 'join'] },
{ page: SettingsPages.GeneralPage, title: 'Message Layout', keywords: ['bubbles', 'compact', 'modern'] },
{ page: SettingsPages.GeneralPage, title: 'Message Spacing', keywords: ['density', 'gap'] },
{
page: SettingsPages.GeneralPage,
title: 'Scroll to Latest on Room Reselect',
keywords: ['timeline', 'jump'],
},
{ page: SettingsPages.GeneralPage, title: 'Legacy Username Color', keywords: ['appearance', 'mxid color'] },
{ page: SettingsPages.GeneralPage, title: 'Hide Membership Change', keywords: ['joins', 'leaves', 'timeline'] },
{ page: SettingsPages.GeneralPage, title: 'Hide Profile Change', keywords: ['avatar', 'displayname', 'timeline'] },
{ page: SettingsPages.GeneralPage, title: 'Disable Media Auto Load', keywords: ['images', 'bandwidth', 'lazy'] },
{ page: SettingsPages.GeneralPage, title: 'Url Preview', keywords: ['link', 'embed', 'unfurl'] },
{
page: SettingsPages.GeneralPage,
title: 'Url Preview in Encrypted Room',
keywords: ['e2ee', 'link', 'embed'],
},
{ page: SettingsPages.GeneralPage, title: 'Show Hidden Events', keywords: ['state', 'debug', 'timeline'] },
// Account
{ page: SettingsPages.AccountPage, title: 'Profile', keywords: ['displayname', 'avatar', 'name'] },
{ page: SettingsPages.AccountPage, title: 'Email Address', keywords: ['contact', 'mail'] },
{ page: SettingsPages.AccountPage, title: 'Ignored Users', keywords: ['block', 'mute', 'ignore'] },
// Notifications
{ page: SettingsPages.NotificationPage, title: 'Desktop Notifications', keywords: ['system', 'os'] },
{ page: SettingsPages.NotificationPage, title: 'Notification Sound', keywords: ['audio', 'alert'] },
{ page: SettingsPages.NotificationPage, title: 'Email Notification', keywords: ['mail'] },
{ page: SettingsPages.NotificationPage, title: 'Background Notifications', keywords: ['tray', 'electron'] },
{ page: SettingsPages.NotificationPage, title: '1-to-1 Chats', keywords: ['dm', 'direct'] },
{ page: SettingsPages.NotificationPage, title: 'Rooms', keywords: ['messages', 'push'] },
{ page: SettingsPages.NotificationPage, title: 'Mention @room', keywords: ['highlight', 'ping'] },
{ page: SettingsPages.NotificationPage, title: 'Keyword Messages', keywords: ['highlight', 'filter'] },
{ page: SettingsPages.NotificationPage, title: 'Special Messages', keywords: ['mentions', 'displayname'] },
{ page: SettingsPages.NotificationPage, title: 'Test Notification', keywords: ['preview', 'sound'] },
// Audio & Video
{ page: SettingsPages.AudioPage, title: 'Input Device', keywords: ['microphone', 'mic'] },
{ page: SettingsPages.AudioPage, title: 'Output Device', keywords: ['speaker', 'headphones'] },
{ page: SettingsPages.AudioPage, title: 'Noise Suppression', keywords: ['microphone', 'processing'] },
{ page: SettingsPages.AudioPage, title: 'Echo Cancellation', keywords: ['microphone', 'aec'] },
{ page: SettingsPages.AudioPage, title: 'Auto Gain Control', keywords: ['microphone', 'agc'] },
{ page: SettingsPages.AudioPage, title: 'Resolution', keywords: ['screen share', 'video'] },
{ page: SettingsPages.AudioPage, title: 'Bitrate', keywords: ['screen share', 'quality'] },
{ page: SettingsPages.AudioPage, title: 'Frame Rate', keywords: ['screen share', 'fps'] },
{ page: SettingsPages.AudioPage, title: 'Show Remote Cursor', keywords: ['screen share', 'pointer'] },
// Devices
{ page: SettingsPages.DevicesPage, title: 'Device Verification', keywords: ['crypto', 'cross signing'] },
{ page: SettingsPages.DevicesPage, title: 'Device Dashboard', keywords: ['sessions', 'logout'] },
{ page: SettingsPages.DevicesPage, title: 'Export Messages Data', keywords: ['backup', 'download'] },
{ page: SettingsPages.DevicesPage, title: 'Import Messages Data', keywords: ['backup', 'restore'] },
// Emojis & Stickers
{
page: SettingsPages.EmojisStickersPage,
title: 'Auto-convert Text Emoticons',
keywords: ['smileys', 'colon'],
},
{ page: SettingsPages.EmojisStickersPage, title: 'Telegram Bot Token', keywords: ['stickers', 'import'] },
{ page: SettingsPages.EmojisStickersPage, title: 'Sticker Pack URL', keywords: ['telegram', 'import'] },
// Developer Tools
{
page: SettingsPages.DeveloperToolsPage,
title: 'Enable Developer Tools',
keywords: ['debug'],
},
{ page: SettingsPages.DeveloperToolsPage, title: 'Access Token', keywords: ['auth', 'secret'] },
{ page: SettingsPages.DeveloperToolsPage, title: 'Account Data', keywords: ['global', 'events'] },
// About
{ page: SettingsPages.AboutPage, title: 'Clear Cache & Reload', keywords: ['reset', 'storage'] },
{
page: SettingsPages.AboutPage,
title: 'Protocol Handler (paarrot://)',
keywords: ['deeplink', 'url scheme'],
},
];

View File

@@ -1,6 +1,27 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { color, config } from 'folds';
export const SequenceCardStyle = style({
padding: config.space.S300,
});
export const SettingsSearchResult = style({
width: '100%',
textAlign: 'left',
cursor: 'pointer',
border: 'none',
background: 'transparent',
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R400,
display: 'flex',
alignItems: 'flex-start',
gap: config.space.S200,
selectors: {
'&:hover': {
backgroundColor: color.Background.ContainerHover,
},
'&[aria-pressed="true"]': {
backgroundColor: color.Background.ContainerActive,
},
},
});