hidden shit, and online status bars, search settings
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
|||||||
@@ -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 { 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 && (
|
||||||
|
|||||||
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 './SettingTile';
|
||||||
|
export * from './SettingsFocus';
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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 (
|
||||||
|
|||||||
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 { 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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
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;
|
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> => {
|
||||||
|
|||||||
Reference in New Issue
Block a user