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

@@ -114,7 +114,7 @@ export function ForumChatComposer({
onClick={() => setToolbar(!toolbar)}
aria-label="Formatting"
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} size="200" />
</IconButton>
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
@@ -158,7 +158,11 @@ export function ForumChatComposer({
type="button"
aria-label="Emoji"
>
<Icon src={Icons.Smile} filled={emojiBoardTab === EmojiBoardTab.Emoji} />
<Icon
src={Icons.Smile}
size="200"
filled={emojiBoardTab === EmojiBoardTab.Emoji}
/>
</IconButton>
</PopOut>
)}
@@ -173,7 +177,7 @@ export function ForumChatComposer({
radii="300"
aria-label="Send"
>
<Icon src={Icons.Send} />
<Icon src={Icons.Send} size="200" />
</IconButton>
)}
</>

View File

@@ -14,6 +14,7 @@ import * as css from './LobbyHeader.css';
import { IPowerLevels } from '../../hooks/usePowerLevels';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt';
import { stopPropagation } from '../../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useBackRoute } from '../../hooks/useBackRoute';
@@ -48,6 +49,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings();
const [invitePrompt, setInvitePrompt] = useState(false);
@@ -117,6 +119,34 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{canEditChildren && (
<UseStateProvider initial={false}>
{(promptRemove, setPromptRemove) => (
<>
<MenuItem
onClick={() => setPromptRemove(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Delete} />}
radii="300"
aria-pressed={promptRemove}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Remove Inaccessible
</Text>
</MenuItem>
{promptRemove && (
<RemoveInaccessiblePrompt
roomId={space.roomId}
onDone={requestClose}
onCancel={() => setPromptRemove(false)}
/>
)}
</>
)}
</UseStateProvider>
)}
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>

View File

@@ -133,7 +133,11 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
}, [mx, roomItems]);
let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId) && !globalSubRoomIds.has(i.roomId));
if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) {
// Only hide when we know the user cannot edit children. If power levels are
// still loading (spacePermissions undefined), keep inaccessible rows visible
// so admins can clean them up.
const canEditChild = spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
if (spacePermissions && !canEditChild) {
// hide unknown rooms for normal user
childItems = childItems?.filter((i) => {
const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;

View File

@@ -24,7 +24,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { TypingIndicator } from '../../components/typing-indicator';
import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import { getViaServers } from '../../plugins/via-servers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
@@ -46,6 +46,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { StateEvent } from '../../../types/matrix/room';
import { mDirectAtom } from '../../state/mDirectList';
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
import { PresenceAvatar } from '../../components/presence';
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
type RoomNavItemMenuProps = {
room: Room;
@@ -289,13 +291,17 @@ export function RoomNavItem({
const avatarUrl = direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
const mDirects = useAtomValue(mDirectAtom);
const isDirect = Boolean(direct || mDirects.has(room.roomId));
const dmUserId = isDirect ? guessDmRoomUserId(room, mx.getSafeUserId()) : undefined;
const dmPresence = useUserPresence(dmUserId ?? '');
const shouldShowIcon = !hideIcon || (hideIcon && avatarUrl);
const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId()
);
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
// Get first participated thread for preview
const firstThreadPreview = useMemo(() => {
@@ -317,7 +323,7 @@ export function RoomNavItem({
// Get parent space for DMs
const parentSpaceInfo = (() => {
if (!direct || !mDirects.has(room.roomId)) return undefined;
if (!isDirect || !mDirects.has(room.roomId)) return undefined;
const orphanParents = getOrphanParents(roomToParents, room.roomId);
if (orphanParents.length === 0) return undefined;
@@ -383,29 +389,60 @@ export function RoomNavItem({
<NavLink to={linkPath} onClick={handleRoomClick}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
{shouldShowIcon && (
<Avatar size="200" radii="400">
{showAvatar || avatarUrl ? (
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
{shouldShowIcon &&
(isDirect ? (
<PresenceAvatar
presence={
dmUserId && dmUserId !== mx.getSafeUserId()
? dmPresence?.presence ?? Presence.Offline
: Presence.Offline
}
>
<Avatar size="200" radii="400">
{showAvatar || avatarUrl ? (
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
/>
)}
</Avatar>
</PresenceAvatar>
) : (
<RoomIcon
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
/>
)}
</Avatar>
)}
<Avatar size="200" radii="400">
{showAvatar || avatarUrl ? (
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
filled={selected}
size="100"
joinRule={room.getJoinRule()}
/>
)}
</Avatar>
))}
<Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{room.name}

View File

@@ -764,7 +764,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
size="300"
radii="300"
>
<Icon src={Icons.PlusCircle} />
<Icon src={Icons.PlusCircle} size="200" />
</IconButton>
<PluginButtonSlot location="composer-actions" />
</>
@@ -777,7 +777,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
radii="300"
onClick={() => setToolbar(!toolbar)}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} size="200" />
</IconButton>
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
@@ -822,6 +822,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
<Icon
src={Icons.Smile}
size="200"
filled={emojiBoardTab === EmojiBoardTab.Emoji}
/>
</IconButton>
@@ -830,7 +831,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</UseStateProvider>
<PluginButtonSlot location="text-composer-toolbar" />
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
<Icon src={Icons.Send} size="200" />
</IconButton>
</>
}

View File

@@ -419,7 +419,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
{isConnecting ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="400" src={Icons.Phone} filled={isActive && activeCall?.callType === CallType.Voice} />
<Icon size="300" src={Icons.Phone} filled={isActive && activeCall?.callType === CallType.Voice} />
)}
</IconButton>
)}
@@ -445,7 +445,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
{isConnecting ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="400" src={Icons.VideoCamera} filled={isActive && activeCall?.callType === CallType.Video} />
<Icon size="300" src={Icons.VideoCamera} filled={isActive && activeCall?.callType === CallType.Video} />
)}
</IconButton>
)}
@@ -507,7 +507,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
{showInPageHeader && (
<Box shrink="No" alignItems="Center">
<IconButton onClick={goBack} aria-label="Back">
<Icon src={Icons.ArrowLeft} />
<Icon size="300" src={Icons.ArrowLeft} />
</IconButton>
</Box>
)}
@@ -593,7 +593,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={handleSearchClick}>
<Icon size="400" src={Icons.Search} />
<Icon size="300" src={Icons.Search} />
</IconButton>
)}
</TooltipProvider>
@@ -631,7 +631,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
</Text>
</Badge>
)}
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
<Icon size="300" src={Icons.Pin} filled={!!pinMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
@@ -666,7 +666,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
<Icon size="400" src={Icons.User} />
<Icon size="300" src={Icons.User} />
</IconButton>
)}
</TooltipProvider>
@@ -686,7 +686,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
onClick={() => setActiveThreadId(undefined)}
aria-pressed={!!activeThreadId}
>
<Icon size="400" src={activeThreadId ? Icons.ArrowLeft : Icons.Thread} filled={!!activeThreadId} />
<Icon size="300" src={activeThreadId ? Icons.ArrowLeft : Icons.Thread} filled={!!activeThreadId} />
</IconButton>
)}
</TooltipProvider>
@@ -703,7 +703,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
>
{(triggerRef) => (
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
<Icon size="300" src={Icons.VerticalDots} filled={!!menuAnchor} />
</IconButton>
)}
</TooltipProvider>

