hidden shit, and online status bars, search settings

This commit is contained in:
2026-07-12 07:49:17 +10:00
parent 5374c10c61
commit 02d96a9758
31 changed files with 1323 additions and 265 deletions

View File

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

View File

@@ -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}
>

View File

@@ -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>

View File

@@ -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}
/>

View File

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

View File

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

View File

@@ -1,35 +1,76 @@
import React, { ReactNode } from 'react';
import React, { CSSProperties, ReactElement, cloneElement, isValidElement } from 'react';
import classNames from 'classnames';
import * 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>
);
}

View File

@@ -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({});

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { Box, Text } from 'folds';
import 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 && (

View File

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

View File

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