From 3f6f2134ad7d24c2d784aeaaef8eee1360f5c9a3 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Thu, 19 Feb 2026 17:49:48 +1100 Subject: [PATCH] feat(call): add docked call panel and remote cursor overlay - Implemented DockedCallPanel component to render the docked call interface when an active call is present. - Created RemoteCursorOverlay component to display remote cursor positions during screen sharing. - Added hooks for managing docked call state and remote cursor functionality. - Introduced pending drag state management for seamless transitions between docked and floating states. - Developed embed filter management components for personal and room-wide settings, allowing users to customize URL embed visibility. - Implemented auto-joining functionality for rooms within spaces, enhancing user experience during space navigation. - Added state management for tracking joining progress of rooms in spaces. --- src/app/components/RenderMessageContent.tsx | 6 +- .../join-space-prompt/JoinSpacePrompt.tsx | 557 ++++++++++++++++++ src/app/components/join-space-prompt/index.ts | 1 + .../leave-space-prompt/LeaveSpacePrompt.tsx | 126 +++- .../manage-rooms-prompt/ManageRoomsPrompt.tsx | 446 ++++++++++++++ .../components/manage-rooms-prompt/index.ts | 1 + src/app/components/room-card/RoomCard.tsx | 33 +- .../components/url-preview/UrlPreviewCard.tsx | 19 +- src/app/features/call/CallOverlay.css.ts | 99 +++- src/app/features/call/CallOverlay.tsx | 196 +++++- src/app/features/call/DockedCallContent.tsx | 455 ++++++++++++++ src/app/features/call/DockedCallPanel.tsx | 26 + src/app/features/call/RemoteCursorOverlay.tsx | 132 +++++ src/app/features/call/index.ts | 3 + src/app/features/call/pendingDragState.ts | 21 + src/app/features/call/useDockedCall.ts | 18 + src/app/features/call/useRemoteCursor.ts | 187 ++++++ .../common-settings/general/EmbedFilters.tsx | 315 ++++++++++ .../features/common-settings/general/index.ts | 1 + src/app/features/lobby/Lobby.tsx | 30 + src/app/features/room-nav/RoomNavItem.tsx | 9 +- .../room-settings/general/General.tsx | 5 + .../permissions/usePermissionItems.ts | 14 + src/app/features/room/RoomTimeline.tsx | 13 + src/app/features/room/message/Message.tsx | 12 +- src/app/features/settings/account/Profile.tsx | 126 ++++ src/app/features/settings/audio/Audio.tsx | 14 + src/app/features/settings/general/General.tsx | 150 +---- src/app/hooks/useAutoJoinSpaceRooms.ts | 247 ++++++++ src/app/hooks/useRoomEmbedFilters.ts | 175 ++++++ .../hooks/useRoomsNotificationPreferences.ts | 1 - src/app/hooks/useSidebarItems.ts | 112 +++- src/app/pages/App.tsx | 2 - src/app/pages/client/ClientLayout.tsx | 2 + src/app/pages/client/SidebarNav.tsx | 2 + src/app/pages/client/home/Home.tsx | 98 ++- src/app/pages/client/sidebar/SpaceTabs.tsx | 312 +++++++++- src/app/state/hooks/useBindAtoms.ts | 2 + src/app/state/joiningProgress.ts | 37 ++ src/app/state/settings.ts | 10 + src/types/matrix/room.ts | 3 + vite.config.js | 2 +- 42 files changed, 3801 insertions(+), 219 deletions(-) create mode 100644 src/app/components/join-space-prompt/JoinSpacePrompt.tsx create mode 100644 src/app/components/join-space-prompt/index.ts create mode 100644 src/app/components/manage-rooms-prompt/ManageRoomsPrompt.tsx create mode 100644 src/app/components/manage-rooms-prompt/index.ts create mode 100644 src/app/features/call/DockedCallContent.tsx create mode 100644 src/app/features/call/DockedCallPanel.tsx create mode 100644 src/app/features/call/RemoteCursorOverlay.tsx create mode 100644 src/app/features/call/pendingDragState.ts create mode 100644 src/app/features/call/useDockedCall.ts create mode 100644 src/app/features/call/useRemoteCursor.ts create mode 100644 src/app/features/common-settings/general/EmbedFilters.tsx create mode 100644 src/app/hooks/useAutoJoinSpaceRooms.ts create mode 100644 src/app/hooks/useRoomEmbedFilters.ts create mode 100644 src/app/state/joiningProgress.ts diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 4cfcb7d..72f1f62 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -31,6 +31,7 @@ import { PdfViewer } from './Pdf-viewer'; import { TextViewer } from './text-viewer'; import { testMatrixTo } from '../plugins/matrix-to'; import { IImageContent } from '../../types/matrix/common'; +import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters'; type RenderMessageContentProps = { displayName: string; @@ -44,6 +45,7 @@ type RenderMessageContentProps = { htmlReactParserOptions: HTMLReactParserOptions; linkifyOpts: Opts; outlineAttachment?: boolean; + disabledEmbedPatterns?: string[]; }; export function RenderMessageContent({ displayName, @@ -57,9 +59,11 @@ export function RenderMessageContent({ htmlReactParserOptions, linkifyOpts, outlineAttachment, + disabledEmbedPatterns, }: RenderMessageContentProps) { const renderUrlsPreview = (urls: string[]) => { - const filteredUrls = urls.filter((url) => !testMatrixTo(url)); + let filteredUrls = urls.filter((url) => !testMatrixTo(url)); + filteredUrls = filterDisabledUrls(filteredUrls, disabledEmbedPatterns ?? []); if (filteredUrls.length === 0) return undefined; return ( diff --git a/src/app/components/join-space-prompt/JoinSpacePrompt.tsx b/src/app/components/join-space-prompt/JoinSpacePrompt.tsx new file mode 100644 index 0000000..72157f1 --- /dev/null +++ b/src/app/components/join-space-prompt/JoinSpacePrompt.tsx @@ -0,0 +1,557 @@ +import React, { useMemo, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Dialog, + Overlay, + OverlayCenter, + OverlayBackdrop, + Header, + config, + Box, + Text, + IconButton, + Icon, + Icons, + color, + Button, + Spinner, + Scroll, + Checkbox, + Avatar, + Badge, +} from 'folds'; +import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { stopPropagation } from '../../utils/keyboard'; +import { RoomType, Membership } from '../../../types/matrix/room'; +import { RoomAvatar } from '../room-avatar'; +import { nameInitials } from '../../utils/common'; +import { mxcUrlToHttp } from '../../utils/matrix'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { + setRoomNotificationPreference, + RoomNotificationMode, +} from '../../hooks/useRoomsNotificationPreferences'; + +const PER_PAGE_COUNT = 100; +const MAX_PAGES = 5; +const HIERARCHY_TIMEOUT_MS = 10000; +const JOIN_DELAY_MS = 500; + +/** Join rules that allow automatic joining */ +const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted']; + +/** + * Extracts the server name from a Matrix room ID + */ +function getServerFromRoomId(roomId: string): string { + const parts = roomId.split(':'); + return parts.length > 1 ? parts[1] : ''; +} + +/** + * Checks if a room/space can be automatically joined based on its join rule + */ +function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean { + const { join_rule: joinRule } = hierarchyRoom; + if (!joinRule) return false; + return AUTO_JOINABLE_RULES.includes(joinRule); +} + +/** + * Delays execution for a specified time + */ +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +type HierarchyRoomItemProps = { + room: IHierarchyRoom; + selected: boolean; + onToggle: () => void; + useAuthentication: boolean; + disabled?: boolean; +}; + +function HierarchyRoomItem({ + room, + selected, + onToggle, + useAuthentication, + disabled, +}: HierarchyRoomItemProps) { + const mx = useMatrixClient(); + const roomIsSpace = room.room_type === RoomType.Space; + const avatarUrl = room.avatar_url + ? mxcUrlToHttp(mx, room.avatar_url, useAuthentication, 48, 48, 'crop') + : undefined; + + return ( + + + + ( + + {nameInitials(room.name || room.room_id)} + + )} + /> + + + + {room.name || room.room_id} + + {room.topic && ( + + {room.topic} + + )} + + {roomIsSpace && ( + + Space + + )} + + ); +} + +type JoinSpacePromptProps = { + roomIdOrAlias: string; + viaServers?: string[]; + onDone: (roomId: string) => void; + onCancel: () => void; +}; + +type JoinPhase = 'initial' | 'joining-space' | 'selecting-rooms' | 'joining-rooms'; + +export function JoinSpacePrompt({ + roomIdOrAlias, + viaServers, + onDone, + onCancel, +}: JoinSpacePromptProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const [phase, setPhase] = useState('initial'); + const [spaceId, setSpaceId] = useState(null); + const [hierarchyRooms, setHierarchyRooms] = useState([]); + const [selectedRoomIds, setSelectedRoomIds] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [progress, setProgress] = useState<{ current: number; total: number } | null>(null); + const [canSkipLoading, setCanSkipLoading] = useState(false); + + /** + * Skips to the space (used when loading takes too long) + */ + const handleSkipToSpace = () => { + if (spaceId) { + onDone(spaceId); + } + }; + + /** + * Joins the space first + */ + const handleJoinSpace = async () => { + setPhase('joining-space'); + setError(null); + setCanSkipLoading(false); + + let joinedSpaceId: string; + + try { + const spaceRoom = await mx.joinRoom(roomIdOrAlias, { viaServers }); + joinedSpaceId = spaceRoom.roomId; + setSpaceId(joinedSpaceId); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to join space'); + setPhase('initial'); + return; + } + + // Space is joined - allow skipping from here + setCanSkipLoading(true); + + // Now fetch the hierarchy with a timeout + setLoading(true); + + const fetchWithTimeout = async (): Promise => { + const allRooms: IHierarchyRoom[] = []; + let nextBatch: string | undefined; + let pageCount = 0; + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timeout')), HIERARCHY_TIMEOUT_MS); + }); + + while (pageCount < MAX_PAGES) { + // eslint-disable-next-line no-await-in-loop + const result = await Promise.race([ + mx.getRoomHierarchy(joinedSpaceId, PER_PAGE_COUNT, undefined, false, nextBatch), + timeoutPromise, + ]); + + allRooms.push(...result.rooms); + nextBatch = result.next_batch; + pageCount += 1; + + if (!nextBatch) break; + } + + return allRooms; + }; + + try { + const allRooms = await fetchWithTimeout(); + + // Filter out items we can't join (excluding the space itself) + const joinableRooms = allRooms.filter((room) => { + // Skip the space itself + if (room.room_id === joinedSpaceId) return false; + // Check if already joined + const existingRoom = mx.getRoom(room.room_id); + if (existingRoom?.getMyMembership() === Membership.Join) { + return false; + } + return canAutoJoin(room); + }); + + setHierarchyRooms(joinableRooms); + // Select all by default + setSelectedRoomIds(new Set(joinableRooms.map((r) => r.room_id))); + } catch { + // Hierarchy fetch failed or timed out - show empty selection + setHierarchyRooms([]); + setSelectedRoomIds(new Set()); + } finally { + setLoading(false); + setPhase('selecting-rooms'); + } + }; + + /** + * Joins selected rooms + */ + const handleJoinSelectedRooms = async () => { + if (!spaceId) return; + + setPhase('joining-rooms'); + setError(null); + + const spaceServer = getServerFromRoomId(spaceId); + + // Get selected rooms + const roomsToJoin = hierarchyRooms.filter((room) => selectedRoomIds.has(room.room_id)); + + // Separate spaces and rooms - join spaces first + const spacesToJoin = roomsToJoin.filter((r) => r.room_type === RoomType.Space); + const regularRoomsToJoin = roomsToJoin.filter((r) => r.room_type !== RoomType.Space); + + const total = roomsToJoin.length; + let current = 0; + + if (total > 0) { + setProgress({ current, total }); + } + + const joinItem = async (item: IHierarchyRoom, isSpace: boolean): Promise => { + try { + const itemServer = getServerFromRoomId(item.room_id); + const itemViaServers = [itemServer, spaceServer].filter(Boolean); + + await mx.joinRoom(item.room_id, { viaServers: itemViaServers }); + + // Set notification preferences for rooms (not spaces) + if (!isSpace) { + await setRoomNotificationPreference( + mx, + item.room_id, + RoomNotificationMode.SpecialMessages, + RoomNotificationMode.Unset + ); + } + + await delay(JOIN_DELAY_MS); + } catch { + // Continue with other items even if one fails + } finally { + current += 1; + setProgress({ current, total }); + } + }; + + // Join spaces first, then rooms + await spacesToJoin.reduce( + (promise, space) => promise.then(() => joinItem(space, true)), + Promise.resolve() + ); + + await regularRoomsToJoin.reduce( + (promise, room) => promise.then(() => joinItem(room, false)), + Promise.resolve() + ); + + setProgress(null); + onDone(spaceId); + }; + + /** + * Skip room selection and just finish + */ + const handleSkip = () => { + if (spaceId) { + onDone(spaceId); + } + }; + + const toggleRoom = (roomId: string) => { + setSelectedRoomIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(roomId)) { + newSet.delete(roomId); + } else { + newSet.add(roomId); + } + return newSet; + }); + }; + + const selectAll = () => { + setSelectedRoomIds(new Set(hierarchyRooms.map((r) => r.room_id))); + }; + + const selectNone = () => { + setSelectedRoomIds(new Set()); + }; + + const isJoining = phase === 'joining-space' || phase === 'joining-rooms'; + const spaces = useMemo( + () => hierarchyRooms.filter((r) => r.room_type === RoomType.Space), + [hierarchyRooms] + ); + const rooms = useMemo( + () => hierarchyRooms.filter((r) => r.room_type !== RoomType.Space), + [hierarchyRooms] + ); + + const getButtonText = () => { + if (phase === 'initial') return 'Join Space'; + if (phase === 'joining-space') return loading ? 'Loading Rooms...' : 'Joining Space...'; + if (phase === 'selecting-rooms') { + const selectedCount = selectedRoomIds.size; + return selectedCount > 0 ? `Join ${selectedCount} Rooms` : 'Done'; + } + if (progress) return `Joining: ${progress.current}/${progress.total}`; + return 'Joining...'; + }; + + const handlePrimaryAction = () => { + if (phase === 'initial') { + handleJoinSpace(); + } else if (phase === 'selecting-rooms') { + if (selectedRoomIds.size > 0) { + handleJoinSelectedRooms(); + } else { + handleSkip(); + } + } + }; + + const canCancel = phase === 'initial' || phase === 'selecting-rooms' || canSkipLoading; + + const getDeactivateHandler = () => { + if (!canCancel) return undefined; + if (canSkipLoading) return handleSkipToSpace; + return onCancel; + }; + + const getCloseHandler = () => { + if (phase === 'selecting-rooms') return handleSkip; + if (canSkipLoading) return handleSkipToSpace; + return onCancel; + }; + + return ( + }> + + + +
+ + + {phase === 'selecting-rooms' ? 'Select Rooms to Join' : 'Join Space'} + + + + + +
+ + {phase === 'initial' && ( + + Join this space to see and select which rooms to join. + + )} + + {phase === 'joining-space' && ( + + + + + {loading ? 'Loading space contents...' : 'Joining space...'} + + + {canSkipLoading && ( + + )} + + )} + + {phase === 'selecting-rooms' && hierarchyRooms.length > 0 && ( + <> + + + Select which rooms and spaces to join: + + + + + + + + + + {spaces.length > 0 && ( + + + Spaces ({spaces.length}) + + {spaces.map((room) => ( + toggleRoom(room.room_id)} + useAuthentication={useAuthentication} + disabled={isJoining} + /> + ))} + + )} + + {rooms.length > 0 && ( + + + Rooms ({rooms.length}) + + {rooms.map((room) => ( + toggleRoom(room.room_id)} + useAuthentication={useAuthentication} + disabled={isJoining} + /> + ))} + + )} + + + + )} + + {phase === 'selecting-rooms' && hierarchyRooms.length === 0 && ( + + This space has no additional rooms to join. + + )} + + {phase === 'joining-rooms' && ( + + + + {progress + ? `Joining rooms: ${progress.current}/${progress.total}` + : 'Joining rooms...'} + + + )} + + {error && ( + + {error} + + )} + + + +
+
+
+
+ ); +} diff --git a/src/app/components/join-space-prompt/index.ts b/src/app/components/join-space-prompt/index.ts new file mode 100644 index 0000000..16abfe0 --- /dev/null +++ b/src/app/components/join-space-prompt/index.ts @@ -0,0 +1 @@ +export * from './JoinSpacePrompt'; diff --git a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx index 8709b94..950b3cd 100644 --- a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx +++ b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { Dialog, @@ -16,10 +16,85 @@ import { Button, Spinner, } from 'folds'; -import { MatrixError } from 'matrix-js-sdk'; +import { MatrixError, Room } from 'matrix-js-sdk'; +import { useAtomValue } from 'jotai'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { stopPropagation } from '../../utils/keyboard'; +import { getSpaceChildren, isSpace } from '../../utils/room'; +import { mDirectAtom } from '../../state/mDirectList'; +import { Membership } from '../../../types/matrix/room'; + +const LEAVE_DELAY_MS = 300; + +/** + * Delays execution for a specified time + */ +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Leaves multiple rooms sequentially with rate limiting + */ +async function leaveRoomsSequentially( + mx: ReturnType, + roomIds: string[], + onProgress?: (current: number, total: number) => void +): Promise { + const total = roomIds.length; + + for (let i = 0; i < roomIds.length; i++) { + const roomId = roomIds[i]; + onProgress?.(i, total); + + try { + await mx.leave(roomId); + await delay(LEAVE_DELAY_MS); + } catch { + // Continue leaving other rooms even if one fails + } + } + + onProgress?.(total, total); +} + +/** + * Gets direct child room IDs from a space that can be left (excluding DMs and subspaces) + */ +function getDirectChildRoomsToLeave( + mx: ReturnType, + spaceRoom: Room, + mDirects: Set +): string[] { + const childIds = getSpaceChildren(spaceRoom); + + return childIds.filter((childId) => { + // Skip DMs + if (mDirects.has(childId)) { + return false; + } + + const childRoom = mx.getRoom(childId); + if (!childRoom) { + return false; + } + + // Skip subspaces (only leave rooms, not nested spaces) + if (isSpace(childRoom)) { + return false; + } + + // Only include rooms we're actually joined to + if (childRoom.getMyMembership() !== Membership.Join) { + return false; + } + + return true; + }); +} type LeaveSpacePromptProps = { roomId: string; @@ -28,11 +103,29 @@ type LeaveSpacePromptProps = { }; export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) { const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + const [progress, setProgress] = useState<{ current: number; total: number } | null>(null); const [leaveState, leaveRoom] = useAsyncCallback( useCallback(async () => { - mx.leave(roomId); - }, [mx, roomId]) + const spaceRoom = mx.getRoom(roomId); + + if (spaceRoom) { + // Get direct child rooms to leave (excluding DMs and subspaces) + const childRoomsToLeave = getDirectChildRoomsToLeave(mx, spaceRoom, mDirects); + + if (childRoomsToLeave.length > 0) { + // Leave child rooms first + await leaveRoomsSequentially(mx, childRoomsToLeave, (current, total) => { + setProgress({ current, total }); + }); + } + } + + // Clear progress and leave the space itself + setProgress(null); + await mx.leave(roomId); + }, [mx, roomId, mDirects]) ); const handleLeave = () => { @@ -45,6 +138,13 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP } }, [leaveState, onDone]); + const isLoading = leaveState.status === AsyncStatus.Loading; + const getButtonText = () => { + if (!isLoading) return 'Leave'; + if (progress) return `Leaving Rooms: ${progress.current}/${progress.total}`; + return 'Leaving Space...'; + }; + return ( }> @@ -74,7 +174,10 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP - Are you sure you want to leave this space? + + Are you sure you want to leave this space? You will also leave all rooms directly + in this space (except DMs). + {leaveState.status === AsyncStatus.Error && ( Failed to leave space! {leaveState.error.message} @@ -86,18 +189,11 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP variant="Critical" onClick={handleLeave} before={ - leaveState.status === AsyncStatus.Loading ? ( - - ) : undefined - } - aria-disabled={ - leaveState.status === AsyncStatus.Loading || - leaveState.status === AsyncStatus.Success + isLoading ? : undefined } + aria-disabled={isLoading || leaveState.status === AsyncStatus.Success} > - - {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'} - + {getButtonText()} diff --git a/src/app/components/manage-rooms-prompt/ManageRoomsPrompt.tsx b/src/app/components/manage-rooms-prompt/ManageRoomsPrompt.tsx new file mode 100644 index 0000000..ed20a05 --- /dev/null +++ b/src/app/components/manage-rooms-prompt/ManageRoomsPrompt.tsx @@ -0,0 +1,446 @@ +import React, { useMemo, useState, useCallback, useRef, useEffect, MouseEvent } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Dialog, + Overlay, + OverlayCenter, + OverlayBackdrop, + Header, + config, + Box, + Text, + IconButton, + Icon, + Icons, + color, + Button, + Spinner, + Scroll, + Checkbox, + Badge, + Line, + Avatar, +} from 'folds'; +import { Room } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { stopPropagation } from '../../utils/keyboard'; +import { RoomAvatar } from '../room-avatar'; +import { nameInitials } from '../../utils/common'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { getRoomAvatarUrl } from '../../utils/room'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; + +const BASE_DELAY_MS = 1000; +const MAX_RETRIES = 3; + +/** + * Delays execution for a specified time + */ +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Gets the retry delay from a rate limit error, or returns a default backoff + */ +function getRetryDelay(error: unknown, attempt: number): number { + // Check for Matrix rate limit error with retry_after_ms + if ( + error && + typeof error === 'object' && + 'data' in error && + error.data && + typeof error.data === 'object' && + 'retry_after_ms' in error.data && + typeof error.data.retry_after_ms === 'number' + ) { + return error.data.retry_after_ms + 100; // Add small buffer + } + // Exponential backoff: 2s, 4s, 8s... + return Math.min(2000 * 2 ** attempt, 30000); +} + +type RoomItemProps = { + room: Room; + selected: boolean; + onToggle: () => void; + onShiftClick: () => void; + disabled?: boolean; +}; + +/** + * Individual room item component with checkbox + */ +function RoomItem({ room, selected, onToggle, onShiftClick, disabled }: RoomItemProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const avatarUrl = getRoomAvatarUrl(mx, room, 32, useAuthentication); + const roomName = room.name || getMxIdLocalPart(room.roomId) || room.roomId; + const memberCount = room.getJoinedMemberCount(); + + const handleClick = (evt: MouseEvent) => { + if (disabled) return; + if (evt.shiftKey) { + evt.preventDefault(); + onShiftClick(); + } + }; + + return ( + + + + ( + {nameInitials(roomName, 2)} + )} + /> + + + + {roomName} + + + {memberCount} {memberCount === 1 ? 'member' : 'members'} + + + + ); +} + +type LeavePhase = 'selecting' | 'leaving' | 'done'; + +type ManageRoomsPromptProps = { + rooms: Room[]; + onDone: () => void; + onCancel: () => void; +}; + +/** + * Dialog for managing multiple rooms with multi-select and mass leave functionality + */ +export function ManageRoomsPrompt({ rooms, onDone, onCancel }: ManageRoomsPromptProps) { + const mx = useMatrixClient(); + const [selectedRoomIds, setSelectedRoomIds] = useState>(new Set()); + const [phase, setPhase] = useState('selecting'); + const [leaveProgress, setLeaveProgress] = useState<{ current: number; total: number } | null>( + null + ); + const [errorMessage, setErrorMessage] = useState(null); + const lastSelectedIndex = useRef(null); + + const sortedRooms = useMemo( + () => + [...rooms].sort((a, b) => { + const nameA = a.name?.toLowerCase() || ''; + const nameB = b.name?.toLowerCase() || ''; + return nameA.localeCompare(nameB); + }), + [rooms] + ); + + /** + * Handles toggling individual room selection + */ + const handleToggle = useCallback((roomId: string, index: number) => { + setSelectedRoomIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(roomId)) { + newSet.delete(roomId); + } else { + newSet.add(roomId); + } + return newSet; + }); + lastSelectedIndex.current = index; + }, []); + + /** + * Handles shift-click for range selection + */ + const handleShiftClick = useCallback( + (roomId: string, index: number) => { + if (lastSelectedIndex.current === null) { + handleToggle(roomId, index); + return; + } + + const start = Math.min(lastSelectedIndex.current, index); + const end = Math.max(lastSelectedIndex.current, index); + + setSelectedRoomIds((prev) => { + const newSet = new Set(prev); + sortedRooms.slice(start, end + 1).forEach((r) => newSet.add(r.roomId)); + return newSet; + }); + lastSelectedIndex.current = index; + }, + [handleToggle, sortedRooms] + ); + + /** + * Select all rooms + */ + const handleSelectAll = useCallback(() => { + setSelectedRoomIds(new Set(sortedRooms.map((r) => r.roomId))); + }, [sortedRooms]); + + /** + * Deselect all rooms + */ + const handleDeselectAll = useCallback(() => { + setSelectedRoomIds(new Set()); + lastSelectedIndex.current = null; + }, []); + + /** + * Leaves a single room with retry on rate limit + */ + const leaveRoom = useCallback( + async (roomId: string, attempt = 0): Promise => { + try { + await mx.leave(roomId); + return true; + } catch (error) { + // Check if it's a rate limit error (429) + const isRateLimited = + error && + typeof error === 'object' && + 'httpStatus' in error && + error.httpStatus === 429; + + if (isRateLimited && attempt < MAX_RETRIES) { + const retryDelay = getRetryDelay(error, attempt); + await delay(retryDelay); + return leaveRoom(roomId, attempt + 1); + } + return false; + } + }, + [mx] + ); + + /** + * Performs mass leave operation + */ + const handleMassLeave = useCallback(async () => { + if (selectedRoomIds.size === 0) return; + + setPhase('leaving'); + setErrorMessage(null); + const roomsToLeave = Array.from(selectedRoomIds); + const total = roomsToLeave.length; + let failedCount = 0; + + // Process rooms sequentially with rate limiting + const processRoom = async (index: number): Promise => { + if (index >= roomsToLeave.length) { + setPhase('done'); + if (failedCount > 0) { + setErrorMessage(`Failed to leave ${failedCount} room(s).`); + } + return; + } + + const roomId = roomsToLeave[index]; + setLeaveProgress({ current: index + 1, total }); + + const success = await leaveRoom(roomId); + if (!success) { + failedCount += 1; + } + + // Rate limiting delay before next room + if (index < roomsToLeave.length - 1) { + await delay(BASE_DELAY_MS); + } + + await processRoom(index + 1); + }; + + await processRoom(0); + }, [leaveRoom, selectedRoomIds]); + + useEffect(() => { + if (phase === 'done' && !errorMessage) { + // Auto close on success after short delay + const timer = setTimeout(onDone, 500); + return () => clearTimeout(timer); + } + return undefined; + }, [phase, errorMessage, onDone]); + + const renderContent = () => { + if (phase === 'leaving') { + return ( + + + + Leaving rooms... {leaveProgress?.current} / {leaveProgress?.total} + + + ); + } + + if (phase === 'done') { + return ( + + {errorMessage ? ( + <> + + {errorMessage} + + + ) : ( + <> + + Successfully left all selected rooms! + + )} + + ); + } + + // Selection phase + return ( + <> + + + + {selectedRoomIds.size} of {sortedRooms.length} rooms selected + + + + + + + + Tip: Hold Shift and click to select a range of rooms + + + + + + + {sortedRooms.map((room, index) => ( + handleToggle(room.roomId, index)} + onShiftClick={() => handleShiftClick(room.roomId, index)} + /> + ))} + + + + + + + + + + ); + }; + + return ( + }> + + + +
+ + Manage Rooms + {selectedRoomIds.size > 0 && ( + + {selectedRoomIds.size} + + )} + + {phase === 'selecting' && ( + + + + )} +
+ {renderContent()} +
+
+
+
+ ); +} diff --git a/src/app/components/manage-rooms-prompt/index.ts b/src/app/components/manage-rooms-prompt/index.ts new file mode 100644 index 0000000..2895b8b --- /dev/null +++ b/src/app/components/manage-rooms-prompt/index.ts @@ -0,0 +1 @@ +export * from './ManageRoomsPrompt'; diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 34a7e24..8a361a6 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -33,6 +33,7 @@ import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { JoinSpacePrompt } from '../join-space-prompt'; type GridColumnCount = '1' | '2' | '3'; const getGridColumnCount = (gridWidth: number): GridColumnCount => { @@ -207,8 +208,32 @@ export const RoomCard = as<'div', RoomCardProps>( const closeTopic = () => setViewTopic(false); const openTopic = () => setViewTopic(true); + const [showSpaceJoinPrompt, setShowSpaceJoinPrompt] = useState(false); + const isSpace = roomType === RoomType.Space || joinedRoom?.isSpaceRoom(); + + const handleJoinClick = () => { + if (isSpace) { + setShowSpaceJoinPrompt(true); + } else { + join(); + } + }; + + const handleSpaceJoinDone = (roomId: string) => { + setShowSpaceJoinPrompt(false); + onView?.(roomId); + }; + return ( + {showSpaceJoinPrompt && ( + setShowSpaceJoinPrompt(false)} + /> + )} ( )} /> - {(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && ( + {isSpace && ( Space @@ -269,10 +294,10 @@ export const RoomCard = as<'div', RoomCardProps>( )} {typeof joinedRoomId !== 'string' && joinState.status !== AsyncStatus.Error && ( + {showParticipants && room && ( + +
+ {allParticipants.map((participantId) => { + const avatarMxc = getMemberAvatarMxc(room, participantId); + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined + : undefined; + const displayName = getMemberDisplayName(room, participantId) + ?? getMxIdLocalPart(participantId) + ?? participantId; + const isMe = participantId === myUserId; + const isMuted = isParticipantMuted(participantId); + const isSpeaking = isParticipantSpeaking(participantId); + + return ( +
+
+ ( + + {displayName.charAt(0).toUpperCase()} + + )} + /> +
+ + + {displayName} + {isMe && (You)} + + + {isMuted && ( + + )} +
+ ); + })} +
+
+ )} + +
+ + {/* Screen shares */} + {allScreenShares.length > 0 && ( + + {allScreenShares.map((share) => ( + + ))} + + )} + + {/* Controls */} +
+ {activeCall.isMuted ? 'Unmute' : 'Mute'}} + > + {(triggerRef) => ( + + + + )} + + + {activeCall.isDeafened ? 'Undeafen' : 'Deafen'}} + > + {(triggerRef) => ( + + + + )} + + + {activeCall.isScreenSharing ? 'Stop Screen Share' : 'Share Screen'}} + > + {(triggerRef) => ( + + + {activeCall.isScreenSharing ? : } + + + )} + + + End Call} + > + {(triggerRef) => ( + + + + )} + +
+ + ); +} diff --git a/src/app/features/call/DockedCallPanel.tsx b/src/app/features/call/DockedCallPanel.tsx new file mode 100644 index 0000000..58d0dde --- /dev/null +++ b/src/app/features/call/DockedCallPanel.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Box, Line } from 'folds'; +import { useIsCallDocked } from './useDockedCall'; +import { DockedCallContent } from './DockedCallContent'; +import * as css from './CallOverlay.css'; + +/** + * Component that renders the docked call panel in the layout flow + * Only renders when there's an active call AND docking is enabled + */ +export function DockedCallPanel() { + const isCallDocked = useIsCallDocked(); + + if (!isCallDocked) { + return null; + } + + return ( + <> + + + + + + ); +} diff --git a/src/app/features/call/RemoteCursorOverlay.tsx b/src/app/features/call/RemoteCursorOverlay.tsx new file mode 100644 index 0000000..fb60c48 --- /dev/null +++ b/src/app/features/call/RemoteCursorOverlay.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getMemberDisplayName } from '../../utils/room'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { useRoomCallMembers } from './useRoomCallMembers'; +import { useCall } from './useCall'; +import { useSendCursor, useReceiveCursors, RemoteCursorState } from './useRemoteCursor'; +import colorMXID from '../../../util/colorMXID'; +import * as css from './CallOverlay.css'; + +/** Props for RemoteCursorOverlay */ +interface RemoteCursorOverlayProps { + /** The screen share ID to track cursors for */ + shareId: string; + /** The room ID of the active call */ + roomId: string; +} + +/** + * Resolves a LiveKit participant identity to a Matrix user ID + * LiveKit identities are typically in the format "deviceId_timestamp" + */ +function resolveParticipantUserId( + livekitIdentity: string, + callMembers: { userId: string; deviceId: string }[] +): string { + const underscoreIndex = livekitIdentity.lastIndexOf('_'); + const possibleDeviceId = + underscoreIndex > 0 ? livekitIdentity.substring(0, underscoreIndex) : livekitIdentity; + + const match = callMembers.find((m) => m.deviceId === possibleDeviceId); + return match?.userId ?? livekitIdentity; +} + +/** + * Single remote cursor dot with label + */ +function CursorDot({ + cursor, + displayName, + cursorColor, +}: { + cursor: RemoteCursorState; + displayName: string; + cursorColor: string; +}) { + if (!cursor.visible) return null; + + const left = `${cursor.x * 100}%`; + const top = `${cursor.y * 100}%`; + + return ( + <> +
+
+ {displayName} +
+ + ); +} + +/** + * Overlay that renders remote cursor positions on top of a screen share + * Also provides mouse event handlers for sending local cursor position + */ +export function RemoteCursorOverlay({ shareId, roomId }: RemoteCursorOverlayProps) { + const mx = useMatrixClient(); + const { callMembers } = useRoomCallMembers(roomId); + const { activeCall } = useCall(); + const cursors = useReceiveCursors(shareId); + const room = mx.getRoom(roomId); + const myUserId = mx.getUserId() ?? ''; + + /** + * Gets a display name for a LiveKit participant + */ + const getDisplayName = (livekitIdentity: string): string => { + const userId = resolveParticipantUserId(livekitIdentity, callMembers); + if (!room) return getMxIdLocalPart(userId) ?? userId; + return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; + }; + + // Filter out our own cursor and only show visible cursors + const myIdentity = activeCall?.participants.find((p) => { + const underscoreIndex = p.lastIndexOf('_'); + const deviceId = underscoreIndex > 0 ? p.substring(0, underscoreIndex) : p; + const member = callMembers.find((m) => m.deviceId === deviceId); + return member?.userId === myUserId; + }); + + const remoteCursors = Array.from(cursors.entries()).filter( + ([participantId]) => participantId !== myIdentity + ); + + if (remoteCursors.length === 0) return null; + + return ( +
+ {remoteCursors.map(([participantId, cursor]) => { + const userId = resolveParticipantUserId(participantId, callMembers); + return ( + + ); + })} +
+ ); +} + +/** + * Wrapper that provides mouse event handlers for cursor sending on a screenshare + * @param shareId - The screen share ID + * @returns Mouse event handlers to attach to the container + */ +export function useScreenShareCursor(shareId: string) { + return useSendCursor(shareId); +} diff --git a/src/app/features/call/index.ts b/src/app/features/call/index.ts index 6b769e8..5b01a8d 100644 --- a/src/app/features/call/index.ts +++ b/src/app/features/call/index.ts @@ -7,3 +7,6 @@ export * from './CallProviderWrapper'; export * from './CallOverlay'; export * from './IncomingCallNotification'; export * from './useRoomCallMembers'; +export * from './useDockedCall'; +export * from './DockedCallPanel'; +export * from './DockedCallContent'; diff --git a/src/app/features/call/pendingDragState.ts b/src/app/features/call/pendingDragState.ts new file mode 100644 index 0000000..df70f3d --- /dev/null +++ b/src/app/features/call/pendingDragState.ts @@ -0,0 +1,21 @@ +import { atom } from 'jotai'; + +/** + * Atom to store pending drag state when transitioning from docked to floating + * When the user starts dragging the docked panel header, we store the mouse position + * so the floating overlay can continue the drag seamlessly + */ +export interface PendingDragState { + /** Mouse X position when drag started */ + startX: number; + /** Mouse Y position when drag started */ + startY: number; + /** Offset from panel left edge to mouse X */ + offsetX: number; + /** Offset from panel top edge to mouse Y */ + offsetY: number; + /** Timestamp to prevent stale drag states */ + timestamp: number; +} + +export const pendingUndockDragAtom = atom(null); diff --git a/src/app/features/call/useDockedCall.ts b/src/app/features/call/useDockedCall.ts new file mode 100644 index 0000000..9c0d588 --- /dev/null +++ b/src/app/features/call/useDockedCall.ts @@ -0,0 +1,18 @@ +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { useCall } from './useCall'; +import { CallState } from './types'; + +/** + * Hook to determine if the call panel should be rendered docked + * Returns true if there's an active call AND the panel is set to docked mode + */ +export function useIsCallDocked(): boolean { + const { activeCall } = useCall(); + const [isDocked] = useSetting(settingsAtom, 'isCallPanelDocked'); + + const hasActiveCall = activeCall && + (activeCall.state === CallState.Connecting || activeCall.state === CallState.Connected); + + return Boolean(hasActiveCall && isDocked); +} diff --git a/src/app/features/call/useRemoteCursor.ts b/src/app/features/call/useRemoteCursor.ts new file mode 100644 index 0000000..bb61a30 --- /dev/null +++ b/src/app/features/call/useRemoteCursor.ts @@ -0,0 +1,187 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { RoomEvent } from 'livekit-client'; +import { useCall } from './useCall'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; + +/** Data topic for cursor position messages */ +const CURSOR_TOPIC = 'remote-cursor'; + +/** How often to send cursor updates (ms) */ +const CURSOR_SEND_INTERVAL = 50; + +/** How long before a cursor is considered stale and hidden (ms) */ +const CURSOR_STALE_TIMEOUT = 3000; + +/** Cursor position data sent over the data channel */ +export interface CursorData { + /** Message type identifier */ + type: 'cursor'; + /** Normalized X position (0–1) */ + x: number; + /** Normalized Y position (0–1) */ + y: number; + /** Whether the cursor is visible (false = user moved mouse away) */ + visible: boolean; + /** Screen share participant ID (Matrix user ID) this cursor relates to */ + shareId: string; +} + +/** Remote cursor state for rendering */ +export interface RemoteCursorState { + /** LiveKit participant identity */ + participantId: string; + /** Normalized X position (0–1) */ + x: number; + /** Normalized Y position (0–1) */ + y: number; + /** Whether the cursor is currently visible */ + visible: boolean; + /** Timestamp of last update */ + lastUpdate: number; +} + +/** + * Hook to send local cursor position over the LiveKit data channel + * Tracks mouse movement over a screenshare container and broadcasts position + * @param shareId - The screen share ID to associate cursor data with + * @returns Object with onMouseMove/onMouseLeave handlers to attach to the container + */ +export function useSendCursor(shareId: string) { + const { callService } = useCall(); + const [showRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor'); + const lastSendRef = useRef(0); + const encoder = useRef(new TextEncoder()); + + const sendCursorData = useCallback( + (data: Omit) => { + const room = callService?.getLiveKitRoom(); + if (!room?.localParticipant) return; + + const now = Date.now(); + if (!data.visible || now - lastSendRef.current >= CURSOR_SEND_INTERVAL) { + lastSendRef.current = now; + const message: CursorData = { ...data, type: 'cursor' }; + const payload = encoder.current.encode(JSON.stringify(message)); + room.localParticipant + .publishData(payload, { reliable: false, topic: CURSOR_TOPIC }) + .catch(() => { + // Data publish can fail silently + }); + } + }, + [callService] + ); + + /** + * Handle mouse movement over a screenshare container + * Calculates normalized position and sends to remote participants + */ + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!showRemoteCursor) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + + sendCursorData({ x, y, visible: true, shareId }); + }, + [showRemoteCursor, sendCursorData, shareId] + ); + + /** + * Handle mouse leaving the screenshare container + * Sends a hidden cursor update so remote stops showing cursor + */ + const handleMouseLeave = useCallback(() => { + if (!showRemoteCursor) return; + sendCursorData({ x: 0, y: 0, visible: false, shareId }); + }, [showRemoteCursor, sendCursorData, shareId]); + + return { handleMouseMove, handleMouseLeave }; +} + +/** + * Hook to receive remote cursor positions from the LiveKit data channel + * Listens for cursor data from other participants and provides state for rendering + * @param shareId - The screen share ID to filter cursor data for + * @returns Map of participant IDs to their cursor states + */ +export function useReceiveCursors(shareId: string): Map { + const { callService } = useCall(); + const [showRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor'); + const [cursors, setCursors] = useState>(new Map()); + const decoder = useRef(new TextDecoder()); + + useEffect(() => { + if (!showRemoteCursor) { + setCursors(new Map()); + return undefined; + } + + const room = callService?.getLiveKitRoom(); + if (!room) return undefined; + + const handleDataReceived = ( + payload: Uint8Array, + participant?: { identity: string }, + _kind?: unknown, + topic?: string + ) => { + if (!participant) return; + // Only process cursor messages by topic + if (topic !== undefined && topic !== CURSOR_TOPIC) return; + + try { + const text = decoder.current.decode(payload); + const data = JSON.parse(text) as CursorData; + + // Validate this is actually a cursor message + if (data.type !== 'cursor') return; + if (data.shareId !== shareId) return; + + setCursors((prev) => { + const next = new Map(prev); + next.set(participant.identity, { + participantId: participant.identity, + x: data.x, + y: data.y, + visible: data.visible, + lastUpdate: Date.now(), + }); + return next; + }); + } catch { + // Ignore malformed data + } + }; + + room.on(RoomEvent.DataReceived, handleDataReceived); + + // Periodically clean up stale cursors + const cleanupInterval = setInterval(() => { + setCursors((prev) => { + const now = Date.now(); + let changed = false; + const next = new Map(prev); + + next.forEach((cursor, key) => { + if (now - cursor.lastUpdate > CURSOR_STALE_TIMEOUT) { + next.delete(key); + changed = true; + } + }); + + return changed ? next : prev; + }); + }, 1000); + + return () => { + room.off(RoomEvent.DataReceived, handleDataReceived); + clearInterval(cleanupInterval); + }; + }, [callService, showRemoteCursor, shareId]); + + return cursors; +} diff --git a/src/app/features/common-settings/general/EmbedFilters.tsx b/src/app/features/common-settings/general/EmbedFilters.tsx new file mode 100644 index 0000000..661b559 --- /dev/null +++ b/src/app/features/common-settings/general/EmbedFilters.tsx @@ -0,0 +1,315 @@ +import React, { FormEventHandler, useCallback, useState } from 'react'; +import { + Box, + Button, + Chip, + color, + config, + Icon, + Icons, + Input, + Spinner, + Text, + Tooltip, + TooltipProvider, +} from 'folds'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../../room-settings/styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { useRoom } from '../../../hooks/useRoom'; +import { + useRoomEmbedFilters, + useRoomWideEmbedFilters, + useSetRoomWideEmbedFilters, + EmbedFiltersContent, +} from '../../../hooks/useRoomEmbedFilters'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { StateEvent } from '../../../../types/matrix/room'; + +/** + * Validates that a string is a valid regex pattern. + * @param pattern - The pattern to validate + * @returns True if the pattern is valid + */ +const isValidPattern = (pattern: string): boolean => { + try { + // eslint-disable-next-line no-new + new RegExp(pattern); + return true; + } catch { + return false; + } +}; + +/** + * Props for the pattern list editor component. + */ +interface PatternListEditorProps { + patterns: string[]; + onAdd: (pattern: string) => Promise; + onRemove: (pattern: string) => Promise; + disabled?: boolean; + saving: boolean; + error?: string; +} + +/** + * Reusable component for adding/removing patterns. + */ +function PatternListEditor({ + patterns, + onAdd, + onRemove, + disabled, + saving, + error, +}: PatternListEditorProps) { + const [newPattern, setNewPattern] = useState(''); + const [patternError, setPatternError] = useState(); + + const handleAddPattern: FormEventHandler = async (evt) => { + evt.preventDefault(); + const pattern = newPattern.trim(); + + if (!pattern) { + setPatternError('Pattern cannot be empty'); + return; + } + + if (!isValidPattern(pattern)) { + setPatternError('Invalid regex pattern'); + return; + } + + if (patterns.includes(pattern)) { + setPatternError('Pattern already exists'); + return; + } + + setPatternError(undefined); + await onAdd(pattern); + setNewPattern(''); + }; + + return ( + + + + ) => { + setNewPattern(e.target.value); + setPatternError(undefined); + }} + placeholder="e.g. ^https?://example\.com" + size="300" + variant="Secondary" + radii="300" + disabled={disabled || saving} + style={{ width: '100%' }} + /> + {patternError && ( + + {patternError} + + )} + + + + + {patterns.length > 0 && ( + + + Active patterns ({patterns.length}): + + + {patterns.map((pattern) => ( + + {pattern} + + } + > + {(triggerRef) => ( + onRemove(pattern)} + after={!disabled && } + disabled={disabled || saving} + > + + {pattern} + + + )} + + ))} + + + )} + + {error && ( + + Failed to save: {error} + + )} + + ); +} + +/** + * Component for managing personal URL embed filter patterns for a room. + * These patterns are stored per-user per-room and filter out URL previews + * that match the patterns. + */ +export function PersonalEmbedFilters() { + const room = useRoom(); + const [filters, setFilters] = useRoomEmbedFilters(room); + + const [saveState, saveFilters] = useAsyncCallback( + useCallback( + async (newFilters: EmbedFiltersContent) => { + await setFilters(newFilters); + }, + [setFilters] + ) + ); + + const saving = saveState.status === AsyncStatus.Loading; + + const handleAdd = async (pattern: string) => { + const newFilters: EmbedFiltersContent = { + disabledPatterns: [...filters.disabledPatterns, pattern], + }; + await saveFilters(newFilters); + }; + + const handleRemove = async (pattern: string) => { + const newFilters: EmbedFiltersContent = { + disabledPatterns: filters.disabledPatterns.filter((p) => p !== pattern), + }; + await saveFilters(newFilters); + }; + + return ( + + + + + ); +} + +/** + * Component for managing room-wide URL embed filter patterns. + * These patterns are set by room admins and apply to all users. + */ +export function RoomWideEmbedFilters() { + const room = useRoom(); + const mx = useMatrixClient(); + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); + + const canEdit = permissions.stateEvent(StateEvent.RoomEmbedFilters, mx.getSafeUserId()); + const filters = useRoomWideEmbedFilters(room); + const setRoomWideFilters = useSetRoomWideEmbedFilters(room); + + const [saveState, saveFilters] = useAsyncCallback( + useCallback( + async (newFilters: EmbedFiltersContent) => { + await setRoomWideFilters(newFilters); + }, + [setRoomWideFilters] + ) + ); + + const saving = saveState.status === AsyncStatus.Loading; + + const handleAdd = async (pattern: string) => { + const newFilters: EmbedFiltersContent = { + disabledPatterns: [...filters.disabledPatterns, pattern], + }; + await saveFilters(newFilters); + }; + + const handleRemove = async (pattern: string) => { + const newFilters: EmbedFiltersContent = { + disabledPatterns: filters.disabledPatterns.filter((p) => p !== pattern), + }; + await saveFilters(newFilters); + }; + + return ( + + + + + ); +} + +/** + * Combined component showing both personal and room-wide embed filters. + */ +export function EmbedFilters() { + return ( + + + + + ); +} diff --git a/src/app/features/common-settings/general/index.ts b/src/app/features/common-settings/general/index.ts index 80804b0..9b2e2ac 100644 --- a/src/app/features/common-settings/general/index.ts +++ b/src/app/features/common-settings/general/index.ts @@ -1,3 +1,4 @@ +export * from './EmbedFilters'; export * from './RoomAddress'; export * from './RoomEncryption'; export * from './RoomHistoryVisibility'; diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 4b19e51..e078ed3 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -56,6 +56,7 @@ import { useGetRoom } from '../../hooks/useGetRoom'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; +import { joiningProgressAtom } from '../../state/joiningProgress'; const useCanDropLobbyItem = ( space: Room, @@ -158,6 +159,8 @@ export function Lobby() { const spacePowerLevels = usePowerLevels(space); const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []); const members = useRoomMembers(mx, space.roomId); + const joiningProgress = useAtomValue(joiningProgressAtom); + const currentJoiningProgress = joiningProgress.get(space.roomId); const scrollRef = useRef(null); const heroSectionRef = useRef(null); @@ -530,6 +533,33 @@ export function Lobby() { )} + {currentJoiningProgress && ( + + } + > + + Joining Channels: {currentJoiningProgress.current}/ + {currentJoiningProgress.total} + + + + )} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 33b21bf..a30a89e 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -181,7 +181,14 @@ const RoomNavItemMenu = forwardRef( {(promptLeave, setPromptLeave) => ( <> setPromptLeave(true)} + onClick={(evt) => { + if (evt.shiftKey) { + mx.leave(room.roomId); + requestClose(); + } else { + setPromptLeave(true); + } + }} variant="Critical" fill="None" size="300" diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index d9c16c9..92eb32e 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useRoom } from '../../../hooks/useRoom'; import { + EmbedFilters, RoomProfile, RoomEncryption, RoomHistoryVisibility, @@ -53,6 +54,10 @@ export function General({ requestClose }: GeneralProps) { + + Link Previews + + Addresses diff --git a/src/app/features/room-settings/permissions/usePermissionItems.ts b/src/app/features/room-settings/permissions/usePermissionItems.ts index 513f82b..5612890 100644 --- a/src/app/features/room-settings/permissions/usePermissionItems.ts +++ b/src/app/features/room-settings/permissions/usePermissionItems.ts @@ -174,6 +174,19 @@ export const usePermissionGroups = (): PermissionGroup[] => { ], }; + const callsGroup: PermissionGroup = { + name: 'Calls', + items: [ + { + location: { + state: true, + key: StateEvent.CallMember, + }, + name: 'Join/Start Calls', + }, + ], + }; + const otherSettingsGroup: PermissionGroup = { name: 'Other', items: [ @@ -199,6 +212,7 @@ export const usePermissionGroups = (): PermissionGroup[] => { moderationGroup, roomOverviewGroup, roomSettingsGroup, + callsGroup, otherSettingsGroup, ]; }, []); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 9e729be..dbc9db1 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -126,6 +126,11 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; +import { + useRoomEmbedFilters, + useRoomWideEmbedFilters, + combineEmbedFilters, +} from '../../hooks/useRoomEmbedFilters'; /** Information about a call member event for grouping */ interface CallEventInfo { @@ -455,6 +460,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; + const [personalEmbedFilters] = useRoomEmbedFilters(room); + const roomWideEmbedFilters = useRoomWideEmbedFilters(room); + const combinedEmbedFilters = useMemo( + () => combineEmbedFilters(personalEmbedFilters, roomWideEmbedFilters), + [personalEmbedFilters, roomWideEmbedFilters] + ); const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); @@ -1119,6 +1130,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} + disabledEmbedPatterns={combinedEmbedFilters} /> )} @@ -1225,6 +1237,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} + disabledEmbedPatterns={combinedEmbedFilters} /> ); } diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 45e4f56..03bd18d 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -509,7 +509,17 @@ export const MessageDeleteItem = as< size="300" after={} radii="300" - onClick={() => setOpen(true)} + onClick={(evt) => { + if (evt.shiftKey) { + const eventId = mEvent.getId(); + if (eventId) { + mx.redactEvent(room.roomId, eventId); + onClose?.(); + } + } else { + setOpen(true); + } + }} aria-pressed={open} {...props} ref={ref} diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index e982a79..c5d5527 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -23,7 +23,10 @@ import { Header, config, Spinner, + color, + toRem, } from 'folds'; +import { HexColorPicker } from 'react-colorful'; import FocusTrap from 'focus-trap-react'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; @@ -43,6 +46,8 @@ import { ModalWide } from '../../../styles/Modal.css'; import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { useCapabilities } from '../../../hooks/useCapabilities'; +import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut'; +import { useUserColor } from '../../../hooks/useUserColor'; type ProfileProps = { profile: UserProfile; @@ -303,6 +308,126 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) { ); } +/** + * Color picker component for user's profile color + * Stored in avatar image metadata, visible to other Paarrot users + */ +function ProfileColor() { + const [userColor, setUserColor, loading] = useUserColor(); + const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6'); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(); + + useEffect(() => { + if (userColor) { + setLocalColor(userColor); + } + }, [userColor]); + + const handleColorChange = (newColor: string) => { + setLocalColor(newColor); + setError(undefined); + }; + + const handleSaveColor = async () => { + setSaving(true); + setError(undefined); + try { + await setUserColor(localColor); + } catch (e) { + setError('Failed to save. Make sure you have an avatar set.'); + } + setSaving(false); + }; + + const handleRemoveColor = async () => { + setSaving(true); + setError(undefined); + try { + await setUserColor(undefined); + setLocalColor('#3b82f6'); + } catch (e) { + setError('Failed to remove color.'); + } + setSaving(false); + }; + + return ( + + Profile Color + + } + description="Embedded in your avatar. Other Paarrot users will see this color." + after={ + + {loading ? ( + Loading... + ) : ( + + + + { + setLocalColor(e.target.value); + setError(undefined); + }} + /> + + + {error && ( + {error} + )} + + } + onRemove={userColor ? handleRemoveColor : undefined} + > + {(onOpen) => ( + + )} + + )} + + } + /> + ); +} + export function Profile() { const mx = useMatrixClient(); const userId = mx.getUserId()!; @@ -318,6 +443,7 @@ export function Profile() { gap="400" > + diff --git a/src/app/features/settings/audio/Audio.tsx b/src/app/features/settings/audio/Audio.tsx index 6a7a9f8..6a44f6e 100644 --- a/src/app/features/settings/audio/Audio.tsx +++ b/src/app/features/settings/audio/Audio.tsx @@ -23,6 +23,8 @@ import { SequenceCard } from '../../../components/sequence-card'; import { SettingTile } from '../../../components/setting-tile'; import { SequenceCardStyle } from '../styles.css'; import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; /** Represents an audio device (microphone or speaker) */ interface AudioDevice { @@ -447,6 +449,7 @@ export function Audio({ requestClose }: AudioProps) { const [speakers, setSpeakers] = useState([]); const [settings, setSettings] = useState(loadAudioSettings); const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'prompt'>('prompt'); + const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor'); useEffect(() => { const loadDevices = async () => { @@ -797,6 +800,17 @@ export function Audio({ requestClose }: AudioProps) { /> } /> + + } + /> ; @@ -431,131 +429,10 @@ function Appearance() { } /> - - ); } -/** - * Color picker component for user's profile color - * Stored in avatar PNG metadata, visible to other Paarrot users - */ -function UserColorPicker() { - const [userColor, setUserColor, loading] = useUserColor(); - const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6'); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(); - - // Update local color when avatar color changes - useEffect(() => { - if (userColor) { - setLocalColor(userColor); - } - }, [userColor]); - - const handleColorChange = (newColor: string) => { - setLocalColor(newColor); - setError(undefined); - }; - - const handleSaveColor = async () => { - setSaving(true); - setError(undefined); - try { - await setUserColor(localColor); - } catch (e) { - setError('Failed to save. Make sure you have an avatar set.'); - } - setSaving(false); - }; - - const handleRemoveColor = async () => { - setSaving(true); - setError(undefined); - try { - await setUserColor(undefined); - setLocalColor('#3b82f6'); - } catch (e) { - setError('Failed to remove color.'); - } - setSaving(false); - }; - - return ( - - - {loading ? ( - Loading... - ) : ( - - - - { - setLocalColor(e.target.value); - setError(undefined); - }} - /> - - - {error && ( - {error} - )} - - } - onRemove={userColor ? handleRemoveColor : undefined} - > - {(onOpen) => ( - - )} - - )} - - } - /> - - ); -} - type DateHintProps = { hasChanges: boolean; handleReset: () => void; @@ -943,6 +820,32 @@ function Editor() { ); } +function Spaces() { + const [autoJoinSpaceRooms, setAutoJoinSpaceRooms] = useSetting( + settingsAtom, + 'autoJoinSpaceRooms' + ); + + return ( + + Spaces + + + } + /> + + + ); +} + function SelectMessageLayout() { const [menuCords, setMenuCords] = useState(); const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout'); @@ -1207,6 +1110,7 @@ export function General({ requestClose }: GeneralProps) { + diff --git a/src/app/hooks/useAutoJoinSpaceRooms.ts b/src/app/hooks/useAutoJoinSpaceRooms.ts new file mode 100644 index 0000000..b29d712 --- /dev/null +++ b/src/app/hooks/useAutoJoinSpaceRooms.ts @@ -0,0 +1,247 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { MatrixClient, Room, RoomEvent } from 'matrix-js-sdk'; +import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { Membership } from '../../types/matrix/room'; +import { isSpace } from '../utils/room'; +import { settingsAtom } from '../state/settings'; +import { + setRoomNotificationPreference, + RoomNotificationMode, +} from './useRoomsNotificationPreferences'; +import { updateJoiningProgressAtom } from '../state/joiningProgress'; + +const PER_PAGE_COUNT = 100; +const MAX_PAGES = 50; +const JOIN_DELAY_MS = 500; + +/** Join rules that allow automatic joining */ +const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted']; + +/** + * Extracts the server name from a Matrix room ID + * @param roomId - The room ID (e.g., "!abc123:matrix.org") + * @returns The server name (e.g., "matrix.org") + */ +function getServerFromRoomId(roomId: string): string { + const parts = roomId.split(':'); + return parts.length > 1 ? parts[1] : ''; +} + +/** + * Checks if a room/space can be automatically joined based on its join rule + * @param hierarchyRoom - The room info from the space hierarchy + * @returns Whether the room/space should be auto-joined + */ +function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean { + const { join_rule: joinRule } = hierarchyRoom; + + // Only join public or restricted rooms/spaces + // Restricted ones are joinable if you're a member of the parent space + if (!joinRule) { + return false; + } + + return AUTO_JOINABLE_RULES.includes(joinRule); +} + +/** + * Checks if a hierarchy item is a space + * @param hierarchyRoom - The room info from the space hierarchy + * @returns Whether the item is a space + */ +function isHierarchySpace(hierarchyRoom: IHierarchyRoom): boolean { + return hierarchyRoom.room_type === 'm.space'; +} + +/** + * Fetches all rooms from a space hierarchy + * @param mx - Matrix client + * @param spaceId - The space room ID + * @returns Array of hierarchy rooms + */ +async function fetchSpaceHierarchy( + mx: MatrixClient, + spaceId: string +): Promise { + const allRooms: IHierarchyRoom[] = []; + let nextBatch: string | undefined; + let pageCount = 0; + + while (pageCount < MAX_PAGES) { + try { + // eslint-disable-next-line no-await-in-loop + const result = await mx.getRoomHierarchy(spaceId, PER_PAGE_COUNT, undefined, false, nextBatch); + allRooms.push(...result.rooms); + nextBatch = result.next_batch; + pageCount += 1; + + if (!nextBatch) { + break; + } + } catch { + break; + } + } + + return allRooms; +} + +/** + * Delays execution for a specified time + * @param ms - Milliseconds to delay + */ +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** Callback for reporting join progress */ +type ProgressCallback = (current: number, total: number) => void; + +/** + * Attempts to join rooms/spaces with rate limiting and sets notification to Mentions & Keywords for rooms + * @param mx - Matrix client + * @param rooms - Array of hierarchy rooms to join + * @param spaceId - The parent space ID (for via servers) + * @param onProgress - Callback to report progress + */ +async function joinRoomsSequentially( + mx: MatrixClient, + rooms: IHierarchyRoom[], + spaceId: string, + onProgress?: ProgressCallback +): Promise { + const spaceServer = getServerFromRoomId(spaceId); + + const itemsToJoin = rooms.filter((room) => { + // Check if already joined + const existingRoom = mx.getRoom(room.room_id); + if (existingRoom?.getMyMembership() === Membership.Join) { + return false; + } + + // Check if can auto-join + return canAutoJoin(room); + }); + + // Separate spaces and rooms - join spaces first (rooms may depend on space membership) + const spacesToJoin = itemsToJoin.filter(isHierarchySpace); + const roomsToJoin = itemsToJoin.filter((r) => !isHierarchySpace(r)); + + const total = itemsToJoin.length; + let current = 0; + + // Report initial progress + if (total > 0) { + onProgress?.(current, total); + } + + // Process item (space or room) + const joinItem = async (item: IHierarchyRoom, isItemSpace: boolean): Promise => { + try { + const itemServer = getServerFromRoomId(item.room_id); + const viaServers = [itemServer, spaceServer].filter(Boolean); + + await mx.joinRoom(item.room_id, { viaServers }); + + // Only set notification preferences for rooms, not spaces + if (!isItemSpace) { + await setRoomNotificationPreference( + mx, + item.room_id, + RoomNotificationMode.SpecialMessages, + RoomNotificationMode.Unset + ); + } + + await delay(JOIN_DELAY_MS); + } catch { + // Continue with other items even if one fails + } finally { + current += 1; + onProgress?.(current, total); + } + }; + + // Join spaces first, then rooms + await spacesToJoin.reduce( + (promise, space) => promise.then(() => joinItem(space, true)), + Promise.resolve() + ); + + await roomsToJoin.reduce( + (promise, room) => promise.then(() => joinItem(room, false)), + Promise.resolve() + ); +} + +/** + * Hook that automatically joins all accessible rooms when joining a space + * @param mx - Matrix client instance + */ +export function useAutoJoinSpaceRooms(mx: MatrixClient): void { + const settings = useAtomValue(settingsAtom); + const setJoiningProgress = useSetAtom(updateJoiningProgressAtom); + const processingSpaces = useRef>(new Set()); + + const handleSpaceJoin = useCallback( + async (room: Room) => { + if (!settings.autoJoinSpaceRooms) { + return; + } + + // Only process spaces + if (!isSpace(room)) { + return; + } + + // Only process when joining (not invites or leaves) + if (room.getMyMembership() !== Membership.Join) { + return; + } + + const spaceId = room.roomId; + + // Prevent duplicate processing + if (processingSpaces.current.has(spaceId)) { + return; + } + + processingSpaces.current.add(spaceId); + + try { + // Fetch the space hierarchy + const hierarchyRooms = await fetchSpaceHierarchy(mx, spaceId); + + // Filter out the space itself + const childRooms = hierarchyRooms.filter((r) => r.room_id !== spaceId); + + // Join all accessible rooms with progress tracking + await joinRoomsSequentially(mx, childRooms, spaceId, (current, total) => { + setJoiningProgress({ spaceId, progress: { current, total } }); + }); + } finally { + processingSpaces.current.delete(spaceId); + // Clear progress when done + setJoiningProgress({ spaceId, progress: null }); + } + }, + [mx, settings.autoJoinSpaceRooms, setJoiningProgress] + ); + + useEffect(() => { + const onMembershipChange = (room: Room, membership: string) => { + if (membership === Membership.Join) { + handleSpaceJoin(room); + } + }; + + mx.on(RoomEvent.MyMembership, onMembershipChange); + + return () => { + mx.removeListener(RoomEvent.MyMembership, onMembershipChange); + }; + }, [mx, handleSpaceJoin]); +} diff --git a/src/app/hooks/useRoomEmbedFilters.ts b/src/app/hooks/useRoomEmbedFilters.ts new file mode 100644 index 0000000..9dad47c --- /dev/null +++ b/src/app/hooks/useRoomEmbedFilters.ts @@ -0,0 +1,175 @@ +import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; +import { useCallback, useEffect, useState } from 'react'; +import { useMatrixClient } from './useMatrixClient'; +import { useStateEvent } from './useStateEvent'; +import { StateEvent } from '../../types/matrix/room'; + +/** + * The room account data type for storing personal embed filter patterns. + */ +export const EMBED_FILTERS_ACCOUNT_DATA_TYPE = 'im.paarrot.embed_filters'; + +/** + * Content structure for embed filter data. + */ +export interface EmbedFiltersContent { + /** + * Array of regex pattern strings to match URLs that should not show embeds. + * Each pattern will be tested against URLs using RegExp.test(). + */ + disabledPatterns: string[]; +} + +/** + * Default empty embed filters content. + */ +const DEFAULT_EMBED_FILTERS: EmbedFiltersContent = { + disabledPatterns: [], +}; + +/** + * Gets the personal embed filters content from room account data. + * @param room - The Matrix room to get filters for + * @returns The embed filters content + */ +const getPersonalEmbedFiltersFromRoom = (room: Room): EmbedFiltersContent => { + const accountDataEvent = room.getAccountData(EMBED_FILTERS_ACCOUNT_DATA_TYPE); + if (!accountDataEvent) { + return DEFAULT_EMBED_FILTERS; + } + + const content = accountDataEvent.getContent() as Partial; + return { + disabledPatterns: Array.isArray(content.disabledPatterns) ? content.disabledPatterns : [], + }; +}; + +/** + * Hook to get and set personal embed filter patterns for a room. + * These patterns are stored in room account data (per-user, per-room). + * @param room - The Matrix room + * @returns A tuple of [filters, setFilters] + */ +export const useRoomEmbedFilters = ( + room: Room +): [EmbedFiltersContent, (filters: EmbedFiltersContent) => Promise] => { + const mx = useMatrixClient(); + const [filters, setFiltersState] = useState(() => + getPersonalEmbedFiltersFromRoom(room) + ); + + useEffect(() => { + const handleAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (event) => { + if (event.getType() === EMBED_FILTERS_ACCOUNT_DATA_TYPE) { + setFiltersState(getPersonalEmbedFiltersFromRoom(room)); + } + }; + + room.on(RoomEvent.AccountData, handleAccountData); + return () => { + room.removeListener(RoomEvent.AccountData, handleAccountData); + }; + }, [room]); + + const setFilters = useCallback( + async (newFilters: EmbedFiltersContent): Promise => { + await mx.setRoomAccountData(room.roomId, EMBED_FILTERS_ACCOUNT_DATA_TYPE, newFilters); + }, + [mx, room.roomId] + ); + + return [filters, setFilters]; +}; + +/** + * Hook to get room-wide embed filter patterns set by room admins. + * These patterns are stored as room state and apply to all users. + * @param room - The Matrix room + * @returns The room-wide embed filters content + */ +export const useRoomWideEmbedFilters = (room: Room): EmbedFiltersContent => { + const stateEvent = useStateEvent(room, StateEvent.RoomEmbedFilters); + if (!stateEvent) { + return DEFAULT_EMBED_FILTERS; + } + + const content = stateEvent.getContent() as Partial; + return { + disabledPatterns: Array.isArray(content.disabledPatterns) ? content.disabledPatterns : [], + }; +}; + +/** + * Hook to set room-wide embed filter patterns. + * Requires appropriate power level to send state events. + * @param room - The Matrix room + * @returns Function to save room-wide filters + */ +export const useSetRoomWideEmbedFilters = ( + room: Room +): ((filters: EmbedFiltersContent) => Promise) => { + const mx = useMatrixClient(); + + const setFilters = useCallback( + async (newFilters: EmbedFiltersContent): Promise => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await mx.sendStateEvent(room.roomId, StateEvent.RoomEmbedFilters as any, newFilters); + }, + [mx, room.roomId] + ); + + return setFilters; +}; + +/** + * Combines personal and room-wide embed filter patterns. + * @param personalFilters - Personal (per-user) filters + * @param roomWideFilters - Room-wide (admin-set) filters + * @returns Combined unique patterns + */ +export const combineEmbedFilters = ( + personalFilters: EmbedFiltersContent, + roomWideFilters: EmbedFiltersContent +): string[] => { + const combined = new Set([ + ...personalFilters.disabledPatterns, + ...roomWideFilters.disabledPatterns, + ]); + return Array.from(combined); +}; + +/** + * Tests if a URL should have its embed disabled based on the filter patterns. + * @param url - The URL to test + * @param disabledPatterns - Array of regex pattern strings + * @returns True if the URL matches any disabled pattern + */ +export const isUrlEmbedDisabled = (url: string, disabledPatterns: string[]): boolean => { + if (!disabledPatterns || disabledPatterns.length === 0) { + return false; + } + + return disabledPatterns.some((pattern) => { + try { + const regex = new RegExp(pattern, 'i'); + return regex.test(url); + } catch { + // Invalid regex pattern, skip it + return false; + } + }); +}; + +/** + * Filters an array of URLs, removing those that match any disabled pattern. + * @param urls - Array of URLs to filter + * @param disabledPatterns - Array of regex pattern strings + * @returns Array of URLs that don't match any disabled pattern + */ +export const filterDisabledUrls = (urls: string[], disabledPatterns: string[]): string[] => { + if (!disabledPatterns || disabledPatterns.length === 0) { + return urls; + } + + return urls.filter((url) => !isUrlEmbedDisabled(url, disabledPatterns)); +}; diff --git a/src/app/hooks/useRoomsNotificationPreferences.ts b/src/app/hooks/useRoomsNotificationPreferences.ts index ceea95f..b078896 100644 --- a/src/app/hooks/useRoomsNotificationPreferences.ts +++ b/src/app/hooks/useRoomsNotificationPreferences.ts @@ -101,7 +101,6 @@ export const useRoomNotificationPreference = ( export const getRoomNotificationModeIcon = (mode?: RoomNotificationMode): IconSrc => { if (mode === RoomNotificationMode.Mute) return Icons.BellMute; - if (mode === RoomNotificationMode.SpecialMessages) return Icons.BellPing; if (mode === RoomNotificationMode.AllMessages) return Icons.BellRing; return Icons.Bell; diff --git a/src/app/hooks/useSidebarItems.ts b/src/app/hooks/useSidebarItems.ts index 16aba22..69b7fbb 100644 --- a/src/app/hooks/useSidebarItems.ts +++ b/src/app/hooks/useSidebarItems.ts @@ -17,6 +17,7 @@ export type SidebarItems = Array; export type InCinnySpacesContent = { shortcut?: string[]; sidebar?: SidebarItems; + hidden?: string[]; }; export const parseSidebar = ( @@ -25,12 +26,14 @@ export const parseSidebar = ( content?: InCinnySpacesContent ) => { const sidebar = content?.sidebar ?? content?.shortcut ?? []; + const hiddenSet = new Set(content?.hidden ?? []); const orphans = new Set(orphanSpaces); const items: SidebarItems = []; const safeToAdd = (spaceId: string): boolean => { if (typeof spaceId !== 'string') return false; + if (hiddenSet.has(spaceId)) return false; const space = mx.getRoom(spaceId); if (space?.getMyMembership() !== Membership.Join) return false; return isSpace(space); @@ -52,17 +55,39 @@ export const parseSidebar = ( ) { const safeContent = item.content.filter(safeToAdd); safeContent.forEach((i) => orphans.delete(i)); - items.push({ - ...item, - content: Array.from(new Set(safeContent)), - }); + if (safeContent.length > 0) { + items.push({ + ...item, + content: Array.from(new Set(safeContent)), + }); + } } }); - orphans.forEach((spaceId) => items.push(spaceId)); + orphans.forEach((spaceId) => { + if (!hiddenSet.has(spaceId)) { + items.push(spaceId); + } + }); return items; }; +/** + * Parse hidden spaces from account data + */ +export const parseHiddenSpaces = ( + mx: MatrixClient, + content?: InCinnySpacesContent +): string[] => { + const hidden = content?.hidden ?? []; + return hidden.filter((spaceId) => { + if (typeof spaceId !== 'string') return false; + const space = mx.getRoom(spaceId); + if (space?.getMyMembership() !== Membership.Join) return false; + return isSpace(space); + }); +}; + export const useSidebarItems = ( orphanSpaces: string[] ): [SidebarItems, Dispatch>] => { @@ -136,3 +161,80 @@ export const makeCinnySpacesContent = ( return newSpacesContent; }; + +/** + * Hook to get and manage hidden spaces + */ +export const useHiddenSpaces = (): [string[], Dispatch>] => { + const mx = useMatrixClient(); + + const [hiddenSpaces, setHiddenSpaces] = useState(() => { + const content = getAccountData( + mx, + AccountDataEvent.CinnySpaces + )?.getContent(); + return parseHiddenSpaces(mx, content); + }); + + useEffect(() => { + const content = getAccountData( + mx, + AccountDataEvent.CinnySpaces + )?.getContent(); + setHiddenSpaces(parseHiddenSpaces(mx, content)); + }, [mx]); + + useAccountDataCallback( + mx, + useCallback( + (mEvent) => { + if (mEvent.getType() === AccountDataEvent.CinnySpaces) { + const newContent = mEvent.getContent(); + setHiddenSpaces(parseHiddenSpaces(mx, newContent)); + } + }, + [mx] + ) + ); + + return [hiddenSpaces, setHiddenSpaces]; +}; + +/** + * Create content for hiding a space + */ +export const makeHideSpaceContent = ( + mx: MatrixClient, + spaceId: string +): InCinnySpacesContent => { + const currentContent = + getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent() ?? {}; + + const currentHidden = currentContent.hidden ?? []; + if (currentHidden.includes(spaceId)) { + return currentContent; + } + + return { + ...currentContent, + hidden: [...currentHidden, spaceId], + }; +}; + +/** + * Create content for unhiding a space + */ +export const makeUnhideSpaceContent = ( + mx: MatrixClient, + spaceId: string +): InCinnySpacesContent => { + const currentContent = + getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent() ?? {}; + + const currentHidden = currentContent.hidden ?? []; + + return { + ...currentContent, + hidden: currentHidden.filter((id) => id !== spaceId), + }; +}; diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 52ec7f2..e920a99 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -3,7 +3,6 @@ import { Provider as JotaiProvider } from 'jotai'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ClientConfigLoader } from '../components/ClientConfigLoader'; import { ClientConfigProvider } from '../hooks/useClientConfig'; @@ -39,7 +38,6 @@ function App() { - )} diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index 4a077ba..86aad0e 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -1,5 +1,6 @@ import React, { ReactNode } from 'react'; import { Box } from 'folds'; +import { DockedCallPanel } from '../../features/call/DockedCallPanel'; type ClientLayoutProps = { nav: ReactNode; @@ -10,6 +11,7 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) { {nav} {children} + ); } diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index a2e1b68..221463a 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -18,6 +18,7 @@ import { SearchTab, } from './sidebar'; import { CreateTab } from './sidebar/CreateTab'; +import { HiddenSpacesTabs } from './sidebar/SpaceTabs'; export function SidebarNav() { const scrollRef = useRef(null); @@ -37,6 +38,7 @@ export function SidebarNav() { + } sticky={ diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index d87db96..33fc3a7 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -18,6 +18,7 @@ import { import { useVirtualizer } from '@tanstack/react-virtual'; import { useAtom, useAtomValue } from 'jotai'; import FocusTrap from 'focus-trap-react'; +import { Room } from 'matrix-js-sdk'; import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; import { NavButton, @@ -64,44 +65,73 @@ import { } from '../../../hooks/useRoomsNotificationPreferences'; import { UseStateProvider } from '../../../components/UseStateProvider'; import { JoinAddressPrompt } from '../../../components/join-address-prompt'; +import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt'; import { _RoomSearchParams } from '../../paths'; type HomeMenuProps = { requestClose: () => void; + onManageRooms: () => void; }; -const HomeMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useHomeRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); +const HomeMenu = forwardRef( + ({ requestClose, onManageRooms }, ref) => { + const orphanRooms = useHomeRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + const mx = useMatrixClient(); - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; + const handleMarkAsRead = () => { + if (!unread) return; + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + requestClose(); + }; - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); + const handleManageRooms = () => { + requestClose(); + onManageRooms(); + }; + + return ( + + + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + } + radii="300" + aria-disabled={orphanRooms.length === 0} + > + + Manage Rooms + + + + + ); + } +); function HomeHeader() { const [menuAnchor, setMenuAnchor] = useState(); + const [showManageRooms, setShowManageRooms] = useState(false); + const mx = useMatrixClient(); + const orphanRooms = useHomeRooms(); + + const rooms = useMemo(() => { + return orphanRooms + .map((roomId) => mx.getRoom(roomId)) + .filter((room): room is Room => room !== null); + }, [mx, orphanRooms]); const handleOpenMenu: MouseEventHandler = (evt) => { const cords = evt.currentTarget.getBoundingClientRect(); @@ -144,10 +174,20 @@ function HomeHeader() { escapeDeactivates: stopPropagation, }} > - setMenuAnchor(undefined)} /> + setMenuAnchor(undefined)} + onManageRooms={() => setShowManageRooms(true)} + /> } /> + {showManageRooms && ( + setShowManageRooms(false)} + onCancel={() => setShowManageRooms(false)} + /> + )} ); } diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 6fc528a..843a693 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -69,8 +69,12 @@ import { SidebarItems, TSidebarItem, makeCinnySpacesContent, + makeHideSpaceContent, + makeUnhideSpaceContent, + parseHiddenSpaces, parseSidebar, sidebarItemWithout, + useHiddenSpaces, useSidebarItems, } from '../../../hooks/useSidebarItems'; import { AccountDataEvent } from '../../../../types/matrix/accountData'; @@ -93,14 +97,17 @@ import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../../components/invite-user-prompt'; +import { UseStateProvider } from '../../../components/UseStateProvider'; +import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; type SpaceMenuProps = { room: Room; requestClose: () => void; onUnpin?: (roomId: string) => void; + onHide?: (roomId: string) => void; }; const SpaceMenu = forwardRef( - ({ room, requestClose, onUnpin }, ref) => { + ({ room, requestClose, onUnpin, onHide }, ref) => { const mx = useMatrixClient(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const roomToParents = useAtomValue(roomToParentsAtom); @@ -146,6 +153,11 @@ const SpaceMenu = forwardRef( requestClose(); }; + const handleHide = () => { + onHide?.(room.roomId); + requestClose(); + }; + return ( {invitePrompt && room && ( @@ -181,6 +193,18 @@ const SpaceMenu = forwardRef( )} + {onHide && ( + } + > + + Hide Space + + + )} @@ -219,6 +243,42 @@ const SpaceMenu = forwardRef( + + + + {(promptLeave, setPromptLeave) => ( + <> + { + if (evt.shiftKey) { + mx.leave(room.roomId); + requestClose(); + } else { + setPromptLeave(true); + } + }} + variant="Critical" + fill="None" + size="300" + after={} + radii="300" + aria-pressed={promptLeave} + > + + Leave Space + + + {promptLeave && ( + setPromptLeave(false)} + /> + )} + + )} + + ); } @@ -390,6 +450,7 @@ type SpaceTabProps = { onDragging: (dragItem?: SidebarDraggable) => void; disabled?: boolean; onUnpin?: (roomId: string) => void; + onHide?: (roomId: string) => void; }; function SpaceTab({ space, @@ -399,6 +460,7 @@ function SpaceTab({ onDragging, disabled, onUnpin, + onHide, }: SpaceTabProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -489,6 +551,7 @@ function SpaceTab({ room={space} requestClose={() => setMenuAnchor(undefined)} onUnpin={onUnpin} + onHide={onHide} /> } @@ -605,6 +668,176 @@ function ClosedSpaceFolder({ ); } +type HiddenSpacesFolderProps = { + hiddenSpaces: string[]; + open: boolean; + onToggle: () => void; + onSpaceClick: MouseEventHandler; + onUnhide: (roomId: string) => void; + selectedSpaceId?: string; +}; + +/** + * A special folder that displays hidden spaces + */ +function HiddenSpacesFolder({ + hiddenSpaces, + open, + onToggle, + onSpaceClick, + onUnhide, + selectedSpaceId, +}: HiddenSpacesFolderProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [menuAnchor, setMenuAnchor] = useState(); + const [menuSpaceId, setMenuSpaceId] = useState(); + + const handleContextMenu = (spaceId: string): MouseEventHandler => (evt) => { + evt.preventDefault(); + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuSpaceId(spaceId); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + + const handleUnhideClick = () => { + if (menuSpaceId) { + onUnhide(menuSpaceId); + } + setMenuAnchor(undefined); + }; + + const [hovered, setHovered] = useState(false); + + // Check if any hidden space has unreads + const hasUnreads = hiddenSpaces.some((spaceId) => { + const space = mx.getRoom(spaceId); + return space && space.getUnreadNotificationCount() > 0; + }); + + if (!open) { + // Only show when hovered, has unreads, or a hidden space is selected + const isVisible = hovered || hasUnreads || (!!selectedSpaceId && hiddenSpaces.includes(selectedSpaceId)); + + return ( + + {(unread) => ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + opacity: isVisible ? 1 : 0, + transition: 'opacity 0.15s ease', + }} + > + + {(tooltipRef) => ( + + + + )} + + {unread && ( + 0}> + 0} count={unread.total} /> + + )} + + )} + + ); + } + + return ( + + + + + + + {hiddenSpaces.map((spaceId) => { + const space = mx.getRoom(spaceId); + if (!space) return null; + const selected = space.roomId === selectedSpaceId; + + return ( + + {(unread) => ( + + + {(triggerRef) => ( + + ( + {nameInitials(space.name, 2)} + )} + /> + + )} + + {unread && ( + 0}> + 0} count={unread.total} /> + + )} + + )} + + ); + })} + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + } + > + + Unhide Space + + + + + + } + /> + )} + + ); +} + type SpaceTabsProps = { scrollRef: RefObject; }; @@ -615,6 +848,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { const roomToParents = useAtomValue(roomToParentsAtom); const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces); + const [, localEchoHiddenSpaces] = useHiddenSpaces(); const navToActivePath = useAtomValue(useNavToActivePathAtom()); const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom()); const [draggingItem, setDraggingItem] = useState(); @@ -797,6 +1031,16 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { [mx, sidebarItems, orphanSpaces, localEchoSidebarItem] ); + const handleHide = useCallback( + (roomId: string) => { + const newContent = makeHideSpaceContent(mx, roomId); + localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newContent)); + localEchoHiddenSpaces(parseHiddenSpaces(mx, newContent)); + mx.setAccountData(AccountDataEvent.CinnySpaces, newContent); + }, + [mx, orphanSpaces, localEchoSidebarItem, localEchoHiddenSpaces] + ); + if (sidebarItems.length === 0) return null; return ( <> @@ -824,6 +1068,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { : false } onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin} + onHide={handleHide} /> ); })} @@ -857,6 +1102,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { onDragging={setDraggingItem} disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false} onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin} + onHide={handleHide} /> ); })} @@ -864,3 +1110,67 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) { ); } + +/** + * Separate component for hidden spaces that can be rendered after other sidebar items + */ +export function HiddenSpacesTabs() { + const navigate = useNavigate(); + const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const navToActivePath = useAtomValue(useNavToActivePathAtom()); + const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom)); + const [hiddenSpaces, localEchoHiddenSpaces] = useHiddenSpaces(); + const [, localEchoSidebarItem] = useSidebarItems(orphanSpaces); + const [hiddenFolderOpen, setHiddenFolderOpen] = useState(false); + + const selectedSpaceId = useSelectedSpace(); + + const handleSpaceClick: MouseEventHandler = (evt) => { + const target = evt.currentTarget; + const targetSpaceId = target.getAttribute('data-id'); + if (!targetSpaceId) return; + + const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); + if (screenSize === ScreenSize.Mobile) { + navigate(spacePath); + return; + } + + const activePath = navToActivePath.get(targetSpaceId); + if (activePath && activePath.pathname.startsWith(spacePath)) { + navigate(joinPathComponent(activePath)); + return; + } + + navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); + }; + + const handleUnhide = useCallback( + (roomId: string) => { + const newContent = makeUnhideSpaceContent(mx, roomId); + localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newContent)); + localEchoHiddenSpaces(parseHiddenSpaces(mx, newContent)); + mx.setAccountData(AccountDataEvent.CinnySpaces, newContent); + }, + [mx, orphanSpaces, localEchoSidebarItem, localEchoHiddenSpaces] + ); + + if (hiddenSpaces.length === 0) return null; + + return ( + <> + + + setHiddenFolderOpen(!hiddenFolderOpen)} + onSpaceClick={handleSpaceClick} + onUnhide={handleUnhide} + selectedSpaceId={selectedSpaceId} + /> + + + ); +} diff --git a/src/app/state/hooks/useBindAtoms.ts b/src/app/state/hooks/useBindAtoms.ts index d4572ff..f5a5aef 100644 --- a/src/app/state/hooks/useBindAtoms.ts +++ b/src/app/state/hooks/useBindAtoms.ts @@ -5,6 +5,7 @@ import { mDirectAtom, useBindMDirectAtom } from '../mDirectList'; import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread'; import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents'; import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers'; +import { useAutoJoinSpaceRooms } from '../../hooks/useAutoJoinSpaceRooms'; export const useBindAtoms = (mx: MatrixClient) => { useBindMDirectAtom(mx, mDirectAtom); @@ -14,4 +15,5 @@ export const useBindAtoms = (mx: MatrixClient) => { useBindRoomToUnreadAtom(mx, roomToUnreadAtom); useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom); + useAutoJoinSpaceRooms(mx); }; diff --git a/src/app/state/joiningProgress.ts b/src/app/state/joiningProgress.ts new file mode 100644 index 0000000..5c60af2 --- /dev/null +++ b/src/app/state/joiningProgress.ts @@ -0,0 +1,37 @@ +import { atom } from 'jotai'; + +/** + * Represents the progress of auto-joining rooms in a space + */ +export interface JoiningProgress { + /** Total number of rooms/spaces to join */ + total: number; + /** Number of rooms/spaces already processed */ + current: number; +} + +/** + * Atom that tracks the progress of auto-joining rooms per space. + * Key is the space ID, value is the progress. + * When joining is complete, the entry is removed. + */ +export const joiningProgressAtom = atom>(new Map()); + +/** + * Writable atom to update joining progress for a specific space + */ +export const updateJoiningProgressAtom = atom( + null, + (get, set, update: { spaceId: string; progress: JoiningProgress | null }) => { + const current = get(joiningProgressAtom); + const newMap = new Map(current); + + if (update.progress === null) { + newMap.delete(update.spaceId); + } else { + newMap.set(update.spaceId, update.progress); + } + + set(joiningProgressAtom, newMap); + } +); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 864b0d5..d273ede 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -28,6 +28,7 @@ export interface Settings { hideActivity: boolean; isPeopleDrawer: boolean; + isCallPanelDocked: boolean; memberSortFilterIndex: number; enterForNewline: boolean; messageLayout: MessageLayout; @@ -43,10 +44,14 @@ export interface Settings { showNotifications: boolean; isNotificationSounds: boolean; + showRemoteCursor: boolean; + hour24Clock: boolean; dateFormatString: string; developerTools: boolean; + + autoJoinSpaceRooms: boolean; } const defaultSettings: Settings = { @@ -62,6 +67,7 @@ const defaultSettings: Settings = { hideActivity: false, isPeopleDrawer: true, + isCallPanelDocked: false, memberSortFilterIndex: 0, enterForNewline: false, messageLayout: 0, @@ -77,10 +83,14 @@ const defaultSettings: Settings = { showNotifications: true, isNotificationSounds: true, + showRemoteCursor: true, + hour24Clock: false, dateFormatString: 'D MMM YYYY', developerTools: false, + + autoJoinSpaceRooms: true, }; export const getSettings = () => { diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 5ab3672..d3c3d05 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -39,6 +39,9 @@ export enum StateEvent { PoniesRoomEmotes = 'im.ponies.room_emotes', PowerLevelTags = 'in.cinny.room.power_level_tags', + /** Room-wide link embed filter patterns (admin-controlled) */ + RoomEmbedFilters = 'im.paarrot.room.embed_filters', + /** Matrix RTC call membership (MSC3401) */ CallMember = 'org.matrix.msc3401.call.member', } diff --git a/vite.config.js b/vite.config.js index 57d9c55..30b17ee 100644 --- a/vite.config.js +++ b/vite.config.js @@ -101,7 +101,7 @@ export default defineConfig({ injectionPoint: undefined, }, devOptions: { - enabled: true, + enabled: false, type: 'module' } }),