diff --git a/package.json b/package.json index e34aa9f..a7580d4 100644 --- a/package.json +++ b/package.json @@ -122,5 +122,9 @@ "vite-plugin-pwa": "1.3.0", "vite-plugin-static-copy": "4.1.1", "vite-plugin-top-level-await": "1.6.0" + }, + "allowScripts": { + "@swc/core@1.15.43": true, + "esbuild@0.28.1": true } } diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index d128ed0..f01559e 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -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), }, ]); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index c477836..1902c2a 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -243,7 +243,7 @@ export const CustomEditor = forwardRef( {top} diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx index 6f7f27e..6b1320a 100644 --- a/src/app/components/editor/Toolbar.tsx +++ b/src/app/components/editor/Toolbar.tsx @@ -64,7 +64,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) { radii="300" disabled={disableInline} > - + )} @@ -95,7 +95,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) { size="400" radii="300" > - + )} @@ -152,7 +152,7 @@ export function HeadingBlockButton() { size="400" radii="300" > - + )} @@ -167,7 +167,7 @@ export function HeadingBlockButton() { size="400" radii="300" > - + )} @@ -182,7 +182,7 @@ export function HeadingBlockButton() { size="400" radii="300" > - + )} @@ -199,8 +199,8 @@ export function HeadingBlockButton() { size="400" radii="300" > - - + + ); @@ -336,7 +336,7 @@ export function Toolbar() { radii="300" disabled={disableInline || !!isAnyMarkActive(editor)} > - + )} diff --git a/src/app/components/icons/Icon.tsx b/src/app/components/icons/Icon.tsx index c6f60da..a0695f5 100644 --- a/src/app/components/icons/Icon.tsx +++ b/src/app/components/icons/Icon.tsx @@ -29,8 +29,10 @@ export const Icon = forwardRef( ? { 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} /> diff --git a/src/app/components/icons/iconSizes.ts b/src/app/components/icons/iconSizes.ts index 4220c9c..f614b4d 100644 --- a/src/app/components/icons/iconSizes.ts +++ b/src/app/components/icons/iconSizes.ts @@ -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 = { '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, }; diff --git a/src/app/components/icons/style.css.ts b/src/app/components/icons/style.css.ts index 3ce3a01..40ae8b0 100644 --- a/src/app/components/icons/style.css.ts +++ b/src/app/components/icons/style.css.ts @@ -3,9 +3,8 @@ import { config } from 'folds'; export const Icon = recipe({ base: { - display: 'inline-block', + display: 'block', flexShrink: 0, - verticalAlign: 'middle', }, variants: { size: { diff --git a/src/app/components/presence/PresenceAvatar.tsx b/src/app/components/presence/PresenceAvatar.tsx index fca5045..deef763 100644 --- a/src/app/components/presence/PresenceAvatar.tsx +++ b/src/app/components/presence/PresenceAvatar.tsx @@ -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.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) + : children; return ( -
- {children} +
+ + {face}
); } diff --git a/src/app/components/presence/styles.css.ts b/src/app/components/presence/styles.css.ts index fd28178..147726a 100644 --- a/src/app/components/presence/styles.css.ts +++ b/src/app/components/presence/styles.css.ts @@ -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({}); diff --git a/src/app/components/remove-inaccessible-prompt/RemoveInaccessiblePrompt.tsx b/src/app/components/remove-inaccessible-prompt/RemoveInaccessiblePrompt.tsx new file mode 100644 index 0000000..2b4df78 --- /dev/null +++ b/src/app/components/remove-inaccessible-prompt/RemoveInaccessiblePrompt.tsx @@ -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 { + 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 { + let hierarchySummaries = new Map(); + 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(); + 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 { + 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(null); + + const [scanState, scan] = useAsyncCallback( + useCallback(async () => collectInaccessibleSpaceChildren(mx, roomId), [mx, roomId]) + ); + + const children = scanResult?.inaccessible ?? null; + + const [removeState, removeChildren] = useAsyncCallback( + 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 ( + }> + + + +
+ + Remove Inaccessible + + + + +
+ + + {isScanning && ( + + + Scanning space hierarchy for inaccessible rooms… + + )} + {scanState.status === AsyncStatus.Error && ( + + Failed to scan space. {scanState.error.message} + + )} + {scanState.status === AsyncStatus.Success && count === 0 && ( + + No inaccessible rooms found in this space. + {scanResult && ( + + Scanned {scanResult.childCount} child + {scanResult.childCount === 1 ? '' : 'ren'}: {scanResult.joinedCount} joined,{' '} + {scanResult.joinableCount} joinable via hierarchy. + + )} + + )} + {scanState.status === AsyncStatus.Success && count > 0 && ( + <> + + Remove {count} inaccessible room{count === 1 ? '' : 's'} from this space? This + only unlinks them from the space; it does not leave or delete rooms. + + + {previewIds.map((id) => ( + + {id} + + ))} + {count > PREVIEW_LIMIT && ( + + …and {count - PREVIEW_LIMIT} more + + )} + + + )} + {removeState.status === AsyncStatus.Error && ( + + Failed to remove some rooms. {removeState.error.message} + + )} + + + +
+
+
+
+ ); +} diff --git a/src/app/components/remove-inaccessible-prompt/index.ts b/src/app/components/remove-inaccessible-prompt/index.ts new file mode 100644 index 0000000..e478b10 --- /dev/null +++ b/src/app/components/remove-inaccessible-prompt/index.ts @@ -0,0 +1 @@ +export * from './RemoveInaccessiblePrompt'; diff --git a/src/app/components/setting-tile/SettingTile.css.ts b/src/app/components/setting-tile/SettingTile.css.ts new file mode 100644 index 0000000..d06085b --- /dev/null +++ b/src/app/components/setting-tile/SettingTile.css.ts @@ -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`, +}); diff --git a/src/app/components/setting-tile/SettingTile.tsx b/src/app/components/setting-tile/SettingTile.tsx index bcf6334..9065b78 100644 --- a/src/app/components/setting-tile/SettingTile.tsx +++ b/src/app/components/setting-tile/SettingTile.tsx @@ -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(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 ( - + {before && {before}} {title && ( diff --git a/src/app/components/setting-tile/SettingsFocus.tsx b/src/app/components/setting-tile/SettingsFocus.tsx new file mode 100644 index 0000000..3c66513 --- /dev/null +++ b/src/app/components/setting-tile/SettingsFocus.tsx @@ -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(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'}`; +} diff --git a/src/app/components/setting-tile/index.ts b/src/app/components/setting-tile/index.ts index 49fede7..f087b2b 100644 --- a/src/app/components/setting-tile/index.ts +++ b/src/app/components/setting-tile/index.ts @@ -1 +1,2 @@ export * from './SettingTile'; +export * from './SettingsFocus'; diff --git a/src/app/features/forum/ForumChatComposer.tsx b/src/app/features/forum/ForumChatComposer.tsx index dbe41b8..7e0ac0c 100644 --- a/src/app/features/forum/ForumChatComposer.tsx +++ b/src/app/features/forum/ForumChatComposer.tsx @@ -114,7 +114,7 @@ export function ForumChatComposer({ onClick={() => setToolbar(!toolbar)} aria-label="Formatting" > - + {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( @@ -158,7 +158,11 @@ export function ForumChatComposer({ type="button" aria-label="Emoji" > - + )} @@ -173,7 +177,7 @@ export function ForumChatComposer({ radii="300" aria-label="Send" > - + )} diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index 28d67cf..0a96a14 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -14,6 +14,7 @@ import * as css from './LobbyHeader.css'; import { IPowerLevels } from '../../hooks/usePowerLevels'; import { UseStateProvider } from '../../components/UseStateProvider'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; +import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt'; import { stopPropagation } from '../../utils/keyboard'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { useBackRoute } from '../../hooks/useBackRoute'; @@ -48,6 +49,7 @@ const LobbyMenu = forwardRef( const permissions = useRoomPermissions(creators, powerLevels); const canInvite = permissions.action('invite', mx.getSafeUserId()); + const canEditChildren = permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()); const openSpaceSettings = useOpenSpaceSettings(); const [invitePrompt, setInvitePrompt] = useState(false); @@ -117,6 +119,34 @@ const LobbyMenu = forwardRef( + {canEditChildren && ( + + {(promptRemove, setPromptRemove) => ( + <> + setPromptRemove(true)} + variant="Critical" + fill="None" + size="300" + after={} + radii="300" + aria-pressed={promptRemove} + > + + Remove Inaccessible + + + {promptRemove && ( + setPromptRemove(false)} + /> + )} + + )} + + )} {(promptLeave, setPromptLeave) => ( <> diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index 473401a..e6cad7f 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -133,7 +133,11 @@ export const SpaceHierarchy = forwardRef( }, [mx, roomItems]); let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId) && !globalSubRoomIds.has(i.roomId)); - if (!spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())) { + // Only hide when we know the user cannot edit children. If power levels are + // still loading (spacePermissions undefined), keep inaccessible rows visible + // so admins can clean them up. + const canEditChild = spacePermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()); + if (spacePermissions && !canEditChild) { // hide unknown rooms for normal user childItems = childItems?.filter((i) => { const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false; diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 6ec0f42..ae439e5 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -24,7 +24,7 @@ import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; import { TypingIndicator } from '../../components/typing-indicator'; import { stopPropagation } from '../../utils/keyboard'; import { getMatrixToRoom } from '../../plugins/matrix-to'; -import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix'; +import { getCanonicalAliasOrRoomId, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix'; import { getViaServers } from '../../plugins/via-servers'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; @@ -46,6 +46,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents'; import { StateEvent } from '../../../types/matrix/room'; import { mDirectAtom } from '../../state/mDirectList'; import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal'; +import { PresenceAvatar } from '../../components/presence'; +import { Presence, useUserPresence } from '../../hooks/useUserPresence'; type RoomNavItemMenuProps = { room: Room; @@ -289,13 +291,17 @@ export function RoomNavItem({ const avatarUrl = direct ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication); + + const mDirects = useAtomValue(mDirectAtom); + const isDirect = Boolean(direct || mDirects.has(room.roomId)); + const dmUserId = isDirect ? guessDmRoomUserId(room, mx.getSafeUserId()) : undefined; + const dmPresence = useUserPresence(dmUserId ?? ''); const shouldShowIcon = !hideIcon || (hideIcon && avatarUrl); const typingMember = useRoomTypingMember(room.roomId).filter( (receipt) => receipt.userId !== mx.getUserId() ); const roomToParents = useAtomValue(roomToParentsAtom); - const mDirects = useAtomValue(mDirectAtom); // Get first participated thread for preview const firstThreadPreview = useMemo(() => { @@ -317,7 +323,7 @@ export function RoomNavItem({ // Get parent space for DMs const parentSpaceInfo = (() => { - if (!direct || !mDirects.has(room.roomId)) return undefined; + if (!isDirect || !mDirects.has(room.roomId)) return undefined; const orphanParents = getOrphanParents(roomToParents, room.roomId); if (orphanParents.length === 0) return undefined; @@ -383,29 +389,60 @@ export function RoomNavItem({ - {shouldShowIcon && ( - - {showAvatar || avatarUrl ? ( - ( - - {nameInitials(room.name)} - - )} - /> + {shouldShowIcon && + (isDirect ? ( + + + {showAvatar || avatarUrl ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + ) : ( - - )} - - )} + + {showAvatar || avatarUrl ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + ))} {room.name} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 3735fc2..e4e582e 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -764,7 +764,7 @@ export const RoomInput = forwardRef( size="300" radii="300" > - + @@ -777,7 +777,7 @@ export const RoomInput = forwardRef( radii="300" onClick={() => setToolbar(!toolbar)} > - + {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( @@ -822,6 +822,7 @@ export const RoomInput = forwardRef( > @@ -830,7 +831,7 @@ export const RoomInput = forwardRef( - + } diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 2971d09..f5dc78f 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -419,7 +419,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) { {isConnecting ? ( ) : ( - + )} )} @@ -445,7 +445,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) { {isConnecting ? ( ) : ( - + )} )} @@ -507,7 +507,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { {showInPageHeader && ( - + )} @@ -593,7 +593,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { > {(triggerRef) => ( - + )} @@ -631,7 +631,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { )} - + )} @@ -666,7 +666,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { > {(triggerRef) => ( setPeopleDrawer((drawer) => !drawer)}> - + )} @@ -686,7 +686,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { onClick={() => setActiveThreadId(undefined)} aria-pressed={!!activeThreadId} > - + )} @@ -703,7 +703,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { > {(triggerRef) => ( - + )} diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index a598d60..057a419 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -282,7 +282,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( radii="300" onClick={() => setToolbar(!toolbar)} > - + {(anchor: RectCords | undefined, setAnchor) => ( @@ -321,7 +321,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( size="300" radii="300" > - + )} diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index befaa9d..26fa823 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -1,5 +1,17 @@ -import React, { useMemo, useState } from 'react'; -import { Avatar, Box, Button, config, IconButton, MenuItem, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Avatar, + Box, + Button, + config, + IconButton, + Input, + MenuItem, + Overlay, + OverlayBackdrop, + OverlayCenter, + Text, +} from 'folds'; import { Icon, Icons, IconSrc } from '../../components/icons'; import FocusTrap from 'focus-trap-react'; import { General } from './general'; @@ -22,18 +34,17 @@ import { Plugins } from './plugins'; import { UseStateProvider } from '../../components/UseStateProvider'; import { stopPropagation } from '../../utils/keyboard'; import { LogoutDialog } from '../../components/LogoutDialog'; +import { fuzzyFilter } from '../../utils/fuzzy'; +import { SETTINGS_SEARCH_CATALOG } from './settingsSearchCatalog'; +import { SettingsPages } from './SettingsPages'; +import { SettingsSearchResult } from './styles.css'; +import { BreakWord } from '../../styles/Text.css'; +import { + SettingsFocusProvider, + settingAnchorId, +} from '../../components/setting-tile'; -export enum SettingsPages { - GeneralPage, - AccountPage, - NotificationPage, - AudioPage, - DevicesPage, - EmojisStickersPage, - PluginsPage, - DeveloperToolsPage, - AboutPage, -} +export { SettingsPages }; type SettingsMenuItem = { page: SettingsPages; @@ -112,7 +123,37 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { if (initialPage) return initialPage; return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage; }); + const [searchQuery, setSearchQuery] = useState(''); + const [focusAnchorId, setFocusAnchorId] = useState(); const menuItems = useSettingsMenuItems(); + const pageNameByPage = useMemo(() => { + const map = new Map(); + menuItems.forEach((item) => map.set(item.page, item.name)); + return map; + }, [menuItems]); + const iconByPage = useMemo(() => { + const map = new Map(); + menuItems.forEach((item) => map.set(item.page, item.icon)); + return map; + }, [menuItems]); + + const searchResults = useMemo(() => { + const q = searchQuery.trim(); + if (!q) return []; + return fuzzyFilter(SETTINGS_SEARCH_CATALOG, q, (entry) => [ + entry.title, + pageNameByPage.get(entry.page), + ...(entry.keywords ?? []), + ]).slice(0, 40); + }, [pageNameByPage, searchQuery]); + + const settingsFocus = useMemo( + () => ({ + anchorId: focusAnchorId, + setAnchorId: setFocusAnchorId, + }), + [focusAnchorId] + ); const handlePageRequestClose = () => { if (screenSize === ScreenSize.Mobile) { @@ -122,120 +163,244 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { requestClose(); }; - return ( - - - - - {nameInitials(displayName)}} - /> - - - Settings - - - - {screenSize === ScreenSize.Mobile && ( - - - - )} - - - - -
- {menuItems.map((item) => ( - } - onClick={() => setActivePage(item.page)} - > - - {item.name} - - - ))} -
-
- - - {(logout, setLogout) => ( - <> - - {logout && ( - }> - - setLogout(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - setLogout(false)} /> - - - - )} - - )} - - -
- - ) + const openSearchResult = useCallback( + (page: SettingsPages, title: string) => { + const pageName = pageNameByPage.get(page); + // Page-level entries just open the page; tile entries scroll into view. + if (title !== pageName) { + setFocusAnchorId(settingAnchorId(title)); + } else { + setFocusAnchorId(undefined); } - > - {activePage === SettingsPages.GeneralPage && ( - - )} - {activePage === SettingsPages.AccountPage && ( - - )} - {activePage === SettingsPages.NotificationPage && ( - - )} - {activePage === SettingsPages.AudioPage && ( -
+ setActivePage(page); + setSearchQuery(''); + }, + [pageNameByPage] + ); + + // Fallback scroll if the page just mounted and tiles need a moment to render. + useEffect(() => { + if (!focusAnchorId) return; + let cancelled = false; + let attempts = 0; + + const tryScroll = () => { + if (cancelled) return; + const el = document.getElementById(focusAnchorId); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return; + } + if (attempts < 40) { + attempts += 1; + window.requestAnimationFrame(tryScroll); + } else { + setFocusAnchorId(undefined); + } + }; + + const timer = window.setTimeout(tryScroll, 50); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [focusAnchorId, activePage]); + + const isSearching = searchQuery.trim().length > 0; + + return ( + + + + + + {nameInitials(displayName)}} + /> + + + Settings + + + + {screenSize === ScreenSize.Mobile && ( + + + + )} + + + + + setSearchQuery(evt.target.value)} + before={} + after={ + searchQuery ? ( + setSearchQuery('')} + aria-label="Clear search" + > + + + ) : undefined + } + /> + + +
+ {isSearching ? ( + searchResults.length > 0 ? ( + searchResults.map(({ item }) => ( + + )) + ) : ( + + + No settings match “{searchQuery.trim()}” + + + ) + ) : ( + menuItems.map((item) => ( + + } + onClick={() => setActivePage(item.page)} + > + + {item.name} + + + )) + )} +
+
+ + + {(logout, setLogout) => ( + <> + + {logout && ( + }> + + setLogout(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + setLogout(false)} /> + + + + )} + + )} + + +
+ + ) + } + > + {activePage === SettingsPages.GeneralPage && ( + + )} + {activePage === SettingsPages.AccountPage && ( + + )} + {activePage === SettingsPages.NotificationPage && ( + + )} + {activePage === SettingsPages.AudioPage && ( +
+
); } diff --git a/src/app/features/settings/SettingsPages.ts b/src/app/features/settings/SettingsPages.ts new file mode 100644 index 0000000..3bec912 --- /dev/null +++ b/src/app/features/settings/SettingsPages.ts @@ -0,0 +1,11 @@ +export enum SettingsPages { + GeneralPage, + AccountPage, + NotificationPage, + AudioPage, + DevicesPage, + EmojisStickersPage, + PluginsPage, + DeveloperToolsPage, + AboutPage, +} diff --git a/src/app/features/settings/plugins/Plugins.tsx b/src/app/features/settings/plugins/Plugins.tsx index a1e812c..b182421 100644 --- a/src/app/features/settings/plugins/Plugins.tsx +++ b/src/app/features/settings/plugins/Plugins.tsx @@ -10,6 +10,7 @@ import { SettingTile } from '../../../components/setting-tile'; import { SequenceCardStyle } from '../styles.css'; import { pluginMarketplaceManager, pluginRegistry, SettingDefinition } from './PluginAPI'; import { stopPropagation } from '../../../utils/keyboard'; +import { fuzzyFilter } from '../../../utils/fuzzy'; /** * Renders settings UI for a plugin based on its settings schema @@ -320,13 +321,6 @@ export function Plugins({ requestClose }: PluginsProps) { [installedPlugins] ); - const fuzzySearch = (query: string, text: string): boolean => { - if (!query) return true; - const searchLower = query.toLowerCase(); - const textLower = text.toLowerCase(); - return textLower.includes(searchLower); - }; - const PluginAvatar = ({ thumbnail, name }: { thumbnail: string; name: string }) => { const [imageError, setImageError] = useState(false); @@ -365,22 +359,22 @@ export function Plugins({ requestClose }: PluginsProps) { }; const filteredMarketplacePlugins = useMemo(() => { - if (!searchQuery) return marketplacePlugins; - return marketplacePlugins.filter((plugin) => - fuzzySearch(searchQuery, plugin.name) || - fuzzySearch(searchQuery, plugin.description) || - fuzzySearch(searchQuery, plugin.author) || - plugin.tags.some((tag) => fuzzySearch(searchQuery, tag)) - ); + if (!searchQuery.trim()) return marketplacePlugins; + return fuzzyFilter(marketplacePlugins, searchQuery, (plugin) => [ + plugin.name, + plugin.description, + plugin.author, + ...plugin.tags, + ]).map(({ item }) => item); }, [marketplacePlugins, searchQuery]); const filteredInstalledPlugins = useMemo(() => { - if (!searchQuery) return installedPlugins; - return installedPlugins.filter((plugin) => - fuzzySearch(searchQuery, plugin.name) || - fuzzySearch(searchQuery, plugin.description) || - fuzzySearch(searchQuery, plugin.author) - ); + if (!searchQuery.trim()) return installedPlugins; + return fuzzyFilter(installedPlugins, searchQuery, (plugin) => [ + plugin.name, + plugin.description, + plugin.author, + ]).map(({ item }) => item); }, [installedPlugins, searchQuery]); return ( diff --git a/src/app/features/settings/settingsSearchCatalog.ts b/src/app/features/settings/settingsSearchCatalog.ts new file mode 100644 index 0000000..dc51981 --- /dev/null +++ b/src/app/features/settings/settingsSearchCatalog.ts @@ -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'], + }, +]; diff --git a/src/app/features/settings/styles.css.ts b/src/app/features/settings/styles.css.ts index ce89c16..9f02fda 100644 --- a/src/app/features/settings/styles.css.ts +++ b/src/app/features/settings/styles.css.ts @@ -1,6 +1,27 @@ import { style } from '@vanilla-extract/css'; -import { config } from 'folds'; +import { color, config } from 'folds'; export const SequenceCardStyle = style({ padding: config.space.S300, }); + +export const SettingsSearchResult = style({ + width: '100%', + textAlign: 'left', + cursor: 'pointer', + border: 'none', + background: 'transparent', + padding: `${config.space.S200} ${config.space.S300}`, + borderRadius: config.radii.R400, + display: 'flex', + alignItems: 'flex-start', + gap: config.space.S200, + selectors: { + '&:hover': { + backgroundColor: color.Background.ContainerHover, + }, + '&[aria-pressed="true"]': { + backgroundColor: color.Background.ContainerActive, + }, + }, +}); diff --git a/src/app/features/space/SpaceOptionsMenu.tsx b/src/app/features/space/SpaceOptionsMenu.tsx index 3a3ad45..fd0f830 100644 --- a/src/app/features/space/SpaceOptionsMenu.tsx +++ b/src/app/features/space/SpaceOptionsMenu.tsx @@ -23,6 +23,7 @@ import { useRoomsUnread } from '../../state/hooks/unread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { UseStateProvider } from '../../components/UseStateProvider'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; +import { RemoveInaccessiblePrompt } from '../../components/remove-inaccessible-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { copyToClipboard } from '../../utils/dom'; import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix'; @@ -32,6 +33,7 @@ import { useNavigate } from 'react-router-dom'; import { getSpaceFeedPath, getSpaceLobbyPath } from '../../pages/pathUtils'; import { shouldShowForumLobby } from '../../utils/room'; import { useSpaceFeedSelected } from '../../hooks/router/useSelectedSpace'; +import { StateEvent } from '../../../types/matrix/room'; type ForumAddOptions = { item: HierarchyItem; @@ -65,6 +67,7 @@ export const SpaceOptionsMenu = forwardRef + {canEditChildren && ( + + {(promptRemove, setPromptRemove) => ( + <> + setPromptRemove(true)} + variant="Critical" + fill="None" + size="300" + after={} + radii="300" + aria-pressed={promptRemove} + > + + Remove Inaccessible + + + {promptRemove && ( + setPromptRemove(false)} + /> + )} + + )} + + )} {(promptLeave, setPromptLeave) => ( <> diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 5137950..65d4008 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -24,23 +24,30 @@ const getUserPresence = (user: User): UserPresence => ({ export const useUserPresence = (userId: string): UserPresence | undefined => { const mx = useMatrixClient(); - const user = mx.getUser(userId); + const user = userId ? mx.getUser(userId) : null; const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); useEffect(() => { - const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { - if (u.userId === user?.userId) { + if (!user) { + setPresence(undefined); + return undefined; + } + + setPresence(getUserPresence(user)); + + const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => { + if (u.userId === user.userId) { setPresence(getUserPresence(user)); } }; - user?.on(UserEvent.Presence, updatePresence); - user?.on(UserEvent.CurrentlyActive, updatePresence); - user?.on(UserEvent.LastPresenceTs, updatePresence); + user.on(UserEvent.Presence, updatePresence); + user.on(UserEvent.CurrentlyActive, updatePresence); + user.on(UserEvent.LastPresenceTs, updatePresence); return () => { - user?.removeListener(UserEvent.Presence, updatePresence); - user?.removeListener(UserEvent.CurrentlyActive, updatePresence); - user?.removeListener(UserEvent.LastPresenceTs, updatePresence); + user.removeListener(UserEvent.Presence, updatePresence); + user.removeListener(UserEvent.CurrentlyActive, updatePresence); + user.removeListener(UserEvent.LastPresenceTs, updatePresence); }; }, [user]); diff --git a/src/app/utils/fuzzy.ts b/src/app/utils/fuzzy.ts new file mode 100644 index 0000000..1dc3ee3 --- /dev/null +++ b/src/app/utils/fuzzy.ts @@ -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): 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( + items: T[], + query: string, + getTexts: (item: T) => Array +): 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; +} diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index e6645bc..8c11b3b 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -181,10 +181,8 @@ export function isValidChild(mEvent: MatrixEvent): boolean { if (mEvent.getType() !== StateEvent.SpaceChild) return false; const stateKey = mEvent.getStateKey(); if (!stateKey || !stateKey.startsWith('!')) return false; - const { via } = mEvent.getContent<{ via?: string[] }>(); - // via is optional on m.space.child; only reject malformed values - if (via !== undefined && !Array.isArray(via)) return false; - return true; + // via is required; omitting it (e.g. empty {}) removes the child per Matrix spec + return Array.isArray(mEvent.getContent<{ via?: string[] }>().via); } export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set => {