View File

@@ -282,7 +282,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
radii="300"
onClick={() => setToolbar(!toolbar)}
>
<Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
<Icon size="200" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<UseStateProvider initial={undefined}>
{(anchor: RectCords | undefined, setAnchor) => (
@@ -321,7 +321,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
size="300"
radii="300"
>
<Icon size="400" src={Icons.Smile} filled={anchor !== undefined} />
<Icon size="200" src={Icons.Smile} filled={anchor !== undefined} />
</IconButton>
</PopOut>
)}

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

View File

@@ -23,6 +23,7 @@ import { useRoomsUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { copyToClipboard } from '../../utils/dom';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
@@ -32,6 +33,7 @@ import { useNavigate } from 'react-router-dom';
import { getSpaceFeedPath, getSpaceLobbyPath } from '../../pages/pathUtils';
import { shouldShowForumLobby } from '../../utils/room';
import { useSpaceFeedSelected } from '../../hooks/router/useSelectedSpace';
import { StateEvent } from '../../../types/matrix/room';
type ForumAddOptions = {
item: HierarchyItem;
@@ -65,6 +67,7 @@ export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate();
const markRoomsAsRead = useMarkRoomsAsRead(mx);
@@ -304,6 +307,34 @@ export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{canEditChildren && (
<UseStateProvider initial={false}>
{(promptRemove, setPromptRemove) => (
<>
<MenuItem
onClick={() => setPromptRemove(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Delete} />}
radii="300"
aria-pressed={promptRemove}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Remove Inaccessible
</Text>
</MenuItem>
{promptRemove && (
<RemoveInaccessiblePrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptRemove(false)}
/>
)}
</>
)}
</UseStateProvider>
)}
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>