From 30edd62d96dcd55ba859c524fe4b0ebcf9c39b80 Mon Sep 17 00:00:00 2001
From: litruv
Date: Wed, 22 Jul 2026 18:00:54 +1000
Subject: [PATCH] Make room switches instant and cut redundant client work.
Keep-alive room cache with CSS-only warm switches, nav loading feedback, and per-context members drawer prefs. Share hierarchy/call/sub-room subscriptions, demux state events, and lazy lobby power-level loading to avoid duplicate scans and listener storms.
---
src/app/components/AnimatedOutlet.css.ts | 21 ++
src/app/components/AnimatedOutlet.tsx | 78 +++++--
.../components/message/MsgTypeRenderers.tsx | 13 +-
.../message/content/ImageContent.tsx | 2 +-
.../message/content/VideoContent.tsx | 2 +-
.../components/message/content/style.css.ts | 13 +-
src/app/components/page/Page.tsx | 13 +-
src/app/features/call/callMemberUtils.ts | 60 ++++++
src/app/features/call/useCall.ts | 60 ++++--
src/app/features/call/useRoomCallMembers.ts | 117 +---------
src/app/features/call/useRtcConfig.ts | 114 +++++-----
.../features/forum/ForumAwareSpaceLayout.tsx | 6 +-
src/app/features/lobby/Lobby.tsx | 54 +++--
src/app/features/lobby/LobbyHeader.tsx | 2 +-
src/app/features/lobby/SpaceHierarchy.tsx | 20 +-
src/app/features/lobby/index.ts | 2 +-
.../room-nav/CallParticipantsIndicator.tsx | 57 ++---
src/app/features/room-nav/RoomNavItem.tsx | 56 ++++-
src/app/features/room-nav/styles.css.ts | 69 +++++-
src/app/features/room/CachedRooms.tsx | 199 ++++++++++++++++++
src/app/features/room/MembersDrawer.tsx | 7 +-
src/app/features/room/Room.tsx | 95 ++++++---
src/app/features/room/RoomTimeline.tsx | 113 +++++++---
src/app/features/room/RoomView.tsx | 182 ++++++++++++----
.../features/room/RoomViewActiveContext.ts | 29 +++
src/app/features/room/RoomViewHeader.tsx | 18 +-
src/app/features/room/index.ts | 1 +
.../settings/plugins/PluginLoader.tsx | 2 -
src/app/hooks/stateEventCallback.types.ts | 7 +
src/app/hooks/useAutoJoinSpaceRooms.ts | 154 +++++---------
src/app/hooks/usePowerLevels.ts | 48 +++--
src/app/hooks/useRoomMembers.ts | 10 +-
src/app/hooks/useSpaceHierarchy.ts | 17 +-
src/app/hooks/useStateEventCallback.ts | 17 +-
src/app/hooks/useUserColor.ts | 74 +++----
src/app/hooks/useUserPresence.ts | 137 +++++++++---
src/app/hooks/useVirtualPaginator.ts | 92 +++++++-
src/app/pages/Router.tsx | 36 +---
src/app/pages/client/direct/DirectLayout.tsx | 69 ++++++
src/app/pages/client/direct/RoomProvider.tsx | 16 +-
src/app/pages/client/direct/index.ts | 1 +
src/app/pages/client/home/Home.tsx | 18 +-
src/app/pages/client/home/HomeLayout.tsx | 66 ++++++
src/app/pages/client/home/RoomProvider.tsx | 14 +-
src/app/pages/client/home/index.ts | 1 +
src/app/pages/client/home/useHomeRooms.ts | 17 +-
src/app/pages/client/space/RoomProvider.tsx | 25 +--
src/app/pages/client/space/Space.tsx | 13 +-
.../pages/client/space/SpaceRoomLayout.tsx | 78 +++++++
src/app/pages/client/space/index.ts | 1 +
src/app/state/activeCallRoomsRegistry.ts | 146 +++++++++++++
src/app/state/globalSubRoomIds.ts | 77 +++++++
src/app/state/hooks/peopleDrawer.ts | 11 +
src/app/state/mediaDimensionCache.ts | 17 ++
src/app/state/pendingRoomNavigation.ts | 7 +
src/app/state/roomTimelineVisibilityGate.ts | 22 ++
src/app/state/roomViewCache.ts | 20 ++
src/app/state/settings.ts | 30 ++-
src/app/state/spaceHierarchyCache.ts | 40 ++++
src/app/state/stateEventDemux.ts | 38 ++++
src/app/utils/imageMetadata.ts | 59 ++----
src/app/utils/viewTransitions.ts | 39 +---
62 files changed, 2020 insertions(+), 802 deletions(-)
create mode 100644 src/app/components/AnimatedOutlet.css.ts
create mode 100644 src/app/features/call/callMemberUtils.ts
create mode 100644 src/app/features/room/CachedRooms.tsx
create mode 100644 src/app/features/room/RoomViewActiveContext.ts
create mode 100644 src/app/hooks/stateEventCallback.types.ts
create mode 100644 src/app/pages/client/direct/DirectLayout.tsx
create mode 100644 src/app/pages/client/home/HomeLayout.tsx
create mode 100644 src/app/pages/client/space/SpaceRoomLayout.tsx
create mode 100644 src/app/state/activeCallRoomsRegistry.ts
create mode 100644 src/app/state/globalSubRoomIds.ts
create mode 100644 src/app/state/hooks/peopleDrawer.ts
create mode 100644 src/app/state/pendingRoomNavigation.ts
create mode 100644 src/app/state/roomTimelineVisibilityGate.ts
create mode 100644 src/app/state/roomViewCache.ts
create mode 100644 src/app/state/spaceHierarchyCache.ts
create mode 100644 src/app/state/stateEventDemux.ts
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
{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);
+ });
};