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

@@ -122,5 +122,9 @@
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.3.0",
"vite-plugin-static-copy": "4.1.1", "vite-plugin-static-copy": "4.1.1",
"vite-plugin-top-level-await": "1.6.0" "vite-plugin-top-level-await": "1.6.0"
},
"allowScripts": {
"@swc/core@1.15.43": true,
"esbuild@0.28.1": true
} }
} }

View File

@@ -16,6 +16,8 @@ export const EditorOptions = style([
DefaultReset, DefaultReset,
{ {
padding: config.space.S200, padding: config.space.S200,
display: 'flex',
alignItems: 'center',
}, },
]); ]);
@@ -26,7 +28,7 @@ export const EditorTextarea = style([
{ {
flexGrow: 1, flexGrow: 1,
height: '100%', height: '100%',
padding: `${toRem(13)} ${toRem(1)}`, padding: `${toRem(10)} ${toRem(1)}`,
selectors: { selectors: {
[`${EditorTextareaScroll}:first-child &`]: { [`${EditorTextareaScroll}:first-child &`]: {
paddingLeft: toRem(13), paddingLeft: toRem(13),
@@ -54,7 +56,7 @@ export const EditorPlaceholderTextVisual = style([
DefaultReset, DefaultReset,
{ {
display: 'block', display: 'block',
paddingTop: toRem(13), paddingTop: toRem(10),
paddingLeft: toRem(1), paddingLeft: toRem(1),
}, },
]); ]);

View File

@@ -243,7 +243,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
<Slate editor={editor} initialValue={initialValue} onChange={onChange}> <Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top} {top}
<Box <Box
alignItems="Start" alignItems={fillHeight ? 'Start' : 'Center'}
grow={fillHeight ? 'Yes' : undefined} grow={fillHeight ? 'Yes' : undefined}
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined} style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
> >

View File

@@ -64,7 +64,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
radii="300" radii="300"
disabled={disableInline} disabled={disableInline}
> >
<Icon size="200" src={icon} /> <Icon size="100" src={icon} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -95,7 +95,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
size="400" size="400"
radii="300" radii="300"
> >
<Icon size="200" src={icon} /> <Icon size="100" src={icon} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -152,7 +152,7 @@ export function HeadingBlockButton() {
size="400" size="400"
radii="300" radii="300"
> >
<Icon size="200" src={Icons.Heading1} /> <Icon size="100" src={Icons.Heading1} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -167,7 +167,7 @@ export function HeadingBlockButton() {
size="400" size="400"
radii="300" radii="300"
> >
<Icon size="200" src={Icons.Heading2} /> <Icon size="100" src={Icons.Heading2} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -182,7 +182,7 @@ export function HeadingBlockButton() {
size="400" size="400"
radii="300" radii="300"
> >
<Icon size="200" src={Icons.Heading3} /> <Icon size="100" src={Icons.Heading3} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -199,8 +199,8 @@ export function HeadingBlockButton() {
size="400" size="400"
radii="300" radii="300"
> >
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} /> <Icon size="100" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} /> <Icon size="100" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
</IconButton> </IconButton>
</PopOut> </PopOut>
); );
@@ -336,7 +336,7 @@ export function Toolbar() {
radii="300" radii="300"
disabled={disableInline || !!isAnyMarkActive(editor)} disabled={disableInline || !!isAnyMarkActive(editor)}
> >
<Icon size="200" src={Icons.Markdown} filled={isMarkdown} /> <Icon size="100" src={Icons.Markdown} filled={isMarkdown} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>

View File

@@ -29,8 +29,10 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(
? { width: pixelSize, height: pixelSize, ...style } ? { width: pixelSize, height: pixelSize, ...style }
: style : style
} }
strokeWidth={2} // Lucide icons are stroke-based; solid fill turns faces (e.g. Smile) into blobs.
fill={fill ?? (filled ? 'currentColor' : 'none')} // Emphasize selection with a heavier stroke instead.
strokeWidth={filled ? 2.75 : 2}
fill={fill ?? 'none'}
aria-hidden={props['aria-hidden'] ?? props['aria-label'] ? undefined : true} aria-hidden={props['aria-hidden'] ?? props['aria-label'] ? undefined : true}
{...props} {...props}
/> />

View File

@@ -1,12 +1,13 @@
import type { IconSize } from './types'; import type { IconSize } from './types';
/** App chrome icons; ~4% above the original folds scale after +30% then -20%. */
export const ICON_PIXEL_SIZES: Record<IconSize, number | undefined> = { export const ICON_PIXEL_SIZES: Record<IconSize, number | undefined> = {
'50': 10, '50': 10,
'100': 12, '100': 13,
'200': 16, '200': 17,
'300': 20, '300': 21,
'400': 24, '400': 25,
'500': 28, '500': 29,
'600': 32, '600': 34,
Inherit: undefined, Inherit: undefined,
}; };

View File

@@ -3,9 +3,8 @@ import { config } from 'folds';
export const Icon = recipe({ export const Icon = recipe({
base: { base: {
display: 'inline-block', display: 'block',
flexShrink: 0, flexShrink: 0,
verticalAlign: 'middle',
}, },
variants: { variants: {
size: { size: {

View File

@@ -1,35 +1,76 @@
import React, { ReactNode } from 'react'; import React, { CSSProperties, ReactElement, cloneElement, isValidElement } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import * as css from './styles.css'; import { config } from 'folds';
import { Presence } from '../../hooks/useUserPresence'; import { Presence } from '../../hooks/useUserPresence';
type PresenceAvatarProps = { type PresenceAvatarProps = {
/** The presence state to display */
presence?: Presence; presence?: Presence;
/** The avatar element to wrap */ children: ReactElement;
children: ReactNode;
/** Additional className */
className?: string; className?: string;
}; };
const PRESENCE_COLOR: Record<Presence, string> = {
[Presence.Online]: '#38842b',
[Presence.Unavailable]: '#959e30',
[Presence.Offline]: '#454545',
};
const rowStyle: CSSProperties = {
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'stretch',
flexShrink: 0,
lineHeight: 0,
};
const faceStyle: CSSProperties = {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
// Keep the right side rounded to match size-200 nav avatars.
borderTopRightRadius: config.radii.R400,
borderBottomRightRadius: config.radii.R400,
};
/** /**
* Wraps an avatar with a left-side presence indicator border. * Presence strip as a real sibling (not ::before).
* Shows green for online, yellow/orange for unavailable/away, grey for offline. * Folds Avatar uses overflow:hidden, which clips outside ::before bars.
*/ */
export function PresenceAvatar({ presence, children, className }: PresenceAvatarProps) { export function PresenceAvatar({
if (!presence) { presence = Presence.Offline,
return <>{children}</>; children,
} className,
}: PresenceAvatarProps) {
const stripColor = PRESENCE_COLOR[presence] ?? PRESENCE_COLOR[Presence.Offline];
const face = isValidElement(children)
? cloneElement(children, {
className: classNames((children.props as { className?: string }).className),
style: {
...((children.props as { style?: CSSProperties }).style ?? {}),
...faceStyle,
},
// Avoid the folds radii shorthand fighting the square-left corners.
radii: '0',
} as Partial<unknown>)
: children;
return ( return (
<div <div className={className} style={rowStyle} data-presence={presence}>
className={classNames(css.PresenceAvatarContainer, css.PresenceIndicator, className, { <span
[css.PresenceOnline]: presence === Presence.Online, aria-hidden
[css.PresenceUnavailable]: presence === Presence.Unavailable, style={{
[css.PresenceOffline]: presence === Presence.Offline, display: 'block',
})} width: 4,
> minWidth: 4,
{children} flex: '0 0 4px',
alignSelf: 'stretch',
// Match folds Avatar size="200" so the strip can't collapse to 0 height.
minHeight: config.size.X200,
borderRadius: '5px 0 0 5px',
backgroundColor: stripColor,
}}
/>
{face}
</div> </div>
); );
} }

View File

@@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { config, color } from 'folds'; import { config } from 'folds';
export const AvatarPresence = style({ export const AvatarPresence = style({
display: 'flex', display: 'flex',
@@ -21,40 +21,67 @@ export const AvatarPresenceBadge = style({
overflow: 'hidden', overflow: 'hidden',
}); });
export const PresenceAvatarContainer = style({ /**
display: 'flex', * DM list avatar with flush left presence strip — same approach as message timeline.
* Square left corners; ::before is the colored strip with outer radius.
*/
export const PresenceAvatarFace = style({
position: 'relative', position: 'relative',
flexShrink: 0, flexShrink: 0,
alignSelf: 'flex-start', // Room for the strip so overflow clipping / parent padding doesn't hide it.
marginLeft: '4px',
overflow: 'visible',
borderTopLeftRadius: '0 !important',
borderBottomLeftRadius: '0 !important',
'::before': { '::before': {
content: '""', content: '""',
position: 'absolute', position: 'absolute',
left: 0, left: '-4px',
top: '10%', top: 0,
height: '80%', height: '100%',
width: '3px', width: '4px',
borderTopRightRadius: '2px', borderTopLeftRadius: '5px',
borderBottomRightRadius: '2px', borderBottomLeftRadius: '5px',
zIndex: 1, zIndex: 1,
backgroundColor: '#454545',
},
selectors: {
'& > *': {
borderTopLeftRadius: '0 !important',
borderBottomLeftRadius: '0 !important',
},
}, },
}); });
export const PresenceIndicator = style({});
export const PresenceOnline = style({ export const PresenceOnline = style({
'::before': { selectors: {
backgroundColor: color.Success.Main, '&::before': {
backgroundColor: '#38842b',
},
}, },
}); });
export const PresenceUnavailable = style({ export const PresenceUnavailable = style({
'::before': { selectors: {
backgroundColor: color.Warning.Main, '&::before': {
backgroundColor: '#959e30',
},
}, },
}); });
export const PresenceOffline = style({ export const PresenceOffline = style({
'::before': { selectors: {
backgroundColor: color.Secondary.Main, '&::before': {
backgroundColor: '#454545',
},
}, },
}); });
/** @deprecated aliases kept for older imports */
export const PresenceAvatarContainer = PresenceAvatarFace;
export const PresenceAvatarRow = PresenceAvatarFace;
export const PresenceStrip = style({});
export const PresenceStripOnline = PresenceOnline;
export const PresenceStripUnavailable = PresenceUnavailable;
export const PresenceStripOffline = PresenceOffline;
export const PresenceIndicator = style({});

View File

@@ -0,0 +1,318 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
color,
Button,
Spinner,
toRem,
} from 'folds';
import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
import { Icon, Icons } from '../icons';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { getSpaceChildren, getStateEvent, isSpace } from '../../utils/room';
import { Membership, StateEvent } from '../../../types/matrix/room';
import { fetchAllHierarchyRooms } from '../../hooks/useSpaceHierarchy';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { IPowerLevels } from '../../hooks/usePowerLevels';
const REMOVE_DELAY_MS = 100;
const PREVIEW_LIMIT = 20;
export type InaccessibleSpaceChild = {
parentId: string;
roomId: string;
};
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function canEditSpaceChildren(mx: MatrixClient, spaceRoom: Room): boolean {
const creators = getRoomCreatorsForRoomId(mx, spaceRoom.roomId);
const powerLevelsEvent = getStateEvent(spaceRoom, StateEvent.RoomPowerLevels);
const powerLevels = (powerLevelsEvent?.getContent() ?? {}) as IPowerLevels;
const permissions = getRoomPermissionsAPI(creators, powerLevels);
return permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
}
export type InaccessibleScanResult = {
inaccessible: InaccessibleSpaceChild[];
/** Total valid m.space.child entries walked (editable spaces only). */
childCount: number;
/** Children skipped because the user is joined to them. */
joinedCount: number;
/** Unjoined children that hierarchy can still summarize (Joinable / not Inaccessible). */
joinableCount: number;
};
/**
* Collects space children that appear as Unknown/Inaccessible in the lobby.
* Matches lobby getRoom (joined-only): not joined + no hierarchy summary.
* Walks nested subspaces the user can edit.
*/
export async function collectInaccessibleSpaceChildren(
mx: MatrixClient,
rootSpaceId: string
): Promise<InaccessibleScanResult> {
let hierarchySummaries = new Map<string, { room_id: string }>();
try {
const hierarchyRooms = await fetchAllHierarchyRooms(mx, rootSpaceId, 3);
hierarchySummaries = new Map(hierarchyRooms.map((room) => [room.room_id, room]));
} catch {
// Hierarchy often fails for restricted spaces; treat missing summaries as inaccessible.
hierarchySummaries = new Map();
}
const inaccessible: InaccessibleSpaceChild[] = [];
const visited = new Set<string>();
let childCount = 0;
let joinedCount = 0;
let joinableCount = 0;
const walk = (spaceId: string) => {
if (visited.has(spaceId)) return;
visited.add(spaceId);
const spaceRoom = mx.getRoom(spaceId);
if (!spaceRoom || spaceRoom.getMyMembership() !== Membership.Join) return;
if (!canEditSpaceChildren(mx, spaceRoom)) return;
for (const childId of getSpaceChildren(spaceRoom)) {
childCount += 1;
const childRoom = mx.getRoom(childId);
// Lobby uses joined-only getRoom; left/invite rooms still render as Inaccessible.
const joined = childRoom?.getMyMembership() === Membership.Join;
if (joined && childRoom && isSpace(childRoom)) {
joinedCount += 1;
walk(childId);
continue;
}
if (joined) {
joinedCount += 1;
continue;
}
const hasHierarchySummary = hierarchySummaries.has(childId);
if (hasHierarchySummary) {
joinableCount += 1;
continue;
}
inaccessible.push({ parentId: spaceId, roomId: childId });
}
};
walk(rootSpaceId);
return { inaccessible, childCount, joinedCount, joinableCount };
}
async function removeSpaceChildrenSequentially(
mx: MatrixClient,
children: InaccessibleSpaceChild[],
onProgress?: (current: number, total: number) => void
): Promise<void> {
const total = children.length;
for (let i = 0; i < children.length; i += 1) {
const { parentId, roomId } = children[i];
onProgress?.(i, total);
try {
await mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId);
await delay(REMOVE_DELAY_MS);
} catch {
// Continue removing others even if one fails
}
}
onProgress?.(total, total);
}
type RemoveInaccessiblePromptProps = {
roomId: string;
onDone: () => void;
onCancel: () => void;
};
export function RemoveInaccessiblePrompt({
roomId,
onDone,
onCancel,
}: RemoveInaccessiblePromptProps) {
const mx = useMatrixClient();
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
const [scanResult, setScanResult] = useState<InaccessibleScanResult | null>(null);
const [scanState, scan] = useAsyncCallback<InaccessibleScanResult, MatrixError | Error, []>(
useCallback(async () => collectInaccessibleSpaceChildren(mx, roomId), [mx, roomId])
);
const children = scanResult?.inaccessible ?? null;
const [removeState, removeChildren] = useAsyncCallback<undefined, MatrixError | Error, []>(
useCallback(async () => {
if (!children || children.length === 0) return;
await removeSpaceChildrenSequentially(mx, children, (current, total) => {
setProgress({ current, total });
});
setProgress(null);
}, [mx, children])
);
useEffect(() => {
scan();
}, [scan]);
useEffect(() => {
if (scanState.status === AsyncStatus.Success) {
setScanResult(scanState.data);
}
}, [scanState]);
useEffect(() => {
if (removeState.status === AsyncStatus.Success) {
onDone();
}
}, [removeState, onDone]);
const previewIds = useMemo(() => (children ?? []).slice(0, PREVIEW_LIMIT).map((c) => c.roomId), [children]);
const isScanning = scanState.status === AsyncStatus.Loading || scanState.status === AsyncStatus.Idle;
const isRemoving = removeState.status === AsyncStatus.Loading;
const count = children?.length ?? 0;
const getButtonText = () => {
if (isRemoving && progress) return `Removing: ${progress.current}/${progress.total}`;
if (isRemoving) return 'Removing...';
if (count === 0) return 'Nothing to Remove';
return `Remove ${count} Room${count === 1 ? '' : 's'}`;
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Remove Inaccessible</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" disabled={isRemoving}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
{isScanning && (
<Box alignItems="Center" gap="200">
<Spinner size="200" />
<Text priority="400">Scanning space hierarchy for inaccessible rooms</Text>
</Box>
)}
{scanState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to scan space. {scanState.error.message}
</Text>
)}
{scanState.status === AsyncStatus.Success && count === 0 && (
<Box direction="Column" gap="100">
<Text priority="400">No inaccessible rooms found in this space.</Text>
{scanResult && (
<Text size="T200" priority="300">
Scanned {scanResult.childCount} child
{scanResult.childCount === 1 ? '' : 'ren'}: {scanResult.joinedCount} joined,{' '}
{scanResult.joinableCount} joinable via hierarchy.
</Text>
)}
</Box>
)}
{scanState.status === AsyncStatus.Success && count > 0 && (
<>
<Text priority="400">
Remove {count} inaccessible room{count === 1 ? '' : 's'} from this space? This
only unlinks them from the space; it does not leave or delete rooms.
</Text>
<Box
direction="Column"
gap="100"
style={{
maxHeight: toRem(200),
overflow: 'auto',
padding: config.space.S200,
backgroundColor: color.SurfaceVariant.Container,
borderRadius: config.radii.R400,
}}
>
{previewIds.map((id) => (
<Text key={id} size="T200" style={{ wordBreak: 'break-all' }}>
{id}
</Text>
))}
{count > PREVIEW_LIMIT && (
<Text size="T200" priority="300">
and {count - PREVIEW_LIMIT} more
</Text>
)}
</Box>
</>
)}
{removeState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to remove some rooms. {removeState.error.message}
</Text>
)}
</Box>
<Button
type="submit"
variant="Critical"
onClick={() => removeChildren()}
before={
isRemoving || isScanning ? (
<Spinner fill="Solid" variant="Critical" size="200" />
) : undefined
}
disabled={
isScanning ||
isRemoving ||
count === 0 ||
removeState.status === AsyncStatus.Success
}
>
<Text size="B400">{getButtonText()}</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

@@ -0,0 +1 @@
export * from './RemoveInaccessiblePrompt';

View File

@@ -0,0 +1,18 @@
import { keyframes, style } from '@vanilla-extract/css';
import { color, config } from 'folds';
const focusPulse = keyframes({
'0%': {
outlineColor: color.Primary.Main,
},
'100%': {
outlineColor: 'transparent',
},
});
export const SettingTileFocus = style({
borderRadius: config.radii.R400,
outline: `${config.borderWidth.B300} solid ${color.Primary.Main}`,
outlineOffset: config.space.S100,
animation: `${focusPulse} 1.2s ease-out forwards`,
});

View File

@@ -1,6 +1,9 @@
import React, { ReactNode } from 'react'; import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { Box, Text } from 'folds'; import { Box, Text } from 'folds';
import classNames from 'classnames';
import { BreakWord } from '../../styles/Text.css'; import { BreakWord } from '../../styles/Text.css';
import { settingAnchorId, useSettingsFocus } from './SettingsFocus';
import { SettingTileFocus } from './SettingTile.css';
type SettingTileProps = { type SettingTileProps = {
title?: ReactNode; title?: ReactNode;
@@ -8,10 +11,65 @@ type SettingTileProps = {
before?: ReactNode; before?: ReactNode;
after?: ReactNode; after?: ReactNode;
children?: ReactNode; children?: ReactNode;
/** Override auto-generated scroll anchor. */
anchorId?: string;
}; };
export function SettingTile({ title, description, before, after, children }: SettingTileProps) { export function SettingTile({
title,
description,
before,
after,
children,
anchorId: anchorIdProp,
}: SettingTileProps) {
const ref = useRef<HTMLDivElement>(null);
const { anchorId: focusAnchorId, setAnchorId } = useSettingsFocus();
const [highlighted, setHighlighted] = useState(false);
const autoAnchorId = typeof title === 'string' ? settingAnchorId(title) : undefined;
const anchorId = anchorIdProp ?? autoAnchorId;
useEffect(() => {
if (!anchorId || !focusAnchorId || anchorId !== focusAnchorId) return;
let cancelled = false;
let attempts = 0;
const run = () => {
if (cancelled) return;
const node = ref.current;
if (node) {
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlighted(true);
setAnchorId(undefined);
return;
}
if (attempts < 40) {
attempts += 1;
window.requestAnimationFrame(run);
}
};
const timer = window.setTimeout(run, 50);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [anchorId, focusAnchorId, setAnchorId]);
useEffect(() => {
if (!highlighted) return;
const timer = window.setTimeout(() => setHighlighted(false), 1400);
return () => window.clearTimeout(timer);
}, [highlighted]);
return ( return (
<Box alignItems="Center" gap="300"> <Box
ref={ref}
id={anchorId}
alignItems="Center"
gap="300"
className={classNames(highlighted && SettingTileFocus)}
>
{before && <Box shrink="No">{before}</Box>} {before && <Box shrink="No">{before}</Box>}
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
{title && ( {title && (

View File

@@ -0,0 +1,32 @@
import { createContext, useContext } from 'react';
export type SettingsFocus = {
/** Stable anchor id for a setting tile (from settingAnchorId). */
anchorId?: string;
setAnchorId: (anchorId: string | undefined) => void;
};
const SettingsFocusContext = createContext<SettingsFocus | null>(null);
export const SettingsFocusProvider = SettingsFocusContext.Provider;
export const useSettingsFocus = (): SettingsFocus => {
const ctx = useContext(SettingsFocusContext);
if (!ctx) {
return {
anchorId: undefined,
setAnchorId: () => undefined,
};
}
return ctx;
};
/** Stable DOM id for a settings tile title. */
export function settingAnchorId(title: string): string {
const slug = title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return `settings-item-${slug || 'untitled'}`;
}

View File

@@ -1 +1,2 @@
export * from './SettingTile'; export * from './SettingTile';
export * from './SettingsFocus';

View File

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

View File

@@ -14,6 +14,7 @@ import * as css from './LobbyHeader.css';
import { IPowerLevels } from '../../hooks/usePowerLevels'; import { IPowerLevels } from '../../hooks/usePowerLevels';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useBackRoute } from '../../hooks/useBackRoute'; import { useBackRoute } from '../../hooks/useBackRoute';
@@ -48,6 +49,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId()); const canInvite = permissions.action('invite', mx.getSafeUserId());
const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings(); const openSpaceSettings = useOpenSpaceSettings();
const [invitePrompt, setInvitePrompt] = useState(false); const [invitePrompt, setInvitePrompt] = useState(false);
@@ -117,6 +119,34 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
</Box> </Box>
<Line variant="Surface" size="300" /> <Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <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}> <UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => ( {(promptLeave, setPromptLeave) => (
<> <>

View File

@@ -133,7 +133,11 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
}, [mx, roomItems]); }, [mx, roomItems]);
let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId) && !globalSubRoomIds.has(i.roomId)); 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 // hide unknown rooms for normal user
childItems = childItems?.filter((i) => { childItems = childItems?.filter((i) => {
const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false; 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 { TypingIndicator } from '../../components/typing-indicator';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to'; 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 { getViaServers } from '../../plugins/via-servers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
@@ -46,6 +46,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal'; import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
import { PresenceAvatar } from '../../components/presence';
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@@ -290,12 +292,16 @@ export function RoomNavItem({
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(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 shouldShowIcon = !hideIcon || (hideIcon && avatarUrl);
const typingMember = useRoomTypingMember(room.roomId).filter( const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId() (receipt) => receipt.userId !== mx.getUserId()
); );
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
// Get first participated thread for preview // Get first participated thread for preview
const firstThreadPreview = useMemo(() => { const firstThreadPreview = useMemo(() => {
@@ -317,7 +323,7 @@ export function RoomNavItem({
// Get parent space for DMs // Get parent space for DMs
const parentSpaceInfo = (() => { const parentSpaceInfo = (() => {
if (!direct || !mDirects.has(room.roomId)) return undefined; if (!isDirect || !mDirects.has(room.roomId)) return undefined;
const orphanParents = getOrphanParents(roomToParents, room.roomId); const orphanParents = getOrphanParents(roomToParents, room.roomId);
if (orphanParents.length === 0) return undefined; if (orphanParents.length === 0) return undefined;
@@ -383,29 +389,60 @@ export function RoomNavItem({
<NavLink to={linkPath} onClick={handleRoomClick}> <NavLink to={linkPath} onClick={handleRoomClick}>
<NavItemContent> <NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}> <Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
{shouldShowIcon && ( {shouldShowIcon &&
<Avatar size="200" radii="400"> (isDirect ? (
{showAvatar || avatarUrl ? ( <PresenceAvatar
<RoomAvatar presence={
roomId={room.roomId} dmUserId && dmUserId !== mx.getSafeUserId()
src={avatarUrl} ? dmPresence?.presence ?? Presence.Offline
alt={room.name} : Presence.Offline
renderFallback={() => ( }
<Text as="span" size="H6"> >
{nameInitials(room.name)} <Avatar size="200" radii="400">
</Text> {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 <Avatar size="200" radii="400">
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }} {showAvatar || avatarUrl ? (
filled={selected} <RoomAvatar
size="100" roomId={room.roomId}
joinRule={room.getJoinRule()} src={avatarUrl}
/> alt={room.name}
)} renderFallback={() => (
</Avatar> <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 }}> <Box as="span" grow="Yes" direction="Column" style={{ overflow: 'hidden', minWidth: 0 }}>
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate> <Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
{room.name} {room.name}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,17 @@
import React, { useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Avatar, Box, Button, config, IconButton, MenuItem, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds'; import {
Avatar,
Box,
Button,
config,
IconButton,
Input,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import { Icon, Icons, IconSrc } from '../../components/icons'; import { Icon, Icons, IconSrc } from '../../components/icons';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { General } from './general'; import { General } from './general';
@@ -22,18 +34,17 @@ import { Plugins } from './plugins';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog'; 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 { export { SettingsPages };
GeneralPage,
AccountPage,
NotificationPage,
AudioPage,
DevicesPage,
EmojisStickersPage,
PluginsPage,
DeveloperToolsPage,
AboutPage,
}
type SettingsMenuItem = { type SettingsMenuItem = {
page: SettingsPages; page: SettingsPages;
@@ -112,7 +123,37 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
if (initialPage) return initialPage; if (initialPage) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage; return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
}); });
const [searchQuery, setSearchQuery] = useState('');
const [focusAnchorId, setFocusAnchorId] = useState<string | undefined>();
const menuItems = useSettingsMenuItems(); 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 = () => { const handlePageRequestClose = () => {
if (screenSize === ScreenSize.Mobile) { if (screenSize === ScreenSize.Mobile) {
@@ -122,120 +163,244 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
requestClose(); requestClose();
}; };
return ( const openSearchResult = useCallback(
<PageRoot (page: SettingsPages, title: string) => {
nav={ const pageName = pageNameByPage.get(page);
screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : ( // Page-level entries just open the page; tile entries scroll into view.
<PageNav size="300"> if (title !== pageName) {
<PageNavHeader outlined={false}> setFocusAnchorId(settingAnchorId(title));
<Box grow="Yes" gap="200"> } else {
<Avatar size="200" radii="300"> setFocusAnchorId(undefined);
<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>
)
} }
> setActivePage(page);
{activePage === SettingsPages.GeneralPage && ( setSearchQuery('');
<General requestClose={handlePageRequestClose} /> },
)} [pageNameByPage]
{activePage === SettingsPages.AccountPage && ( );
<Account requestClose={handlePageRequestClose} />
)} // Fallback scroll if the page just mounted and tiles need a moment to render.
{activePage === SettingsPages.NotificationPage && ( useEffect(() => {
<Notifications requestClose={handlePageRequestClose} /> if (!focusAnchorId) return;
)} let cancelled = false;
{activePage === SettingsPages.AudioPage && ( let attempts = 0;
<Audio requestClose={handlePageRequestClose} />
)} const tryScroll = () => {
{activePage === SettingsPages.DevicesPage && ( if (cancelled) return;
<Devices requestClose={handlePageRequestClose} /> const el = document.getElementById(focusAnchorId);
)} if (el) {
{activePage === SettingsPages.EmojisStickersPage && ( el.scrollIntoView({ behavior: 'smooth', block: 'center' });
<EmojisStickers requestClose={handlePageRequestClose} /> return;
)} }
{activePage === SettingsPages.PluginsPage && ( if (attempts < 40) {
<Plugins requestClose={handlePageRequestClose} /> attempts += 1;
)} window.requestAnimationFrame(tryScroll);
{activePage === SettingsPages.DeveloperToolsPage && ( } else {
<DeveloperTools requestClose={handlePageRequestClose} /> setFocusAnchorId(undefined);
)} }
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />} };
</PageRoot>
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 { SequenceCardStyle } from '../styles.css';
import { pluginMarketplaceManager, pluginRegistry, SettingDefinition } from './PluginAPI'; import { pluginMarketplaceManager, pluginRegistry, SettingDefinition } from './PluginAPI';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { fuzzyFilter } from '../../../utils/fuzzy';
/** /**
* Renders settings UI for a plugin based on its settings schema * Renders settings UI for a plugin based on its settings schema
@@ -320,13 +321,6 @@ export function Plugins({ requestClose }: PluginsProps) {
[installedPlugins] [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 PluginAvatar = ({ thumbnail, name }: { thumbnail: string; name: string }) => {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
@@ -365,22 +359,22 @@ export function Plugins({ requestClose }: PluginsProps) {
}; };
const filteredMarketplacePlugins = useMemo(() => { const filteredMarketplacePlugins = useMemo(() => {
if (!searchQuery) return marketplacePlugins; if (!searchQuery.trim()) return marketplacePlugins;
return marketplacePlugins.filter((plugin) => return fuzzyFilter(marketplacePlugins, searchQuery, (plugin) => [
fuzzySearch(searchQuery, plugin.name) || plugin.name,
fuzzySearch(searchQuery, plugin.description) || plugin.description,
fuzzySearch(searchQuery, plugin.author) || plugin.author,
plugin.tags.some((tag) => fuzzySearch(searchQuery, tag)) ...plugin.tags,
); ]).map(({ item }) => item);
}, [marketplacePlugins, searchQuery]); }, [marketplacePlugins, searchQuery]);
const filteredInstalledPlugins = useMemo(() => { const filteredInstalledPlugins = useMemo(() => {
if (!searchQuery) return installedPlugins; if (!searchQuery.trim()) return installedPlugins;
return installedPlugins.filter((plugin) => return fuzzyFilter(installedPlugins, searchQuery, (plugin) => [
fuzzySearch(searchQuery, plugin.name) || plugin.name,
fuzzySearch(searchQuery, plugin.description) || plugin.description,
fuzzySearch(searchQuery, plugin.author) plugin.author,
); ]).map(({ item }) => item);
}, [installedPlugins, searchQuery]); }, [installedPlugins, searchQuery]);
return ( 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 { style } from '@vanilla-extract/css';
import { config } from 'folds'; import { color, config } from 'folds';
export const SequenceCardStyle = style({ export const SequenceCardStyle = style({
padding: config.space.S300, 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 { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt';
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { copyToClipboard } from '../../utils/dom'; import { copyToClipboard } from '../../utils/dom';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
@@ -32,6 +33,7 @@ import { useNavigate } from 'react-router-dom';
import { getSpaceFeedPath, getSpaceLobbyPath } from '../../pages/pathUtils'; import { getSpaceFeedPath, getSpaceLobbyPath } from '../../pages/pathUtils';
import { shouldShowForumLobby } from '../../utils/room'; import { shouldShowForumLobby } from '../../utils/room';
import { useSpaceFeedSelected } from '../../hooks/router/useSelectedSpace'; import { useSpaceFeedSelected } from '../../hooks/router/useSelectedSpace';
import { StateEvent } from '../../../types/matrix/room';
type ForumAddOptions = { type ForumAddOptions = {
item: HierarchyItem; item: HierarchyItem;
@@ -65,6 +67,7 @@ export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId()); const canInvite = permissions.action('invite', mx.getSafeUserId());
const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings(); const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const markRoomsAsRead = useMarkRoomsAsRead(mx); const markRoomsAsRead = useMarkRoomsAsRead(mx);
@@ -304,6 +307,34 @@ export const SpaceOptionsMenu = forwardRef<HTMLDivElement, SpaceOptionsMenuProps
</Box> </Box>
<Line variant="Surface" size="300" /> <Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <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}> <UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => ( {(promptLeave, setPromptLeave) => (
<> <>

View File

@@ -24,23 +24,30 @@ const getUserPresence = (user: User): UserPresence => ({
export const useUserPresence = (userId: string): UserPresence | undefined => { export const useUserPresence = (userId: string): UserPresence | undefined => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const user = mx.getUser(userId); const user = userId ? mx.getUser(userId) : null;
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
useEffect(() => { useEffect(() => {
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { if (!user) {
if (u.userId === user?.userId) { setPresence(undefined);
return undefined;
}
setPresence(getUserPresence(user));
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => {
if (u.userId === user.userId) {
setPresence(getUserPresence(user)); setPresence(getUserPresence(user));
} }
}; };
user?.on(UserEvent.Presence, updatePresence); user.on(UserEvent.Presence, updatePresence);
user?.on(UserEvent.CurrentlyActive, updatePresence); user.on(UserEvent.CurrentlyActive, updatePresence);
user?.on(UserEvent.LastPresenceTs, updatePresence); user.on(UserEvent.LastPresenceTs, updatePresence);
return () => { return () => {
user?.removeListener(UserEvent.Presence, updatePresence); user.removeListener(UserEvent.Presence, updatePresence);
user?.removeListener(UserEvent.CurrentlyActive, updatePresence); user.removeListener(UserEvent.CurrentlyActive, updatePresence);
user?.removeListener(UserEvent.LastPresenceTs, updatePresence); user.removeListener(UserEvent.LastPresenceTs, updatePresence);
}; };
}, [user]); }, [user]);

114
src/app/utils/fuzzy.ts Normal file
View File

@@ -0,0 +1,114 @@
export type FuzzyMatch = {
score: number;
indices: number[];
};
/**
* Subsequence fuzzy match with fzf-style scoring.
* All query characters must appear in order; higher scores rank first.
*/
export function fuzzyMatch(query: string, text: string): FuzzyMatch | null {
const q = query.trim().toLowerCase();
if (!q) return { score: 0, indices: [] };
const t = text.toLowerCase();
if (!t) return null;
// Fast path: contiguous substring
const substringIndex = t.indexOf(q);
if (substringIndex !== -1) {
const indices = Array.from({ length: q.length }, (_, i) => substringIndex + i);
// Prefer earlier matches and shorter haystacks
const score = 10_000 - substringIndex * 10 - (t.length - q.length);
return { score, indices };
}
let ti = 0;
const indices: number[] = [];
let score = 0;
let prevIndex = -1;
let consecutive = 0;
for (let qi = 0; qi < q.length; qi += 1) {
const ch = q[qi];
let found = -1;
for (; ti < t.length; ti += 1) {
if (t[ti] === ch) {
found = ti;
ti += 1;
break;
}
}
if (found === -1) return null;
indices.push(found);
// Consecutive bonus
if (prevIndex === found - 1) {
consecutive += 1;
score += 15 + consecutive * 5;
} else {
consecutive = 0;
score += 1;
}
// Word-boundary / start bonus
if (
found === 0 ||
t[found - 1] === ' ' ||
t[found - 1] === '-' ||
t[found - 1] === '_' ||
t[found - 1] === '&' ||
t[found - 1] === '/'
) {
score += 20;
}
// Prefer earlier matches
score -= found;
prevIndex = found;
}
// Prefer tighter overall spans
const span = indices[indices.length - 1] - indices[0] + 1;
score += Math.max(0, 50 - (span - q.length) * 2);
// Prefer shorter labels slightly
score -= Math.max(0, t.length - q.length) * 0.1;
return { score, indices };
}
/** Best fuzzy match across multiple strings (title, keywords, etc.). */
export function fuzzyMatchAny(query: string, texts: Array<string | undefined | null>): FuzzyMatch | null {
let best: FuzzyMatch | null = null;
for (const text of texts) {
if (!text) continue;
const match = fuzzyMatch(query, text);
if (match && (!best || match.score > best.score)) {
best = match;
}
}
return best;
}
export function fuzzyFilter<T>(
items: T[],
query: string,
getTexts: (item: T) => Array<string | undefined | null>
): Array<{ item: T; score: number }> {
const q = query.trim();
if (!q) {
return items.map((item) => ({ item, score: 0 }));
}
const results: Array<{ item: T; score: number }> = [];
for (const item of items) {
const match = fuzzyMatchAny(q, getTexts(item));
if (match) {
results.push({ item, score: match.score });
}
}
results.sort((a, b) => b.score - a.score);
return results;
}

View File

@@ -181,10 +181,8 @@ export function isValidChild(mEvent: MatrixEvent): boolean {
if (mEvent.getType() !== StateEvent.SpaceChild) return false; if (mEvent.getType() !== StateEvent.SpaceChild) return false;
const stateKey = mEvent.getStateKey(); const stateKey = mEvent.getStateKey();
if (!stateKey || !stateKey.startsWith('!')) return false; if (!stateKey || !stateKey.startsWith('!')) return false;
const { via } = mEvent.getContent<{ via?: string[] }>(); // via is required; omitting it (e.g. empty {}) removes the child per Matrix spec
// via is optional on m.space.child; only reject malformed values return Array.isArray(mEvent.getContent<{ via?: string[] }>().via);
if (via !== undefined && !Array.isArray(via)) return false;
return true;
} }
export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set<string> => { export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set<string> => {