hidden shit, and online status bars, search settings
This commit is contained in:
@@ -16,6 +16,8 @@ export const EditorOptions = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: config.space.S200,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -26,7 +28,7 @@ export const EditorTextarea = style([
|
||||
{
|
||||
flexGrow: 1,
|
||||
height: '100%',
|
||||
padding: `${toRem(13)} ${toRem(1)}`,
|
||||
padding: `${toRem(10)} ${toRem(1)}`,
|
||||
selectors: {
|
||||
[`${EditorTextareaScroll}:first-child &`]: {
|
||||
paddingLeft: toRem(13),
|
||||
@@ -54,7 +56,7 @@ export const EditorPlaceholderTextVisual = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'block',
|
||||
paddingTop: toRem(13),
|
||||
paddingTop: toRem(10),
|
||||
paddingLeft: toRem(1),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -243,7 +243,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||
{top}
|
||||
<Box
|
||||
alignItems="Start"
|
||||
alignItems={fillHeight ? 'Start' : 'Center'}
|
||||
grow={fillHeight ? 'Yes' : undefined}
|
||||
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
|
||||
>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
|
||||
radii="300"
|
||||
disabled={disableInline}
|
||||
>
|
||||
<Icon size="200" src={icon} />
|
||||
<Icon size="100" src={icon} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -95,7 +95,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={icon} />
|
||||
<Icon size="100" src={icon} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -152,7 +152,7 @@ export function HeadingBlockButton() {
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={Icons.Heading1} />
|
||||
<Icon size="100" src={Icons.Heading1} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -167,7 +167,7 @@ export function HeadingBlockButton() {
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={Icons.Heading2} />
|
||||
<Icon size="100" src={Icons.Heading2} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -182,7 +182,7 @@ export function HeadingBlockButton() {
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={Icons.Heading3} />
|
||||
<Icon size="100" src={Icons.Heading3} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -199,8 +199,8 @@ export function HeadingBlockButton() {
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
|
||||
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
|
||||
<Icon size="100" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
|
||||
<Icon size="100" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
);
|
||||
@@ -336,7 +336,7 @@ export function Toolbar() {
|
||||
radii="300"
|
||||
disabled={disableInline || !!isAnyMarkActive(editor)}
|
||||
>
|
||||
<Icon size="200" src={Icons.Markdown} filled={isMarkdown} />
|
||||
<Icon size="100" src={Icons.Markdown} filled={isMarkdown} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -29,8 +29,10 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(
|
||||
? { width: pixelSize, height: pixelSize, ...style }
|
||||
: style
|
||||
}
|
||||
strokeWidth={2}
|
||||
fill={fill ?? (filled ? 'currentColor' : 'none')}
|
||||
// Lucide icons are stroke-based; solid fill turns faces (e.g. Smile) into blobs.
|
||||
// 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}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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> = {
|
||||
'50': 10,
|
||||
'100': 12,
|
||||
'200': 16,
|
||||
'300': 20,
|
||||
'400': 24,
|
||||
'500': 28,
|
||||
'600': 32,
|
||||
'100': 13,
|
||||
'200': 17,
|
||||
'300': 21,
|
||||
'400': 25,
|
||||
'500': 29,
|
||||
'600': 34,
|
||||
Inherit: undefined,
|
||||
};
|
||||
|
||||
@@ -3,9 +3,8 @@ import { config } from 'folds';
|
||||
|
||||
export const Icon = recipe({
|
||||
base: {
|
||||
display: 'inline-block',
|
||||
display: 'block',
|
||||
flexShrink: 0,
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
|
||||
@@ -1,35 +1,76 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { CSSProperties, ReactElement, cloneElement, isValidElement } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './styles.css';
|
||||
import { config } from 'folds';
|
||||
import { Presence } from '../../hooks/useUserPresence';
|
||||
|
||||
type PresenceAvatarProps = {
|
||||
/** The presence state to display */
|
||||
presence?: Presence;
|
||||
/** The avatar element to wrap */
|
||||
children: ReactNode;
|
||||
/** Additional className */
|
||||
children: ReactElement;
|
||||
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.
|
||||
* Shows green for online, yellow/orange for unavailable/away, grey for offline.
|
||||
* Presence strip as a real sibling (not ::before).
|
||||
* Folds Avatar uses overflow:hidden, which clips outside ::before bars.
|
||||
*/
|
||||
export function PresenceAvatar({ presence, children, className }: PresenceAvatarProps) {
|
||||
if (!presence) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
export function PresenceAvatar({
|
||||
presence = Presence.Offline,
|
||||
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 (
|
||||
<div
|
||||
className={classNames(css.PresenceAvatarContainer, css.PresenceIndicator, className, {
|
||||
[css.PresenceOnline]: presence === Presence.Online,
|
||||
[css.PresenceUnavailable]: presence === Presence.Unavailable,
|
||||
[css.PresenceOffline]: presence === Presence.Offline,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
<div className={className} style={rowStyle} data-presence={presence}>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
display: 'block',
|
||||
width: 4,
|
||||
minWidth: 4,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, color } from 'folds';
|
||||
import { config } from 'folds';
|
||||
|
||||
export const AvatarPresence = style({
|
||||
display: 'flex',
|
||||
@@ -21,40 +21,67 @@ export const AvatarPresenceBadge = style({
|
||||
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',
|
||||
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': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: '10%',
|
||||
height: '80%',
|
||||
width: '3px',
|
||||
borderTopRightRadius: '2px',
|
||||
borderBottomRightRadius: '2px',
|
||||
left: '-4px',
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: '4px',
|
||||
borderTopLeftRadius: '5px',
|
||||
borderBottomLeftRadius: '5px',
|
||||
zIndex: 1,
|
||||
backgroundColor: '#454545',
|
||||
},
|
||||
selectors: {
|
||||
'& > *': {
|
||||
borderTopLeftRadius: '0 !important',
|
||||
borderBottomLeftRadius: '0 !important',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PresenceIndicator = style({});
|
||||
|
||||
export const PresenceOnline = style({
|
||||
'::before': {
|
||||
backgroundColor: color.Success.Main,
|
||||
selectors: {
|
||||
'&::before': {
|
||||
backgroundColor: '#38842b',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PresenceUnavailable = style({
|
||||
'::before': {
|
||||
backgroundColor: color.Warning.Main,
|
||||
selectors: {
|
||||
'&::before': {
|
||||
backgroundColor: '#959e30',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PresenceOffline = style({
|
||||
'::before': {
|
||||
backgroundColor: color.Secondary.Main,
|
||||
selectors: {
|
||||
'&::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({});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
1
src/app/components/remove-inaccessible-prompt/index.ts
Normal file
1
src/app/components/remove-inaccessible-prompt/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './RemoveInaccessiblePrompt';
|
||||
18
src/app/components/setting-tile/SettingTile.css.ts
Normal file
18
src/app/components/setting-tile/SettingTile.css.ts
Normal 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`,
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { settingAnchorId, useSettingsFocus } from './SettingsFocus';
|
||||
import { SettingTileFocus } from './SettingTile.css';
|
||||
|
||||
type SettingTileProps = {
|
||||
title?: ReactNode;
|
||||
@@ -8,10 +11,65 @@ type SettingTileProps = {
|
||||
before?: ReactNode;
|
||||
after?: 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 (
|
||||
<Box alignItems="Center" gap="300">
|
||||
<Box
|
||||
ref={ref}
|
||||
id={anchorId}
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
className={classNames(highlighted && SettingTileFocus)}
|
||||
>
|
||||
{before && <Box shrink="No">{before}</Box>}
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
{title && (
|
||||
|
||||
32
src/app/components/setting-tile/SettingsFocus.tsx
Normal file
32
src/app/components/setting-tile/SettingsFocus.tsx
Normal 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'}`;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './SettingTile';
|
||||
export * from './SettingsFocus';
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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) => (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
11
src/app/features/settings/SettingsPages.ts
Normal file
11
src/app/features/settings/SettingsPages.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export enum SettingsPages {
|
||||
GeneralPage,
|
||||
AccountPage,
|
||||
NotificationPage,
|
||||
AudioPage,
|
||||
DevicesPage,
|
||||
EmojisStickersPage,
|
||||
PluginsPage,
|
||||
DeveloperToolsPage,
|
||||
AboutPage,
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
137
src/app/features/settings/settingsSearchCatalog.ts
Normal file
137
src/app/features/settings/settingsSearchCatalog.ts
Normal 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'],
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) => (
|
||||
<>
|
||||
|
||||
@@ -24,23 +24,30 @@ const getUserPresence = (user: User): UserPresence => ({
|
||||
|
||||
export const useUserPresence = (userId: string): UserPresence | undefined => {
|
||||
const mx = useMatrixClient();
|
||||
const user = mx.getUser(userId);
|
||||
const user = userId ? mx.getUser(userId) : null;
|
||||
|
||||
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
||||
|
||||
useEffect(() => {
|
||||
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
|
||||
if (u.userId === user?.userId) {
|
||||
if (!user) {
|
||||
setPresence(undefined);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setPresence(getUserPresence(user));
|
||||
|
||||
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => {
|
||||
if (u.userId === user.userId) {
|
||||
setPresence(getUserPresence(user));
|
||||
}
|
||||
};
|
||||
user?.on(UserEvent.Presence, updatePresence);
|
||||
user?.on(UserEvent.CurrentlyActive, updatePresence);
|
||||
user?.on(UserEvent.LastPresenceTs, updatePresence);
|
||||
user.on(UserEvent.Presence, updatePresence);
|
||||
user.on(UserEvent.CurrentlyActive, updatePresence);
|
||||
user.on(UserEvent.LastPresenceTs, updatePresence);
|
||||
return () => {
|
||||
user?.removeListener(UserEvent.Presence, updatePresence);
|
||||
user?.removeListener(UserEvent.CurrentlyActive, updatePresence);
|
||||
user?.removeListener(UserEvent.LastPresenceTs, updatePresence);
|
||||
user.removeListener(UserEvent.Presence, updatePresence);
|
||||
user.removeListener(UserEvent.CurrentlyActive, updatePresence);
|
||||
user.removeListener(UserEvent.LastPresenceTs, updatePresence);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
|
||||
114
src/app/utils/fuzzy.ts
Normal file
114
src/app/utils/fuzzy.ts
Normal 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;
|
||||
}
|
||||
@@ -181,10 +181,8 @@ export function isValidChild(mEvent: MatrixEvent): boolean {
|
||||
if (mEvent.getType() !== StateEvent.SpaceChild) return false;
|
||||
const stateKey = mEvent.getStateKey();
|
||||
if (!stateKey || !stateKey.startsWith('!')) return false;
|
||||
const { via } = mEvent.getContent<{ via?: string[] }>();
|
||||
// via is optional on m.space.child; only reject malformed values
|
||||
if (via !== undefined && !Array.isArray(via)) return false;
|
||||
return true;
|
||||
// via is required; omitting it (e.g. empty {}) removes the child per Matrix spec
|
||||
return Array.isArray(mEvent.getContent<{ via?: string[] }>().via);
|
||||
}
|
||||
|
||||
export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set<string> => {
|
||||
|
||||
Reference in New Issue
Block a user