hidden shit, and online status bars, search settings
This commit is contained in:
@@ -16,6 +16,8 @@ export const EditorOptions = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: config.space.S200,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -26,7 +28,7 @@ export const EditorTextarea = style([
|
||||
{
|
||||
flexGrow: 1,
|
||||
height: '100%',
|
||||
padding: `${toRem(13)} ${toRem(1)}`,
|
||||
padding: `${toRem(10)} ${toRem(1)}`,
|
||||
selectors: {
|
||||
[`${EditorTextareaScroll}:first-child &`]: {
|
||||
paddingLeft: toRem(13),
|
||||
@@ -54,7 +56,7 @@ export const EditorPlaceholderTextVisual = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'block',
|
||||
paddingTop: toRem(13),
|
||||
paddingTop: toRem(10),
|
||||
paddingLeft: toRem(1),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -243,7 +243,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||
{top}
|
||||
<Box
|
||||
alignItems="Start"
|
||||
alignItems={fillHeight ? 'Start' : 'Center'}
|
||||
grow={fillHeight ? 'Yes' : undefined}
|
||||
style={fillHeight ? { width: '100%', minHeight: 0, flex: '1 1 auto' } : undefined}
|
||||
>
|
||||
|
||||
@@ -64,7 +64,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
|
||||
radii="300"
|
||||
disabled={disableInline}
|
||||
>
|
||||
<Icon size="200" src={icon} />
|
||||
<Icon size="100" src={icon} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -95,7 +95,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={icon} />
|
||||
<Icon size="100" src={icon} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -152,7 +152,7 @@ export function HeadingBlockButton() {
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={Icons.Heading1} />
|
||||
<Icon size="100" src={Icons.Heading1} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -167,7 +167,7 @@ export function HeadingBlockButton() {
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={Icons.Heading2} />
|
||||
<Icon size="100" src={Icons.Heading2} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -182,7 +182,7 @@ export function HeadingBlockButton() {
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={Icons.Heading3} />
|
||||
<Icon size="100" src={Icons.Heading3} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
@@ -199,8 +199,8 @@ export function HeadingBlockButton() {
|
||||
size="400"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="200" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
|
||||
<Icon size="200" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
|
||||
<Icon size="100" src={level ? Icons[`Heading${level}`] : Icons.Heading1} />
|
||||
<Icon size="100" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
);
|
||||
@@ -336,7 +336,7 @@ export function Toolbar() {
|
||||
radii="300"
|
||||
disabled={disableInline || !!isAnyMarkActive(editor)}
|
||||
>
|
||||
<Icon size="200" src={Icons.Markdown} filled={isMarkdown} />
|
||||
<Icon size="100" src={Icons.Markdown} filled={isMarkdown} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -29,8 +29,10 @@ export const Icon = forwardRef<SVGSVGElement, IconProps>(
|
||||
? { width: pixelSize, height: pixelSize, ...style }
|
||||
: style
|
||||
}
|
||||
strokeWidth={2}
|
||||
fill={fill ?? (filled ? 'currentColor' : 'none')}
|
||||
// Lucide icons are stroke-based; solid fill turns faces (e.g. Smile) into blobs.
|
||||
// Emphasize selection with a heavier stroke instead.
|
||||
strokeWidth={filled ? 2.75 : 2}
|
||||
fill={fill ?? 'none'}
|
||||
aria-hidden={props['aria-hidden'] ?? props['aria-label'] ? undefined : true}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { IconSize } from './types';
|
||||
|
||||
/** App chrome icons; ~4% above the original folds scale after +30% then -20%. */
|
||||
export const ICON_PIXEL_SIZES: Record<IconSize, number | undefined> = {
|
||||
'50': 10,
|
||||
'100': 12,
|
||||
'200': 16,
|
||||
'300': 20,
|
||||
'400': 24,
|
||||
'500': 28,
|
||||
'600': 32,
|
||||
'100': 13,
|
||||
'200': 17,
|
||||
'300': 21,
|
||||
'400': 25,
|
||||
'500': 29,
|
||||
'600': 34,
|
||||
Inherit: undefined,
|
||||
};
|
||||
|
||||
@@ -3,9 +3,8 @@ import { config } from 'folds';
|
||||
|
||||
export const Icon = recipe({
|
||||
base: {
|
||||
display: 'inline-block',
|
||||
display: 'block',
|
||||
flexShrink: 0,
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
|
||||
@@ -1,35 +1,76 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { CSSProperties, ReactElement, cloneElement, isValidElement } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './styles.css';
|
||||
import { config } from 'folds';
|
||||
import { Presence } from '../../hooks/useUserPresence';
|
||||
|
||||
type PresenceAvatarProps = {
|
||||
/** The presence state to display */
|
||||
presence?: Presence;
|
||||
/** The avatar element to wrap */
|
||||
children: ReactNode;
|
||||
/** Additional className */
|
||||
children: ReactElement;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const PRESENCE_COLOR: Record<Presence, string> = {
|
||||
[Presence.Online]: '#38842b',
|
||||
[Presence.Unavailable]: '#959e30',
|
||||
[Presence.Offline]: '#454545',
|
||||
};
|
||||
|
||||
const rowStyle: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'stretch',
|
||||
flexShrink: 0,
|
||||
lineHeight: 0,
|
||||
};
|
||||
|
||||
const faceStyle: CSSProperties = {
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
// Keep the right side rounded to match size-200 nav avatars.
|
||||
borderTopRightRadius: config.radii.R400,
|
||||
borderBottomRightRadius: config.radii.R400,
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps an avatar with a left-side presence indicator border.
|
||||
* Shows green for online, yellow/orange for unavailable/away, grey for offline.
|
||||
* Presence strip as a real sibling (not ::before).
|
||||
* Folds Avatar uses overflow:hidden, which clips outside ::before bars.
|
||||
*/
|
||||
export function PresenceAvatar({ presence, children, className }: PresenceAvatarProps) {
|
||||
if (!presence) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
export function PresenceAvatar({
|
||||
presence = Presence.Offline,
|
||||
children,
|
||||
className,
|
||||
}: PresenceAvatarProps) {
|
||||
const stripColor = PRESENCE_COLOR[presence] ?? PRESENCE_COLOR[Presence.Offline];
|
||||
|
||||
const face = isValidElement(children)
|
||||
? cloneElement(children, {
|
||||
className: classNames((children.props as { className?: string }).className),
|
||||
style: {
|
||||
...((children.props as { style?: CSSProperties }).style ?? {}),
|
||||
...faceStyle,
|
||||
},
|
||||
// Avoid the folds radii shorthand fighting the square-left corners.
|
||||
radii: '0',
|
||||
} as Partial<unknown>)
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(css.PresenceAvatarContainer, css.PresenceIndicator, className, {
|
||||
[css.PresenceOnline]: presence === Presence.Online,
|
||||
[css.PresenceUnavailable]: presence === Presence.Unavailable,
|
||||
[css.PresenceOffline]: presence === Presence.Offline,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
<div className={className} style={rowStyle} data-presence={presence}>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
display: 'block',
|
||||
width: 4,
|
||||
minWidth: 4,
|
||||
flex: '0 0 4px',
|
||||
alignSelf: 'stretch',
|
||||
// Match folds Avatar size="200" so the strip can't collapse to 0 height.
|
||||
minHeight: config.size.X200,
|
||||
borderRadius: '5px 0 0 5px',
|
||||
backgroundColor: stripColor,
|
||||
}}
|
||||
/>
|
||||
{face}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, color } from 'folds';
|
||||
import { config } from 'folds';
|
||||
|
||||
export const AvatarPresence = style({
|
||||
display: 'flex',
|
||||
@@ -21,40 +21,67 @@ export const AvatarPresenceBadge = style({
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const PresenceAvatarContainer = style({
|
||||
display: 'flex',
|
||||
/**
|
||||
* DM list avatar with flush left presence strip — same approach as message timeline.
|
||||
* Square left corners; ::before is the colored strip with outer radius.
|
||||
*/
|
||||
export const PresenceAvatarFace = style({
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
alignSelf: 'flex-start',
|
||||
// Room for the strip so overflow clipping / parent padding doesn't hide it.
|
||||
marginLeft: '4px',
|
||||
overflow: 'visible',
|
||||
borderTopLeftRadius: '0 !important',
|
||||
borderBottomLeftRadius: '0 !important',
|
||||
'::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: '10%',
|
||||
height: '80%',
|
||||
width: '3px',
|
||||
borderTopRightRadius: '2px',
|
||||
borderBottomRightRadius: '2px',
|
||||
left: '-4px',
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: '4px',
|
||||
borderTopLeftRadius: '5px',
|
||||
borderBottomLeftRadius: '5px',
|
||||
zIndex: 1,
|
||||
backgroundColor: '#454545',
|
||||
},
|
||||
selectors: {
|
||||
'& > *': {
|
||||
borderTopLeftRadius: '0 !important',
|
||||
borderBottomLeftRadius: '0 !important',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PresenceIndicator = style({});
|
||||
|
||||
export const PresenceOnline = style({
|
||||
'::before': {
|
||||
backgroundColor: color.Success.Main,
|
||||
selectors: {
|
||||
'&::before': {
|
||||
backgroundColor: '#38842b',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PresenceUnavailable = style({
|
||||
'::before': {
|
||||
backgroundColor: color.Warning.Main,
|
||||
selectors: {
|
||||
'&::before': {
|
||||
backgroundColor: '#959e30',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PresenceOffline = style({
|
||||
'::before': {
|
||||
backgroundColor: color.Secondary.Main,
|
||||
selectors: {
|
||||
'&::before': {
|
||||
backgroundColor: '#454545',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** @deprecated aliases kept for older imports */
|
||||
export const PresenceAvatarContainer = PresenceAvatarFace;
|
||||
export const PresenceAvatarRow = PresenceAvatarFace;
|
||||
export const PresenceStrip = style({});
|
||||
export const PresenceStripOnline = PresenceOnline;
|
||||
export const PresenceStripUnavailable = PresenceUnavailable;
|
||||
export const PresenceStripOffline = PresenceOffline;
|
||||
export const PresenceIndicator = style({});
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Dialog,
|
||||
Overlay,
|
||||
OverlayCenter,
|
||||
OverlayBackdrop,
|
||||
Header,
|
||||
config,
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
color,
|
||||
Button,
|
||||
Spinner,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { getSpaceChildren, getStateEvent, isSpace } from '../../utils/room';
|
||||
import { Membership, StateEvent } from '../../../types/matrix/room';
|
||||
import { fetchAllHierarchyRooms } from '../../hooks/useSpaceHierarchy';
|
||||
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||
import { IPowerLevels } from '../../hooks/usePowerLevels';
|
||||
|
||||
const REMOVE_DELAY_MS = 100;
|
||||
const PREVIEW_LIMIT = 20;
|
||||
|
||||
export type InaccessibleSpaceChild = {
|
||||
parentId: string;
|
||||
roomId: string;
|
||||
};
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function canEditSpaceChildren(mx: MatrixClient, spaceRoom: Room): boolean {
|
||||
const creators = getRoomCreatorsForRoomId(mx, spaceRoom.roomId);
|
||||
const powerLevelsEvent = getStateEvent(spaceRoom, StateEvent.RoomPowerLevels);
|
||||
const powerLevels = (powerLevelsEvent?.getContent() ?? {}) as IPowerLevels;
|
||||
const permissions = getRoomPermissionsAPI(creators, powerLevels);
|
||||
return permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId());
|
||||
}
|
||||
|
||||
export type InaccessibleScanResult = {
|
||||
inaccessible: InaccessibleSpaceChild[];
|
||||
/** Total valid m.space.child entries walked (editable spaces only). */
|
||||
childCount: number;
|
||||
/** Children skipped because the user is joined to them. */
|
||||
joinedCount: number;
|
||||
/** Unjoined children that hierarchy can still summarize (Joinable / not Inaccessible). */
|
||||
joinableCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects space children that appear as Unknown/Inaccessible in the lobby.
|
||||
* Matches lobby getRoom (joined-only): not joined + no hierarchy summary.
|
||||
* Walks nested subspaces the user can edit.
|
||||
*/
|
||||
export async function collectInaccessibleSpaceChildren(
|
||||
mx: MatrixClient,
|
||||
rootSpaceId: string
|
||||
): Promise<InaccessibleScanResult> {
|
||||
let hierarchySummaries = new Map<string, { room_id: string }>();
|
||||
try {
|
||||
const hierarchyRooms = await fetchAllHierarchyRooms(mx, rootSpaceId, 3);
|
||||
hierarchySummaries = new Map(hierarchyRooms.map((room) => [room.room_id, room]));
|
||||
} catch {
|
||||
// Hierarchy often fails for restricted spaces; treat missing summaries as inaccessible.
|
||||
hierarchySummaries = new Map();
|
||||
}
|
||||
|
||||
const inaccessible: InaccessibleSpaceChild[] = [];
|
||||
const visited = new Set<string>();
|
||||
let childCount = 0;
|
||||
let joinedCount = 0;
|
||||
let joinableCount = 0;
|
||||
|
||||
const walk = (spaceId: string) => {
|
||||
if (visited.has(spaceId)) return;
|
||||
visited.add(spaceId);
|
||||
|
||||
const spaceRoom = mx.getRoom(spaceId);
|
||||
if (!spaceRoom || spaceRoom.getMyMembership() !== Membership.Join) return;
|
||||
if (!canEditSpaceChildren(mx, spaceRoom)) return;
|
||||
|
||||
for (const childId of getSpaceChildren(spaceRoom)) {
|
||||
childCount += 1;
|
||||
const childRoom = mx.getRoom(childId);
|
||||
// Lobby uses joined-only getRoom; left/invite rooms still render as Inaccessible.
|
||||
const joined = childRoom?.getMyMembership() === Membership.Join;
|
||||
|
||||
if (joined && childRoom && isSpace(childRoom)) {
|
||||
joinedCount += 1;
|
||||
walk(childId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (joined) {
|
||||
joinedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasHierarchySummary = hierarchySummaries.has(childId);
|
||||
if (hasHierarchySummary) {
|
||||
joinableCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
inaccessible.push({ parentId: spaceId, roomId: childId });
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootSpaceId);
|
||||
return { inaccessible, childCount, joinedCount, joinableCount };
|
||||
}
|
||||
|
||||
async function removeSpaceChildrenSequentially(
|
||||
mx: MatrixClient,
|
||||
children: InaccessibleSpaceChild[],
|
||||
onProgress?: (current: number, total: number) => void
|
||||
): Promise<void> {
|
||||
const total = children.length;
|
||||
|
||||
for (let i = 0; i < children.length; i += 1) {
|
||||
const { parentId, roomId } = children[i];
|
||||
onProgress?.(i, total);
|
||||
|
||||
try {
|
||||
await mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId);
|
||||
await delay(REMOVE_DELAY_MS);
|
||||
} catch {
|
||||
// Continue removing others even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(total, total);
|
||||
}
|
||||
|
||||
type RemoveInaccessiblePromptProps = {
|
||||
roomId: string;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function RemoveInaccessiblePrompt({
|
||||
roomId,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: RemoveInaccessiblePromptProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
|
||||
const [scanResult, setScanResult] = useState<InaccessibleScanResult | null>(null);
|
||||
|
||||
const [scanState, scan] = useAsyncCallback<InaccessibleScanResult, MatrixError | Error, []>(
|
||||
useCallback(async () => collectInaccessibleSpaceChildren(mx, roomId), [mx, roomId])
|
||||
);
|
||||
|
||||
const children = scanResult?.inaccessible ?? null;
|
||||
|
||||
const [removeState, removeChildren] = useAsyncCallback<undefined, MatrixError | Error, []>(
|
||||
useCallback(async () => {
|
||||
if (!children || children.length === 0) return;
|
||||
await removeSpaceChildrenSequentially(mx, children, (current, total) => {
|
||||
setProgress({ current, total });
|
||||
});
|
||||
setProgress(null);
|
||||
}, [mx, children])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
scan();
|
||||
}, [scan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scanState.status === AsyncStatus.Success) {
|
||||
setScanResult(scanState.data);
|
||||
}
|
||||
}, [scanState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (removeState.status === AsyncStatus.Success) {
|
||||
onDone();
|
||||
}
|
||||
}, [removeState, onDone]);
|
||||
|
||||
const previewIds = useMemo(() => (children ?? []).slice(0, PREVIEW_LIMIT).map((c) => c.roomId), [children]);
|
||||
const isScanning = scanState.status === AsyncStatus.Loading || scanState.status === AsyncStatus.Idle;
|
||||
const isRemoving = removeState.status === AsyncStatus.Loading;
|
||||
const count = children?.length ?? 0;
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isRemoving && progress) return `Removing: ${progress.current}/${progress.total}`;
|
||||
if (isRemoving) return 'Removing...';
|
||||
if (count === 0) return 'Nothing to Remove';
|
||||
return `Remove ${count} Room${count === 1 ? '' : 's'}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: onCancel,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface">
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="H4">Remove Inaccessible</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300" disabled={isRemoving}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Box direction="Column" gap="200">
|
||||
{isScanning && (
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Spinner size="200" />
|
||||
<Text priority="400">Scanning space hierarchy for inaccessible rooms…</Text>
|
||||
</Box>
|
||||
)}
|
||||
{scanState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
Failed to scan space. {scanState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
{scanState.status === AsyncStatus.Success && count === 0 && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text priority="400">No inaccessible rooms found in this space.</Text>
|
||||
{scanResult && (
|
||||
<Text size="T200" priority="300">
|
||||
Scanned {scanResult.childCount} child
|
||||
{scanResult.childCount === 1 ? '' : 'ren'}: {scanResult.joinedCount} joined,{' '}
|
||||
{scanResult.joinableCount} joinable via hierarchy.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{scanState.status === AsyncStatus.Success && count > 0 && (
|
||||
<>
|
||||
<Text priority="400">
|
||||
Remove {count} inaccessible room{count === 1 ? '' : 's'} from this space? This
|
||||
only unlinks them from the space; it does not leave or delete rooms.
|
||||
</Text>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{
|
||||
maxHeight: toRem(200),
|
||||
overflow: 'auto',
|
||||
padding: config.space.S200,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
}}
|
||||
>
|
||||
{previewIds.map((id) => (
|
||||
<Text key={id} size="T200" style={{ wordBreak: 'break-all' }}>
|
||||
{id}
|
||||
</Text>
|
||||
))}
|
||||
{count > PREVIEW_LIMIT && (
|
||||
<Text size="T200" priority="300">
|
||||
…and {count - PREVIEW_LIMIT} more
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{removeState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
Failed to remove some rooms. {removeState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="Critical"
|
||||
onClick={() => removeChildren()}
|
||||
before={
|
||||
isRemoving || isScanning ? (
|
||||
<Spinner fill="Solid" variant="Critical" size="200" />
|
||||
) : undefined
|
||||
}
|
||||
disabled={
|
||||
isScanning ||
|
||||
isRemoving ||
|
||||
count === 0 ||
|
||||
removeState.status === AsyncStatus.Success
|
||||
}
|
||||
>
|
||||
<Text size="B400">{getButtonText()}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
1
src/app/components/remove-inaccessible-prompt/index.ts
Normal file
1
src/app/components/remove-inaccessible-prompt/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './RemoveInaccessiblePrompt';
|
||||
18
src/app/components/setting-tile/SettingTile.css.ts
Normal file
18
src/app/components/setting-tile/SettingTile.css.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { color, config } from 'folds';
|
||||
|
||||
const focusPulse = keyframes({
|
||||
'0%': {
|
||||
outlineColor: color.Primary.Main,
|
||||
},
|
||||
'100%': {
|
||||
outlineColor: 'transparent',
|
||||
},
|
||||
});
|
||||
|
||||
export const SettingTileFocus = style({
|
||||
borderRadius: config.radii.R400,
|
||||
outline: `${config.borderWidth.B300} solid ${color.Primary.Main}`,
|
||||
outlineOffset: config.space.S100,
|
||||
animation: `${focusPulse} 1.2s ease-out forwards`,
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { settingAnchorId, useSettingsFocus } from './SettingsFocus';
|
||||
import { SettingTileFocus } from './SettingTile.css';
|
||||
|
||||
type SettingTileProps = {
|
||||
title?: ReactNode;
|
||||
@@ -8,10 +11,65 @@ type SettingTileProps = {
|
||||
before?: ReactNode;
|
||||
after?: ReactNode;
|
||||
children?: ReactNode;
|
||||
/** Override auto-generated scroll anchor. */
|
||||
anchorId?: string;
|
||||
};
|
||||
export function SettingTile({ title, description, before, after, children }: SettingTileProps) {
|
||||
export function SettingTile({
|
||||
title,
|
||||
description,
|
||||
before,
|
||||
after,
|
||||
children,
|
||||
anchorId: anchorIdProp,
|
||||
}: SettingTileProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { anchorId: focusAnchorId, setAnchorId } = useSettingsFocus();
|
||||
const [highlighted, setHighlighted] = useState(false);
|
||||
|
||||
const autoAnchorId = typeof title === 'string' ? settingAnchorId(title) : undefined;
|
||||
const anchorId = anchorIdProp ?? autoAnchorId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchorId || !focusAnchorId || anchorId !== focusAnchorId) return;
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
|
||||
const run = () => {
|
||||
if (cancelled) return;
|
||||
const node = ref.current;
|
||||
if (node) {
|
||||
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setHighlighted(true);
|
||||
setAnchorId(undefined);
|
||||
return;
|
||||
}
|
||||
if (attempts < 40) {
|
||||
attempts += 1;
|
||||
window.requestAnimationFrame(run);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = window.setTimeout(run, 50);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [anchorId, focusAnchorId, setAnchorId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlighted) return;
|
||||
const timer = window.setTimeout(() => setHighlighted(false), 1400);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [highlighted]);
|
||||
|
||||
return (
|
||||
<Box alignItems="Center" gap="300">
|
||||
<Box
|
||||
ref={ref}
|
||||
id={anchorId}
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
className={classNames(highlighted && SettingTileFocus)}
|
||||
>
|
||||
{before && <Box shrink="No">{before}</Box>}
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
{title && (
|
||||
|
||||
32
src/app/components/setting-tile/SettingsFocus.tsx
Normal file
32
src/app/components/setting-tile/SettingsFocus.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type SettingsFocus = {
|
||||
/** Stable anchor id for a setting tile (from settingAnchorId). */
|
||||
anchorId?: string;
|
||||
setAnchorId: (anchorId: string | undefined) => void;
|
||||
};
|
||||
|
||||
const SettingsFocusContext = createContext<SettingsFocus | null>(null);
|
||||
|
||||
export const SettingsFocusProvider = SettingsFocusContext.Provider;
|
||||
|
||||
export const useSettingsFocus = (): SettingsFocus => {
|
||||
const ctx = useContext(SettingsFocusContext);
|
||||
if (!ctx) {
|
||||
return {
|
||||
anchorId: undefined,
|
||||
setAnchorId: () => undefined,
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
/** Stable DOM id for a settings tile title. */
|
||||
export function settingAnchorId(title: string): string {
|
||||
const slug = title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
return `settings-item-${slug || 'untitled'}`;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './SettingTile';
|
||||
export * from './SettingsFocus';
|
||||
|
||||
Reference in New Issue
Block a user