diff --git a/src/app/components/AnimatedOutlet.css.ts b/src/app/components/AnimatedOutlet.css.ts new file mode 100644 index 0000000..fba6579 --- /dev/null +++ b/src/app/components/AnimatedOutlet.css.ts @@ -0,0 +1,21 @@ +import { style } from '@vanilla-extract/css'; + +export const RoutePane = style({ + flex: 1, + minWidth: 0, + minHeight: 0, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', +}); + +/** Full-bleed overlay for the press→navigate gap only. */ +export const PendingOverlay = style({ + position: 'absolute', + inset: 0, + zIndex: 2, + display: 'flex', + minWidth: 0, + minHeight: 0, + pointerEvents: 'none', +}); diff --git a/src/app/components/AnimatedOutlet.tsx b/src/app/components/AnimatedOutlet.tsx index 83e56ff..fc9af0a 100644 --- a/src/app/components/AnimatedOutlet.tsx +++ b/src/app/components/AnimatedOutlet.tsx @@ -1,27 +1,61 @@ -import React from 'react'; -import { Outlet, useLocation } from 'react-router-dom'; +import React, { useEffect } from 'react'; +import { Outlet } from 'react-router-dom'; +import { useAtom, useAtomValue } from 'jotai'; +import { pendingRoomNavigationAtom } from '../state/pendingRoomNavigation'; +import { useMatrixClient } from '../hooks/useMatrixClient'; +import { IsDirectRoomProvider, RoomProvider } from '../hooks/useRoom'; +import { mDirectAtom } from '../state/mDirectList'; +import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; +import { RoomFrame } from '../features/room/Room'; +import * as css from './AnimatedOutlet.css'; + +/** Chrome-only shell for the press→navigate gap (no timeline — avoids scroll fights). */ +function PendingRoomShell({ roomId }: { roomId: string }) { + const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + const room = mx.getRoom(roomId); + + if (!room) return null; -/** - * Wrapper for Outlet that adds route-based animation - * Forces remount on route change by using location as key - */ -export function AnimatedOutlet() { - const location = useLocation(); - return ( -
- +
+ + + + + +
+ ); +} + +/** Optimistic room navigation: brief pending chrome until the route settles. */ +export function AnimatedOutlet() { + const selectedRoomId = useSelectedRoom(); + const [pendingRoomId, setPendingRoomId] = useAtom(pendingRoomNavigationAtom); + + // Safety: if navigation never lands, don't leave pending forever. + useEffect(() => { + if (!pendingRoomId) return undefined; + const t = window.setTimeout(() => setPendingRoomId(null), 4000); + return () => window.clearTimeout(t); + }, [pendingRoomId, setPendingRoomId]); + + // Only cover the gap before the target room route is active. + const showOverlay = + typeof pendingRoomId === 'string' && + pendingRoomId.length > 0 && + pendingRoomId !== selectedRoomId; + + return ( +
+ {showOverlay && } + {/* + Do NOT key this by pathname — room switches share CachedRooms keep-alive. + Remounting here wiped the cache and made warm rooms feel as slow as cold ones. + */} +
+ +
); } diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 7f29d36..6ade4a4 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -27,8 +27,8 @@ import { MATRIX_SPOILER_REASON_PROPERTY_NAME, } from '../../../types/matrix/common'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; -import { parseGeoUri, scaleYDimension, fitMediaSize } from '../../utils/common'; -import { getMediaDimensions } from '../../state/mediaDimensionCache'; +import { parseGeoUri, scaleYDimension } from '../../utils/common'; +import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; import { FileHeader, FileDownloadButton } from './FileHeader'; @@ -206,18 +206,11 @@ type MImageProps = { outlined?: boolean; }; -const ATTACHMENT_MAX = 400; - const resolveMediaBoxSize = ( mxcUrl: string, w?: number, h?: number -): { width: number; height: number } => { - const cached = (!w || !h) ? getMediaDimensions(mxcUrl) : undefined; - const naturalW = w && w > 0 ? w : cached?.w; - const naturalH = h && h > 0 ? h : cached?.h; - return fitMediaSize(naturalW ?? 0, naturalH ?? 0, ATTACHMENT_MAX, ATTACHMENT_MAX); -}; +): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h); export function MImage({ content, renderImageContent, outlined }: MImageProps) { const imgInfo = content?.info; diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 1891867..0f3520f 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -169,7 +169,7 @@ export const ImageContent = as<'div', ImageContentProps>( setShowPlaceholder(true); return undefined; } - const timer = window.setTimeout(() => setShowPlaceholder(false), 300); + const timer = window.setTimeout(() => setShowPlaceholder(false), 540); return () => window.clearTimeout(timer); }, [load]); diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index 9fcf31a..ca615a6 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -134,7 +134,7 @@ export const VideoContent = as<'div', VideoContentProps>( setShowPlaceholder(true); return undefined; } - const timer = window.setTimeout(() => setShowPlaceholder(false), 300); + const timer = window.setTimeout(() => setShowPlaceholder(false), 540); return () => window.clearTimeout(timer); }, [load]); diff --git a/src/app/components/message/content/style.css.ts b/src/app/components/message/content/style.css.ts index f777f9b..481ef65 100644 --- a/src/app/components/message/content/style.css.ts +++ b/src/app/components/message/content/style.css.ts @@ -78,6 +78,15 @@ export const MediaSkeleton = style([ }, ]); +const blurhashFadeIn = keyframes({ + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, +}); + /** Soft blurhash layer that fills the reserved media box. */ export const BlurhashPlaceholder = style([ DefaultReset, @@ -90,6 +99,8 @@ export const BlurhashPlaceholder = style([ // Soften the decode so it reads as color blobs; slight scale hides blur edges. filter: 'blur(14px)', transform: 'scale(1.06)', + opacity: 0, + animation: `${blurhashFadeIn} 560ms cubic-bezier(0.22, 1, 0.36, 1) forwards`, }, ]); @@ -97,7 +108,7 @@ export const BlurhashPlaceholder = style([ export const MediaFadeIn = style({ zIndex: 1, opacity: 0, - transition: 'opacity 280ms ease-out', + transition: 'opacity 520ms cubic-bezier(0.22, 1, 0.36, 1)', }); export const MediaFadeInLoaded = style({ diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 29b0e26..0252012 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -1,12 +1,17 @@ -import React, { ComponentProps, MutableRefObject, ReactNode } from 'react'; +import React, { ComponentProps, MutableRefObject, ReactNode, Suspense, lazy } from 'react'; import { Box, Header, Line, Scroll, Text, as } from 'folds'; import classNames from 'classnames'; import { ContainerColor } from '../../styles/ContainerColor.css'; import * as css from './style.css'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel'; import { useShowCompactMasterView } from '../../hooks/useCompactNav'; +const SidebarDockedCallPanel = lazy(() => + import('../../features/call/SidebarDockedCallPanel').then((module) => ({ + default: module.SidebarDockedCallPanel, + })) +); + type PageRootProps = { nav: ReactNode; children: ReactNode; @@ -46,7 +51,9 @@ export function PageNav({ > {children} - + + + ); diff --git a/src/app/features/call/callMemberUtils.ts b/src/app/features/call/callMemberUtils.ts new file mode 100644 index 0000000..fb970c5 --- /dev/null +++ b/src/app/features/call/callMemberUtils.ts @@ -0,0 +1,60 @@ +import { MatrixEvent, Room } from 'matrix-js-sdk'; + +/** The Matrix state event type for call membership (MSC3401) */ +export const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member'; + +/** Represents an active call member in a room */ +export interface CallMember { + userId: string; + deviceId: string; + sessionId: string; + expiresTs: number; +} + +/** + * Extracts active call members from a room's state events + */ +export function getActiveCallMembers(room: Room): CallMember[] { + const members: CallMember[] = []; + const now = Date.now(); + + try { + const stateEvents = room.currentState.getStateEvents(CALL_MEMBER_EVENT_TYPE); + + stateEvents.forEach((event) => { + const content = event.getContent(); + const userId = event.getStateKey(); + + if (!userId || !content['m.calls']) return; + + const calls = content['m.calls']; + if (!Array.isArray(calls)) return; + + calls.forEach((call) => { + const devices = call['m.devices']; + if (!Array.isArray(devices)) return; + + devices.forEach((device) => { + const expiresTs = device.expires_ts || 0; + + if (expiresTs > now) { + members.push({ + userId, + deviceId: device.device_id, + sessionId: device.session_id, + expiresTs, + }); + } + }); + }); + }); + } catch { + // Ignore malformed call member events + } + + return members; +} + +export function isCallMemberEvent(event: MatrixEvent): boolean { + return event.getType() === CALL_MEMBER_EVENT_TYPE; +} diff --git a/src/app/features/call/useCall.ts b/src/app/features/call/useCall.ts index 6b0417d..914be50 100644 --- a/src/app/features/call/useCall.ts +++ b/src/app/features/call/useCall.ts @@ -230,62 +230,76 @@ export function useCallService(): CallContextValue { /** * Hook to check if a call is active in a specific room */ +/** Persists across keep-alive remounts so room switches don't re-hit well-known. */ +const roomCallSupportCache = new Map(); + export function useRoomCall(roomId: string) { const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall(); const mx = useMatrixClient(); - const [roomCallSupported, setRoomCallSupported] = useState(false); - const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(true); + const [roomCallSupported, setRoomCallSupported] = useState( + () => roomCallSupportCache.get(roomId) ?? false + ); + const [roomCallSupportLoading, setRoomCallSupportLoading] = useState( + () => !roomCallSupportCache.has(roomId) + ); // Check if calls are supported for this specific room by checking all priority homeservers useEffect(() => { + const cached = roomCallSupportCache.get(roomId); + if (cached !== undefined) { + setRoomCallSupported(cached); + setRoomCallSupportLoading(false); + return undefined; + } + + let cancelled = false; + const checkRoomCallSupport = async () => { setRoomCallSupportLoading(true); - + const room = mx.getRoom(roomId); if (!room) { - setRoomCallSupported(false); - setRoomCallSupportLoading(false); + if (!cancelled) { + setRoomCallSupported(false); + setRoomCallSupportLoading(false); + } return; } - // Get homeserver priority list for this room const homeserverPriority = getLiveKitHomeserverPriority( room, mx.getHomeserverUrl(), mx.getSafeUserId() ); - console.log(`Checking call support for room ${roomId}, homeserver priority:`, homeserverPriority); - - // Check each homeserver in priority order for (const homeserver of homeserverPriority) { try { - console.log(`Fetching well-known from ${homeserver}...`); const wellKnown = await fetchWellKnownWithRTC(homeserver); - console.log(`Well-known response from ${homeserver}:`, wellKnown); - const livekitFocus = getLiveKitFocus(wellKnown); - console.log(`LiveKit focus from ${homeserver}:`, livekitFocus); - if (livekitFocus) { - console.log(`Call support found for room ${roomId} on homeserver: ${homeserver}`); - setRoomCallSupported(true); - setRoomCallSupportLoading(false); + roomCallSupportCache.set(roomId, true); + if (!cancelled) { + setRoomCallSupported(true); + setRoomCallSupportLoading(false); + } return; } - } catch (error) { - console.warn(`Failed to check call support on ${homeserver}:`, error); + } catch { // Continue to next homeserver } } - // No homeserver supports calls - console.log(`No call support found for room ${roomId}`); - setRoomCallSupported(false); - setRoomCallSupportLoading(false); + roomCallSupportCache.set(roomId, false); + if (!cancelled) { + setRoomCallSupported(false); + setRoomCallSupportLoading(false); + } }; checkRoomCallSupport(); + return () => { + cancelled = true; + }; }, [roomId, mx]); const isInCall = activeCall?.roomId === roomId; diff --git a/src/app/features/call/useRoomCallMembers.ts b/src/app/features/call/useRoomCallMembers.ts index b6ffde3..a39813a 100644 --- a/src/app/features/call/useRoomCallMembers.ts +++ b/src/app/features/call/useRoomCallMembers.ts @@ -1,115 +1,12 @@ -/* eslint-disable no-console */ -import { useCallback, useEffect, useState } from 'react'; -import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useRoomCallMembersFromRegistry } from '../../state/activeCallRoomsRegistry'; -/** The Matrix state event type for call membership (MSC3401) */ -const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member'; - -/** Represents an active call member in a room */ -export interface CallMember { - userId: string; - deviceId: string; - sessionId: string; - expiresTs: number; -} +export type { CallMember } from './callMemberUtils'; +export { getActiveCallMembers, CALL_MEMBER_EVENT_TYPE } from './callMemberUtils'; /** - * Extracts active call members from a room's state events - * @param room - The Matrix room to check - * @returns Array of active call members + * Hook to get the list of users currently in a call in a room. + * Backed by a shared registry — one state listener for all rooms. */ -export function getActiveCallMembers(room: Room): CallMember[] { - const members: CallMember[] = []; - const now = Date.now(); - - try { - const stateEvents = room.currentState.getStateEvents(CALL_MEMBER_EVENT_TYPE); - - stateEvents.forEach((event) => { - const content = event.getContent(); - const userId = event.getStateKey(); - - if (!userId || !content['m.calls']) return; - - const calls = content['m.calls']; - if (!Array.isArray(calls)) return; - - calls.forEach((call) => { - const devices = call['m.devices']; - if (!Array.isArray(devices)) return; - - devices.forEach((device) => { - const expiresTs = device.expires_ts || 0; - - // Check if the call membership is still valid (not expired) - if (expiresTs > now) { - members.push({ - userId, - deviceId: device.device_id, - sessionId: device.session_id, - expiresTs, - }); - } - }); - }); - }); - } catch (e) { - console.error('Error reading call member events:', e); - } - - return members; -} - -/** - * Hook to get the list of users currently in a call in a room - * @param roomId - The Matrix room ID to monitor - * @returns Object with call members and whether a call is active - */ -export function useRoomCallMembers(roomId: string) { - const mx = useMatrixClient(); - const [callMembers, setCallMembers] = useState([]); - - const updateCallMembers = useCallback(() => { - const room = mx.getRoom(roomId); - if (!room) { - setCallMembers([]); - return; - } - - const members = getActiveCallMembers(room); - setCallMembers(members); - }, [mx, roomId]); - - useEffect(() => { - updateCallMembers(); - - // Listen for state event changes - const handleStateEvent = (event: MatrixEvent) => { - if (event.getRoomId() === roomId && event.getType() === CALL_MEMBER_EVENT_TYPE) { - updateCallMembers(); - } - }; - - mx.on(RoomStateEvent.Events, handleStateEvent); - - // Also refresh periodically to handle expired memberships - const interval = setInterval(updateCallMembers, 30000); - - return () => { - mx.off(RoomStateEvent.Events, handleStateEvent); - clearInterval(interval); - }; - }, [mx, roomId, updateCallMembers]); - - const isCallActive = callMembers.length > 0; - const myUserId = mx.getUserId(); - const othersInCall = callMembers.filter((m) => m.userId !== myUserId); - - return { - callMembers, - isCallActive, - othersInCall, - callMemberCount: callMembers.length, - }; +export function useRoomCallMembers(roomId: string, enabled = true) { + return useRoomCallMembersFromRegistry(roomId, enabled); } diff --git a/src/app/features/call/useRtcConfig.ts b/src/app/features/call/useRtcConfig.ts index 84d74ce..8d2b636 100644 --- a/src/app/features/call/useRtcConfig.ts +++ b/src/app/features/call/useRtcConfig.ts @@ -10,9 +10,7 @@ import { getActiveCallMembers } from './useRoomCallMembers'; * Get room server from room ID (format: !roomid:server.com) */ function getRoomServer(roomId: string): string | undefined { - const server = getMxIdServer(roomId); - console.log(`[getRoomServer] Room ID: ${roomId} -> Server: ${server}`); - return server; + return getMxIdServer(roomId); } /** @@ -31,31 +29,24 @@ export function getLiveKitHomeserverPriority( const servers: string[] = []; const memberCount = room.getMembers().length; const isDM = memberCount <= 2; - - console.log(`[getLiveKitHomeserverPriority] Room: ${room.roomId}`); - console.log(`[getLiveKitHomeserverPriority] Member count: ${memberCount}, isDM: ${isDM}`); - console.log(`[getLiveKitHomeserverPriority] User homeserver: ${userHomeserver}`); - + // Check for active call members const activeCallMembers = getActiveCallMembers(room); const hasActiveCall = activeCallMembers.length > 0; - console.log(`[getLiveKitHomeserverPriority] Active call members: ${activeCallMembers.length}, hasActiveCall: ${hasActiveCall}`); - if (hasActiveCall) { // Joining existing call - prioritize room's homeserver (where call is hosted) const roomServer = getRoomServer(room.roomId); - console.log(`[getLiveKitHomeserverPriority] Room server (active call): ${roomServer}`); if (roomServer) { servers.push(`https://${roomServer}`); } - + // Add user's homeserver as fallback const userDomain = userHomeserver.replace('https://', '').replace('http://', ''); if (!roomServer || roomServer !== userDomain) { servers.push(userHomeserver); } - + // For DMs, also try other member's homeserver if (isDM) { const otherUserId = guessDmRoomUserId(room, myUserId); @@ -66,42 +57,31 @@ export function getLiveKitHomeserverPriority( } } } - } else { - // Starting new call - use original priority logic - if (isDM) { - console.log(`[getLiveKitHomeserverPriority] DM path - user homeserver first`); - // For DMs: user's homeserver first, then other member's homeserver - servers.push(userHomeserver); - - const otherUserId = guessDmRoomUserId(room, myUserId); - console.log(`[getLiveKitHomeserverPriority] Other user ID: ${otherUserId}`); - if (otherUserId && otherUserId !== myUserId) { - const otherServer = getMxIdServer(otherUserId); - console.log(`[getLiveKitHomeserverPriority] Other user server: ${otherServer}`); - const userDomain = userHomeserver.replace('https://', '').replace('http://', ''); - if (otherServer && otherServer !== userDomain) { - servers.push(`https://${otherServer}`); - } - } - } else { - console.log(`[getLiveKitHomeserverPriority] Channel path - room homeserver first`); - // For channels: room's homeserver first, then user's homeserver - const roomServer = getRoomServer(room.roomId); - console.log(`[getLiveKitHomeserverPriority] Room server from ID: ${roomServer}`); - if (roomServer) { - servers.push(`https://${roomServer}`); - } - - // Fallback to user's homeserver if different + } else if (isDM) { + // For DMs: user's homeserver first, then other member's homeserver + servers.push(userHomeserver); + + const otherUserId = guessDmRoomUserId(room, myUserId); + if (otherUserId && otherUserId !== myUserId) { + const otherServer = getMxIdServer(otherUserId); const userDomain = userHomeserver.replace('https://', '').replace('http://', ''); - console.log(`[getLiveKitHomeserverPriority] User domain: ${userDomain}, room server: ${roomServer}`); - if (!roomServer || roomServer !== userDomain) { - servers.push(userHomeserver); + if (otherServer && otherServer !== userDomain) { + servers.push(`https://${otherServer}`); } } + } else { + // For channels: room's homeserver first, then user's homeserver + const roomServer = getRoomServer(room.roomId); + if (roomServer) { + servers.push(`https://${roomServer}`); + } + + const userDomain = userHomeserver.replace('https://', '').replace('http://', ''); + if (!roomServer || roomServer !== userDomain) { + servers.push(userHomeserver); + } } - console.log(`[getLiveKitHomeserverPriority] Final server list:`, servers); return servers; } @@ -112,46 +92,46 @@ export function getLiveKitHomeserverPriority( */ export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined { const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci']; - console.log(`[getLiveKitFocus] rtcFoci from well-known:`, rtcFoci); - + if (!rtcFoci || !Array.isArray(rtcFoci)) { - console.log(`[getLiveKitFocus] No rtc_foci found or not an array`); return undefined; } - const livekitFocus = rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit'); - console.log(`[getLiveKitFocus] Found LiveKit focus:`, livekitFocus); - return livekitFocus; + return rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit'); } /** - * Fetches the well-known client configuration from a homeserver - * @param baseUrl - The homeserver base URL - * @returns The well-known response with RTC configuration + * Fetches the well-known client configuration from a homeserver. + * Results are cached — room switches must not re-hit the network. */ +const wellKnownCache = new Map>(); + export async function fetchWellKnownWithRTC(baseUrl: string): Promise { - // In development mode (localhost), use CORS proxy to avoid CORS issues + const cacheKey = baseUrl.replace(/\/$/, ''); + const cached = wellKnownCache.get(cacheKey); + if (cached) return cached; + const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; - + let wellKnownUrl: string; if (isDev) { - // Extract hostname from baseUrl for proxy - const hostname = baseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, ''); + const hostname = cacheKey.replace('https://', '').replace('http://', ''); wellKnownUrl = `/_matrix-cors-proxy/${hostname}/.well-known/matrix/client`; } else { - wellKnownUrl = `${baseUrl}/.well-known/matrix/client`; + wellKnownUrl = `${cacheKey}/.well-known/matrix/client`; } - console.log(`[fetchWellKnownWithRTC] Fetching from: ${wellKnownUrl}`); + const fetchPromise = (async () => { + const response = await fetch(wellKnownUrl); + if (!response.ok) { + wellKnownCache.delete(cacheKey); + throw new Error(`Failed to fetch well-known: ${response.status}`); + } + return (await response.json()) as WellKnownWithRTC; + })(); - const response = await fetch(wellKnownUrl); - if (!response.ok) { - throw new Error(`Failed to fetch well-known: ${response.status}`); - } - - const data = await response.json(); - console.log(`[fetchWellKnownWithRTC] Response data:`, data); - return data; + wellKnownCache.set(cacheKey, fetchPromise); + return fetchPromise; } /** diff --git a/src/app/features/forum/ForumAwareSpaceLayout.tsx b/src/app/features/forum/ForumAwareSpaceLayout.tsx index 30750a9..650d9d2 100644 --- a/src/app/features/forum/ForumAwareSpaceLayout.tsx +++ b/src/app/features/forum/ForumAwareSpaceLayout.tsx @@ -1,9 +1,9 @@ import React, { useMemo } from 'react'; import { PageRoot } from '../../components/page'; -import { AnimatedOutlet } from '../../components/AnimatedOutlet'; import { MobileFriendlyPageNav } from '../../pages/MobileFriendly'; import { SPACE_PATH } from '../../pages/paths'; import { Space } from '../../pages/client/space/Space'; +import { SpaceRoomLayout } from '../../pages/client/space/SpaceRoomLayout'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useSpace } from '../../hooks/useSpace'; @@ -11,7 +11,7 @@ import { isForumContainer, shouldShowForumLobby } from '../../utils/room'; import { ForumBoardProvider } from './ForumBoardContext'; /** - * Space sidebar + outlet. Forum spaces render ForumFeedSidebar in Space.tsx. + * Space sidebar + room keep-alive host. Forum spaces render ForumFeedSidebar in Space.tsx. */ export function ForumAwareSpaceLayout() { const mx = useMatrixClient(); @@ -33,7 +33,7 @@ export function ForumAwareSpaceLayout() { } > - + ); diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index d8de5f1..3027315 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -60,6 +60,10 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { joiningProgressAtom } from '../../state/joiningProgress'; +import { + getCachedHierarchyRooms, + hierarchyRoomsToMap, +} from '../../state/spaceHierarchyCache'; const useCanDropLobbyItem = ( space: Room, @@ -166,7 +170,6 @@ function SpaceCardLobby() { const isForum = shouldShowForumLobby(space); 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); @@ -174,7 +177,8 @@ function SpaceCardLobby() { const heroSectionRef = useRef(null); const [heroSectionHeight, setHeroSectionHeight] = useState(); const [spaceRooms, setSpaceRooms] = useAtom(spaceRoomsAtom); - const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [isDrawer] = useSetting(settingsAtom, 'peopleDrawerInRooms'); + const members = useRoomMembers(mx, space.roomId, isDrawer); const screenSize = useScreenSizeContext(); const [onTop, setOnTop] = useState(true); const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom()); @@ -190,7 +194,10 @@ function SpaceCardLobby() { return new Set(sideSpaces); }, [sidebarItems]); - const [spacesItems, setSpacesItem] = useState>(() => new Map()); + const [spacesItems, setSpacesItem] = useState>(() => { + const cached = getCachedHierarchyRooms(space.roomId, 3); + return cached ? hierarchyRoomsToMap(cached) : new Map(); + }); useEffect(() => { let cancelled = false; @@ -202,11 +209,7 @@ function SpaceCardLobby() { type: 'PUT', roomIds: all.filter(isHierarchySpaceRoom).map((r) => r.room_id), }); - setSpacesItem((current) => - produce(current, (draft) => { - all.forEach((r) => draft.set(r.room_id, r)); - }) - ); + setSpacesItem(hierarchyRoomsToMap(all)); } catch { // hierarchy may be unavailable for some memberships } @@ -246,20 +249,33 @@ function SpaceCardLobby() { }); const vItems = virtualizer.getVirtualItems(); - const roomsPowerLevels = useRoomsPowerLevels( - useMemo( - () => - hierarchy - .flatMap((i) => { - const childRooms = Array.isArray(i.rooms) ? i.rooms.map((r) => getRoom(r.roomId)) : []; + const powerLevelRoomIds = useMemo(() => { + const roomIds = new Set([space.roomId]); + vItems.forEach((vItem) => { + const item = hierarchy[vItem.index]; + if (!item) return; + roomIds.add(item.space.roomId); + if (item.space.parentId) roomIds.add(item.space.parentId); + item.rooms?.forEach((room) => roomIds.add(room.roomId)); + }); + if (draggingItem) { + roomIds.add(draggingItem.roomId); + if (draggingItem.parentId) roomIds.add(draggingItem.parentId); + } + return Array.from(roomIds).sort().join('\0'); + }, [vItems, hierarchy, space.roomId, draggingItem]); - return [getRoom(i.space.roomId), ...childRooms]; - }) - .filter((r) => !!r) as Room[], - [hierarchy, getRoom] - ) + const powerLevelRooms = useMemo( + () => + powerLevelRoomIds + .split('\0') + .map((roomId) => getRoom(roomId)) + .filter((room): room is Room => !!room), + [powerLevelRoomIds, getRoom] ); + const roomsPowerLevels = useRoomsPowerLevels(powerLevelRooms); + const canDrop: CanDropCallback = useCanDropLobbyItem(space, roomsPowerLevels, getRoom); const [reorderSpaceState, reorderSpace] = useAsyncCallback( diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index 0a96a14..7085781 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -191,7 +191,7 @@ export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: Lobb const space = useSpace(); const creators = useRoomCreators(space); const spacePermissions = useRoomPermissions(creators, powerLevels); - const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); + const setPeopleDrawer = useSetSetting(settingsAtom, 'peopleDrawerInRooms'); const [menuAnchor, setMenuAnchor] = useState(); const [addExisting, setAddExisting] = useState(null); const screenSize = useScreenSizeContext(); diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index e6cad7f..4713e19 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -20,6 +20,7 @@ import { SequenceCard } from '../../components/sequence-card'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { PaarrotSubRoomsContent } from '../../hooks/useRoomSubRooms'; +import { useGlobalSubRoomIds } from '../../state/globalSubRoomIds'; type SpaceHierarchyProps = { summary: IHierarchyRoom | undefined; @@ -71,7 +72,14 @@ export const SpaceHierarchy = forwardRef( ) => { const mx = useMatrixClient(); - const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(spaceItem.roomId, true); + // Lobby bulk-fetches hierarchy once; per-category fetch duplicated N×50 API pages. + const parentHasSummaries = hierarchySummaries.size > 0; + const enableFetch = !closed && !parentHasSummaries; + const { fetching, error, rooms: fetchedRooms } = useFetchSpaceHierarchyLevel( + spaceItem.roomId, + enableFetch + ); + const rooms = parentHasSummaries ? hierarchySummaries : fetchedRooms; const subspaces = useMemo(() => { const s: Map = new Map(); @@ -105,15 +113,7 @@ export const SpaceHierarchy = forwardRef( // Build a global set of all sub-room IDs by checking ALL joined rooms // This ensures sub-rooms are hidden even if their parent isn't in this space - const globalSubRoomIds = useMemo(() => { - const subRoomIds = new Set(); - mx.getRooms().forEach((room) => { - const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); - const content = subRoomsEvent?.getContent(); - content?.children?.forEach((childId: string) => subRoomIds.add(childId)); - }); - return subRoomIds; - }, [mx, allJoinedRooms]); + const globalSubRoomIds = useGlobalSubRoomIds(); // Build a map of parent room -> sub-room IDs for nested rendering (only for rooms in this space) const subRoomsMap = useMemo(() => { diff --git a/src/app/features/lobby/index.ts b/src/app/features/lobby/index.ts index 08355c7..d3b7700 100644 --- a/src/app/features/lobby/index.ts +++ b/src/app/features/lobby/index.ts @@ -1 +1 @@ -export * from './Lobby'; +export { Lobby } from './Lobby'; diff --git a/src/app/features/room-nav/CallParticipantsIndicator.tsx b/src/app/features/room-nav/CallParticipantsIndicator.tsx index 95cbd3d..18b8364 100644 --- a/src/app/features/room-nav/CallParticipantsIndicator.tsx +++ b/src/app/features/room-nav/CallParticipantsIndicator.tsx @@ -2,12 +2,13 @@ import React from 'react'; import { Avatar, Box, Text, config } from 'folds'; import { Icon, Icons } from '../../components/icons'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useRoomCallMembers } from '../call/useRoomCallMembers'; import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; import { mxcUrlToHttp, getMxIdLocalPart } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { nameInitials } from '../../utils/common'; import { useCall } from '../call/useCall'; +import { useRoomHasActiveCall } from '../../state/activeCallRoomsRegistry'; +import { useRoomCallMembers } from '../call/useRoomCallMembers'; import * as css from './CallParticipantsIndicator.css'; const MAX_VISIBLE_PARTICIPANTS = 8; @@ -17,6 +18,12 @@ type CallParticipantsIndicatorProps = { }; export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorProps) { + const hasCall = useRoomHasActiveCall(roomId); + if (!hasCall) return null; + return ; +} + +function CallParticipantsIndicatorActive({ roomId }: CallParticipantsIndicatorProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const room = mx.getRoom(roomId); @@ -41,20 +48,14 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; }; - /** - * Check if a participant is currently speaking - */ const isParticipantSpeaking = (userId: string): boolean => { if (!activeCall) return false; - - // Direct check for Matrix user ID (works for local user) + if (activeCall.activeSpeakers.has(userId)) return true; - - // For remote users, activeSpeakers contains LiveKit identities (deviceId_timestamp) + const member = callMembers.find((m) => m.userId === userId); if (!member) return false; - - // Check if any active speaker has this user's deviceId + return Array.from(activeCall.activeSpeakers).some((speakerId) => { const underscoreIndex = speakerId.lastIndexOf('_'); const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId; @@ -62,20 +63,14 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP }); }; - /** - * Check if a participant is currently muted - */ const isParticipantMuted = (userId: string): boolean => { if (!activeCall) return false; - - // For local user, check activeCall.isMuted + if (userId === myUserId) return activeCall.isMuted; - - // For remote users, mutedParticipants contains LiveKit identities (deviceId_timestamp) + const member = callMembers.find((m) => m.userId === userId); if (!member) return false; - - // Check if any muted participant has this user's deviceId + return Array.from(activeCall.mutedParticipants).some((mutedId) => { const underscoreIndex = mutedId.lastIndexOf('_'); const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId; @@ -83,17 +78,11 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP }); }; - /** - * Check if the local user is deafened - */ const isLocalUserDeafened = (userId: string): boolean => { if (!activeCall || userId !== myUserId) return false; return activeCall.isDeafened; }; - /** - * Get the appropriate audio icon for a participant - */ const getAudioIcon = (userId: string): string => { if (isLocalUserDeafened(userId)) { return Icons.VolumeMute; @@ -115,7 +104,7 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP const isSpeaking = isParticipantSpeaking(member.userId); const isMuted = isParticipantMuted(member.userId); const isDeafened = isLocalUserDeafened(member.userId); - + return ( - @@ -148,13 +137,13 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP )} - {getParticipantName(member.userId)} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index ae439e5..996e53e 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -1,7 +1,8 @@ import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react'; import { Room, EventTimeline } from 'matrix-js-sdk'; -import { useAtomValue } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useNavigate } from 'react-router-dom'; +import { flushSync } from 'react-dom'; import { Avatar, Box, IconButton, Text, Menu, MenuItem, config, PopOut, toRem, Line, RectCords, Badge, Spinner, Tooltip, TooltipProvider } from 'folds'; import { Icon, Icons } from '../../components/icons'; import { useFocusWithin, useHover } from 'react-aria'; @@ -48,6 +49,9 @@ import { mDirectAtom } from '../../state/mDirectList'; import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal'; import { PresenceAvatar } from '../../components/presence'; import { Presence, useUserPresence } from '../../hooks/useUserPresence'; +import { pendingRoomNavigationAtom, roomNavLoadingAtom } from '../../state/pendingRoomNavigation'; +import * as roomNavCss from './styles.css'; +import classNames from 'classnames'; type RoomNavItemMenuProps = { room: Room; @@ -287,6 +291,14 @@ export function RoomNavItem({ const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); + const pendingRoomId = useAtomValue(pendingRoomNavigationAtom); + const setPendingRoomId = useSetAtom(pendingRoomNavigationAtom); + const loadingRoomId = useAtomValue(roomNavLoadingAtom); + const setLoadingRoomId = useSetAtom(roomNavLoadingAtom); + // Instant highlight on press — don't wait for the route to settle. + const isPending = pendingRoomId === room.roomId; + const isLoading = loadingRoomId === room.roomId; + const effectivelySelected = selected || isPending || isLoading; const avatarUrl = direct ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) @@ -368,6 +380,21 @@ export function RoomNavItem({ if (shouldScroll) { navigate(linkPath, { replace: true, state: { scrollToLatest: Date.now() } }); } + return; + } + + // Always mark pending so the nav loading bar shows until the room view settles. + setPendingRoomId(room.roomId); + setLoadingRoomId(room.roomId); + }; + + const handleRoomPointerDown: MouseEventHandler = () => { + if (!selected) { + // Paint highlight + loading bar before click navigation blocks the main thread. + flushSync(() => { + setPendingRoomId(room.roomId); + setLoadingRoomId(room.roomId); + }); } }; @@ -376,17 +403,32 @@ export function RoomNavItem({ return ( - + {isLoading && ( + <> +
+
+ + )} + {shouldShowIcon && @@ -413,7 +455,7 @@ export function RoomNavItem({ ) : ( @@ -436,7 +478,7 @@ export function RoomNavItem({ ) : ( @@ -470,7 +512,7 @@ export function RoomNavItem({ {optionsVisible && ( - + {parentSpaceInfo && ( (null); + const savedScrollRef = useRef<{ top: number; atBottom: boolean } | null>(null); + const setLoadingRoomId = useSetAtom(roomNavLoadingAtom); + // After the first hide, the next show is a warm keep-alive switch. + const warmNextActivateRef = useRef(false); + + useLayoutEffect(() => { + setRoomTimelineVisible(room.roomId, active); + return () => clearRoomTimelineVisibilityGate(room.roomId); + }, [active, room.roomId]); + + // Save/restore timeline scroll so warm switches don't jump. + useLayoutEffect(() => { + const scrollEl = slotRef.current?.querySelector( + '[data-room-timeline-scroll]' + ) as HTMLElement | null; + if (!scrollEl) return undefined; + + if (!active) { + const distanceFromBottom = + scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight; + savedScrollRef.current = { + top: scrollEl.scrollTop, + atBottom: distanceFromBottom <= 50, + }; + return undefined; + } + + const saved = savedScrollRef.current; + if (!saved) return undefined; + + const restore = () => { + if (saved.atBottom) { + scrollEl.scrollTop = scrollEl.scrollHeight; + } else { + scrollEl.scrollTop = saved.top; + } + }; + + restore(); + // Composer remounts on rAF and changes timeline height — re-apply after that. + const raf1 = window.requestAnimationFrame(() => { + restore(); + const raf2 = window.requestAnimationFrame(() => { + restore(); + window.setTimeout(restore, 50); + }); + void raf2; + }); + return () => window.cancelAnimationFrame(raf1); + }, [active]); + + useLayoutEffect(() => { + if (!active) { + warmNextActivateRef.current = true; + return undefined; + } + if (!warmNextActivateRef.current) return undefined; + // Warm path: timeline already mounted — clear the nav bar after a short flash. + const t = window.setTimeout(() => { + setLoadingRoomId((id) => (id === room.roomId ? null : id)); + }, 200); + return () => window.clearTimeout(t); + }, [active, room.roomId, setLoadingRoomId]); + + return ( +
) : undefined)} + > + + + + {active && } + + + + +
+ ); +}); + +/** + * Keeps the last N room views mounted (hidden when inactive) so switching back + * is instant — no remount. + * + * Active room comes from the URL (`useSelectedRoom`) so this can live outside + * the room route element and survive param changes. + */ +export function CachedRooms() { + const mx = useMatrixClient(); + const selectedRoomId = useSelectedRoom(); + const mDirects = useAtomValue(mDirectAtom); + const [cache, setCache] = useAtom(roomViewCacheAtom); + const { eventId: rawEventId } = useParams(); + const eventId = selectedRoomId ? decodeRouteParam(rawEventId) : undefined; + + useLayoutEffect(() => { + if (!selectedRoomId) return; + setCache((prev) => touchRoomViewCache(prev, selectedRoomId, ROOM_VIEW_CACHE_LIMIT).next); + }, [selectedRoomId, setCache]); + + const ids = + selectedRoomId && !cache.includes(selectedRoomId) + ? [selectedRoomId, ...cache.filter((id) => id !== selectedRoomId)].slice( + 0, + ROOM_VIEW_CACHE_LIMIT + ) + : cache; + + if (ids.length === 0) return null; + + return ( +
+ {ids.map((roomId) => { + const room = mx.getRoom(roomId); + if (!room) return null; + const active = roomId === selectedRoomId; + + return ( + + ); + })} +
+ ); +} diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 0782a74..affb248 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -24,7 +24,8 @@ import { useDebounce } from '../../hooks/useDebounce'; import { TypingIndicator } from '../../components/typing-indicator'; import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room'; import { getMxIdLocalPart } from '../../utils/matrix'; -import { useSetSetting, useSetting } from '../../state/hooks/settings'; +import { useSetting } from '../../state/hooks/settings'; +import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer'; import { settingsAtom } from '../../state/settings'; import { millify } from '../../plugins/millify'; import { ScrollTopContainer } from '../../components/scroll-top-container'; @@ -41,12 +42,14 @@ import { useSpaceOptionally } from '../../hooks/useSpace'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useIsDirectRoom } from '../../hooks/useRoom'; type MemberDrawerHeaderProps = { room: Room; }; function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) { - const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); + const isDirect = useIsDirectRoom(); + const [, setPeopleDrawer] = usePeopleDrawerSetting(isDirect); return (
diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 27ac886..bf383e2 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -1,5 +1,6 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { memo, useCallback, useLayoutEffect } from 'react'; import { Box, Line } from 'folds'; +import { Room as MatrixRoom } from 'matrix-js-sdk'; import { decodeRouteParam } from '../../pages/pathUtils'; import { useParams } from 'react-router-dom'; import { isKeyHotkey } from 'is-hotkey'; @@ -8,9 +9,10 @@ import { RoomView } from './RoomView'; import { MembersDrawer } from './MembersDrawer'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { useSetting } from '../../state/hooks/settings'; +import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer'; import { settingsAtom } from '../../state/settings'; import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels'; -import { useRoom } from '../../hooks/useRoom'; +import { useIsDirectRoom, useRoom } from '../../hooks/useRoom'; import { useKeyDown } from '../../hooks/useKeyDown'; import { useMarkAsRead } from '../../hooks/useMarkAsRead'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -19,25 +21,64 @@ import { activeRoomIdAtom } from '../../state/activeRoom'; import { isForum } from '../../utils/room'; import { ForumRoomView } from './ForumRoomView'; -export function Room() { - const { eventId: rawEventId } = useParams(); - const eventId = decodeRouteParam(rawEventId); - const room = useRoom(); - const mx = useMatrixClient(); - const setActiveRoomId = useSetAtom(activeRoomIdAtom); +type RoomFrameProps = { + room: MatrixRoom; + eventId?: string; + /** Optimistic shell only — no live timeline. */ + pending?: boolean; + /** + * Visible keep-alive slot. Drives composer mount immediately (Slate cannot + * stay mounted in hidden rooms). Timeline ignores this via memo. + */ + active?: boolean; +}; - const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); +/** Shared room column layout used by the live room and the pending-nav shell. */ +export const RoomFrame = memo(function RoomFrame({ + room, + eventId, + pending = false, + active = true, +}: RoomFrameProps) { + const mx = useMatrixClient(); + const isDirect = useIsDirectRoom(); + const [isDrawer] = usePeopleDrawerSetting(isDirect); const screenSize = useScreenSizeContext(); const powerLevels = usePowerLevels(room); - const members = useRoomMembers(mx, room.roomId); - const markAsRead = useMarkAsRead(mx); const forumRoom = isForum(room); + // Mount whenever the account pref is on — including inactive keep-alive slots — + // so members are already loaded when you switch rooms (no pop-in). + const showDrawer = !pending && screenSize === ScreenSize.Desktop && isDrawer; + const members = useRoomMembers(mx, room.roomId, showDrawer); - // Update titlebar with current room ID - useEffect(() => { + return ( + + + {forumRoom && !pending ? ( + + ) : ( + + )} + {showDrawer && ( + <> + + + + )} + + + ); +}); + +/** Titlebar + escape-to-mark-read — only for the visible cached room. */ +export function ActiveRoomEffects({ room }: { room: MatrixRoom }) { + const mx = useMatrixClient(); + const setActiveRoomId = useSetAtom(activeRoomIdAtom); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const markAsRead = useMarkAsRead(mx); + + useLayoutEffect(() => { setActiveRoomId(room.roomId); - return () => setActiveRoomId(undefined); }, [room.roomId, setActiveRoomId]); useKeyDown( @@ -52,17 +93,19 @@ export function Room() { ) ); + return null; +} + +/** @deprecated Prefer CachedRooms for route outlets — kept for single-room mounts. */ +export function Room() { + const { eventId: rawEventId } = useParams(); + const eventId = decodeRouteParam(rawEventId); + const room = useRoom(); + return ( - - - {forumRoom ? : } - {screenSize === ScreenSize.Desktop && isDrawer && ( - <> - - - - )} - - + <> + + + ); } diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index dd75ffe..81a35d3 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -10,6 +10,7 @@ import React, { useMemo, useRef, useState, + memo, } from 'react'; import { Direction, @@ -117,6 +118,8 @@ import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useTheme } from '../../hooks/useTheme'; +import { useRoomVisibilityRef } from './RoomViewActiveContext'; +import { roomNavLoadingAtom } from '../../state/pendingRoomNavigation'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { @@ -553,8 +556,15 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => { }; }; -export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) { +export const RoomTimeline = memo(function RoomTimeline({ + room, + eventId, + roomInputRef, + editor, +}: RoomTimelineProps) { const mx = useMatrixClient(); + const visibilityRef = useRoomVisibilityRef(); + const setLoadingRoomId = useSetAtom(roomNavLoadingAtom); const useAuthentication = useMediaAuthentication(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); @@ -716,6 +726,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli [] ), onEnd: handleTimelinePagination, + enabledRef: visibilityRef, + roomId: room.roomId, }); const loadEventTimeline = useEventTimelineLoader( @@ -759,6 +771,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker; const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent); + // Hidden keep-alive: only slide the virtual window when stuck to bottom. + // Shifting range while scrolled-up remaps scrollTop onto different messages. + if (!visibilityRef.current) { + if (shouldStickToBottom) { + setTimeline((ct) => ({ + ...ct, + range: { + start: ct.range.start + 1, + end: ct.range.end + 1, + }, + })); + } + return; + } + // Always update timeline with new message setTimeline((ct) => ({ ...ct, @@ -771,7 +798,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // Handle own messages and focus if (isOwnMessage) { setLatestUnreadMessage(null); - if (document.hasFocus() && (!unreadInfo)) { + if (document.hasFocus() && !unreadInfo) { const roomId = mEvt.getRoomId(); if (roomId) { requestAnimationFrame(() => markAsRead(roomId, hideActivity)); @@ -803,7 +830,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli }); } }, - [mx, room, unreadInfo, hideActivity, markAsRead] + [visibilityRef, mx, room, unreadInfo, hideActivity, markAsRead] ) ); @@ -840,10 +867,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useLiveTimelineRefresh( room, useCallback(() => { + if (!visibilityRef.current) return; if (liveTimelineLinked) { setTimeline(getInitialTimeline(room)); } - }, [room, liveTimelineLinked]) + }, [visibilityRef, room, liveTimelineLinked]) ); // Stay at bottom when room editor resize @@ -856,6 +884,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli mounted = true; return; } + if (!visibilityRef.current) return; if (!roomInputRef.current) return; const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries); const scrollElement = getScrollElement(); @@ -865,29 +894,37 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli autoScrollToBottom(scrollElement, false, true); } }; - }, [getScrollElement, roomInputRef]), + }, [getScrollElement, roomInputRef, visibilityRef]), useCallback(() => roomInputRef.current, [roomInputRef]) ); useResizeObserver( - useCallback((entries) => { - const timelineContent = timelineContentRef.current; - const scrollElement = getScrollElement(); - if (!timelineContent || !scrollElement) return; + useCallback( + (entries) => { + if (!visibilityRef.current) return; + const timelineContent = timelineContentRef.current; + const scrollElement = getScrollElement(); + if (!timelineContent || !scrollElement) return; - const timelineEntry = getResizeObserverEntry(timelineContent, entries); - if (!timelineEntry) return; + const timelineEntry = getResizeObserverEntry(timelineContent, entries); + if (!timelineEntry) return; - const nextHeight = timelineEntry.contentRect.height; - const previousHeight = timelineContentHeightRef.current; - timelineContentHeightRef.current = nextHeight; + const nextHeight = timelineEntry.contentRect.height; + const previousHeight = timelineContentHeightRef.current; + timelineContentHeightRef.current = nextHeight; - if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) { - return; - } + if ( + previousHeight === 0 || + nextHeight <= previousHeight || + !isScrolledToBottom(scrollElement) + ) { + return; + } - autoScrollToBottom(scrollElement, false, true); - }, [getScrollElement]), + autoScrollToBottom(scrollElement, false, true); + }, + [getScrollElement, visibilityRef] + ), useCallback(() => timelineContentRef.current, []) ); @@ -915,6 +952,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useIntersectionObserver( useCallback( (entries) => { + if (!visibilityRef.current) return; const target = atBottomAnchorRef.current; if (!target) return; const targetEntry = getIntersectionObserverEntry(target, entries); @@ -927,7 +965,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli } } }, - [debounceSetAtBottom, tryAutoMarkAsRead] + [visibilityRef, debounceSetAtBottom, tryAutoMarkAsRead] ), useCallback( () => ({ @@ -965,6 +1003,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli window, useCallback( (evt) => { + if (!visibilityRef.current) return; if ( isKeyHotkey('arrowup', evt) && editableActiveElement() && @@ -980,7 +1019,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli evt.preventDefault(); } }, - [mx, room, editor] + [visibilityRef, mx, room, editor] ) ); @@ -1011,6 +1050,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli } }, []); + // Drop the room-nav loading bar once this timeline has painted (or after a + // short idle). flushSync paints the bar before this heavy mount work. + useEffect(() => { + let cleared = false; + const clear = () => { + if (cleared) return; + cleared = true; + setLoadingRoomId((id) => (id === room.roomId ? null : id)); + }; + + const idleId = + typeof window.requestIdleCallback === 'function' + ? window.requestIdleCallback(clear, { timeout: 2000 }) + : undefined; + const timeoutId = window.setTimeout(clear, idleId === undefined ? 400 : 2500); + + return () => { + cleared = true; + if (idleId !== undefined && typeof window.cancelIdleCallback === 'function') { + window.cancelIdleCallback(idleId); + } + window.clearTimeout(timeoutId); + }; + }, [room.roomId, setLoadingRoomId]); + // if live timeline is linked and unreadInfo change // Scroll to last read message useLayoutEffect(() => { @@ -1053,6 +1117,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const scrollToBottomCount = scrollToBottomRef.current.count; useLayoutEffect(() => { if (scrollToBottomCount > 0) { + if (!visibilityRef.current) return; const scrollEl = scrollRef.current; if (scrollEl) { autoScrollToBottom( @@ -1062,7 +1127,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); } } - }, [scrollToBottomCount]); + }, [scrollToBottomCount, visibilityRef]); // Remove unreadInfo on mark as read useEffect(() => { @@ -2291,7 +2356,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli )} - + ); -} +}); diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 6d7a7f7..f93d45e 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -1,9 +1,10 @@ -import React, { useCallback, useRef } from 'react'; +import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Box, Text, config } from 'folds'; import { EventType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; -import { useAtomValue } from 'jotai'; +import { useAtomValue, useSetAtom, getDefaultStore } from 'jotai'; +import { Editor } from 'slate'; import { useStateEvent } from '../../hooks/useStateEvent'; import { StateEvent } from '../../../types/matrix/room'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; @@ -24,6 +25,8 @@ import { useSetting } from '../../state/hooks/settings'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { activeThreadIdAtomFamily } from '../../state/activeThread'; +import { pendingRoomNavigationAtom } from '../../state/pendingRoomNavigation'; +import { activeRoomIdAtom } from '../../state/activeRoom'; import { ThreadView } from './ThreadView'; const FN_KEYS_REGEX = /^F\d+$/; @@ -33,10 +36,8 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { return false; } - // do not focus on F keys if (FN_KEYS_REGEX.test(code)) return false; - // do not focus on numlock/scroll lock if ( code.startsWith('OS') || code.startsWith('Meta') || @@ -59,7 +60,99 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { return true; }; -export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { +/** + * Slate must not stay mounted in hidden keep-alive rooms — multiple Editable + * trees crash with findPath. Timeline stays warm; only the composer remounts. + */ +function RoomComposerSlot({ + room, + roomId, + editor, + canMessage, + active, + roomViewRef, + roomInputRef, +}: { + room: Room; + roomId: string; + editor: Editor; + canMessage: boolean; + active: boolean; + roomViewRef: React.RefObject; + roomInputRef: React.RefObject; +}) { + const [mountComposer, setMountComposer] = useState(active); + + useLayoutEffect(() => { + if (!active) { + setMountComposer(false); + try { + ReactEditor.blur(editor); + } catch { + // Editor may already be disconnected. + } + return undefined; + } + const raf = window.requestAnimationFrame(() => setMountComposer(true)); + return () => window.cancelAnimationFrame(raf); + }, [active, editor]); + + useEffect(() => { + if (active) return undefined; + try { + ReactEditor.blur(editor); + } catch { + // ignore + } + return undefined; + }, [active, editor]); + + if (!canMessage) { + return ( + + You do not have permission to post in this room + + ); + } + + // Gate on `active` in render (not only state) so Slate unmounts the same + // commit the slot hides — waiting for useLayoutEffect is one frame too late. + if (active && mountComposer) { + return ( + + ); + } + + return ( +
+ +
+ ); +} + +export const RoomView = memo(function RoomView({ + room, + eventId, + pending = false, + active = true, +}: { + room: Room; + eventId?: string; + /** Optimistic shell only — no live timeline (used for press→navigate gap). */ + pending?: boolean; + /** False for hidden keep-alive slots — unmounts Slate only. */ + active?: boolean; +}) { const roomInputRef = useRef(null); const roomViewRef = useRef(null); @@ -69,6 +162,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { const editor = useEditor(); const mx = useMatrixClient(); + const setPendingRoomId = useSetAtom(pendingRoomNavigationAtom); const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone); const powerLevels = usePowerLevelsContext(); @@ -78,11 +172,20 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId()); const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId)); + const showThread = !pending && !!activeThreadId; + const composerActive = active && !pending; + + useLayoutEffect(() => { + if (!composerActive) return; + setPendingRoomId((id) => (id === roomId ? null : id)); + }, [composerActive, roomId, setPendingRoomId]); useKeyDown( window, useCallback( (evt) => { + if (!composerActive) return; + if (getDefaultStore().get(activeRoomIdAtom) !== roomId) return; if (editableActiveElement()) return; const portalContainer = document.getElementById('portalContainer'); if (portalContainer && portalContainer.children.length > 0) { @@ -90,33 +193,45 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { } if (shouldFocusMessageField(evt)) { evt.preventDefault(); - ReactEditor.focus(editor); + try { + ReactEditor.focus(editor); + } catch { + return; + } if (evt.key.length === 1) { editor.insertText(evt.key); } } else if (isKeyHotkey('mod+v', evt)) { - ReactEditor.focus(editor); + try { + ReactEditor.focus(editor); + } catch { + // Composer may not be mounted yet. + } } }, - [editor] + [composerActive, editor, roomId] ) ); return ( - {activeThreadId ? ( - + {showThread ? ( + ) : ( <> - - + + {!pending && ( +
+ +
+ )}
@@ -128,26 +243,15 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { replacementRoomId={tombstoneEvent.getContent().replacement_room} /> ) : ( - <> - {canMessage && ( - - )} - {!canMessage && ( - - You do not have permission to post in this room - - )} - + )}
{hideActivity ? : } @@ -156,4 +260,4 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { )} ); -} +}); diff --git a/src/app/features/room/RoomViewActiveContext.ts b/src/app/features/room/RoomViewActiveContext.ts new file mode 100644 index 0000000..9a8d0ca --- /dev/null +++ b/src/app/features/room/RoomViewActiveContext.ts @@ -0,0 +1,29 @@ +import { createContext, MutableRefObject, useContext } from 'react'; +import { useAtomValue } from 'jotai'; +import { useRoom } from '../../hooks/useRoom'; +import { activeRoomIdAtom } from '../../state/activeRoom'; + +/** + * Mutable visibility flag for keep-alive rooms. Updating `.current` does not + * re-render consumers — use for timeline side-effects / paginator gates. + */ +export const RoomVisibilityRefContext = createContext>({ + current: true, +}); + +export const useRoomVisibilityRef = (): MutableRefObject => + useContext(RoomVisibilityRefContext); + +/** + * True when this room is the visible/selected room. + * Subscribes to `activeRoomIdAtom` (set by ActiveRoomEffects) so headers/call + * UI update without forcing the timeline tree to re-render. + */ +export const useRoomViewActive = (): boolean => { + const room = useRoom(); + const activeRoomId = useAtomValue(activeRoomIdAtom); + return activeRoomId === room.roomId; +}; + +/** @deprecated Prefer useRoomViewActive / useRoomVisibilityRef. */ +export const RoomViewActiveContext = createContext(true); diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index f5dc78f..e024661 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -14,8 +14,10 @@ import { UseStateProvider } from '../../components/UseStateProvider'; import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { StateEvent } from '../../../types/matrix/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useRoom } from '../../hooks/useRoom'; +import { useRoom, useIsDirectRoom } from '../../hooks/useRoom'; +import { useRoomViewActive } from './RoomViewActiveContext'; import { useSetting } from '../../state/hooks/settings'; +import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer'; import { settingsAtom } from '../../state/settings'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils'; @@ -358,12 +360,6 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) { callSupportLoading, } = useRoomCall(roomId); - // Debug logging - React.useEffect(() => { - // eslint-disable-next-line no-console - console.log('RoomCallButtons render:', { callSupportLoading, callSupported, roomId }); - }, [callSupportLoading, callSupported, roomId]); - if (callSupportLoading || !callSupported) { return null; } @@ -465,6 +461,8 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { const screenSize = useScreenSizeContext(); const { showInPageHeader, goBack } = useBackRoute(); const room = useRoom(); + const isDirect = useIsDirectRoom(); + const roomActive = useRoomViewActive(); const space = useSpaceOptionally(); const [menuAnchor, setMenuAnchor] = useState(); const [pinMenuAnchor, setPinMenuAnchor] = useState(); @@ -480,7 +478,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; - const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [peopleDrawer, setPeopleDrawer] = usePeopleDrawerSetting(isDirect); const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId)); const handleSearchClick = () => { @@ -579,8 +577,8 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { - - + {roomActive && } + {roomActive && } {!ecryptedRoom && ( { const loadPlugins = async () => { try { diff --git a/src/app/hooks/stateEventCallback.types.ts b/src/app/hooks/stateEventCallback.types.ts new file mode 100644 index 0000000..dc643ce --- /dev/null +++ b/src/app/hooks/stateEventCallback.types.ts @@ -0,0 +1,7 @@ +import { MatrixEvent, RoomState } from 'matrix-js-sdk'; + +export type StateEventCallback = ( + event: MatrixEvent, + state: RoomState, + lastStateEvent: MatrixEvent | null +) => void; diff --git a/src/app/hooks/useAutoJoinSpaceRooms.ts b/src/app/hooks/useAutoJoinSpaceRooms.ts index 6cc9023..4a73049 100644 --- a/src/app/hooks/useAutoJoinSpaceRooms.ts +++ b/src/app/hooks/useAutoJoinSpaceRooms.ts @@ -11,105 +11,44 @@ import { } from './useRoomsNotificationPreferences'; import { updateJoiningProgressAtom } from '../state/joiningProgress'; import { useMatrixClient } from './useMatrixClient'; +import { fetchAllHierarchyRooms } from './useSpaceHierarchy'; -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']; +/** Spaces we've already kicked off auto-join for this session (avoid repeat on revisit). */ +const autoJoinStartedForSpace = new Set(); + +/** Spaces currently running auto-join (avoid concurrent runs). */ const processingSpaces = new Set(); -/** - * 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 (including m.forum category spaces) - * @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' || hierarchyRoom.room_type === 'm.forum'; } -/** - * 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[], @@ -119,29 +58,23 @@ async function joinRoomsSequentially( 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); @@ -149,7 +82,6 @@ async function joinRoomsSequentially( await mx.joinRoom(item.room_id, { viaServers }); - // Only set notification preferences for rooms, not spaces if (!isItemSpace) { await setRoomNotificationPreference( mx, @@ -168,21 +100,18 @@ async function joinRoomsSequentially( } }; - // Join spaces first, then rooms await spacesToJoin.reduce( - (promise, space) => promise.then(() => joinItem(space, true)), + (promise, item) => promise.then(() => joinItem(item, true)), Promise.resolve() ); await roomsToJoin.reduce( - (promise, room) => promise.then(() => joinItem(room, false)), + (promise, item) => promise.then(() => joinItem(item, false)), Promise.resolve() ); } -/** - * Joins all auto-joinable children of a space (and nested spaces/rooms). - */ +/** Joins all auto-joinable children of a space (and nested spaces/rooms). */ export async function autoJoinSpaceChildren( mx: MatrixClient, spaceId: string, @@ -195,7 +124,7 @@ export async function autoJoinSpaceChildren( processingSpaces.add(spaceId); try { - const hierarchyRooms = await fetchSpaceHierarchy(mx, spaceId); + const hierarchyRooms = await fetchAllHierarchyRooms(mx, spaceId, undefined); const childRooms = hierarchyRooms.filter((r) => r.room_id !== spaceId); await joinRoomsSequentially(mx, childRooms, spaceId, onProgress); } finally { @@ -203,10 +132,7 @@ export async function autoJoinSpaceChildren( } } -/** - * Hook that automatically joins all accessible rooms when joining a space - * @param mx - Matrix client instance - */ +/** Hook that automatically joins all accessible rooms when joining a space. */ export function useAutoJoinSpaceRooms(mx: MatrixClient): void { const settings = useAtomValue(settingsAtom); const setJoiningProgress = useSetAtom(updateJoiningProgressAtom); @@ -216,13 +142,9 @@ export function useAutoJoinSpaceRooms(mx: MatrixClient): void { if (!settings.autoJoinSpaceRooms) { return; } - - // Only process spaces (includes m.forum containers) if (!isSpace(room)) { return; } - - // Only process when joining (not invites or leaves) if (room.getMyMembership() !== Membership.Join) { return; } @@ -256,7 +178,8 @@ export function useAutoJoinSpaceRooms(mx: MatrixClient): void { } /** - * Auto-joins space children when visiting an already-joined space (e.g. forum spaces). + * Auto-joins space children when visiting an already-joined space. + * Deferred so opening a space stays responsive. */ export function useAutoJoinOnSpaceVisit(space: Room | undefined): void { const mx = useMatrixClient(); @@ -265,34 +188,57 @@ export function useAutoJoinOnSpaceVisit(space: Room | undefined): void { useEffect(() => { if (!space || !settings.autoJoinSpaceRooms) { - return; + return undefined; } if (!isSpace(space)) { - return; + return undefined; } if (space.getMyMembership() !== Membership.Join) { - return; + return undefined; } const spaceId = space.roomId; - let cancelled = false; + if (autoJoinStartedForSpace.has(spaceId)) { + return undefined; + } - void (async () => { - try { - await autoJoinSpaceChildren(mx, spaceId, (current, total) => { + let cancelled = false; + let idleId: number | undefined; + let timeoutId: number | undefined; + + const run = () => { + if (cancelled || autoJoinStartedForSpace.has(spaceId)) return; + autoJoinStartedForSpace.add(spaceId); + + void (async () => { + try { + await autoJoinSpaceChildren(mx, spaceId, (current, total) => { + if (!cancelled) { + setJoiningProgress({ spaceId, progress: { current, total } }); + } + }); + } finally { if (!cancelled) { - setJoiningProgress({ spaceId, progress: { current, total } }); + setJoiningProgress({ spaceId, progress: null }); } - }); - } finally { - if (!cancelled) { - setJoiningProgress({ spaceId, progress: null }); } - } - })(); + })(); + }; + + if (typeof window.requestIdleCallback === 'function') { + idleId = window.requestIdleCallback(run, { timeout: 4000 }); + } else { + timeoutId = window.setTimeout(run, 2000); + } return () => { cancelled = true; + if (idleId !== undefined && typeof window.cancelIdleCallback === 'function') { + window.cancelIdleCallback(idleId); + } + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } }; }, [space, settings.autoJoinSpaceRooms, mx, setJoiningProgress]); -} \ No newline at end of file +} diff --git a/src/app/hooks/usePowerLevels.ts b/src/app/hooks/usePowerLevels.ts index e5efddf..abcd972 100644 --- a/src/app/hooks/usePowerLevels.ts +++ b/src/app/hooks/usePowerLevels.ts @@ -1,5 +1,5 @@ import { MatrixEvent, Room } from 'matrix-js-sdk'; -import { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { produce } from 'immer'; import { useStateEvent } from './useStateEvent'; import { StateEvent } from '../../types/matrix/room'; @@ -87,18 +87,35 @@ export const usePowerLevelsContext = (): IPowerLevels => { export const useRoomsPowerLevels = (rooms: Room[]): Map => { const mx = useMatrixClient(); - const getRoomsPowerLevels = useCallback(() => { + const roomIdsKey = useMemo( + () => rooms.map((room) => room.roomId).sort().join('\0'), + [rooms] + ); + const roomById = useMemo(() => { + const map = new Map(); + rooms.forEach((room) => map.set(room.roomId, room)); + return map; + }, [roomIdsKey, rooms]); + + const [overrides, setOverrides] = useState>(() => new Map()); + + useEffect(() => { + setOverrides(new Map()); + }, [roomIdsKey]); + + const roomToPowerLevels = useMemo(() => { const rToPl = new Map(); - - rooms.forEach((room) => { + roomById.forEach((room, roomId) => { + const override = overrides.get(roomId); + if (override) { + rToPl.set(roomId, override); + return; + } const mEvent = getStateEvent(room, StateEvent.RoomPowerLevels, ''); - rToPl.set(room.roomId, getPowersLevelFromMatrixEvent(mEvent)); + rToPl.set(roomId, getPowersLevelFromMatrixEvent(mEvent)); }); - return rToPl; - }, [rooms]); - - const [roomToPowerLevels, setRoomToPowerLevels] = useState(() => getRoomsPowerLevels()); + }, [roomById, overrides]); useStateEventCallback( mx, @@ -107,14 +124,19 @@ export const useRoomsPowerLevels = (rooms: Room[]): Map => const roomId = event.getRoomId(); if ( roomId && + roomById.has(roomId) && event.getType() === StateEvent.RoomPowerLevels && - event.getStateKey() === '' && - rooms.find((r) => r.roomId === roomId) + event.getStateKey() === '' ) { - setRoomToPowerLevels(getRoomsPowerLevels()); + const pl = getPowersLevelFromMatrixEvent(event); + setOverrides((prev) => { + const next = new Map(prev); + next.set(roomId, pl); + return next; + }); } }, - [rooms, getRoomsPowerLevels] + [roomById] ) ); diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index df36901..790f962 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -1,10 +1,16 @@ import { MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from 'matrix-js-sdk'; import { useEffect, useState } from 'react'; -export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => { +export const useRoomMembers = ( + mx: MatrixClient, + roomId: string, + enabled = true +): RoomMember[] => { const [members, setMembers] = useState([]); useEffect(() => { + if (!enabled) return undefined; + const room = mx.getRoom(roomId); let loadingMembers = true; let disposed = false; @@ -31,7 +37,7 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] = mx.removeListener(RoomMemberEvent.Membership, updateMemberList); mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList); }; - }, [mx, roomId]); + }, [mx, roomId, enabled]); return members; }; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index eae4975..7a51223 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -11,6 +11,10 @@ import { isRoomId } from '../utils/matrix'; import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort'; import { useStateEventCallback } from './useStateEventCallback'; import { ErrorCode } from '../cs-errorcode'; +import { + getCachedHierarchyRooms, + setCachedHierarchyRooms, +} from '../state/spaceHierarchyCache'; export type HierarchyItemSpace = { roomId: string; @@ -65,8 +69,14 @@ export function isHierarchySpaceRoom(room: IHierarchyRoom | undefined): boolean export async function fetchAllHierarchyRooms( mx: MatrixClient, spaceId: string, - maxDepth = 3 + maxDepth: number | undefined = 3, + options?: { skipCache?: boolean } ): Promise { + if (!options?.skipCache) { + const cached = getCachedHierarchyRooms(spaceId, maxDepth); + if (cached) return cached; + } + const allRooms: IHierarchyRoom[] = []; let nextBatch: string | undefined; @@ -77,6 +87,7 @@ export async function fetchAllHierarchyRooms( if (!nextBatch) break; } + setCachedHierarchyRooms(spaceId, allRooms, maxDepth); return allRooms; } @@ -426,6 +437,7 @@ export const useFetchSpaceHierarchyLevel = ( ); const queryResponse = useInfiniteQuery({ + enabled: enable, refetchOnMount: enable, queryKey: [roomId, 'hierarchy_level'], initialPageParam: undefined, @@ -450,6 +462,7 @@ export const useFetchSpaceHierarchyLevel = ( const { data, isLoading, isFetchingNextPage, error, fetchNextPage, hasNextPage } = queryResponse; useEffect(() => { + if (!enable) return; if ( hasNextPage && pageNoRef.current <= MAX_AUTO_PAGE_COUNT && @@ -460,7 +473,7 @@ export const useFetchSpaceHierarchyLevel = ( pageNoRef.current += 1; fetchNextPage(); } - }, [fetchNextPage, hasNextPage, data, error]); + }, [enable, fetchNextPage, hasNextPage, data, error]); const rooms: Map = useMemo(() => { const roomsMap: Map = new Map(); diff --git a/src/app/hooks/useStateEventCallback.ts b/src/app/hooks/useStateEventCallback.ts index 85be3ae..0610a55 100644 --- a/src/app/hooks/useStateEventCallback.ts +++ b/src/app/hooks/useStateEventCallback.ts @@ -1,17 +1,10 @@ -import { MatrixClient, MatrixEvent, RoomState, RoomStateEvent } from 'matrix-js-sdk'; +import { MatrixClient } from 'matrix-js-sdk'; import { useEffect } from 'react'; +import { subscribeStateEvents } from '../state/stateEventDemux'; +import type { StateEventCallback } from './stateEventCallback.types'; -export type StateEventCallback = ( - event: MatrixEvent, - state: RoomState, - lastStateEvent: MatrixEvent | null -) => void; +export type { StateEventCallback } from './stateEventCallback.types'; export const useStateEventCallback = (mx: MatrixClient, onStateEvent: StateEventCallback) => { - useEffect(() => { - mx.on(RoomStateEvent.Events, onStateEvent); - return () => { - mx.removeListener(RoomStateEvent.Events, onStateEvent); - }; - }, [mx, onStateEvent]); + useEffect(() => subscribeStateEvents(mx, onStateEvent), [mx, onStateEvent]); }; diff --git a/src/app/hooks/useUserColor.ts b/src/app/hooks/useUserColor.ts index 083c515..b52d8f7 100644 --- a/src/app/hooks/useUserColor.ts +++ b/src/app/hooks/useUserColor.ts @@ -252,64 +252,66 @@ const userColorCache = new LRUCache(cachedColor); + const freshCached = + cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS ? cachedEntry : undefined; + + // Only used while a color is being fetched for an uncached avatar. + const [fetchedColor, setFetchedColor] = useState(); + const [fetchedKey, setFetchedKey] = useState(''); useEffect(() => { - if (!avatarMxc) { - console.log('[useOtherUserColor] No avatarMxc provided for', userId); - setColor(undefined); - return; + if (!avatarMxc) return undefined; + + const key = `${userId}:${avatarMxc}`; + const cached = userColorCache.get(key); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return undefined; } - // Check cache first - const cacheKey = `${userId}:${avatarMxc}`; - const cached = userColorCache.get(cacheKey); - if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { - console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color); - setColor(cached.color); - return; - } + let cancelled = false; const loadColor = async () => { const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); - console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl); if (!httpUrl) { - console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc); - setColor(undefined); + userColorCache.set(key, { color: undefined, timestamp: Date.now() }); + if (!cancelled) { + setFetchedKey(key); + setFetchedColor(undefined); + } return; } - // Always use current session's token to avoid stale tokens during account switches const accessToken = getCurrentAccessToken(); - const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null); - - console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor); - - // Cache the result - userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() }); - - setColor(extractedColor); + const extractedColor = await fetchAndExtractColor( + httpUrl, + useAuthentication ? accessToken : null + ); + + userColorCache.set(key, { color: extractedColor, timestamp: Date.now() }); + if (!cancelled) { + setFetchedKey(key); + setFetchedColor(extractedColor); + } }; loadColor(); + return () => { + cancelled = true; + }; }, [mx, useAuthentication, avatarMxc, userId]); - return color; + if (!avatarMxc) return undefined; + if (freshCached) return freshCached.color; + if (fetchedKey === cacheKey) return fetchedColor; + return undefined; } diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 65d4008..081dfbe 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react'; import { User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; @@ -22,36 +22,125 @@ const getUserPresence = (user: User): UserPresence => ({ lastActiveTs: user.getLastActiveTs(), }); +const presenceEqual = (a: UserPresence | undefined, b: UserPresence | undefined): boolean => { + if (a === b) return true; + if (!a || !b) return false; + return ( + a.presence === b.presence && + a.status === b.status && + a.active === b.active && + a.lastActiveTs === b.lastActiveTs + ); +}; + +type PresenceEntry = { + /** Stable reference — only replaced when presence data changes. */ + presence: UserPresence; + listeners: Set<() => void>; + user: User; + onChange: UserEventHandlerMap[UserEvent.Presence]; +}; + +/** One EventEmitter subscription per userId, shared across all hook consumers. */ +const presenceEntries = new Map(); + +const subscribeNoop = () => () => undefined; + +const bindUser = (userId: string, user: User): PresenceEntry => { + const existing = presenceEntries.get(userId); + if (existing) { + if (existing.user === user) return existing; + existing.user.removeListener(UserEvent.Presence, existing.onChange); + existing.user.removeListener(UserEvent.CurrentlyActive, existing.onChange); + existing.user.removeListener(UserEvent.LastPresenceTs, existing.onChange); + presenceEntries.delete(userId); + } + + const entry: PresenceEntry = { + presence: getUserPresence(user), + listeners: new Set(), + user, + onChange: () => undefined, + }; + + entry.onChange = (_event, u) => { + const current = presenceEntries.get(userId); + if (!current || u.userId !== current.user.userId) return; + const next = getUserPresence(current.user); + if (presenceEqual(current.presence, next)) return; + current.presence = next; + current.listeners.forEach((listener) => listener()); + }; + + user.on(UserEvent.Presence, entry.onChange); + user.on(UserEvent.CurrentlyActive, entry.onChange); + user.on(UserEvent.LastPresenceTs, entry.onChange); + presenceEntries.set(userId, entry); + return entry; +}; + +const releaseIfUnused = (userId: string) => { + const entry = presenceEntries.get(userId); + if (!entry || entry.listeners.size > 0) return; + entry.user.removeListener(UserEvent.Presence, entry.onChange); + entry.user.removeListener(UserEvent.CurrentlyActive, entry.onChange); + entry.user.removeListener(UserEvent.LastPresenceTs, entry.onChange); + presenceEntries.delete(userId); +}; + +/** + * Live presence for a Matrix user. Safe to call from every message row — + * listeners are demuxed to one EventEmitter subscription per userId. + */ export const useUserPresence = (userId: string): UserPresence | undefined => { const mx = useMatrixClient(); - const user = userId ? mx.getUser(userId) : null; + // Cache getSnapshot result so useSyncExternalStore doesn't loop. + const snapshotRef = useRef(undefined); - const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (!userId) return subscribeNoop(); - useEffect(() => { - if (!user) { - setPresence(undefined); + const user = mx.getUser(userId); + if (!user) return subscribeNoop(); + + const entry = bindUser(userId, user); + snapshotRef.current = entry.presence; + entry.listeners.add(onStoreChange); + return () => { + entry.listeners.delete(onStoreChange); + releaseIfUnused(userId); + }; + }, + [mx, userId] + ); + + const getSnapshot = useCallback(() => { + if (!userId) { + snapshotRef.current = undefined; return undefined; } + const cached = presenceEntries.get(userId); + if (cached) { + snapshotRef.current = cached.presence; + return cached.presence; + } + const user = mx.getUser(userId); + if (!user) { + snapshotRef.current = undefined; + return undefined; + } + // Not subscribed yet — return a stable fallback without allocating each read + // once we have a previous snapshot that still matches. + const next = getUserPresence(user); + if (presenceEqual(snapshotRef.current, next)) { + return snapshotRef.current; + } + snapshotRef.current = next; + return next; + }, [mx, userId]); - setPresence(getUserPresence(user)); - - const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => { - if (u.userId === user.userId) { - setPresence(getUserPresence(user)); - } - }; - user.on(UserEvent.Presence, updatePresence); - user.on(UserEvent.CurrentlyActive, updatePresence); - user.on(UserEvent.LastPresenceTs, updatePresence); - return () => { - user.removeListener(UserEvent.Presence, updatePresence); - user.removeListener(UserEvent.CurrentlyActive, updatePresence); - user.removeListener(UserEvent.LastPresenceTs, updatePresence); - }; - }, [user]); - - return presence; + return useSyncExternalStore(subscribe, getSnapshot, () => undefined); }; export const usePresenceLabel = (): Record => diff --git a/src/app/hooks/useVirtualPaginator.ts b/src/app/hooks/useVirtualPaginator.ts index cacd6b4..8c8a722 100644 --- a/src/app/hooks/useVirtualPaginator.ts +++ b/src/app/hooks/useVirtualPaginator.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import { MutableRefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { OnIntersectionCallback, useIntersectionObserver } from './useIntersectionObserver'; import { canFitInScrollView, @@ -7,6 +7,10 @@ import { isIntersectingScrollView, scrollToPosition, } from '../utils/dom'; +import { + armRoomTimelineNetworkPaginate, + canRoomTimelineNetworkPaginate, +} from '../state/roomTimelineVisibilityGate'; const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor'; @@ -55,6 +59,15 @@ type VirtualPaginatorOptions = { getScrollElement: () => TScrollElement | null; getItemElement: (index: number) => HTMLElement | undefined; onEnd?: (back: boolean) => void; + /** When false, skip observe/paginate (keep-alive inactive rooms). */ + enabled?: boolean; + /** + * Mutable gate for keep-alive visibility. Prefer this over flipping `enabled` + * so parent re-renders don't tear down the timeline tree. + */ + enabledRef?: MutableRefObject; + /** Room id for keep-alive network-paginate suppress across CSS-only switches. */ + roomId?: string; }; type VirtualPaginator = { @@ -163,9 +176,42 @@ const useObserveAnchorHandle = ( export const useVirtualPaginator = ( options: VirtualPaginatorOptions ): VirtualPaginator => { - const { count, limit, range, onRangeChange, getScrollElement, getItemElement, onEnd } = options; + const { + count, + limit, + range, + onRangeChange, + getScrollElement, + getItemElement, + onEnd, + enabled = true, + enabledRef, + roomId, + } = options; const initialRenderRef = useRef(true); + /** After keep-alive re-show, don't network-paginate until the user scrolls. */ + const allowNetworkPaginateRef = useRef(true); + const enabledRefStable = enabledRef; + + const isPaginatorEnabled = useCallback(() => { + if (!enabled) return false; + if (enabledRefStable && !enabledRefStable.current) return false; + return true; + }, [enabled, enabledRefStable]); + + const canNetworkPaginate = useCallback(() => { + if (!allowNetworkPaginateRef.current) return false; + if (roomId && !canRoomTimelineNetworkPaginate(roomId)) return false; + return true; + }, [roomId]); + + // Arm suppress while hidden so re-show can't race intersection → paginate/decrypt. + useLayoutEffect(() => { + if (!enabled) { + allowNetworkPaginateRef.current = false; + } + }, [enabled]); const restoreScrollRef = useRef<{ scrollTop: number; @@ -269,6 +315,10 @@ export const useVirtualPaginator = ( const paginate = useCallback( (direction: Direction) => { + if (!isPaginatorEnabled()) { + allowNetworkPaginateRef.current = false; + return; + } const scrollEl = getScrollElement(); const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current; let { start, end } = currentRange; @@ -276,7 +326,7 @@ export const useVirtualPaginator = ( if (direction === Direction.Backward) { restoreScrollRef.current = undefined; if (start === 0) { - onEnd?.(true); + if (canNetworkPaginate()) onEnd?.(true); return; } if (scrollEl) { @@ -294,7 +344,7 @@ export const useVirtualPaginator = ( if (direction === Direction.Forward) { restoreScrollRef.current = undefined; if (end === currentCount) { - onEnd?.(false); + if (canNetworkPaginate()) onEnd?.(false); return; } if (scrollEl) { @@ -315,11 +365,15 @@ export const useVirtualPaginator = ( end, }); }, - [getScrollElement, getItemElement, onEnd, onRangeChange] + [getScrollElement, getItemElement, onEnd, onRangeChange, isPaginatorEnabled, canNetworkPaginate] ); const handlePaginatorElIntersection: OnIntersectionCallback = useCallback( (entries) => { + if (!isPaginatorEnabled()) { + allowNetworkPaginateRef.current = false; + return; + } const anchorB = entries.find( (entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Backward ); @@ -333,7 +387,7 @@ export const useVirtualPaginator = ( paginate(Direction.Forward); } }, - [paginate] + [paginate, isPaginatorEnabled] ); const intersectionObserver = useIntersectionObserver( @@ -346,6 +400,7 @@ export const useVirtualPaginator = ( ) ); + // Keep observers attached for warm keep-alive rooms; gate in handlers via enabledRef. const observeBackAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Backward); const observeFrontAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Forward); @@ -390,9 +445,13 @@ export const useVirtualPaginator = ( // check if pagination anchor are in visible view height // and trigger pagination useEffect(() => { - if (initialRenderRef.current) { - // Do not trigger pagination on initial render - // anchor intersection observable will trigger pagination on mount + if (!isPaginatorEnabled()) { + allowNetworkPaginateRef.current = false; + return; + } + // Skip the first cycle after mount OR while network paginate is suppressed + // (keep-alive re-show) — otherwise intersecting anchors re-trigger decrypt. + if (initialRenderRef.current || !canNetworkPaginate()) { initialRenderRef.current = false; return; } @@ -412,7 +471,20 @@ export const useVirtualPaginator = ( if (frontAnchor && isIntersectingScrollView(scrollElement, frontAnchor)) { paginate(Direction.Forward); } - }, [range, getScrollElement, paginate]); + }, [range, getScrollElement, paginate, isPaginatorEnabled, canNetworkPaginate]); + + // Re-arm network pagination after the user scrolls (keep-alive re-show safe). + useEffect(() => { + const el = getScrollElement(); + if (!el) return undefined; + const onScroll = () => { + if (!isPaginatorEnabled()) return; + allowNetworkPaginateRef.current = true; + if (roomId) armRoomTimelineNetworkPaginate(roomId); + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => el.removeEventListener('scroll', onScroll); + }, [isPaginatorEnabled, getScrollElement, range, roomId]); return { getItems, diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 3cc7ab4..b4aed9b 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -40,15 +40,14 @@ import { getOriginBaseUrl, } from './pathUtils'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; -import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; -import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; +import { Home, HomeLayout, HomeRouteRoomProvider, HomeSearch } from './client/home'; +import { Direct, DirectCreate, DirectLayout, DirectRouteRoomProvider } from './client/direct'; import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space'; import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout'; import { ForumFeedPage } from '../features/forum/ForumFeedPage'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Notifications, Inbox, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; -import { Room } from '../features/room'; import { Lobby } from '../features/lobby'; import { WelcomePage } from './client/WelcomePage'; import { SidebarNav } from './client/SidebarNav'; @@ -190,7 +189,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } > - + } > @@ -198,14 +197,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> join

} /> } /> - - - - } - /> + } /> } > - + } > @@ -225,14 +217,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> } /> } /> - - - - } - /> + } /> } /> } /> } /> - - - - } - /> + } /> `. + * Room param changes must not remount CachedRooms — that was making every + * "cached" switch as slow as a cold open. + */ +export function DirectLayout() { + const mx = useMatrixClient(); + const selectedRoomId = useSelectedRoom(); + const directs = useDirectRooms(); + const cache = useAtomValue(roomViewCacheAtom); + const setActiveRoomId = useSetAtom(activeRoomIdAtom); + + const room = selectedRoomId ? mx.getRoom(selectedRoomId) : undefined; + const showingRoom = !!(room && directs.includes(room.roomId)); + // Keep the host mounted while we still have warm rooms, even on /direct/. + const keepHost = showingRoom || cache.length > 0; + + useEffect(() => { + if (!showingRoom) setActiveRoomId(undefined); + }, [showingRoom, setActiveRoomId]); + + return ( +
+ {keepHost && ( +
+ +
+ )} +
+ +
+
+ ); +} diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index 275805d..b4dcf74 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -1,13 +1,16 @@ -import React, { ReactNode } from 'react'; +import React from 'react'; import { useParams } from 'react-router-dom'; import { decodeRouteParam } from '../../pathUtils'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { useDirectRooms } from './useDirectRooms'; -export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { +/** + * Room-route gate only. The actual room UI is rendered by DirectLayout's + * CachedRooms host so keep-alive survives room id changes. + */ +export function DirectRouteRoomProvider() { const mx = useMatrixClient(); const rooms = useDirectRooms(); @@ -26,9 +29,6 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { ); } - return ( - - {children} - - ); + // Valid room — DirectLayout / CachedRooms owns the UI. + return null; } diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts index d247bbc..998da15 100644 --- a/src/app/pages/client/direct/index.ts +++ b/src/app/pages/client/direct/index.ts @@ -1,3 +1,4 @@ export * from './Direct'; +export * from './DirectLayout'; export * from './RoomProvider'; export * from './DirectCreate'; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 06d1137..b9fa3d5 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -56,6 +56,7 @@ import { JoinAddressPrompt } from '../../../components/join-address-prompt'; import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt'; import { _RoomSearchParams } from '../../paths'; import { StateEvent } from '../../../../types/matrix/room'; +import { useGlobalSubRoomIds } from '../../../state/globalSubRoomIds'; type HomeMenuProps = { requestClose: () => void; @@ -256,6 +257,8 @@ export function Home() { return items; }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]); + const globalSubRoomIds = useGlobalSubRoomIds(); + // Flatten rooms and their sub-rooms into a hierarchical list const listItems = useMemo(() => { const items: RoomListItem[] = []; @@ -285,26 +288,15 @@ export function Home() { } }; - // Build set of all sub-room IDs to identify top-level rooms - const allSubRoomIds = new Set(); - sortedRooms.forEach(roomId => { - const room = mx.getRoom(roomId); - if (room) { - const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); - const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? []; - subRooms.forEach(id => allSubRoomIds.add(id)); - } - }); - // Only add top-level rooms (rooms that aren't sub-rooms of another) sortedRooms.forEach(roomId => { - if (!allSubRoomIds.has(roomId)) { + if (!globalSubRoomIds.has(roomId)) { addRoomAndSubRooms(roomId); } }); return items; - }, [mx, sortedRooms]); + }, [mx, sortedRooms, globalSubRoomIds]); const virtualizer = useVirtualizer({ count: listItems.length, diff --git a/src/app/pages/client/home/HomeLayout.tsx b/src/app/pages/client/home/HomeLayout.tsx new file mode 100644 index 0000000..1a25c3d --- /dev/null +++ b/src/app/pages/client/home/HomeLayout.tsx @@ -0,0 +1,66 @@ +import React, { useEffect } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { AnimatedOutlet } from '../../../components/AnimatedOutlet'; +import { CachedRooms } from '../../../features/room/CachedRooms'; +import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { activeRoomIdAtom } from '../../../state/activeRoom'; +import { roomViewCacheAtom } from '../../../state/roomViewCache'; +import { useHomeRooms } from './useHomeRooms'; + +/** + * Hosts home room keep-alive OUTSIDE the room route ``. + */ +export function HomeLayout() { + const mx = useMatrixClient(); + const selectedRoomId = useSelectedRoom(); + const rooms = useHomeRooms(); + const cache = useAtomValue(roomViewCacheAtom); + const setActiveRoomId = useSetAtom(activeRoomIdAtom); + + const room = selectedRoomId ? mx.getRoom(selectedRoomId) : undefined; + const showingRoom = !!(room && rooms.includes(room.roomId)); + const keepHost = showingRoom || cache.length > 0; + + useEffect(() => { + if (!showingRoom) setActiveRoomId(undefined); + }, [showingRoom, setActiveRoomId]); + + return ( +
+ {keepHost && ( +
+ +
+ )} +
+ +
+
+ ); +} diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index ebd88c5..a9ed0e9 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -1,14 +1,16 @@ -import React, { ReactNode } from 'react'; +import React from 'react'; import { useParams } from 'react-router-dom'; import { decodeRouteParam } from '../../pathUtils'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { useHomeRooms } from './useHomeRooms'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; -export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { +/** + * Room-route gate only. HomeLayout's CachedRooms owns the UI. + */ +export function HomeRouteRoomProvider() { const mx = useMatrixClient(); const rooms = useHomeRooms(); @@ -29,9 +31,5 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { ); } - return ( - - {children} - - ); + return null; } diff --git a/src/app/pages/client/home/index.ts b/src/app/pages/client/home/index.ts index 8076448..0cc091c 100644 --- a/src/app/pages/client/home/index.ts +++ b/src/app/pages/client/home/index.ts @@ -1,3 +1,4 @@ export * from './Home'; +export * from './HomeLayout'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/app/pages/client/home/useHomeRooms.ts b/src/app/pages/client/home/useHomeRooms.ts index 3459224..80268dc 100644 --- a/src/app/pages/client/home/useHomeRooms.ts +++ b/src/app/pages/client/home/useHomeRooms.ts @@ -6,12 +6,14 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { useOrphanRooms } from '../../../state/hooks/roomList'; import { RoomType, StateEvent } from '../../../../types/matrix/room'; +import { useGlobalSubRoomIds } from '../../../state/globalSubRoomIds'; export const useHomeRooms = () => { const mx = useMatrixClient(); const mDirects = useAtomValue(mDirectAtom); const roomToParents = useAtomValue(roomToParentsAtom); const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents); + const globalSubRoomIds = useGlobalSubRoomIds(); // Filter out rooms that are sub-rooms of ANY room (not just orphan rooms) const rooms = useMemo(() => { @@ -34,17 +36,10 @@ export const useHomeRooms = () => { return roomType === RoomType.Space || roomType === RoomType.Forum; }; - // Build a set of all sub-room IDs from ALL joined rooms - const allSubRoomIds = new Set(); - mx.getRooms().forEach((room) => { - const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); - const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? []; - subRooms.forEach((id) => allSubRoomIds.add(id)); - }); - - // Return only rooms that are not sub-rooms of another room - return orphanRooms.filter((roomId) => !allSubRoomIds.has(roomId) && !isSpaceLikeRoom(roomId)); - }, [mx, orphanRooms]); + return orphanRooms.filter( + (roomId) => !globalSubRoomIds.has(roomId) && !isSpaceLikeRoom(roomId) + ); + }, [mx, orphanRooms, globalSubRoomIds]); return rooms; }; diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx index 088cae3..f123e07 100644 --- a/src/app/pages/client/space/RoomProvider.tsx +++ b/src/app/pages/client/space/RoomProvider.tsx @@ -1,9 +1,8 @@ -import React, { ReactNode } from 'react'; +import React from 'react'; import { useParams } from 'react-router-dom'; import { useAtom, useAtomValue } from 'jotai'; import { decodeRouteParam } from '../../pathUtils'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { useSpace } from '../../../hooks/useSpace'; @@ -11,16 +10,17 @@ import { getAllParents, getSpaceChildren, isSpace } from '../../../utils/room'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { allRoomsAtom } from '../../../state/room-list/roomList'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; -import { mDirectAtom } from '../../../state/mDirectList'; import { settingsAtom } from '../../../state/settings'; import { useSetting } from '../../../state/hooks/settings'; -export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { +/** + * Room-route gate only. SpaceRoomLayout's CachedRooms owns the UI. + */ +export function SpaceRouteRoomProvider() { const mx = useMatrixClient(); const space = useSpace(); const [developerTools] = useSetting(settingsAtom, 'developerTools'); const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom); - const mDirects = useAtomValue(mDirectAtom); const allRooms = useAtomValue(allRoomsAtom); const { roomIdOrAlias: rawRoomIdOrAlias, eventId: rawEventId } = useParams(); @@ -31,7 +31,6 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { const room = mx.getRoom(roomId); if (!room || !allRooms.includes(room.roomId)) { - // room is not joined return ( - {children} - - ); + return null; } if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) { if (getSpaceChildren(space).includes(room.roomId)) { - // fill missing roomToParent mapping setRoomToParents({ type: 'PUT', parent: space.roomId, @@ -69,9 +62,5 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) { ); } - return ( - - {children} - - ); + return null; } diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 71473bf..31309fb 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -54,6 +54,7 @@ import { Membership, StateEvent } from '../../../../types/matrix/room'; import { stopPropagation } from '../../../utils/keyboard'; import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot'; import { getMatrixToRoom } from '../../../plugins/matrix-to'; +import { useGlobalSubRoomIds } from '../../../state/globalSubRoomIds'; import { getViaServers } from '../../../plugins/via-servers'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; @@ -255,17 +256,7 @@ export function Space() { ) ); - // Build a global set of all sub-room IDs to filter from hierarchy - // This prevents sub-rooms from appearing both as top-level entries AND nested under parents - const globalSubRoomIds = useMemo(() => { - const subRoomIds = new Set(); - mx.getRooms().forEach((room) => { - const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); - const content = subRoomsEvent?.getContent<{ children: string[] }>(); - content?.children?.forEach((childId: string) => subRoomIds.add(childId)); - }); - return subRoomIds; - }, [mx, allJoinedRooms, hierarchy]); + const globalSubRoomIds = useGlobalSubRoomIds(); // Filter hierarchy to exclude sub-rooms (they'll be shown nested under their parent) const filteredHierarchy = useMemo(() => { diff --git a/src/app/pages/client/space/SpaceRoomLayout.tsx b/src/app/pages/client/space/SpaceRoomLayout.tsx new file mode 100644 index 0000000..8cdb619 --- /dev/null +++ b/src/app/pages/client/space/SpaceRoomLayout.tsx @@ -0,0 +1,78 @@ +import React, { useEffect } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { AnimatedOutlet } from '../../../components/AnimatedOutlet'; +import { CachedRooms } from '../../../features/room/CachedRooms'; +import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useSpace } from '../../../hooks/useSpace'; +import { activeRoomIdAtom } from '../../../state/activeRoom'; +import { roomViewCacheAtom } from '../../../state/roomViewCache'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { getAllParents, isSpace } from '../../../utils/room'; +import { settingsAtom } from '../../../state/settings'; +import { useSetting } from '../../../state/hooks/settings'; + +/** + * Hosts space room keep-alive OUTSIDE the room route outlet. + */ +export function SpaceRoomLayout() { + const mx = useMatrixClient(); + const space = useSpace(); + const selectedRoomId = useSelectedRoom(); + const cache = useAtomValue(roomViewCacheAtom); + const allRooms = useAtomValue(allRoomsAtom); + const roomToParents = useAtomValue(roomToParentsAtom); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); + const setActiveRoomId = useSetAtom(activeRoomIdAtom); + + const room = selectedRoomId ? mx.getRoom(selectedRoomId) : undefined; + const showingRoom = (() => { + if (!room || !allRooms.includes(room.roomId)) return false; + if (developerTools && isSpace(room) && room.roomId === space.roomId) return true; + return getAllParents(roomToParents, room.roomId).has(space.roomId); + })(); + const keepHost = showingRoom || cache.length > 0; + + useEffect(() => { + if (!showingRoom) setActiveRoomId(undefined); + }, [showingRoom, setActiveRoomId]); + + return ( +
+ {keepHost && ( +
+ +
+ )} +
+ +
+
+ ); +} diff --git a/src/app/pages/client/space/index.ts b/src/app/pages/client/space/index.ts index 686dc7b..777153e 100644 --- a/src/app/pages/client/space/index.ts +++ b/src/app/pages/client/space/index.ts @@ -1,5 +1,6 @@ export * from './SpaceIndexRedirect'; export * from './SpaceProvider'; export * from './Space'; +export * from './SpaceRoomLayout'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/app/state/activeCallRoomsRegistry.ts b/src/app/state/activeCallRoomsRegistry.ts new file mode 100644 index 0000000..9b2287f --- /dev/null +++ b/src/app/state/activeCallRoomsRegistry.ts @@ -0,0 +1,146 @@ +import { MatrixClient, MatrixEvent } from 'matrix-js-sdk'; +import { useCallback, useSyncExternalStore } from 'react'; +import { useMatrixClient } from '../hooks/useMatrixClient'; +import { + CallMember, + CALL_MEMBER_EVENT_TYPE, + getActiveCallMembers, +} from '../features/call/callMemberUtils'; +import { subscribeStateEvents } from './stateEventDemux'; + +const EXPIRY_CHECK_MS = 30_000; +const EMPTY_CALL_MEMBERS: CallMember[] = []; + +type RoomSnapshot = { + members: CallMember[]; + isActive: boolean; +}; + +type Registry = { + snapshots: Map; + globalListeners: Set<() => void>; + roomListeners: Map void>>; + unsubState: () => void; + expiryTimer: ReturnType; +}; + +const registries = new WeakMap(); + +const notifyRoom = (reg: Registry, roomId: string) => { + reg.roomListeners.get(roomId)?.forEach((listener) => listener()); + reg.globalListeners.forEach((listener) => listener()); +}; + +const refreshRoom = (reg: Registry, mx: MatrixClient, roomId: string) => { + const room = mx.getRoom(roomId); + const members = room ? getActiveCallMembers(room) : []; + const isActive = members.length > 0; + const prev = reg.snapshots.get(roomId); + + if (prev && prev.isActive === isActive) { + const unchanged = + prev.members.length === members.length && + prev.members.every((member, index) => member.userId === members[index]?.userId); + if (unchanged) return; + } + + if (isActive) { + reg.snapshots.set(roomId, { members, isActive }); + } else { + reg.snapshots.delete(roomId); + } + notifyRoom(reg, roomId); +}; + +const refreshAllRooms = (reg: Registry, mx: MatrixClient) => { + const seen = new Set(); + mx.getRooms().forEach((room) => { + seen.add(room.roomId); + refreshRoom(reg, mx, room.roomId); + }); + reg.snapshots.forEach((_, roomId) => { + if (!seen.has(roomId)) { + reg.snapshots.delete(roomId); + notifyRoom(reg, roomId); + } + }); +}; + +const getOrCreateRegistry = (mx: MatrixClient): Registry => { + let reg = registries.get(mx); + if (reg) return reg; + + reg = { + snapshots: new Map(), + globalListeners: new Set(), + roomListeners: new Map(), + unsubState: subscribeStateEvents(mx, (event: MatrixEvent) => { + if (event.getType() !== CALL_MEMBER_EVENT_TYPE) return; + const roomId = event.getRoomId(); + if (roomId) refreshRoom(reg!, mx, roomId); + }), + expiryTimer: setInterval(() => refreshAllRooms(reg!, mx), EXPIRY_CHECK_MS), + }; + + refreshAllRooms(reg, mx); + registries.set(mx, reg); + return reg; +}; + +const subscribeRoom = (mx: MatrixClient, roomId: string, listener: () => void): (() => void) => { + const reg = getOrCreateRegistry(mx); + let roomSet = reg.roomListeners.get(roomId); + if (!roomSet) { + roomSet = new Set(); + reg.roomListeners.set(roomId, roomSet); + } + roomSet.add(listener); + return () => { + roomSet!.delete(listener); + if (roomSet!.size === 0) { + reg.roomListeners.delete(roomId); + } + }; +}; + +export const roomHasActiveCall = (mx: MatrixClient, roomId: string): boolean => + getOrCreateRegistry(mx).snapshots.get(roomId)?.isActive ?? false; + +export const getRoomCallMembersSnapshot = (mx: MatrixClient, roomId: string): CallMember[] => + getOrCreateRegistry(mx).snapshots.get(roomId)?.members ?? EMPTY_CALL_MEMBERS; + +export const useRoomHasActiveCall = (roomId: string): boolean => { + const mx = useMatrixClient(); + return useSyncExternalStore( + useCallback((listener) => subscribeRoom(mx, roomId, listener), [mx, roomId]), + () => roomHasActiveCall(mx, roomId), + () => false + ); +}; + +export const useRoomCallMembersFromRegistry = (roomId: string, enabled = true) => { + const mx = useMatrixClient(); + + const subscribe = useCallback( + (listener: () => void) => (enabled ? subscribeRoom(mx, roomId, listener) : () => {}), + [mx, roomId, enabled] + ); + + const getSnapshot = useCallback( + () => (enabled ? getRoomCallMembersSnapshot(mx, roomId) : EMPTY_CALL_MEMBERS), + [mx, roomId, enabled] + ); + + const callMembers = useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_CALL_MEMBERS); + + const isCallActive = callMembers.length > 0; + const myUserId = mx.getUserId(); + const othersInCall = callMembers.filter((m) => m.userId !== myUserId); + + return { + callMembers, + isCallActive, + othersInCall, + callMemberCount: callMembers.length, + }; +}; diff --git a/src/app/state/globalSubRoomIds.ts b/src/app/state/globalSubRoomIds.ts new file mode 100644 index 0000000..3f61d13 --- /dev/null +++ b/src/app/state/globalSubRoomIds.ts @@ -0,0 +1,77 @@ +import { useCallback, useSyncExternalStore } from 'react'; +import { ClientEvent, MatrixClient } from 'matrix-js-sdk'; +import { useMatrixClient } from '../hooks/useMatrixClient'; +import { StateEvent } from '../../types/matrix/room'; +import { subscribeStateEvents } from './stateEventDemux'; + +const EMPTY_SUB_ROOM_IDS = new Set(); + +const setsEqual = (a: ReadonlySet, b: ReadonlySet): boolean => { + if (a === b) return true; + if (a.size !== b.size) return false; + for (const id of a) { + if (!b.has(id)) return false; + } + return true; +}; + +export const computeGlobalSubRoomIds = (mx: MatrixClient): ReadonlySet => { + const subRoomIds = new Set(); + mx.getRooms().forEach((room) => { + const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); + const content = subRoomsEvent?.getContent<{ children?: string[] }>(); + content?.children?.forEach((childId) => subRoomIds.add(childId)); + }); + return subRoomIds; +}; + +type SubRoomStore = { + snapshot: ReadonlySet; + listeners: Set<() => void>; +}; + +const subRoomStores = new WeakMap(); + +const getSubRoomStore = (mx: MatrixClient): SubRoomStore => { + let store = subRoomStores.get(mx); + if (!store) { + store = { + snapshot: computeGlobalSubRoomIds(mx), + listeners: new Set(), + }; + subRoomStores.set(mx, store); + + const notify = () => { + const next = computeGlobalSubRoomIds(mx); + if (setsEqual(store!.snapshot, next)) return; + store!.snapshot = next; + store!.listeners.forEach((listener) => listener()); + }; + + subscribeStateEvents(mx, (event) => { + if (event.getType() === StateEvent.PaarrotSubRooms) { + notify(); + } + }); + + mx.on(ClientEvent.Room, notify); + } + return store; +}; + +const subscribeGlobalSubRoomIds = (mx: MatrixClient, listener: () => void): (() => void) => { + const store = getSubRoomStore(mx); + store.listeners.add(listener); + return () => { + store.listeners.delete(listener); + }; +}; + +export const useGlobalSubRoomIds = (): ReadonlySet => { + const mx = useMatrixClient(); + return useSyncExternalStore( + useCallback((listener) => subscribeGlobalSubRoomIds(mx, listener), [mx]), + () => getSubRoomStore(mx).snapshot, + () => EMPTY_SUB_ROOM_IDS + ); +}; diff --git a/src/app/state/hooks/peopleDrawer.ts b/src/app/state/hooks/peopleDrawer.ts new file mode 100644 index 0000000..9514ee4 --- /dev/null +++ b/src/app/state/hooks/peopleDrawer.ts @@ -0,0 +1,11 @@ +import { SettingSetter, useSetting } from './settings'; +import { settingsAtom } from '../settings'; + +/** Members drawer preference for the current room kind (DM vs channel). */ +export const usePeopleDrawerSetting = ( + isDirect: boolean +): [boolean, (value: SettingSetter) => void] => { + const rooms = useSetting(settingsAtom, 'peopleDrawerInRooms'); + const directs = useSetting(settingsAtom, 'peopleDrawerInDirects'); + return isDirect ? directs : rooms; +}; diff --git a/src/app/state/mediaDimensionCache.ts b/src/app/state/mediaDimensionCache.ts index 2b894a6..73c216a 100644 --- a/src/app/state/mediaDimensionCache.ts +++ b/src/app/state/mediaDimensionCache.ts @@ -1,9 +1,13 @@ import { encodeBlurHash, validBlurHash } from '../utils/blurHash'; +import { fitMediaSize } from '../utils/common'; const STORAGE_KEY = 'paarrot.media.meta'; const LEGACY_DIMENSIONS_KEY = 'paarrot.media.dimensions'; const MAX_ENTRIES = 500; +/** Same max box as MImage / MVideo attachment rendering. */ +export const ATTACHMENT_MAX_SIZE = 400; + export type MediaMeta = { w: number; h: number; @@ -88,6 +92,19 @@ export const getMediaDimensions = (mxcUrl: string): MediaDimensions | undefined return { w: entry.w, h: entry.h }; }; +/** Fit attachment box size using event info, then local dimension cache. */ +export const resolveAttachmentBoxSize = ( + mxcUrl: string | undefined, + w?: number, + h?: number, + maxSize: number = ATTACHMENT_MAX_SIZE +): { width: number; height: number } => { + const cached = mxcUrl && (!w || !h) ? getMediaDimensions(mxcUrl) : undefined; + const naturalW = w && w > 0 ? w : cached?.w; + const naturalH = h && h > 0 ? h : cached?.h; + return fitMediaSize(naturalW ?? 0, naturalH ?? 0, maxSize, maxSize); +}; + export const setMediaDimensions = (mxcUrl: string, w: number, h: number): void => { if (!mxcUrl || w <= 0 || h <= 0) return; touchEntry(mxcUrl, { w, h }); diff --git a/src/app/state/pendingRoomNavigation.ts b/src/app/state/pendingRoomNavigation.ts new file mode 100644 index 0000000..ea59233 --- /dev/null +++ b/src/app/state/pendingRoomNavigation.ts @@ -0,0 +1,7 @@ +import { atom } from 'jotai'; + +/** Room id being navigated to — for instant nav highlight before the route settles. */ +export const pendingRoomNavigationAtom = atom(null); + +/** Room id showing the nav loading bar (stays until the room view is ready). */ +export const roomNavLoadingAtom = atom(null); diff --git a/src/app/state/roomTimelineVisibilityGate.ts b/src/app/state/roomTimelineVisibilityGate.ts new file mode 100644 index 0000000..681f45c --- /dev/null +++ b/src/app/state/roomTimelineVisibilityGate.ts @@ -0,0 +1,22 @@ +/** Keep-alive room visibility gates for timeline network pagination (no React). */ + +const needsScrollBeforeNetwork = new Set(); + +export const setRoomTimelineVisible = (roomId: string, visible: boolean): void => { + if (visible) { + // Stays in needsScroll until the user scrolls (armed while hidden). + return; + } + needsScrollBeforeNetwork.add(roomId); +}; + +export const armRoomTimelineNetworkPaginate = (roomId: string): void => { + needsScrollBeforeNetwork.delete(roomId); +}; + +export const canRoomTimelineNetworkPaginate = (roomId: string): boolean => + !needsScrollBeforeNetwork.has(roomId); + +export const clearRoomTimelineVisibilityGate = (roomId: string): void => { + needsScrollBeforeNetwork.delete(roomId); +}; diff --git a/src/app/state/roomViewCache.ts b/src/app/state/roomViewCache.ts new file mode 100644 index 0000000..6b27194 --- /dev/null +++ b/src/app/state/roomViewCache.ts @@ -0,0 +1,20 @@ +import { atom } from 'jotai'; + +export const ROOM_VIEW_CACHE_LIMIT = 3; + +/** LRU of room ids whose Room views should stay mounted (newest first). */ +export const roomViewCacheAtom = atom([]); + +export const isRoomViewCached = (cache: string[], roomId: string): boolean => + cache.includes(roomId); + +/** Move roomId to front; returns whether it was already cached (warm revisit). */ +export const touchRoomViewCache = ( + cache: string[], + roomId: string, + limit: number = ROOM_VIEW_CACHE_LIMIT +): { next: string[]; wasCached: boolean } => { + const wasCached = cache.includes(roomId); + const next = [roomId, ...cache.filter((id) => id !== roomId)].slice(0, limit); + return { next, wasCached }; +}; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 8e26011..14b1183 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -36,7 +36,10 @@ export interface Settings { pageZoom: number; hideActivity: boolean; - isPeopleDrawer: boolean; + /** Last open/closed state of the members panel in non-DM rooms. */ + peopleDrawerInRooms: boolean; + /** Last open/closed state of the members panel in DMs. */ + peopleDrawerInDirects: boolean; isCallPanelDocked: boolean; callPanelDockPosition: CallPanelDockPosition; memberSortFilterIndex: number; @@ -79,7 +82,8 @@ const defaultSettings: Settings = { pageZoom: 100, hideActivity: false, - isPeopleDrawer: true, + peopleDrawerInRooms: true, + peopleDrawerInDirects: false, isCallPanelDocked: false, callPanelDockPosition: 'right', memberSortFilterIndex: 0, @@ -112,15 +116,29 @@ const defaultSettings: Settings = { export const getSettings = () => { const settings = localStorage.getItem(STORAGE_KEY); if (settings === null) return defaultSettings; - - const parsed = JSON.parse(settings) as Settings & { twitterEmoji?: boolean }; - + + const parsed = JSON.parse(settings) as Settings & { + twitterEmoji?: boolean; + isPeopleDrawer?: boolean; + }; + // Migrate old twitterEmoji boolean to new emojiStyle enum if ('twitterEmoji' in parsed && parsed.emojiStyle === undefined) { parsed.emojiStyle = parsed.twitterEmoji ? EmojiStyle.Twemoji : EmojiStyle.Apple; delete parsed.twitterEmoji; } - + + // Migrate single people-drawer flag into room/DM prefs. + if ( + typeof parsed.isPeopleDrawer === 'boolean' && + parsed.peopleDrawerInRooms === undefined && + parsed.peopleDrawerInDirects === undefined + ) { + parsed.peopleDrawerInRooms = parsed.isPeopleDrawer; + parsed.peopleDrawerInDirects = parsed.isPeopleDrawer; + } + delete parsed.isPeopleDrawer; + return { ...defaultSettings, ...parsed, diff --git a/src/app/state/spaceHierarchyCache.ts b/src/app/state/spaceHierarchyCache.ts new file mode 100644 index 0000000..fbf6501 --- /dev/null +++ b/src/app/state/spaceHierarchyCache.ts @@ -0,0 +1,40 @@ +import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; + +const CACHE_TTL_MS = 5 * 60 * 1000; + +type CacheEntry = { + rooms: IHierarchyRoom[]; + fetchedAt: number; +}; + +const hierarchyCache = new Map(); + +const hierarchyCacheKey = (spaceId: string, maxDepth?: number): string => + `${spaceId}:${maxDepth ?? 'all'}`; + +export const getCachedHierarchyRooms = ( + spaceId: string, + maxDepth?: number +): IHierarchyRoom[] | undefined => { + const entry = hierarchyCache.get(hierarchyCacheKey(spaceId, maxDepth)); + if (!entry) return undefined; + if (Date.now() - entry.fetchedAt > CACHE_TTL_MS) { + hierarchyCache.delete(hierarchyCacheKey(spaceId, maxDepth)); + return undefined; + } + return entry.rooms; +}; + +export const setCachedHierarchyRooms = ( + spaceId: string, + rooms: IHierarchyRoom[], + maxDepth?: number +): void => { + hierarchyCache.set(hierarchyCacheKey(spaceId, maxDepth), { rooms, fetchedAt: Date.now() }); +}; + +export const hierarchyRoomsToMap = (rooms: IHierarchyRoom[]): Map => { + const map = new Map(); + rooms.forEach((room) => map.set(room.room_id, room)); + return map; +}; diff --git a/src/app/state/stateEventDemux.ts b/src/app/state/stateEventDemux.ts new file mode 100644 index 0000000..5da7164 --- /dev/null +++ b/src/app/state/stateEventDemux.ts @@ -0,0 +1,38 @@ +import { MatrixClient, RoomStateEvent } from 'matrix-js-sdk'; +import type { StateEventCallback } from '../hooks/stateEventCallback.types'; + +type ClientRegistry = { + listener: StateEventCallback; + subscribers: Set; +}; + +const registries = new WeakMap(); + +const getOrCreateRegistry = (mx: MatrixClient): ClientRegistry => { + let reg = registries.get(mx); + if (!reg) { + const subscribers = new Set(); + const listener: StateEventCallback = (event, state, lastStateEvent) => { + subscribers.forEach((cb) => cb(event, state, lastStateEvent)); + }; + mx.on(RoomStateEvent.Events, listener); + reg = { listener, subscribers }; + registries.set(mx, reg); + } + return reg; +}; + +export const subscribeStateEvents = ( + mx: MatrixClient, + callback: StateEventCallback +): (() => void) => { + const reg = getOrCreateRegistry(mx); + reg.subscribers.add(callback); + return () => { + reg.subscribers.delete(callback); + if (reg.subscribers.size === 0) { + mx.removeListener(RoomStateEvent.Events, reg.listener); + registries.delete(mx); + } + }; +}; diff --git a/src/app/utils/imageMetadata.ts b/src/app/utils/imageMetadata.ts index 9611c7f..ff45717 100644 --- a/src/app/utils/imageMetadata.ts +++ b/src/app/utils/imageMetadata.ts @@ -169,9 +169,7 @@ export function detectImageFormat(data: ArrayBuffer | Uint8Array): ImageFormat { */ export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined { const format = detectImageFormat(imageData); - - console.log('[extractColorFromImage] Detected format:', format); - + switch (format) { case 'png': return extractColorFromPNG(imageData); @@ -182,7 +180,6 @@ export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): stri case 'gif': return extractColorFromGIF(imageData); default: - console.warn('[extractColorFromImage] Unknown image format'); return undefined; } } @@ -212,24 +209,18 @@ export function extractBannerFromImage(imageData: ArrayBuffer | Uint8Array): str */ export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata { const format = detectImageFormat(imageData); - - console.log('[extractMetadataFromImage] Detected format:', format); - + switch (format) { case 'png': - const pngMetadata = extractMetadataFromPNG(imageData); - console.log('[extractMetadataFromImage] PNG metadata:', pngMetadata); - return pngMetadata; + return extractMetadataFromPNG(imageData); // For other formats, extract color only for now case 'webp': case 'jpeg': case 'gif': { const color = extractColorFromImage(imageData); - console.log('[extractMetadataFromImage] Non-PNG color:', color); return color ? { color } : {}; } default: - console.warn('[extractMetadataFromImage] Unknown image format'); return {}; } } @@ -365,7 +356,6 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n // Check if full metadata is cached const cached = avatarMetadataCache.get(url); if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) { - console.log('[fetchAndExtractColor] Using cached color for', url, ':', cached.metadata.color); return cached.metadata.color; } @@ -373,33 +363,20 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n const inflightKey = `${url}:${accessToken || ''}`; const existingFetch = inflightColorFetches.get(inflightKey); if (existingFetch) { - console.log('[fetchAndExtractColor] Reusing in-flight fetch for', url); return existingFetch; } - console.log('[fetchAndExtractColor] Fetching and extracting color from', url); - const fetchPromise = (async () => { try { const response = await fetch(url, { method: 'GET', headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, }); - - console.log('[fetchAndExtractColor] Fetch response status:', response.ok, response.status); - + if (!response.ok) return undefined; - + const data = await response.arrayBuffer(); - console.log('[fetchAndExtractColor] Downloaded', data.byteLength, 'bytes'); - - const color = extractColorFromImage(data); - console.log('[fetchAndExtractColor] Extracted color:', color); - - // Don't cache here - fetchAndExtractMetadata will cache full metadata - // This avoids cache collision where color-only fetch overwrites full metadata - - return color; + return extractColorFromImage(data); } catch (error) { console.error('[fetchAndExtractColor] Error fetching/extracting color:', error); return undefined; @@ -422,7 +399,6 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string // Check cache first const cached = avatarMetadataCache.get(url); if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) { - console.log('[fetchAndExtractMetadata] Using cached metadata for', url, ':', cached.metadata); return cached.metadata; } @@ -430,35 +406,26 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string const inflightKey = `${url}:${accessToken || ''}`; const existingFetch = inflightMetadataFetches.get(inflightKey); if (existingFetch) { - console.log('[fetchAndExtractMetadata] Reusing in-flight fetch for', url); return existingFetch; } - console.log('[fetchAndExtractMetadata] Fetching metadata from', url); - const fetchPromise = (async () => { try { const response = await fetch(url, { method: 'GET', headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, }); - - console.log('[fetchAndExtractMetadata] Fetch response status:', response.ok, response.status); - + if (!response.ok) return {}; - + const data = await response.arrayBuffer(); - console.log('[fetchAndExtractMetadata] Downloaded', data.byteLength, 'bytes'); - const metadata = extractMetadataFromImage(data); - console.log('[fetchAndExtractMetadata] Extracted metadata:', metadata); - - // Cache the result - avatarMetadataCache.set(url, { - metadata, - timestamp: Date.now() + + avatarMetadataCache.set(url, { + metadata, + timestamp: Date.now(), }); - + return metadata; } catch { return {}; diff --git a/src/app/utils/viewTransitions.ts b/src/app/utils/viewTransitions.ts index 7944ecf..a06ede6 100644 --- a/src/app/utils/viewTransitions.ts +++ b/src/app/utils/viewTransitions.ts @@ -38,43 +38,20 @@ export const withViewTransition = async (callback: () => void | Promise): }; /** - * Enable view transitions for router navigation - * Call this in your app initialization + * Enable light view transitions for browser back/forward only. + * + * Do NOT intercept in-app /NavLink clicks: wrapping room switches in + * document.startViewTransition keeps the previous room painted until the new + * Room tree finishes mounting (often ~1s), which feels like a dead click. */ export const enableViewTransitionsForNavigation = (): void => { if (!supportsViewTransitions()) { return; } - // Intercept link clicks and navigation to add view transitions - const handleLinkClick = (event: MouseEvent) => { - const target = (event.target as HTMLElement).closest('a'); - - if (!target) return; - if (target.target === '_blank') return; - if (target.origin !== window.location.origin) return; - if (event.metaKey || event.ctrlKey || event.shiftKey) return; - - const url = new URL(target.href); - - // Only handle same-origin navigation - if (url.origin === window.location.origin) { - event.preventDefault(); - - withViewTransition(() => { - window.history.pushState({}, '', target.href); - window.dispatchEvent(new PopStateEvent('popstate')); - }); - } - }; - - // Handle back/forward navigation - const handlePopState = () => { + window.addEventListener('popstate', () => { withViewTransition(() => { - // The URL has already changed, just trigger re-render + // URL already changed; transition just softens the re-render. }); - }; - - window.addEventListener('click', handleLinkClick); - window.addEventListener('popstate', handlePopState); + }); };