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.
This commit is contained in:
2026-07-22 18:00:54 +10:00
parent 821724b594
commit 30edd62d96
62 changed files with 2020 additions and 802 deletions

View File

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

View File

@@ -1,27 +1,61 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Outlet, useLocation } from 'react-router-dom'; 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). */
* Wrapper for Outlet that adds route-based animation function PendingRoomShell({ roomId }: { roomId: string }) {
* Forces remount on route change by using location as key const mx = useMatrixClient();
*/ const mDirects = useAtomValue(mDirectAtom);
export function AnimatedOutlet() { const room = mx.getRoom(roomId);
const location = useLocation();
if (!room) return null;
return ( return (
<div <div className={css.PendingOverlay} aria-busy aria-label={`Opening ${room.name}`}>
key={location.pathname} <RoomProvider key={room.roomId} value={room}>
data-route-transition="true" <IsDirectRoomProvider value={mDirects.has(room.roomId)}>
style={{ <RoomFrame room={room} pending />
flex: 1, </IsDirectRoomProvider>
minWidth: 0, </RoomProvider>
minHeight: 0, </div>
overflow: 'hidden', );
display: 'flex', }
flexDirection: 'column',
}} /** Optimistic room navigation: brief pending chrome until the route settles. */
> export function AnimatedOutlet() {
<Outlet /> 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 (
<div style={{ flex: 1, minWidth: 0, minHeight: 0, position: 'relative', display: 'flex' }}>
{showOverlay && <PendingRoomShell roomId={pendingRoomId} />}
{/*
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.
*/}
<div className={css.RoutePane}>
<Outlet />
</div>
</div> </div>
); );
} }

View File

@@ -27,8 +27,8 @@ import {
MATRIX_SPOILER_REASON_PROPERTY_NAME, MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common'; } from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes'; import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension, fitMediaSize } from '../../utils/common'; import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { getMediaDimensions } from '../../state/mediaDimensionCache'; import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader'; import { FileHeader, FileDownloadButton } from './FileHeader';
@@ -206,18 +206,11 @@ type MImageProps = {
outlined?: boolean; outlined?: boolean;
}; };
const ATTACHMENT_MAX = 400;
const resolveMediaBoxSize = ( const resolveMediaBoxSize = (
mxcUrl: string, mxcUrl: string,
w?: number, w?: number,
h?: number h?: number
): { width: number; height: number } => { ): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h);
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);
};
export function MImage({ content, renderImageContent, outlined }: MImageProps) { export function MImage({ content, renderImageContent, outlined }: MImageProps) {
const imgInfo = content?.info; const imgInfo = content?.info;

View File

@@ -169,7 +169,7 @@ export const ImageContent = as<'div', ImageContentProps>(
setShowPlaceholder(true); setShowPlaceholder(true);
return undefined; return undefined;
} }
const timer = window.setTimeout(() => setShowPlaceholder(false), 300); const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [load]); }, [load]);

View File

@@ -134,7 +134,7 @@ export const VideoContent = as<'div', VideoContentProps>(
setShowPlaceholder(true); setShowPlaceholder(true);
return undefined; return undefined;
} }
const timer = window.setTimeout(() => setShowPlaceholder(false), 300); const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [load]); }, [load]);

View File

@@ -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. */ /** Soft blurhash layer that fills the reserved media box. */
export const BlurhashPlaceholder = style([ export const BlurhashPlaceholder = style([
DefaultReset, DefaultReset,
@@ -90,6 +99,8 @@ export const BlurhashPlaceholder = style([
// Soften the decode so it reads as color blobs; slight scale hides blur edges. // Soften the decode so it reads as color blobs; slight scale hides blur edges.
filter: 'blur(14px)', filter: 'blur(14px)',
transform: 'scale(1.06)', 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({ export const MediaFadeIn = style({
zIndex: 1, zIndex: 1,
opacity: 0, opacity: 0,
transition: 'opacity 280ms ease-out', transition: 'opacity 520ms cubic-bezier(0.22, 1, 0.36, 1)',
}); });
export const MediaFadeInLoaded = style({ export const MediaFadeInLoaded = style({

View File

@@ -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 { Box, Header, Line, Scroll, Text, as } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './style.css'; import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
import { useShowCompactMasterView } from '../../hooks/useCompactNav'; import { useShowCompactMasterView } from '../../hooks/useCompactNav';
const SidebarDockedCallPanel = lazy(() =>
import('../../features/call/SidebarDockedCallPanel').then((module) => ({
default: module.SidebarDockedCallPanel,
}))
);
type PageRootProps = { type PageRootProps = {
nav: ReactNode; nav: ReactNode;
children: ReactNode; children: ReactNode;
@@ -46,7 +51,9 @@ export function PageNav({
> >
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
{children} {children}
<SidebarDockedCallPanel /> <Suspense fallback={null}>
<SidebarDockedCallPanel />
</Suspense>
</Box> </Box>
</Box> </Box>
); );

View File

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

View File

@@ -230,62 +230,76 @@ export function useCallService(): CallContextValue {
/** /**
* Hook to check if a call is active in a specific room * 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<string, boolean>();
export function useRoomCall(roomId: string) { export function useRoomCall(roomId: string) {
const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall(); const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall();
const mx = useMatrixClient(); const mx = useMatrixClient();
const [roomCallSupported, setRoomCallSupported] = useState(false); const [roomCallSupported, setRoomCallSupported] = useState(
const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(true); () => 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 // Check if calls are supported for this specific room by checking all priority homeservers
useEffect(() => { useEffect(() => {
const cached = roomCallSupportCache.get(roomId);
if (cached !== undefined) {
setRoomCallSupported(cached);
setRoomCallSupportLoading(false);
return undefined;
}
let cancelled = false;
const checkRoomCallSupport = async () => { const checkRoomCallSupport = async () => {
setRoomCallSupportLoading(true); setRoomCallSupportLoading(true);
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room) { if (!room) {
setRoomCallSupported(false); if (!cancelled) {
setRoomCallSupportLoading(false); setRoomCallSupported(false);
setRoomCallSupportLoading(false);
}
return; return;
} }
// Get homeserver priority list for this room
const homeserverPriority = getLiveKitHomeserverPriority( const homeserverPriority = getLiveKitHomeserverPriority(
room, room,
mx.getHomeserverUrl(), mx.getHomeserverUrl(),
mx.getSafeUserId() mx.getSafeUserId()
); );
console.log(`Checking call support for room ${roomId}, homeserver priority:`, homeserverPriority);
// Check each homeserver in priority order
for (const homeserver of homeserverPriority) { for (const homeserver of homeserverPriority) {
try { try {
console.log(`Fetching well-known from ${homeserver}...`);
const wellKnown = await fetchWellKnownWithRTC(homeserver); const wellKnown = await fetchWellKnownWithRTC(homeserver);
console.log(`Well-known response from ${homeserver}:`, wellKnown);
const livekitFocus = getLiveKitFocus(wellKnown); const livekitFocus = getLiveKitFocus(wellKnown);
console.log(`LiveKit focus from ${homeserver}:`, livekitFocus);
if (livekitFocus) { if (livekitFocus) {
console.log(`Call support found for room ${roomId} on homeserver: ${homeserver}`); roomCallSupportCache.set(roomId, true);
setRoomCallSupported(true); if (!cancelled) {
setRoomCallSupportLoading(false); setRoomCallSupported(true);
setRoomCallSupportLoading(false);
}
return; return;
} }
} catch (error) { } catch {
console.warn(`Failed to check call support on ${homeserver}:`, error);
// Continue to next homeserver // Continue to next homeserver
} }
} }
// No homeserver supports calls roomCallSupportCache.set(roomId, false);
console.log(`No call support found for room ${roomId}`); if (!cancelled) {
setRoomCallSupported(false); setRoomCallSupported(false);
setRoomCallSupportLoading(false); setRoomCallSupportLoading(false);
}
}; };
checkRoomCallSupport(); checkRoomCallSupport();
return () => {
cancelled = true;
};
}, [roomId, mx]); }, [roomId, mx]);
const isInCall = activeCall?.roomId === roomId; const isInCall = activeCall?.roomId === roomId;

View File

@@ -1,115 +1,12 @@
/* eslint-disable no-console */ import { useRoomCallMembersFromRegistry } from '../../state/activeCallRoomsRegistry';
import { useCallback, useEffect, useState } from 'react';
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
/** The Matrix state event type for call membership (MSC3401) */ export type { CallMember } from './callMemberUtils';
const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member'; export { getActiveCallMembers, CALL_MEMBER_EVENT_TYPE } from './callMemberUtils';
/** 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 * Hook to get the list of users currently in a call in a room.
* @param room - The Matrix room to check * Backed by a shared registry — one state listener for all rooms.
* @returns Array of active call members
*/ */
export function getActiveCallMembers(room: Room): CallMember[] { export function useRoomCallMembers(roomId: string, enabled = true) {
const members: CallMember[] = []; return useRoomCallMembersFromRegistry(roomId, enabled);
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<CallMember[]>([]);
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,
};
} }

View File

@@ -10,9 +10,7 @@ import { getActiveCallMembers } from './useRoomCallMembers';
* Get room server from room ID (format: !roomid:server.com) * Get room server from room ID (format: !roomid:server.com)
*/ */
function getRoomServer(roomId: string): string | undefined { function getRoomServer(roomId: string): string | undefined {
const server = getMxIdServer(roomId); return getMxIdServer(roomId);
console.log(`[getRoomServer] Room ID: ${roomId} -> Server: ${server}`);
return server;
} }
/** /**
@@ -32,20 +30,13 @@ export function getLiveKitHomeserverPriority(
const memberCount = room.getMembers().length; const memberCount = room.getMembers().length;
const isDM = memberCount <= 2; 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 // Check for active call members
const activeCallMembers = getActiveCallMembers(room); const activeCallMembers = getActiveCallMembers(room);
const hasActiveCall = activeCallMembers.length > 0; const hasActiveCall = activeCallMembers.length > 0;
console.log(`[getLiveKitHomeserverPriority] Active call members: ${activeCallMembers.length}, hasActiveCall: ${hasActiveCall}`);
if (hasActiveCall) { if (hasActiveCall) {
// Joining existing call - prioritize room's homeserver (where call is hosted) // Joining existing call - prioritize room's homeserver (where call is hosted)
const roomServer = getRoomServer(room.roomId); const roomServer = getRoomServer(room.roomId);
console.log(`[getLiveKitHomeserverPriority] Room server (active call): ${roomServer}`);
if (roomServer) { if (roomServer) {
servers.push(`https://${roomServer}`); servers.push(`https://${roomServer}`);
} }
@@ -66,42 +57,31 @@ export function getLiveKitHomeserverPriority(
} }
} }
} }
} else { } else if (isDM) {
// Starting new call - use original priority logic // For DMs: user's homeserver first, then other member's homeserver
if (isDM) { servers.push(userHomeserver);
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); const otherUserId = guessDmRoomUserId(room, myUserId);
console.log(`[getLiveKitHomeserverPriority] Other user ID: ${otherUserId}`); if (otherUserId && otherUserId !== myUserId) {
if (otherUserId && otherUserId !== myUserId) { const otherServer = getMxIdServer(otherUserId);
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
const userDomain = userHomeserver.replace('https://', '').replace('http://', ''); const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
console.log(`[getLiveKitHomeserverPriority] User domain: ${userDomain}, room server: ${roomServer}`); if (otherServer && otherServer !== userDomain) {
if (!roomServer || roomServer !== userDomain) { servers.push(`https://${otherServer}`);
servers.push(userHomeserver);
} }
} }
} 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; return servers;
} }
@@ -112,46 +92,46 @@ export function getLiveKitHomeserverPriority(
*/ */
export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined { export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined {
const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci']; const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci'];
console.log(`[getLiveKitFocus] rtcFoci from well-known:`, rtcFoci);
if (!rtcFoci || !Array.isArray(rtcFoci)) { if (!rtcFoci || !Array.isArray(rtcFoci)) {
console.log(`[getLiveKitFocus] No rtc_foci found or not an array`);
return undefined; return undefined;
} }
const livekitFocus = rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit'); return rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit');
console.log(`[getLiveKitFocus] Found LiveKit focus:`, livekitFocus);
return livekitFocus;
} }
/** /**
* Fetches the well-known client configuration from a homeserver * Fetches the well-known client configuration from a homeserver.
* @param baseUrl - The homeserver base URL * Results are cached — room switches must not re-hit the network.
* @returns The well-known response with RTC configuration
*/ */
const wellKnownCache = new Map<string, Promise<WellKnownWithRTC>>();
export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> { export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> {
// 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'; const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let wellKnownUrl: string; let wellKnownUrl: string;
if (isDev) { if (isDev) {
// Extract hostname from baseUrl for proxy const hostname = cacheKey.replace('https://', '').replace('http://', '');
const hostname = baseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, '');
wellKnownUrl = `/_matrix-cors-proxy/${hostname}/.well-known/matrix/client`; wellKnownUrl = `/_matrix-cors-proxy/${hostname}/.well-known/matrix/client`;
} else { } 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); wellKnownCache.set(cacheKey, fetchPromise);
if (!response.ok) { return fetchPromise;
throw new Error(`Failed to fetch well-known: ${response.status}`);
}
const data = await response.json();
console.log(`[fetchWellKnownWithRTC] Response data:`, data);
return data;
} }
/** /**

View File

@@ -1,9 +1,9 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { PageRoot } from '../../components/page'; import { PageRoot } from '../../components/page';
import { AnimatedOutlet } from '../../components/AnimatedOutlet';
import { MobileFriendlyPageNav } from '../../pages/MobileFriendly'; import { MobileFriendlyPageNav } from '../../pages/MobileFriendly';
import { SPACE_PATH } from '../../pages/paths'; import { SPACE_PATH } from '../../pages/paths';
import { Space } from '../../pages/client/space/Space'; import { Space } from '../../pages/client/space/Space';
import { SpaceRoomLayout } from '../../pages/client/space/SpaceRoomLayout';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useSpace } from '../../hooks/useSpace'; import { useSpace } from '../../hooks/useSpace';
@@ -11,7 +11,7 @@ import { isForumContainer, shouldShowForumLobby } from '../../utils/room';
import { ForumBoardProvider } from './ForumBoardContext'; 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() { export function ForumAwareSpaceLayout() {
const mx = useMatrixClient(); const mx = useMatrixClient();
@@ -33,7 +33,7 @@ export function ForumAwareSpaceLayout() {
</MobileFriendlyPageNav> </MobileFriendlyPageNav>
} }
> >
<AnimatedOutlet /> <SpaceRoomLayout />
</PageRoot> </PageRoot>
); );

View File

@@ -60,6 +60,10 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { joiningProgressAtom } from '../../state/joiningProgress'; import { joiningProgressAtom } from '../../state/joiningProgress';
import {
getCachedHierarchyRooms,
hierarchyRoomsToMap,
} from '../../state/spaceHierarchyCache';
const useCanDropLobbyItem = ( const useCanDropLobbyItem = (
space: Room, space: Room,
@@ -166,7 +170,6 @@ function SpaceCardLobby() {
const isForum = shouldShowForumLobby(space); const isForum = shouldShowForumLobby(space);
const spacePowerLevels = usePowerLevels(space); const spacePowerLevels = usePowerLevels(space);
const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []); const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []);
const members = useRoomMembers(mx, space.roomId);
const joiningProgress = useAtomValue(joiningProgressAtom); const joiningProgress = useAtomValue(joiningProgressAtom);
const currentJoiningProgress = joiningProgress.get(space.roomId); const currentJoiningProgress = joiningProgress.get(space.roomId);
@@ -174,7 +177,8 @@ function SpaceCardLobby() {
const heroSectionRef = useRef<HTMLDivElement>(null); const heroSectionRef = useRef<HTMLDivElement>(null);
const [heroSectionHeight, setHeroSectionHeight] = useState<number>(); const [heroSectionHeight, setHeroSectionHeight] = useState<number>();
const [spaceRooms, setSpaceRooms] = useAtom(spaceRoomsAtom); 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 screenSize = useScreenSizeContext();
const [onTop, setOnTop] = useState(true); const [onTop, setOnTop] = useState(true);
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom()); const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
@@ -190,7 +194,10 @@ function SpaceCardLobby() {
return new Set(sideSpaces); return new Set(sideSpaces);
}, [sidebarItems]); }, [sidebarItems]);
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map()); const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => {
const cached = getCachedHierarchyRooms(space.roomId, 3);
return cached ? hierarchyRoomsToMap(cached) : new Map();
});
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -202,11 +209,7 @@ function SpaceCardLobby() {
type: 'PUT', type: 'PUT',
roomIds: all.filter(isHierarchySpaceRoom).map((r) => r.room_id), roomIds: all.filter(isHierarchySpaceRoom).map((r) => r.room_id),
}); });
setSpacesItem((current) => setSpacesItem(hierarchyRoomsToMap(all));
produce(current, (draft) => {
all.forEach((r) => draft.set(r.room_id, r));
})
);
} catch { } catch {
// hierarchy may be unavailable for some memberships // hierarchy may be unavailable for some memberships
} }
@@ -246,20 +249,33 @@ function SpaceCardLobby() {
}); });
const vItems = virtualizer.getVirtualItems(); const vItems = virtualizer.getVirtualItems();
const roomsPowerLevels = useRoomsPowerLevels( const powerLevelRoomIds = useMemo(() => {
useMemo( const roomIds = new Set<string>([space.roomId]);
() => vItems.forEach((vItem) => {
hierarchy const item = hierarchy[vItem.index];
.flatMap((i) => { if (!item) return;
const childRooms = Array.isArray(i.rooms) ? i.rooms.map((r) => getRoom(r.roomId)) : []; roomIds.add(item.space.roomId);
if (item.space.parentId) roomIds.add(item.space.parentId);
item.rooms?.forEach((room) => roomIds.add(room.roomId));
});
if (draggingItem) {
roomIds.add(draggingItem.roomId);
if (draggingItem.parentId) roomIds.add(draggingItem.parentId);
}
return Array.from(roomIds).sort().join('\0');
}, [vItems, hierarchy, space.roomId, draggingItem]);
return [getRoom(i.space.roomId), ...childRooms]; const powerLevelRooms = useMemo(
}) () =>
.filter((r) => !!r) as Room[], powerLevelRoomIds
[hierarchy, getRoom] .split('\0')
) .map((roomId) => getRoom(roomId))
.filter((room): room is Room => !!room),
[powerLevelRoomIds, getRoom]
); );
const roomsPowerLevels = useRoomsPowerLevels(powerLevelRooms);
const canDrop: CanDropCallback = useCanDropLobbyItem(space, roomsPowerLevels, getRoom); const canDrop: CanDropCallback = useCanDropLobbyItem(space, roomsPowerLevels, getRoom);
const [reorderSpaceState, reorderSpace] = useAsyncCallback( const [reorderSpaceState, reorderSpace] = useAsyncCallback(

View File

@@ -191,7 +191,7 @@ export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: Lobb
const space = useSpace(); const space = useSpace();
const creators = useRoomCreators(space); const creators = useRoomCreators(space);
const spacePermissions = useRoomPermissions(creators, powerLevels); const spacePermissions = useRoomPermissions(creators, powerLevels);
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); const setPeopleDrawer = useSetSetting(settingsAtom, 'peopleDrawerInRooms');
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [addExisting, setAddExisting] = useState<OpenAddExistingOptions | null>(null); const [addExisting, setAddExisting] = useState<OpenAddExistingOptions | null>(null);
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();

View File

@@ -20,6 +20,7 @@ import { SequenceCard } from '../../components/sequence-card';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { PaarrotSubRoomsContent } from '../../hooks/useRoomSubRooms'; import { PaarrotSubRoomsContent } from '../../hooks/useRoomSubRooms';
import { useGlobalSubRoomIds } from '../../state/globalSubRoomIds';
type SpaceHierarchyProps = { type SpaceHierarchyProps = {
summary: IHierarchyRoom | undefined; summary: IHierarchyRoom | undefined;
@@ -71,7 +72,14 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
) => { ) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(spaceItem.roomId, true); // Lobby bulk-fetches hierarchy once; per-category fetch duplicated N×50 API pages.
const parentHasSummaries = hierarchySummaries.size > 0;
const enableFetch = !closed && !parentHasSummaries;
const { fetching, error, rooms: fetchedRooms } = useFetchSpaceHierarchyLevel(
spaceItem.roomId,
enableFetch
);
const rooms = parentHasSummaries ? hierarchySummaries : fetchedRooms;
const subspaces = useMemo(() => { const subspaces = useMemo(() => {
const s: Map<string, IHierarchyRoom> = new Map(); const s: Map<string, IHierarchyRoom> = new Map();
@@ -105,15 +113,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
// Build a global set of all sub-room IDs by checking ALL joined rooms // Build a global set of all sub-room IDs by checking ALL joined rooms
// This ensures sub-rooms are hidden even if their parent isn't in this space // This ensures sub-rooms are hidden even if their parent isn't in this space
const globalSubRoomIds = useMemo(() => { const globalSubRoomIds = useGlobalSubRoomIds();
const subRoomIds = new Set<string>();
mx.getRooms().forEach((room) => {
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
const content = subRoomsEvent?.getContent<PaarrotSubRoomsContent>();
content?.children?.forEach((childId: string) => subRoomIds.add(childId));
});
return subRoomIds;
}, [mx, allJoinedRooms]);
// Build a map of parent room -> sub-room IDs for nested rendering (only for rooms in this space) // Build a map of parent room -> sub-room IDs for nested rendering (only for rooms in this space)
const subRoomsMap = useMemo(() => { const subRoomsMap = useMemo(() => {

View File

@@ -1 +1 @@
export * from './Lobby'; export { Lobby } from './Lobby';

View File

@@ -2,12 +2,13 @@ import React from 'react';
import { Avatar, Box, Text, config } from 'folds'; import { Avatar, Box, Text, config } from 'folds';
import { Icon, Icons } from '../../components/icons'; import { Icon, Icons } from '../../components/icons';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomCallMembers } from '../call/useRoomCallMembers';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { mxcUrlToHttp, getMxIdLocalPart } from '../../utils/matrix'; import { mxcUrlToHttp, getMxIdLocalPart } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { useCall } from '../call/useCall'; import { useCall } from '../call/useCall';
import { useRoomHasActiveCall } from '../../state/activeCallRoomsRegistry';
import { useRoomCallMembers } from '../call/useRoomCallMembers';
import * as css from './CallParticipantsIndicator.css'; import * as css from './CallParticipantsIndicator.css';
const MAX_VISIBLE_PARTICIPANTS = 8; const MAX_VISIBLE_PARTICIPANTS = 8;
@@ -17,6 +18,12 @@ type CallParticipantsIndicatorProps = {
}; };
export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorProps) { export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorProps) {
const hasCall = useRoomHasActiveCall(roomId);
if (!hasCall) return null;
return <CallParticipantsIndicatorActive roomId={roomId} />;
}
function CallParticipantsIndicatorActive({ roomId }: CallParticipantsIndicatorProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
@@ -41,20 +48,14 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
}; };
/**
* Check if a participant is currently speaking
*/
const isParticipantSpeaking = (userId: string): boolean => { const isParticipantSpeaking = (userId: string): boolean => {
if (!activeCall) return false; if (!activeCall) return false;
// Direct check for Matrix user ID (works for local user)
if (activeCall.activeSpeakers.has(userId)) return true; if (activeCall.activeSpeakers.has(userId)) return true;
// For remote users, activeSpeakers contains LiveKit identities (deviceId_timestamp)
const member = callMembers.find((m) => m.userId === userId); const member = callMembers.find((m) => m.userId === userId);
if (!member) return false; if (!member) return false;
// Check if any active speaker has this user's deviceId
return Array.from(activeCall.activeSpeakers).some((speakerId) => { return Array.from(activeCall.activeSpeakers).some((speakerId) => {
const underscoreIndex = speakerId.lastIndexOf('_'); const underscoreIndex = speakerId.lastIndexOf('_');
const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId; const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId;
@@ -62,20 +63,14 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
}); });
}; };
/**
* Check if a participant is currently muted
*/
const isParticipantMuted = (userId: string): boolean => { const isParticipantMuted = (userId: string): boolean => {
if (!activeCall) return false; if (!activeCall) return false;
// For local user, check activeCall.isMuted
if (userId === myUserId) return activeCall.isMuted; if (userId === myUserId) return activeCall.isMuted;
// For remote users, mutedParticipants contains LiveKit identities (deviceId_timestamp)
const member = callMembers.find((m) => m.userId === userId); const member = callMembers.find((m) => m.userId === userId);
if (!member) return false; if (!member) return false;
// Check if any muted participant has this user's deviceId
return Array.from(activeCall.mutedParticipants).some((mutedId) => { return Array.from(activeCall.mutedParticipants).some((mutedId) => {
const underscoreIndex = mutedId.lastIndexOf('_'); const underscoreIndex = mutedId.lastIndexOf('_');
const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId; const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId;
@@ -83,17 +78,11 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
}); });
}; };
/**
* Check if the local user is deafened
*/
const isLocalUserDeafened = (userId: string): boolean => { const isLocalUserDeafened = (userId: string): boolean => {
if (!activeCall || userId !== myUserId) return false; if (!activeCall || userId !== myUserId) return false;
return activeCall.isDeafened; return activeCall.isDeafened;
}; };
/**
* Get the appropriate audio icon for a participant
*/
const getAudioIcon = (userId: string): string => { const getAudioIcon = (userId: string): string => {
if (isLocalUserDeafened(userId)) { if (isLocalUserDeafened(userId)) {
return Icons.VolumeMute; return Icons.VolumeMute;
@@ -153,7 +142,7 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
size="50" size="50"
style={{ style={{
opacity: config.opacity.P500, opacity: config.opacity.P500,
color: isDeafened ? 'var(--color-Critical-Main)' : isMuted ? 'var(--color-Critical-Main)' : undefined color: isDeafened ? 'var(--color-Critical-Main)' : isMuted ? 'var(--color-Critical-Main)' : undefined,
}} }}
/> />
<Text as="span" size="T200" truncate> <Text as="span" size="T200" truncate>

View File

@@ -1,7 +1,8 @@
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react'; import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
import { Room, EventTimeline } from 'matrix-js-sdk'; import { Room, EventTimeline } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { flushSync } from 'react-dom';
import { Avatar, Box, IconButton, Text, Menu, MenuItem, config, PopOut, toRem, Line, RectCords, Badge, Spinner, Tooltip, TooltipProvider } from 'folds'; import { Avatar, Box, IconButton, Text, Menu, MenuItem, config, PopOut, toRem, Line, RectCords, Badge, Spinner, Tooltip, TooltipProvider } from 'folds';
import { Icon, Icons } from '../../components/icons'; import { Icon, Icons } from '../../components/icons';
import { useFocusWithin, useHover } from 'react-aria'; import { useFocusWithin, useHover } from 'react-aria';
@@ -48,6 +49,9 @@ import { mDirectAtom } from '../../state/mDirectList';
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal'; import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
import { PresenceAvatar } from '../../components/presence'; import { PresenceAvatar } from '../../components/presence';
import { Presence, useUserPresence } from '../../hooks/useUserPresence'; import { Presence, useUserPresence } from '../../hooks/useUserPresence';
import { pendingRoomNavigationAtom, roomNavLoadingAtom } from '../../state/pendingRoomNavigation';
import * as roomNavCss from './styles.css';
import classNames from 'classnames';
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@@ -287,6 +291,14 @@ export function RoomNavItem({
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const pendingRoomId = useAtomValue(pendingRoomNavigationAtom);
const setPendingRoomId = useSetAtom(pendingRoomNavigationAtom);
const loadingRoomId = useAtomValue(roomNavLoadingAtom);
const setLoadingRoomId = useSetAtom(roomNavLoadingAtom);
// Instant highlight on press — don't wait for the route to settle.
const isPending = pendingRoomId === room.roomId;
const isLoading = loadingRoomId === room.roomId;
const effectivelySelected = selected || isPending || isLoading;
const avatarUrl = direct const avatarUrl = direct
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
@@ -368,6 +380,21 @@ export function RoomNavItem({
if (shouldScroll) { if (shouldScroll) {
navigate(linkPath, { replace: true, state: { scrollToLatest: Date.now() } }); navigate(linkPath, { replace: true, state: { scrollToLatest: Date.now() } });
} }
return;
}
// Always mark pending so the nav loading bar shows until the room view settles.
setPendingRoomId(room.roomId);
setLoadingRoomId(room.roomId);
};
const handleRoomPointerDown: MouseEventHandler<HTMLAnchorElement> = () => {
if (!selected) {
// Paint highlight + loading bar before click navigation blocks the main thread.
flushSync(() => {
setPendingRoomId(room.roomId);
setLoadingRoomId(room.roomId);
});
} }
}; };
@@ -376,17 +403,32 @@ export function RoomNavItem({
return ( return (
<Box direction="Column" style={{ width: '100%' }}> <Box direction="Column" style={{ width: '100%' }}>
<NavItem <NavItem
className={!shouldShowIcon ? css.ThreadItem : undefined} className={classNames(
!shouldShowIcon && css.ThreadItem,
isLoading && roomNavCss.RoomNavItemLoading
)}
variant="Background" variant="Background"
radii="400" radii="400"
highlight={unread !== undefined} highlight={unread !== undefined}
aria-selected={selected} aria-selected={effectivelySelected}
aria-busy={isLoading || undefined}
data-hover={!!menuAnchor} data-hover={!!menuAnchor}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
{...hoverProps} {...hoverProps}
{...focusWithinProps} {...focusWithinProps}
> >
<NavLink to={linkPath} onClick={handleRoomClick}> {isLoading && (
<>
<div className={roomNavCss.RoomNavLoadingBg} aria-hidden />
<div className={roomNavCss.RoomNavLoadingBar} aria-hidden />
</>
)}
<NavLink
className={isLoading ? roomNavCss.RoomNavLoadingContent : undefined}
to={linkPath}
onClick={handleRoomClick}
onPointerDown={handleRoomPointerDown}
>
<NavItemContent> <NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}> <Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
{shouldShowIcon && {shouldShowIcon &&
@@ -413,7 +455,7 @@ export function RoomNavItem({
) : ( ) : (
<RoomIcon <RoomIcon
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }} style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
filled={selected} filled={effectivelySelected}
size="100" size="100"
joinRule={room.getJoinRule()} joinRule={room.getJoinRule()}
/> />
@@ -436,7 +478,7 @@ export function RoomNavItem({
) : ( ) : (
<RoomIcon <RoomIcon
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }} style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
filled={selected} filled={effectivelySelected}
size="100" size="100"
joinRule={room.getJoinRule()} joinRule={room.getJoinRule()}
/> />
@@ -470,7 +512,7 @@ export function RoomNavItem({
</NavItemContent> </NavItemContent>
</NavLink> </NavLink>
{optionsVisible && ( {optionsVisible && (
<NavItemOptions> <NavItemOptions className={isLoading ? roomNavCss.RoomNavLoadingContent : undefined}>
<Box alignItems="Center" gap="100"> <Box alignItems="Center" gap="100">
{parentSpaceInfo && ( {parentSpaceInfo && (
<TooltipProvider <TooltipProvider

View File

@@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css';
import { config } from 'folds'; import { color, config, toRem } from 'folds';
export const CategoryButton = style({ export const CategoryButton = style({
flexGrow: 1, flexGrow: 1,
@@ -7,3 +7,68 @@ export const CategoryButton = style({
export const CategoryButtonIcon = style({ export const CategoryButtonIcon = style({
opacity: config.opacity.P400, opacity: config.opacity.P400,
}); });
const indeterminate = keyframes({
'0%': {
transform: 'translateX(-100%)',
},
'100%': {
transform: 'translateX(200%)',
},
});
const pulse = keyframes({
'0%, 100%': {
opacity: 0.35,
},
'50%': {
opacity: 0.55,
},
});
/** Nav item is in a loading/opening state. */
export const RoomNavItemLoading = style({
position: 'relative',
overflow: 'hidden',
});
/** Soft primary wash over the whole room button. */
export const RoomNavLoadingBg = style({
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
backgroundColor: color.Primary.Container,
animation: `${pulse} 1.2s ease-in-out infinite`,
pointerEvents: 'none',
zIndex: 0,
});
/** Classic indeterminate bar along the bottom edge. */
export const RoomNavLoadingBar = style({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: toRem(3),
borderRadius: `0 0 ${config.radii.R400} ${config.radii.R400}`,
backgroundColor: color.Primary.ContainerActive,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 2,
selectors: {
'&::after': {
content: '""',
position: 'absolute',
inset: 0,
width: '45%',
backgroundColor: color.Primary.Main,
animation: `${indeterminate} 1s ease-in-out infinite`,
},
},
});
/** Keep nav content above the loading wash. */
export const RoomNavLoadingContent = style({
position: 'relative',
zIndex: 1,
});

View File

@@ -0,0 +1,199 @@
import React, { memo, useLayoutEffect, useRef } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { Room as MatrixRoom } from 'matrix-js-sdk';
import { decodeRouteParam } from '../../pages/pathUtils';
import { useParams } from 'react-router-dom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { IsDirectRoomProvider, RoomProvider } from '../../hooks/useRoom';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { mDirectAtom } from '../../state/mDirectList';
import { ROOM_VIEW_CACHE_LIMIT, roomViewCacheAtom, touchRoomViewCache } from '../../state/roomViewCache';
import { roomNavLoadingAtom } from '../../state/pendingRoomNavigation';
import { RoomFrame, ActiveRoomEffects } from './Room';
import { RoomVisibilityRefContext } from './RoomViewActiveContext';
import {
clearRoomTimelineVisibilityGate,
setRoomTimelineVisible,
} from '../../state/roomTimelineVisibilityGate';
const activeSlotStyle: React.CSSProperties = {
flex: 1,
minWidth: 0,
minHeight: 0,
display: 'flex',
position: 'relative',
zIndex: 1,
};
const inactiveSlotStyle: React.CSSProperties = {
position: 'absolute',
inset: 0,
display: 'flex',
// Prefer opacity over visibility:hidden — the latter can reset overflow scrollTop.
opacity: 0,
pointerEvents: 'none',
zIndex: 0,
// Keep in layout for scroll metrics; inert + aria-hidden handle a11y.
};
type CachedRoomSlotProps = {
room: MatrixRoom;
active: boolean;
isDirect: boolean;
eventId?: string;
};
/**
* Warm switch = CSS visibility only. Do not pass a changing `active` into
* RoomFrame/RoomTimeline — that re-renders every message and feels sluggish.
*/
const CachedRoomSlot = memo(function CachedRoomSlot({
room,
active,
isDirect,
eventId,
}: CachedRoomSlotProps) {
const visibilityRef = useRef(active);
visibilityRef.current = active;
const slotRef = useRef<HTMLDivElement>(null);
const savedScrollRef = useRef<{ top: number; atBottom: boolean } | null>(null);
const setLoadingRoomId = useSetAtom(roomNavLoadingAtom);
// After the first hide, the next show is a warm keep-alive switch.
const warmNextActivateRef = useRef(false);
useLayoutEffect(() => {
setRoomTimelineVisible(room.roomId, active);
return () => clearRoomTimelineVisibilityGate(room.roomId);
}, [active, room.roomId]);
// Save/restore timeline scroll so warm switches don't jump.
useLayoutEffect(() => {
const scrollEl = slotRef.current?.querySelector(
'[data-room-timeline-scroll]'
) as HTMLElement | null;
if (!scrollEl) return undefined;
if (!active) {
const distanceFromBottom =
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
savedScrollRef.current = {
top: scrollEl.scrollTop,
atBottom: distanceFromBottom <= 50,
};
return undefined;
}
const saved = savedScrollRef.current;
if (!saved) return undefined;
const restore = () => {
if (saved.atBottom) {
scrollEl.scrollTop = scrollEl.scrollHeight;
} else {
scrollEl.scrollTop = saved.top;
}
};
restore();
// Composer remounts on rAF and changes timeline height — re-apply after that.
const raf1 = window.requestAnimationFrame(() => {
restore();
const raf2 = window.requestAnimationFrame(() => {
restore();
window.setTimeout(restore, 50);
});
void raf2;
});
return () => window.cancelAnimationFrame(raf1);
}, [active]);
useLayoutEffect(() => {
if (!active) {
warmNextActivateRef.current = true;
return undefined;
}
if (!warmNextActivateRef.current) return undefined;
// Warm path: timeline already mounted — clear the nav bar after a short flash.
const t = window.setTimeout(() => {
setLoadingRoomId((id) => (id === room.roomId ? null : id));
}, 200);
return () => window.clearTimeout(t);
}, [active, room.roomId, setLoadingRoomId]);
return (
<div
ref={slotRef}
style={active ? activeSlotStyle : inactiveSlotStyle}
aria-hidden={!active}
{...(!active ? ({ inert: '' } as React.HTMLAttributes<HTMLDivElement>) : undefined)}
>
<RoomVisibilityRefContext.Provider value={visibilityRef}>
<RoomProvider value={room}>
<IsDirectRoomProvider value={isDirect}>
{active && <ActiveRoomEffects room={room} />}
<RoomFrame room={room} eventId={eventId} active={active} />
</IsDirectRoomProvider>
</RoomProvider>
</RoomVisibilityRefContext.Provider>
</div>
);
});
/**
* Keeps the last N room views mounted (hidden when inactive) so switching back
* is instant — no remount.
*
* Active room comes from the URL (`useSelectedRoom`) so this can live outside
* the room route element and survive param changes.
*/
export function CachedRooms() {
const mx = useMatrixClient();
const selectedRoomId = useSelectedRoom();
const mDirects = useAtomValue(mDirectAtom);
const [cache, setCache] = useAtom(roomViewCacheAtom);
const { eventId: rawEventId } = useParams();
const eventId = selectedRoomId ? decodeRouteParam(rawEventId) : undefined;
useLayoutEffect(() => {
if (!selectedRoomId) return;
setCache((prev) => touchRoomViewCache(prev, selectedRoomId, ROOM_VIEW_CACHE_LIMIT).next);
}, [selectedRoomId, setCache]);
const ids =
selectedRoomId && !cache.includes(selectedRoomId)
? [selectedRoomId, ...cache.filter((id) => id !== selectedRoomId)].slice(
0,
ROOM_VIEW_CACHE_LIMIT
)
: cache;
if (ids.length === 0) return null;
return (
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
position: 'relative',
display: 'flex',
}}
>
{ids.map((roomId) => {
const room = mx.getRoom(roomId);
if (!room) return null;
const active = roomId === selectedRoomId;
return (
<CachedRoomSlot
key={roomId}
room={room}
active={active}
isDirect={mDirects.has(roomId)}
eventId={active ? eventId : undefined}
/>
);
})}
</div>
);
}

View File

@@ -24,7 +24,8 @@ import { useDebounce } from '../../hooks/useDebounce';
import { TypingIndicator } from '../../components/typing-indicator'; import { TypingIndicator } from '../../components/typing-indicator';
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room'; import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import { useSetSetting, useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { millify } from '../../plugins/millify'; import { millify } from '../../plugins/millify';
import { ScrollTopContainer } from '../../components/scroll-top-container'; import { ScrollTopContainer } from '../../components/scroll-top-container';
@@ -41,12 +42,14 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useIsDirectRoom } from '../../hooks/useRoom';
type MemberDrawerHeaderProps = { type MemberDrawerHeaderProps = {
room: Room; room: Room;
}; };
function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) { function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); const isDirect = useIsDirectRoom();
const [, setPeopleDrawer] = usePeopleDrawerSetting(isDirect);
return ( return (
<Header className={css.MembersDrawerHeader} variant="Background" size="600"> <Header className={css.MembersDrawerHeader} variant="Background" size="600">

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect } from 'react'; import React, { memo, useCallback, useLayoutEffect } from 'react';
import { Box, Line } from 'folds'; import { Box, Line } from 'folds';
import { Room as MatrixRoom } from 'matrix-js-sdk';
import { decodeRouteParam } from '../../pages/pathUtils'; import { decodeRouteParam } from '../../pages/pathUtils';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
@@ -8,9 +9,10 @@ import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer'; import { MembersDrawer } from './MembersDrawer';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels'; import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom'; import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { useMarkAsRead } from '../../hooks/useMarkAsRead'; import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -19,25 +21,64 @@ import { activeRoomIdAtom } from '../../state/activeRoom';
import { isForum } from '../../utils/room'; import { isForum } from '../../utils/room';
import { ForumRoomView } from './ForumRoomView'; import { ForumRoomView } from './ForumRoomView';
export function Room() { type RoomFrameProps = {
const { eventId: rawEventId } = useParams(); room: MatrixRoom;
const eventId = decodeRouteParam(rawEventId); eventId?: string;
const room = useRoom(); /** Optimistic shell only — no live timeline. */
const mx = useMatrixClient(); pending?: boolean;
const setActiveRoomId = useSetAtom(activeRoomIdAtom); /**
* Visible keep-alive slot. Drives composer mount immediately (Slate cannot
* stay mounted in hidden rooms). Timeline ignores this via memo.
*/
active?: boolean;
};
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); /** Shared room column layout used by the live room and the pending-nav shell. */
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); export const RoomFrame = memo(function RoomFrame({
room,
eventId,
pending = false,
active = true,
}: RoomFrameProps) {
const mx = useMatrixClient();
const isDirect = useIsDirectRoom();
const [isDrawer] = usePeopleDrawerSetting(isDirect);
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const markAsRead = useMarkAsRead(mx);
const forumRoom = isForum(room); const forumRoom = isForum(room);
// Mount whenever the account pref is on — including inactive keep-alive slots —
// so members are already loaded when you switch rooms (no pop-in).
const showDrawer = !pending && screenSize === ScreenSize.Desktop && isDrawer;
const members = useRoomMembers(mx, room.roomId, showDrawer);
// Update titlebar with current room ID return (
useEffect(() => { <PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
{forumRoom && !pending ? (
<ForumRoomView room={room} eventId={eventId} />
) : (
<RoomView room={room} eventId={eventId} pending={pending} active={active} />
)}
{showDrawer && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} />
</>
)}
</Box>
</PowerLevelsContextProvider>
);
});
/** Titlebar + escape-to-mark-read — only for the visible cached room. */
export function ActiveRoomEffects({ room }: { room: MatrixRoom }) {
const mx = useMatrixClient();
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const markAsRead = useMarkAsRead(mx);
useLayoutEffect(() => {
setActiveRoomId(room.roomId); setActiveRoomId(room.roomId);
return () => setActiveRoomId(undefined);
}, [room.roomId, setActiveRoomId]); }, [room.roomId, setActiveRoomId]);
useKeyDown( useKeyDown(
@@ -52,17 +93,19 @@ export function Room() {
) )
); );
return null;
}
/** @deprecated Prefer CachedRooms for route outlets — kept for single-room mounts. */
export function Room() {
const { eventId: rawEventId } = useParams();
const eventId = decodeRouteParam(rawEventId);
const room = useRoom();
return ( return (
<PowerLevelsContextProvider value={powerLevels}> <>
<Box grow="Yes"> <ActiveRoomEffects room={room} />
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />} <RoomFrame room={room} eventId={eventId} />
{screenSize === ScreenSize.Desktop && isDrawer && ( </>
<>
<Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} />
</>
)}
</Box>
</PowerLevelsContextProvider>
); );
} }

View File

@@ -10,6 +10,7 @@ import React, {
useMemo, useMemo,
useRef, useRef,
useState, useState,
memo,
} from 'react'; } from 'react';
import { import {
Direction, Direction,
@@ -117,6 +118,8 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import { useRoomVisibilityRef } from './RoomViewActiveContext';
import { roomNavLoadingAtom } from '../../state/pendingRoomNavigation';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { import {
@@ -553,8 +556,15 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
}; };
}; };
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) { export const RoomTimeline = memo(function RoomTimeline({
room,
eventId,
roomInputRef,
editor,
}: RoomTimelineProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const visibilityRef = useRoomVisibilityRef();
const setLoadingRoomId = useSetAtom(roomNavLoadingAtom);
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
@@ -716,6 +726,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[] []
), ),
onEnd: handleTimelinePagination, onEnd: handleTimelinePagination,
enabledRef: visibilityRef,
roomId: room.roomId,
}); });
const loadEventTimeline = useEventTimelineLoader( const loadEventTimeline = useEventTimelineLoader(
@@ -759,6 +771,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker; mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent); const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
// Hidden keep-alive: only slide the virtual window when stuck to bottom.
// Shifting range while scrolled-up remaps scrollTop onto different messages.
if (!visibilityRef.current) {
if (shouldStickToBottom) {
setTimeline((ct) => ({
...ct,
range: {
start: ct.range.start + 1,
end: ct.range.end + 1,
},
}));
}
return;
}
// Always update timeline with new message // Always update timeline with new message
setTimeline((ct) => ({ setTimeline((ct) => ({
...ct, ...ct,
@@ -771,7 +798,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Handle own messages and focus // Handle own messages and focus
if (isOwnMessage) { if (isOwnMessage) {
setLatestUnreadMessage(null); setLatestUnreadMessage(null);
if (document.hasFocus() && (!unreadInfo)) { if (document.hasFocus() && !unreadInfo) {
const roomId = mEvt.getRoomId(); const roomId = mEvt.getRoomId();
if (roomId) { if (roomId) {
requestAnimationFrame(() => markAsRead(roomId, hideActivity)); requestAnimationFrame(() => markAsRead(roomId, hideActivity));
@@ -803,7 +830,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}); });
} }
}, },
[mx, room, unreadInfo, hideActivity, markAsRead] [visibilityRef, mx, room, unreadInfo, hideActivity, markAsRead]
) )
); );
@@ -840,10 +867,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useLiveTimelineRefresh( useLiveTimelineRefresh(
room, room,
useCallback(() => { useCallback(() => {
if (!visibilityRef.current) return;
if (liveTimelineLinked) { if (liveTimelineLinked) {
setTimeline(getInitialTimeline(room)); setTimeline(getInitialTimeline(room));
} }
}, [room, liveTimelineLinked]) }, [visibilityRef, room, liveTimelineLinked])
); );
// Stay at bottom when room editor resize // Stay at bottom when room editor resize
@@ -856,6 +884,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
mounted = true; mounted = true;
return; return;
} }
if (!visibilityRef.current) return;
if (!roomInputRef.current) return; if (!roomInputRef.current) return;
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries); const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
const scrollElement = getScrollElement(); const scrollElement = getScrollElement();
@@ -865,29 +894,37 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
autoScrollToBottom(scrollElement, false, true); autoScrollToBottom(scrollElement, false, true);
} }
}; };
}, [getScrollElement, roomInputRef]), }, [getScrollElement, roomInputRef, visibilityRef]),
useCallback(() => roomInputRef.current, [roomInputRef]) useCallback(() => roomInputRef.current, [roomInputRef])
); );
useResizeObserver( useResizeObserver(
useCallback((entries) => { useCallback(
const timelineContent = timelineContentRef.current; (entries) => {
const scrollElement = getScrollElement(); if (!visibilityRef.current) return;
if (!timelineContent || !scrollElement) return; const timelineContent = timelineContentRef.current;
const scrollElement = getScrollElement();
if (!timelineContent || !scrollElement) return;
const timelineEntry = getResizeObserverEntry(timelineContent, entries); const timelineEntry = getResizeObserverEntry(timelineContent, entries);
if (!timelineEntry) return; if (!timelineEntry) return;
const nextHeight = timelineEntry.contentRect.height; const nextHeight = timelineEntry.contentRect.height;
const previousHeight = timelineContentHeightRef.current; const previousHeight = timelineContentHeightRef.current;
timelineContentHeightRef.current = nextHeight; timelineContentHeightRef.current = nextHeight;
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) { if (
return; previousHeight === 0 ||
} nextHeight <= previousHeight ||
!isScrolledToBottom(scrollElement)
) {
return;
}
autoScrollToBottom(scrollElement, false, true); autoScrollToBottom(scrollElement, false, true);
}, [getScrollElement]), },
[getScrollElement, visibilityRef]
),
useCallback(() => timelineContentRef.current, []) useCallback(() => timelineContentRef.current, [])
); );
@@ -915,6 +952,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useIntersectionObserver( useIntersectionObserver(
useCallback( useCallback(
(entries) => { (entries) => {
if (!visibilityRef.current) return;
const target = atBottomAnchorRef.current; const target = atBottomAnchorRef.current;
if (!target) return; if (!target) return;
const targetEntry = getIntersectionObserverEntry(target, entries); const targetEntry = getIntersectionObserverEntry(target, entries);
@@ -927,7 +965,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
} }
} }
}, },
[debounceSetAtBottom, tryAutoMarkAsRead] [visibilityRef, debounceSetAtBottom, tryAutoMarkAsRead]
), ),
useCallback( useCallback(
() => ({ () => ({
@@ -965,6 +1003,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
window, window,
useCallback( useCallback(
(evt) => { (evt) => {
if (!visibilityRef.current) return;
if ( if (
isKeyHotkey('arrowup', evt) && isKeyHotkey('arrowup', evt) &&
editableActiveElement() && editableActiveElement() &&
@@ -980,7 +1019,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
evt.preventDefault(); evt.preventDefault();
} }
}, },
[mx, room, editor] [visibilityRef, mx, room, editor]
) )
); );
@@ -1011,6 +1050,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
} }
}, []); }, []);
// Drop the room-nav loading bar once this timeline has painted (or after a
// short idle). flushSync paints the bar before this heavy mount work.
useEffect(() => {
let cleared = false;
const clear = () => {
if (cleared) return;
cleared = true;
setLoadingRoomId((id) => (id === room.roomId ? null : id));
};
const idleId =
typeof window.requestIdleCallback === 'function'
? window.requestIdleCallback(clear, { timeout: 2000 })
: undefined;
const timeoutId = window.setTimeout(clear, idleId === undefined ? 400 : 2500);
return () => {
cleared = true;
if (idleId !== undefined && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(idleId);
}
window.clearTimeout(timeoutId);
};
}, [room.roomId, setLoadingRoomId]);
// if live timeline is linked and unreadInfo change // if live timeline is linked and unreadInfo change
// Scroll to last read message // Scroll to last read message
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -1053,6 +1117,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const scrollToBottomCount = scrollToBottomRef.current.count; const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => { useLayoutEffect(() => {
if (scrollToBottomCount > 0) { if (scrollToBottomCount > 0) {
if (!visibilityRef.current) return;
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
if (scrollEl) { if (scrollEl) {
autoScrollToBottom( autoScrollToBottom(
@@ -1062,7 +1127,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
); );
} }
} }
}, [scrollToBottomCount]); }, [scrollToBottomCount, visibilityRef]);
// Remove unreadInfo on mark as read // Remove unreadInfo on mark as read
useEffect(() => { useEffect(() => {
@@ -2291,7 +2356,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Chip> </Chip>
</TimelineFloat> </TimelineFloat>
)} )}
<Scroll ref={scrollRef} visibility="Hover"> <Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
<Box <Box
ref={timelineContentRef} ref={timelineContentRef}
direction="Column" direction="Column"
@@ -2420,4 +2485,4 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)} )}
</Box> </Box>
); );
} });

View File

@@ -1,9 +1,10 @@
import React, { useCallback, useRef } from 'react'; import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Box, Text, config } from 'folds'; import { Box, Text, config } from 'folds';
import { EventType, Room } from 'matrix-js-sdk'; import { EventType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai'; import { useAtomValue, useSetAtom, getDefaultStore } from 'jotai';
import { Editor } from 'slate';
import { useStateEvent } from '../../hooks/useStateEvent'; import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
@@ -24,6 +25,8 @@ import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { activeThreadIdAtomFamily } from '../../state/activeThread'; import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { pendingRoomNavigationAtom } from '../../state/pendingRoomNavigation';
import { activeRoomIdAtom } from '../../state/activeRoom';
import { ThreadView } from './ThreadView'; import { ThreadView } from './ThreadView';
const FN_KEYS_REGEX = /^F\d+$/; const FN_KEYS_REGEX = /^F\d+$/;
@@ -33,10 +36,8 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
return false; return false;
} }
// do not focus on F keys
if (FN_KEYS_REGEX.test(code)) return false; if (FN_KEYS_REGEX.test(code)) return false;
// do not focus on numlock/scroll lock
if ( if (
code.startsWith('OS') || code.startsWith('OS') ||
code.startsWith('Meta') || code.startsWith('Meta') ||
@@ -59,7 +60,99 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
return true; return true;
}; };
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { /**
* Slate must not stay mounted in hidden keep-alive rooms — multiple Editable
* trees crash with findPath. Timeline stays warm; only the composer remounts.
*/
function RoomComposerSlot({
room,
roomId,
editor,
canMessage,
active,
roomViewRef,
roomInputRef,
}: {
room: Room;
roomId: string;
editor: Editor;
canMessage: boolean;
active: boolean;
roomViewRef: React.RefObject<HTMLDivElement | null>;
roomInputRef: React.RefObject<HTMLDivElement | null>;
}) {
const [mountComposer, setMountComposer] = useState(active);
useLayoutEffect(() => {
if (!active) {
setMountComposer(false);
try {
ReactEditor.blur(editor);
} catch {
// Editor may already be disconnected.
}
return undefined;
}
const raf = window.requestAnimationFrame(() => setMountComposer(true));
return () => window.cancelAnimationFrame(raf);
}, [active, editor]);
useEffect(() => {
if (active) return undefined;
try {
ReactEditor.blur(editor);
} catch {
// ignore
}
return undefined;
}, [active, editor]);
if (!canMessage) {
return (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
);
}
// Gate on `active` in render (not only state) so Slate unmounts the same
// commit the slot hides — waiting for useLayoutEffect is one frame too late.
if (active && mountComposer) {
return (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
);
}
return (
<div ref={roomInputRef}>
<RoomInputPlaceholder style={{ padding: config.space.S200 }} />
</div>
);
}
export const RoomView = memo(function RoomView({
room,
eventId,
pending = false,
active = true,
}: {
room: Room;
eventId?: string;
/** Optimistic shell only — no live timeline (used for press→navigate gap). */
pending?: boolean;
/** False for hidden keep-alive slots — unmounts Slate only. */
active?: boolean;
}) {
const roomInputRef = useRef<HTMLDivElement>(null); const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null); const roomViewRef = useRef<HTMLDivElement>(null);
@@ -69,6 +162,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const editor = useEditor(); const editor = useEditor();
const mx = useMatrixClient(); const mx = useMatrixClient();
const setPendingRoomId = useSetAtom(pendingRoomNavigationAtom);
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone); const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
const powerLevels = usePowerLevelsContext(); const powerLevels = usePowerLevelsContext();
@@ -78,11 +172,20 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId()); const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId)); const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
const showThread = !pending && !!activeThreadId;
const composerActive = active && !pending;
useLayoutEffect(() => {
if (!composerActive) return;
setPendingRoomId((id) => (id === roomId ? null : id));
}, [composerActive, roomId, setPendingRoomId]);
useKeyDown( useKeyDown(
window, window,
useCallback( useCallback(
(evt) => { (evt) => {
if (!composerActive) return;
if (getDefaultStore().get(activeRoomIdAtom) !== roomId) return;
if (editableActiveElement()) return; if (editableActiveElement()) return;
const portalContainer = document.getElementById('portalContainer'); const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) { if (portalContainer && portalContainer.children.length > 0) {
@@ -90,33 +193,45 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
} }
if (shouldFocusMessageField(evt)) { if (shouldFocusMessageField(evt)) {
evt.preventDefault(); evt.preventDefault();
ReactEditor.focus(editor); try {
ReactEditor.focus(editor);
} catch {
return;
}
if (evt.key.length === 1) { if (evt.key.length === 1) {
editor.insertText(evt.key); editor.insertText(evt.key);
} }
} else if (isKeyHotkey('mod+v', evt)) { } else if (isKeyHotkey('mod+v', evt)) {
ReactEditor.focus(editor); try {
ReactEditor.focus(editor);
} catch {
// Composer may not be mounted yet.
}
} }
}, },
[editor] [composerActive, editor, roomId]
) )
); );
return ( return (
<Page ref={roomViewRef}> <Page ref={roomViewRef}>
<RoomViewHeader /> <RoomViewHeader />
{activeThreadId ? ( {showThread ? (
<ThreadView room={room} threadRootId={activeThreadId} /> <ThreadView room={room} threadRootId={activeThreadId!} />
) : ( ) : (
<> <>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
<RoomTimeline {!pending && (
key={roomId} <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
room={room} <RoomTimeline
eventId={eventId} key={roomId}
roomInputRef={roomInputRef} room={room}
editor={editor} eventId={eventId}
/> roomInputRef={roomInputRef}
editor={editor}
/>
</div>
)}
<RoomViewTyping room={room} /> <RoomViewTyping room={room} />
</Box> </Box>
<Box shrink="No" direction="Column"> <Box shrink="No" direction="Column">
@@ -128,26 +243,15 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
replacementRoomId={tombstoneEvent.getContent().replacement_room} replacementRoomId={tombstoneEvent.getContent().replacement_room}
/> />
) : ( ) : (
<> <RoomComposerSlot
{canMessage && ( room={room}
<RoomInput roomId={roomId}
room={room} editor={editor}
editor={editor} canMessage={canMessage}
roomId={roomId} active={composerActive}
fileDropContainerRef={roomViewRef} roomViewRef={roomViewRef}
ref={roomInputRef} roomInputRef={roomInputRef}
/> />
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)} )}
</div> </div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />} {hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
@@ -156,4 +260,4 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
)} )}
</Page> </Page>
); );
} });

View File

@@ -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<MutableRefObject<boolean>>({
current: true,
});
export const useRoomVisibilityRef = (): MutableRefObject<boolean> =>
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);

View File

@@ -14,8 +14,10 @@ import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient'; 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 { useSetting } from '../../state/hooks/settings';
import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils'; import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
@@ -358,12 +360,6 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
callSupportLoading, callSupportLoading,
} = useRoomCall(roomId); } = 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) { if (callSupportLoading || !callSupported) {
return null; return null;
} }
@@ -465,6 +461,8 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const { showInPageHeader, goBack } = useBackRoute(); const { showInPageHeader, goBack } = useBackRoute();
const room = useRoom(); const room = useRoom();
const isDirect = useIsDirectRoom();
const roomActive = useRoomViewActive();
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>(); const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
@@ -480,7 +478,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined; : undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [peopleDrawer, setPeopleDrawer] = usePeopleDrawerSetting(isDirect);
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId)); const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
const handleSearchClick = () => { const handleSearchClick = () => {
@@ -579,8 +577,8 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
</Box> </Box>
</Box> </Box>
<Box shrink="No" alignItems="Center" gap="100"> <Box shrink="No" alignItems="Center" gap="100">
<CallIndicator roomId={room.roomId} /> {roomActive && <CallIndicator roomId={room.roomId} />}
<RoomCallButtons roomId={room.roomId} /> {roomActive && <RoomCallButtons roomId={room.roomId} />}
{!ecryptedRoom && ( {!ecryptedRoom && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"

View File

@@ -1 +1,2 @@
export * from './Room'; export * from './Room';
export * from './CachedRooms';

View File

@@ -16,8 +16,6 @@ interface PluginLoaderProps {
* Should be placed high in the component tree after MatrixClient is available. * Should be placed high in the component tree after MatrixClient is available.
*/ */
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) { export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
useEffect(() => { useEffect(() => {
const loadPlugins = async () => { const loadPlugins = async () => {
try { try {

View File

@@ -0,0 +1,7 @@
import { MatrixEvent, RoomState } from 'matrix-js-sdk';
export type StateEventCallback = (
event: MatrixEvent,
state: RoomState,
lastStateEvent: MatrixEvent | null
) => void;

View File

@@ -11,105 +11,44 @@ import {
} from './useRoomsNotificationPreferences'; } from './useRoomsNotificationPreferences';
import { updateJoiningProgressAtom } from '../state/joiningProgress'; import { updateJoiningProgressAtom } from '../state/joiningProgress';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { fetchAllHierarchyRooms } from './useSpaceHierarchy';
const PER_PAGE_COUNT = 100;
const MAX_PAGES = 50;
const JOIN_DELAY_MS = 500; const JOIN_DELAY_MS = 500;
/** Join rules that allow automatic joining */ /** Join rules that allow automatic joining */
const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted']; 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<string>();
/** Spaces currently running auto-join (avoid concurrent runs). */
const processingSpaces = new Set<string>(); const processingSpaces = new Set<string>();
/**
* 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 { function getServerFromRoomId(roomId: string): string {
const parts = roomId.split(':'); const parts = roomId.split(':');
return parts.length > 1 ? parts[1] : ''; 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 { function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean {
const { join_rule: joinRule } = hierarchyRoom; 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) { if (!joinRule) {
return false; return false;
} }
return AUTO_JOINABLE_RULES.includes(joinRule); 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 { function isHierarchySpace(hierarchyRoom: IHierarchyRoom): boolean {
return hierarchyRoom.room_type === 'm.space' || hierarchyRoom.room_type === 'm.forum'; 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<IHierarchyRoom[]> {
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<void> { function delay(ms: number): Promise<void> {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
setTimeout(resolve, ms); setTimeout(resolve, ms);
}); });
} }
/** Callback for reporting join progress */
type ProgressCallback = (current: number, total: number) => void; 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( async function joinRoomsSequentially(
mx: MatrixClient, mx: MatrixClient,
rooms: IHierarchyRoom[], rooms: IHierarchyRoom[],
@@ -119,29 +58,23 @@ async function joinRoomsSequentially(
const spaceServer = getServerFromRoomId(spaceId); const spaceServer = getServerFromRoomId(spaceId);
const itemsToJoin = rooms.filter((room) => { const itemsToJoin = rooms.filter((room) => {
// Check if already joined
const existingRoom = mx.getRoom(room.room_id); const existingRoom = mx.getRoom(room.room_id);
if (existingRoom?.getMyMembership() === Membership.Join) { if (existingRoom?.getMyMembership() === Membership.Join) {
return false; return false;
} }
// Check if can auto-join
return canAutoJoin(room); return canAutoJoin(room);
}); });
// Separate spaces and rooms - join spaces first (rooms may depend on space membership)
const spacesToJoin = itemsToJoin.filter(isHierarchySpace); const spacesToJoin = itemsToJoin.filter(isHierarchySpace);
const roomsToJoin = itemsToJoin.filter((r) => !isHierarchySpace(r)); const roomsToJoin = itemsToJoin.filter((r) => !isHierarchySpace(r));
const total = itemsToJoin.length; const total = itemsToJoin.length;
let current = 0; let current = 0;
// Report initial progress
if (total > 0) { if (total > 0) {
onProgress?.(current, total); onProgress?.(current, total);
} }
// Process item (space or room)
const joinItem = async (item: IHierarchyRoom, isItemSpace: boolean): Promise<void> => { const joinItem = async (item: IHierarchyRoom, isItemSpace: boolean): Promise<void> => {
try { try {
const itemServer = getServerFromRoomId(item.room_id); const itemServer = getServerFromRoomId(item.room_id);
@@ -149,7 +82,6 @@ async function joinRoomsSequentially(
await mx.joinRoom(item.room_id, { viaServers }); await mx.joinRoom(item.room_id, { viaServers });
// Only set notification preferences for rooms, not spaces
if (!isItemSpace) { if (!isItemSpace) {
await setRoomNotificationPreference( await setRoomNotificationPreference(
mx, mx,
@@ -168,21 +100,18 @@ async function joinRoomsSequentially(
} }
}; };
// Join spaces first, then rooms
await spacesToJoin.reduce( await spacesToJoin.reduce(
(promise, space) => promise.then(() => joinItem(space, true)), (promise, item) => promise.then(() => joinItem(item, true)),
Promise.resolve() Promise.resolve()
); );
await roomsToJoin.reduce( await roomsToJoin.reduce(
(promise, room) => promise.then(() => joinItem(room, false)), (promise, item) => promise.then(() => joinItem(item, false)),
Promise.resolve() 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( export async function autoJoinSpaceChildren(
mx: MatrixClient, mx: MatrixClient,
spaceId: string, spaceId: string,
@@ -195,7 +124,7 @@ export async function autoJoinSpaceChildren(
processingSpaces.add(spaceId); processingSpaces.add(spaceId);
try { try {
const hierarchyRooms = await fetchSpaceHierarchy(mx, spaceId); const hierarchyRooms = await fetchAllHierarchyRooms(mx, spaceId, undefined);
const childRooms = hierarchyRooms.filter((r) => r.room_id !== spaceId); const childRooms = hierarchyRooms.filter((r) => r.room_id !== spaceId);
await joinRoomsSequentially(mx, childRooms, spaceId, onProgress); await joinRoomsSequentially(mx, childRooms, spaceId, onProgress);
} finally { } finally {
@@ -203,10 +132,7 @@ export async function autoJoinSpaceChildren(
} }
} }
/** /** Hook that automatically joins all accessible rooms when joining a space. */
* Hook that automatically joins all accessible rooms when joining a space
* @param mx - Matrix client instance
*/
export function useAutoJoinSpaceRooms(mx: MatrixClient): void { export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const setJoiningProgress = useSetAtom(updateJoiningProgressAtom); const setJoiningProgress = useSetAtom(updateJoiningProgressAtom);
@@ -216,13 +142,9 @@ export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
if (!settings.autoJoinSpaceRooms) { if (!settings.autoJoinSpaceRooms) {
return; return;
} }
// Only process spaces (includes m.forum containers)
if (!isSpace(room)) { if (!isSpace(room)) {
return; return;
} }
// Only process when joining (not invites or leaves)
if (room.getMyMembership() !== Membership.Join) { if (room.getMyMembership() !== Membership.Join) {
return; 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 { export function useAutoJoinOnSpaceVisit(space: Room | undefined): void {
const mx = useMatrixClient(); const mx = useMatrixClient();
@@ -265,34 +188,57 @@ export function useAutoJoinOnSpaceVisit(space: Room | undefined): void {
useEffect(() => { useEffect(() => {
if (!space || !settings.autoJoinSpaceRooms) { if (!space || !settings.autoJoinSpaceRooms) {
return; return undefined;
} }
if (!isSpace(space)) { if (!isSpace(space)) {
return; return undefined;
} }
if (space.getMyMembership() !== Membership.Join) { if (space.getMyMembership() !== Membership.Join) {
return; return undefined;
} }
const spaceId = space.roomId; const spaceId = space.roomId;
let cancelled = false; if (autoJoinStartedForSpace.has(spaceId)) {
return undefined;
}
void (async () => { let cancelled = false;
try { let idleId: number | undefined;
await autoJoinSpaceChildren(mx, spaceId, (current, total) => { 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) { 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 () => { return () => {
cancelled = true; cancelled = true;
if (idleId !== undefined && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(idleId);
}
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
}; };
}, [space, settings.autoJoinSpaceRooms, mx, setJoiningProgress]); }, [space, settings.autoJoinSpaceRooms, mx, setJoiningProgress]);
} }

View File

@@ -1,5 +1,5 @@
import { MatrixEvent, Room } from 'matrix-js-sdk'; 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 { produce } from 'immer';
import { useStateEvent } from './useStateEvent'; import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
@@ -87,18 +87,35 @@ export const usePowerLevelsContext = (): IPowerLevels => {
export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> => { export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> => {
const mx = useMatrixClient(); 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<string, Room>();
rooms.forEach((room) => map.set(room.roomId, room));
return map;
}, [roomIdsKey, rooms]);
const [overrides, setOverrides] = useState<Map<string, IPowerLevels>>(() => new Map());
useEffect(() => {
setOverrides(new Map());
}, [roomIdsKey]);
const roomToPowerLevels = useMemo(() => {
const rToPl = new Map<string, IPowerLevels>(); const rToPl = new Map<string, IPowerLevels>();
roomById.forEach((room, roomId) => {
rooms.forEach((room) => { const override = overrides.get(roomId);
if (override) {
rToPl.set(roomId, override);
return;
}
const mEvent = getStateEvent(room, StateEvent.RoomPowerLevels, ''); const mEvent = getStateEvent(room, StateEvent.RoomPowerLevels, '');
rToPl.set(room.roomId, getPowersLevelFromMatrixEvent(mEvent)); rToPl.set(roomId, getPowersLevelFromMatrixEvent(mEvent));
}); });
return rToPl; return rToPl;
}, [rooms]); }, [roomById, overrides]);
const [roomToPowerLevels, setRoomToPowerLevels] = useState(() => getRoomsPowerLevels());
useStateEventCallback( useStateEventCallback(
mx, mx,
@@ -107,14 +124,19 @@ export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> =>
const roomId = event.getRoomId(); const roomId = event.getRoomId();
if ( if (
roomId && roomId &&
roomById.has(roomId) &&
event.getType() === StateEvent.RoomPowerLevels && event.getType() === StateEvent.RoomPowerLevels &&
event.getStateKey() === '' && event.getStateKey() === ''
rooms.find((r) => r.roomId === roomId)
) { ) {
setRoomToPowerLevels(getRoomsPowerLevels()); const pl = getPowersLevelFromMatrixEvent(event);
setOverrides((prev) => {
const next = new Map(prev);
next.set(roomId, pl);
return next;
});
} }
}, },
[rooms, getRoomsPowerLevels] [roomById]
) )
); );

View File

@@ -1,10 +1,16 @@
import { MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from 'matrix-js-sdk'; import { MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from 'matrix-js-sdk';
import { useEffect, useState } from 'react'; 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<RoomMember[]>([]); const [members, setMembers] = useState<RoomMember[]>([]);
useEffect(() => { useEffect(() => {
if (!enabled) return undefined;
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
let loadingMembers = true; let loadingMembers = true;
let disposed = false; let disposed = false;
@@ -31,7 +37,7 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] =
mx.removeListener(RoomMemberEvent.Membership, updateMemberList); mx.removeListener(RoomMemberEvent.Membership, updateMemberList);
mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList); mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList);
}; };
}, [mx, roomId]); }, [mx, roomId, enabled]);
return members; return members;
}; };

View File

@@ -11,6 +11,10 @@ import { isRoomId } from '../utils/matrix';
import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort'; import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
import { useStateEventCallback } from './useStateEventCallback'; import { useStateEventCallback } from './useStateEventCallback';
import { ErrorCode } from '../cs-errorcode'; import { ErrorCode } from '../cs-errorcode';
import {
getCachedHierarchyRooms,
setCachedHierarchyRooms,
} from '../state/spaceHierarchyCache';
export type HierarchyItemSpace = { export type HierarchyItemSpace = {
roomId: string; roomId: string;
@@ -65,8 +69,14 @@ export function isHierarchySpaceRoom(room: IHierarchyRoom | undefined): boolean
export async function fetchAllHierarchyRooms( export async function fetchAllHierarchyRooms(
mx: MatrixClient, mx: MatrixClient,
spaceId: string, spaceId: string,
maxDepth = 3 maxDepth: number | undefined = 3,
options?: { skipCache?: boolean }
): Promise<IHierarchyRoom[]> { ): Promise<IHierarchyRoom[]> {
if (!options?.skipCache) {
const cached = getCachedHierarchyRooms(spaceId, maxDepth);
if (cached) return cached;
}
const allRooms: IHierarchyRoom[] = []; const allRooms: IHierarchyRoom[] = [];
let nextBatch: string | undefined; let nextBatch: string | undefined;
@@ -77,6 +87,7 @@ export async function fetchAllHierarchyRooms(
if (!nextBatch) break; if (!nextBatch) break;
} }
setCachedHierarchyRooms(spaceId, allRooms, maxDepth);
return allRooms; return allRooms;
} }
@@ -426,6 +437,7 @@ export const useFetchSpaceHierarchyLevel = (
); );
const queryResponse = useInfiniteQuery({ const queryResponse = useInfiniteQuery({
enabled: enable,
refetchOnMount: enable, refetchOnMount: enable,
queryKey: [roomId, 'hierarchy_level'], queryKey: [roomId, 'hierarchy_level'],
initialPageParam: undefined, initialPageParam: undefined,
@@ -450,6 +462,7 @@ export const useFetchSpaceHierarchyLevel = (
const { data, isLoading, isFetchingNextPage, error, fetchNextPage, hasNextPage } = queryResponse; const { data, isLoading, isFetchingNextPage, error, fetchNextPage, hasNextPage } = queryResponse;
useEffect(() => { useEffect(() => {
if (!enable) return;
if ( if (
hasNextPage && hasNextPage &&
pageNoRef.current <= MAX_AUTO_PAGE_COUNT && pageNoRef.current <= MAX_AUTO_PAGE_COUNT &&
@@ -460,7 +473,7 @@ export const useFetchSpaceHierarchyLevel = (
pageNoRef.current += 1; pageNoRef.current += 1;
fetchNextPage(); fetchNextPage();
} }
}, [fetchNextPage, hasNextPage, data, error]); }, [enable, fetchNextPage, hasNextPage, data, error]);
const rooms: Map<string, IHierarchyRoom> = useMemo(() => { const rooms: Map<string, IHierarchyRoom> = useMemo(() => {
const roomsMap: Map<string, IHierarchyRoom> = new Map(); const roomsMap: Map<string, IHierarchyRoom> = new Map();

View File

@@ -1,17 +1,10 @@
import { MatrixClient, MatrixEvent, RoomState, RoomStateEvent } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { subscribeStateEvents } from '../state/stateEventDemux';
import type { StateEventCallback } from './stateEventCallback.types';
export type StateEventCallback = ( export type { StateEventCallback } from './stateEventCallback.types';
event: MatrixEvent,
state: RoomState,
lastStateEvent: MatrixEvent | null
) => void;
export const useStateEventCallback = (mx: MatrixClient, onStateEvent: StateEventCallback) => { export const useStateEventCallback = (mx: MatrixClient, onStateEvent: StateEventCallback) => {
useEffect(() => { useEffect(() => subscribeStateEvents(mx, onStateEvent), [mx, onStateEvent]);
mx.on(RoomStateEvent.Events, onStateEvent);
return () => {
mx.removeListener(RoomStateEvent.Events, onStateEvent);
};
}, [mx, onStateEvent]);
}; };

View File

@@ -252,64 +252,66 @@ const userColorCache = new LRUCache<string, { color: string | undefined; timesta
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
/** /**
* Hook to get another user's chosen profile color from their avatar metadata * Hook to get another user's chosen profile color from their avatar metadata.
* @param userId - The user ID to get color for * Cache hits are synchronous (no setState / no logs) so timeline remounts stay cheap.
* @param avatarMxc - The user's avatar MXC URL
* @returns The user's chosen color or undefined
*/ */
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined { export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
// Check cache synchronously BEFORE initializing state to avoid flash
const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : ''; const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : '';
const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined; const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined;
const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS const freshCached =
? cachedEntry.color cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS ? cachedEntry : undefined;
: undefined;
const [color, setColor] = useState<string | undefined>(cachedColor); // Only used while a color is being fetched for an uncached avatar.
const [fetchedColor, setFetchedColor] = useState<string | undefined>();
const [fetchedKey, setFetchedKey] = useState('');
useEffect(() => { useEffect(() => {
if (!avatarMxc) { if (!avatarMxc) return undefined;
console.log('[useOtherUserColor] No avatarMxc provided for', userId);
setColor(undefined); const key = `${userId}:${avatarMxc}`;
return; const cached = userColorCache.get(key);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return undefined;
} }
// Check cache first let cancelled = false;
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;
}
const loadColor = async () => { const loadColor = async () => {
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl);
if (!httpUrl) { if (!httpUrl) {
console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc); userColorCache.set(key, { color: undefined, timestamp: Date.now() });
setColor(undefined); if (!cancelled) {
setFetchedKey(key);
setFetchedColor(undefined);
}
return; return;
} }
// Always use current session's token to avoid stale tokens during account switches
const accessToken = getCurrentAccessToken(); const accessToken = getCurrentAccessToken();
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null); const extractedColor = await fetchAndExtractColor(
httpUrl,
useAuthentication ? accessToken : null
);
console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor); userColorCache.set(key, { color: extractedColor, timestamp: Date.now() });
if (!cancelled) {
// Cache the result setFetchedKey(key);
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() }); setFetchedColor(extractedColor);
}
setColor(extractedColor);
}; };
loadColor(); loadColor();
return () => {
cancelled = true;
};
}, [mx, useAuthentication, avatarMxc, userId]); }, [mx, useAuthentication, avatarMxc, userId]);
return color; if (!avatarMxc) return undefined;
if (freshCached) return freshCached.color;
if (fetchedKey === cacheKey) return fetchedColor;
return undefined;
} }

View File

@@ -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 { User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
@@ -22,36 +22,125 @@ const getUserPresence = (user: User): UserPresence => ({
lastActiveTs: user.getLastActiveTs(), 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<string, PresenceEntry>();
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 => { export const useUserPresence = (userId: string): UserPresence | undefined => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const user = userId ? mx.getUser(userId) : null; // Cache getSnapshot result so useSyncExternalStore doesn't loop.
const snapshotRef = useRef<UserPresence | undefined>(undefined);
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); const subscribe = useCallback(
(onStoreChange: () => void) => {
if (!userId) return subscribeNoop();
useEffect(() => { const user = mx.getUser(userId);
if (!user) { if (!user) return subscribeNoop();
setPresence(undefined);
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; 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)); return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
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;
}; };
export const usePresenceLabel = (): Record<Presence, string> => export const usePresenceLabel = (): Record<Presence, string> =>

View File

@@ -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 { OnIntersectionCallback, useIntersectionObserver } from './useIntersectionObserver';
import { import {
canFitInScrollView, canFitInScrollView,
@@ -7,6 +7,10 @@ import {
isIntersectingScrollView, isIntersectingScrollView,
scrollToPosition, scrollToPosition,
} from '../utils/dom'; } from '../utils/dom';
import {
armRoomTimelineNetworkPaginate,
canRoomTimelineNetworkPaginate,
} from '../state/roomTimelineVisibilityGate';
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor'; const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
@@ -55,6 +59,15 @@ type VirtualPaginatorOptions<TScrollElement extends HTMLElement> = {
getScrollElement: () => TScrollElement | null; getScrollElement: () => TScrollElement | null;
getItemElement: (index: number) => HTMLElement | undefined; getItemElement: (index: number) => HTMLElement | undefined;
onEnd?: (back: boolean) => void; 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<boolean>;
/** Room id for keep-alive network-paginate suppress across CSS-only switches. */
roomId?: string;
}; };
type VirtualPaginator = { type VirtualPaginator = {
@@ -163,9 +176,42 @@ const useObserveAnchorHandle = (
export const useVirtualPaginator = <TScrollElement extends HTMLElement>( export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
options: VirtualPaginatorOptions<TScrollElement> options: VirtualPaginatorOptions<TScrollElement>
): VirtualPaginator => { ): 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); 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<{ const restoreScrollRef = useRef<{
scrollTop: number; scrollTop: number;
@@ -269,6 +315,10 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
const paginate = useCallback( const paginate = useCallback(
(direction: Direction) => { (direction: Direction) => {
if (!isPaginatorEnabled()) {
allowNetworkPaginateRef.current = false;
return;
}
const scrollEl = getScrollElement(); const scrollEl = getScrollElement();
const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current; const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current;
let { start, end } = currentRange; let { start, end } = currentRange;
@@ -276,7 +326,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
if (direction === Direction.Backward) { if (direction === Direction.Backward) {
restoreScrollRef.current = undefined; restoreScrollRef.current = undefined;
if (start === 0) { if (start === 0) {
onEnd?.(true); if (canNetworkPaginate()) onEnd?.(true);
return; return;
} }
if (scrollEl) { if (scrollEl) {
@@ -294,7 +344,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
if (direction === Direction.Forward) { if (direction === Direction.Forward) {
restoreScrollRef.current = undefined; restoreScrollRef.current = undefined;
if (end === currentCount) { if (end === currentCount) {
onEnd?.(false); if (canNetworkPaginate()) onEnd?.(false);
return; return;
} }
if (scrollEl) { if (scrollEl) {
@@ -315,11 +365,15 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
end, end,
}); });
}, },
[getScrollElement, getItemElement, onEnd, onRangeChange] [getScrollElement, getItemElement, onEnd, onRangeChange, isPaginatorEnabled, canNetworkPaginate]
); );
const handlePaginatorElIntersection: OnIntersectionCallback = useCallback( const handlePaginatorElIntersection: OnIntersectionCallback = useCallback(
(entries) => { (entries) => {
if (!isPaginatorEnabled()) {
allowNetworkPaginateRef.current = false;
return;
}
const anchorB = entries.find( const anchorB = entries.find(
(entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Backward (entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Backward
); );
@@ -333,7 +387,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
paginate(Direction.Forward); paginate(Direction.Forward);
} }
}, },
[paginate] [paginate, isPaginatorEnabled]
); );
const intersectionObserver = useIntersectionObserver( const intersectionObserver = useIntersectionObserver(
@@ -346,6 +400,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
) )
); );
// Keep observers attached for warm keep-alive rooms; gate in handlers via enabledRef.
const observeBackAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Backward); const observeBackAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Backward);
const observeFrontAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Forward); const observeFrontAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Forward);
@@ -390,9 +445,13 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
// check if pagination anchor are in visible view height // check if pagination anchor are in visible view height
// and trigger pagination // and trigger pagination
useEffect(() => { useEffect(() => {
if (initialRenderRef.current) { if (!isPaginatorEnabled()) {
// Do not trigger pagination on initial render allowNetworkPaginateRef.current = false;
// anchor intersection observable will trigger pagination on mount 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; initialRenderRef.current = false;
return; return;
} }
@@ -412,7 +471,20 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
if (frontAnchor && isIntersectingScrollView(scrollElement, frontAnchor)) { if (frontAnchor && isIntersectingScrollView(scrollElement, frontAnchor)) {
paginate(Direction.Forward); 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 { return {
getItems, getItems,

View File

@@ -40,15 +40,14 @@ import {
getOriginBaseUrl, getOriginBaseUrl,
} from './pathUtils'; } from './pathUtils';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Home, HomeLayout, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { Direct, DirectCreate, DirectLayout, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space'; import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout'; import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
import { ForumFeedPage } from '../features/forum/ForumFeedPage'; import { ForumFeedPage } from '../features/forum/ForumFeedPage';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
import { Notifications, Inbox, Invites } from './client/inbox'; import { Notifications, Inbox, Invites } from './client/inbox';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { Room } from '../features/room';
import { Lobby } from '../features/lobby'; import { Lobby } from '../features/lobby';
import { WelcomePage } from './client/WelcomePage'; import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav'; import { SidebarNav } from './client/SidebarNav';
@@ -190,7 +189,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</MobileFriendlyPageNav> </MobileFriendlyPageNav>
} }
> >
<AnimatedOutlet /> <HomeLayout />
</PageRoot> </PageRoot>
} }
> >
@@ -198,14 +197,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_CREATE_PATH} element={<HomeCreateRoom />} /> <Route path={_CREATE_PATH} element={<HomeCreateRoom />} />
<Route path={_JOIN_PATH} element={<p>join</p>} /> <Route path={_JOIN_PATH} element={<p>join</p>} />
<Route path={_SEARCH_PATH} element={<HomeSearch />} /> <Route path={_SEARCH_PATH} element={<HomeSearch />} />
<Route <Route path={_ROOM_PATH} element={<HomeRouteRoomProvider />} />
path={_ROOM_PATH}
element={
<HomeRouteRoomProvider>
<Room />
</HomeRouteRoomProvider>
}
/>
</Route> </Route>
<Route <Route
path={DIRECT_PATH} path={DIRECT_PATH}
@@ -217,7 +209,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</MobileFriendlyPageNav> </MobileFriendlyPageNav>
} }
> >
<AnimatedOutlet /> <DirectLayout />
</PageRoot> </PageRoot>
} }
> >
@@ -225,14 +217,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_CREATE_PATH} element={<DirectCreate />} /> <Route path={_CREATE_PATH} element={<DirectCreate />} />
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} /> <Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
<Route path={_INVITES_PATH} element={<Invites />} /> <Route path={_INVITES_PATH} element={<Invites />} />
<Route <Route path={_ROOM_PATH} element={<DirectRouteRoomProvider />} />
path={_ROOM_PATH}
element={
<DirectRouteRoomProvider>
<Room />
</DirectRouteRoomProvider>
}
/>
</Route> </Route>
<Route <Route
path={SPACE_PATH} path={SPACE_PATH}
@@ -246,14 +231,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_FEED_PATH} element={<ForumFeedPage />} /> <Route path={_FEED_PATH} element={<ForumFeedPage />} />
<Route path={_LOBBY_PATH} element={<Lobby />} /> <Route path={_LOBBY_PATH} element={<Lobby />} />
<Route path={_SEARCH_PATH} element={<SpaceSearch />} /> <Route path={_SEARCH_PATH} element={<SpaceSearch />} />
<Route <Route path={_ROOM_PATH} element={<SpaceRouteRoomProvider />} />
path={_ROOM_PATH}
element={
<SpaceRouteRoomProvider>
<Room />
</SpaceRouteRoomProvider>
}
/>
</Route> </Route>
<Route <Route
path={EXPLORE_PATH} path={EXPLORE_PATH}

View File

@@ -0,0 +1,69 @@
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 { useDirectRooms } from './useDirectRooms';
/**
* Hosts the DM room keep-alive OUTSIDE the room route `<Outlet />`.
* 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 (
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
position: 'relative',
display: 'flex',
}}
>
{keepHost && (
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
display: showingRoom ? 'flex' : 'none',
position: 'relative',
}}
aria-hidden={!showingRoom}
>
<CachedRooms />
</div>
)}
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
display: showingRoom ? 'none' : 'flex',
position: 'relative',
}}
>
<AnimatedOutlet />
</div>
</div>
);
}

View File

@@ -1,13 +1,16 @@
import React, { ReactNode } from 'react'; import React from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { decodeRouteParam } from '../../pathUtils'; import { decodeRouteParam } from '../../pathUtils';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useDirectRooms } from './useDirectRooms'; 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 mx = useMatrixClient();
const rooms = useDirectRooms(); const rooms = useDirectRooms();
@@ -26,9 +29,6 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
); );
} }
return ( // Valid room — DirectLayout / CachedRooms owns the UI.
<RoomProvider key={room.roomId} value={room}> return null;
<IsDirectRoomProvider value>{children}</IsDirectRoomProvider>
</RoomProvider>
);
} }

View File

@@ -1,3 +1,4 @@
export * from './Direct'; export * from './Direct';
export * from './DirectLayout';
export * from './RoomProvider'; export * from './RoomProvider';
export * from './DirectCreate'; export * from './DirectCreate';

View File

@@ -56,6 +56,7 @@ import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt'; import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
import { _RoomSearchParams } from '../../paths'; import { _RoomSearchParams } from '../../paths';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { useGlobalSubRoomIds } from '../../../state/globalSubRoomIds';
type HomeMenuProps = { type HomeMenuProps = {
requestClose: () => void; requestClose: () => void;
@@ -256,6 +257,8 @@ export function Home() {
return items; return items;
}, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]); }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
const globalSubRoomIds = useGlobalSubRoomIds();
// Flatten rooms and their sub-rooms into a hierarchical list // Flatten rooms and their sub-rooms into a hierarchical list
const listItems = useMemo(() => { const listItems = useMemo(() => {
const items: RoomListItem[] = []; 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<string>();
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) // Only add top-level rooms (rooms that aren't sub-rooms of another)
sortedRooms.forEach(roomId => { sortedRooms.forEach(roomId => {
if (!allSubRoomIds.has(roomId)) { if (!globalSubRoomIds.has(roomId)) {
addRoomAndSubRooms(roomId); addRoomAndSubRooms(roomId);
} }
}); });
return items; return items;
}, [mx, sortedRooms]); }, [mx, sortedRooms, globalSubRoomIds]);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: listItems.length, count: listItems.length,

View File

@@ -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 `<Outlet />`.
*/
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 (
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
position: 'relative',
display: 'flex',
}}
>
{keepHost && (
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
display: showingRoom ? 'flex' : 'none',
position: 'relative',
}}
aria-hidden={!showingRoom}
>
<CachedRooms />
</div>
)}
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
display: showingRoom ? 'none' : 'flex',
position: 'relative',
}}
>
<AnimatedOutlet />
</div>
</div>
);
}

View File

@@ -1,14 +1,16 @@
import React, { ReactNode } from 'react'; import React from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { decodeRouteParam } from '../../pathUtils'; import { decodeRouteParam } from '../../pathUtils';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useHomeRooms } from './useHomeRooms'; import { useHomeRooms } from './useHomeRooms';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; 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 mx = useMatrixClient();
const rooms = useHomeRooms(); const rooms = useHomeRooms();
@@ -29,9 +31,5 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
); );
} }
return ( return null;
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value={false}>{children}</IsDirectRoomProvider>
</RoomProvider>
);
} }

View File

@@ -1,3 +1,4 @@
export * from './Home'; export * from './Home';
export * from './HomeLayout';
export * from './Search'; export * from './Search';
export * from './RoomProvider'; export * from './RoomProvider';

View File

@@ -6,12 +6,14 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useOrphanRooms } from '../../../state/hooks/roomList'; import { useOrphanRooms } from '../../../state/hooks/roomList';
import { RoomType, StateEvent } from '../../../../types/matrix/room'; import { RoomType, StateEvent } from '../../../../types/matrix/room';
import { useGlobalSubRoomIds } from '../../../state/globalSubRoomIds';
export const useHomeRooms = () => { export const useHomeRooms = () => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents); const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
const globalSubRoomIds = useGlobalSubRoomIds();
// Filter out rooms that are sub-rooms of ANY room (not just orphan rooms) // Filter out rooms that are sub-rooms of ANY room (not just orphan rooms)
const rooms = useMemo(() => { const rooms = useMemo(() => {
@@ -34,17 +36,10 @@ export const useHomeRooms = () => {
return roomType === RoomType.Space || roomType === RoomType.Forum; return roomType === RoomType.Space || roomType === RoomType.Forum;
}; };
// Build a set of all sub-room IDs from ALL joined rooms return orphanRooms.filter(
const allSubRoomIds = new Set<string>(); (roomId) => !globalSubRoomIds.has(roomId) && !isSpaceLikeRoom(roomId)
mx.getRooms().forEach((room) => { );
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, ''); }, [mx, orphanRooms, globalSubRoomIds]);
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 rooms; return rooms;
}; };

View File

@@ -1,9 +1,8 @@
import React, { ReactNode } from 'react'; import React from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { decodeRouteParam } from '../../pathUtils'; import { decodeRouteParam } from '../../pathUtils';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useSpace } from '../../../hooks/useSpace'; import { useSpace } from '../../../hooks/useSpace';
@@ -11,16 +10,17 @@ import { getAllParents, getSpaceChildren, isSpace } from '../../../utils/room';
import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
import { mDirectAtom } from '../../../state/mDirectList';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { useSetting } from '../../../state/hooks/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 mx = useMatrixClient();
const space = useSpace(); const space = useSpace();
const [developerTools] = useSetting(settingsAtom, 'developerTools'); const [developerTools] = useSetting(settingsAtom, 'developerTools');
const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom); const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
const allRooms = useAtomValue(allRoomsAtom); const allRooms = useAtomValue(allRoomsAtom);
const { roomIdOrAlias: rawRoomIdOrAlias, eventId: rawEventId } = useParams(); const { roomIdOrAlias: rawRoomIdOrAlias, eventId: rawEventId } = useParams();
@@ -31,7 +31,6 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room || !allRooms.includes(room.roomId)) { if (!room || !allRooms.includes(room.roomId)) {
// room is not joined
return ( return (
<JoinBeforeNavigate <JoinBeforeNavigate
roomIdOrAlias={roomIdOrAlias ?? rawRoomIdOrAlias!} roomIdOrAlias={roomIdOrAlias ?? rawRoomIdOrAlias!}
@@ -42,17 +41,11 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
} }
if (developerTools && isSpace(room) && room.roomId === space.roomId) { if (developerTools && isSpace(room) && room.roomId === space.roomId) {
// allow to view space timeline return null;
return (
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
</RoomProvider>
);
} }
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) { if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
if (getSpaceChildren(space).includes(room.roomId)) { if (getSpaceChildren(space).includes(room.roomId)) {
// fill missing roomToParent mapping
setRoomToParents({ setRoomToParents({
type: 'PUT', type: 'PUT',
parent: space.roomId, parent: space.roomId,
@@ -69,9 +62,5 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
); );
} }
return ( return null;
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
</RoomProvider>
);
} }

View File

@@ -54,6 +54,7 @@ import { Membership, StateEvent } from '../../../../types/matrix/room';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot'; import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { useGlobalSubRoomIds } from '../../../state/globalSubRoomIds';
import { getViaServers } from '../../../plugins/via-servers'; import { getViaServers } from '../../../plugins/via-servers';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/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 const globalSubRoomIds = useGlobalSubRoomIds();
// This prevents sub-rooms from appearing both as top-level entries AND nested under parents
const globalSubRoomIds = useMemo(() => {
const subRoomIds = new Set<string>();
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]);
// Filter hierarchy to exclude sub-rooms (they'll be shown nested under their parent) // Filter hierarchy to exclude sub-rooms (they'll be shown nested under their parent)
const filteredHierarchy = useMemo(() => { const filteredHierarchy = useMemo(() => {

View File

@@ -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 (
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
position: 'relative',
display: 'flex',
}}
>
{keepHost && (
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
display: showingRoom ? 'flex' : 'none',
position: 'relative',
}}
aria-hidden={!showingRoom}
>
<CachedRooms />
</div>
)}
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
display: showingRoom ? 'none' : 'flex',
position: 'relative',
}}
>
<AnimatedOutlet />
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
export * from './SpaceIndexRedirect'; export * from './SpaceIndexRedirect';
export * from './SpaceProvider'; export * from './SpaceProvider';
export * from './Space'; export * from './Space';
export * from './SpaceRoomLayout';
export * from './Search'; export * from './Search';
export * from './RoomProvider'; export * from './RoomProvider';

View File

@@ -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<string, RoomSnapshot>;
globalListeners: Set<() => void>;
roomListeners: Map<string, Set<() => void>>;
unsubState: () => void;
expiryTimer: ReturnType<typeof setInterval>;
};
const registries = new WeakMap<MatrixClient, Registry>();
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<string>();
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,
};
};

View File

@@ -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<string>();
const setsEqual = (a: ReadonlySet<string>, b: ReadonlySet<string>): 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<string> => {
const subRoomIds = new Set<string>();
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<string>;
listeners: Set<() => void>;
};
const subRoomStores = new WeakMap<MatrixClient, SubRoomStore>();
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<string> => {
const mx = useMatrixClient();
return useSyncExternalStore(
useCallback((listener) => subscribeGlobalSubRoomIds(mx, listener), [mx]),
() => getSubRoomStore(mx).snapshot,
() => EMPTY_SUB_ROOM_IDS
);
};

View File

@@ -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<boolean>) => void] => {
const rooms = useSetting(settingsAtom, 'peopleDrawerInRooms');
const directs = useSetting(settingsAtom, 'peopleDrawerInDirects');
return isDirect ? directs : rooms;
};

View File

@@ -1,9 +1,13 @@
import { encodeBlurHash, validBlurHash } from '../utils/blurHash'; import { encodeBlurHash, validBlurHash } from '../utils/blurHash';
import { fitMediaSize } from '../utils/common';
const STORAGE_KEY = 'paarrot.media.meta'; const STORAGE_KEY = 'paarrot.media.meta';
const LEGACY_DIMENSIONS_KEY = 'paarrot.media.dimensions'; const LEGACY_DIMENSIONS_KEY = 'paarrot.media.dimensions';
const MAX_ENTRIES = 500; const MAX_ENTRIES = 500;
/** Same max box as MImage / MVideo attachment rendering. */
export const ATTACHMENT_MAX_SIZE = 400;
export type MediaMeta = { export type MediaMeta = {
w: number; w: number;
h: number; h: number;
@@ -88,6 +92,19 @@ export const getMediaDimensions = (mxcUrl: string): MediaDimensions | undefined
return { w: entry.w, h: entry.h }; 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 => { export const setMediaDimensions = (mxcUrl: string, w: number, h: number): void => {
if (!mxcUrl || w <= 0 || h <= 0) return; if (!mxcUrl || w <= 0 || h <= 0) return;
touchEntry(mxcUrl, { w, h }); touchEntry(mxcUrl, { w, h });

View File

@@ -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<string | null>(null);
/** Room id showing the nav loading bar (stays until the room view is ready). */
export const roomNavLoadingAtom = atom<string | null>(null);

View File

@@ -0,0 +1,22 @@
/** Keep-alive room visibility gates for timeline network pagination (no React). */
const needsScrollBeforeNetwork = new Set<string>();
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);
};

View File

@@ -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<string[]>([]);
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 };
};

View File

@@ -36,7 +36,10 @@ export interface Settings {
pageZoom: number; pageZoom: number;
hideActivity: boolean; 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; isCallPanelDocked: boolean;
callPanelDockPosition: CallPanelDockPosition; callPanelDockPosition: CallPanelDockPosition;
memberSortFilterIndex: number; memberSortFilterIndex: number;
@@ -79,7 +82,8 @@ const defaultSettings: Settings = {
pageZoom: 100, pageZoom: 100,
hideActivity: false, hideActivity: false,
isPeopleDrawer: true, peopleDrawerInRooms: true,
peopleDrawerInDirects: false,
isCallPanelDocked: false, isCallPanelDocked: false,
callPanelDockPosition: 'right', callPanelDockPosition: 'right',
memberSortFilterIndex: 0, memberSortFilterIndex: 0,
@@ -113,7 +117,10 @@ export const getSettings = () => {
const settings = localStorage.getItem(STORAGE_KEY); const settings = localStorage.getItem(STORAGE_KEY);
if (settings === null) return defaultSettings; 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 // Migrate old twitterEmoji boolean to new emojiStyle enum
if ('twitterEmoji' in parsed && parsed.emojiStyle === undefined) { if ('twitterEmoji' in parsed && parsed.emojiStyle === undefined) {
@@ -121,6 +128,17 @@ export const getSettings = () => {
delete parsed.twitterEmoji; 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 { return {
...defaultSettings, ...defaultSettings,
...parsed, ...parsed,

View File

@@ -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<string, CacheEntry>();
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<string, IHierarchyRoom> => {
const map = new Map<string, IHierarchyRoom>();
rooms.forEach((room) => map.set(room.room_id, room));
return map;
};

View File

@@ -0,0 +1,38 @@
import { MatrixClient, RoomStateEvent } from 'matrix-js-sdk';
import type { StateEventCallback } from '../hooks/stateEventCallback.types';
type ClientRegistry = {
listener: StateEventCallback;
subscribers: Set<StateEventCallback>;
};
const registries = new WeakMap<MatrixClient, ClientRegistry>();
const getOrCreateRegistry = (mx: MatrixClient): ClientRegistry => {
let reg = registries.get(mx);
if (!reg) {
const subscribers = new Set<StateEventCallback>();
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);
}
};
};

View File

@@ -170,8 +170,6 @@ export function detectImageFormat(data: ArrayBuffer | Uint8Array): ImageFormat {
export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined { export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined {
const format = detectImageFormat(imageData); const format = detectImageFormat(imageData);
console.log('[extractColorFromImage] Detected format:', format);
switch (format) { switch (format) {
case 'png': case 'png':
return extractColorFromPNG(imageData); return extractColorFromPNG(imageData);
@@ -182,7 +180,6 @@ export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): stri
case 'gif': case 'gif':
return extractColorFromGIF(imageData); return extractColorFromGIF(imageData);
default: default:
console.warn('[extractColorFromImage] Unknown image format');
return undefined; return undefined;
} }
} }
@@ -213,23 +210,17 @@ export function extractBannerFromImage(imageData: ArrayBuffer | Uint8Array): str
export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata { export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
const format = detectImageFormat(imageData); const format = detectImageFormat(imageData);
console.log('[extractMetadataFromImage] Detected format:', format);
switch (format) { switch (format) {
case 'png': case 'png':
const pngMetadata = extractMetadataFromPNG(imageData); return extractMetadataFromPNG(imageData);
console.log('[extractMetadataFromImage] PNG metadata:', pngMetadata);
return pngMetadata;
// For other formats, extract color only for now // For other formats, extract color only for now
case 'webp': case 'webp':
case 'jpeg': case 'jpeg':
case 'gif': { case 'gif': {
const color = extractColorFromImage(imageData); const color = extractColorFromImage(imageData);
console.log('[extractMetadataFromImage] Non-PNG color:', color);
return color ? { color } : {}; return color ? { color } : {};
} }
default: default:
console.warn('[extractMetadataFromImage] Unknown image format');
return {}; return {};
} }
} }
@@ -365,7 +356,6 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
// Check if full metadata is cached // Check if full metadata is cached
const cached = avatarMetadataCache.get(url); const cached = avatarMetadataCache.get(url);
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) { 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; return cached.metadata.color;
} }
@@ -373,12 +363,9 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
const inflightKey = `${url}:${accessToken || ''}`; const inflightKey = `${url}:${accessToken || ''}`;
const existingFetch = inflightColorFetches.get(inflightKey); const existingFetch = inflightColorFetches.get(inflightKey);
if (existingFetch) { if (existingFetch) {
console.log('[fetchAndExtractColor] Reusing in-flight fetch for', url);
return existingFetch; return existingFetch;
} }
console.log('[fetchAndExtractColor] Fetching and extracting color from', url);
const fetchPromise = (async () => { const fetchPromise = (async () => {
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@@ -386,20 +373,10 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
}); });
console.log('[fetchAndExtractColor] Fetch response status:', response.ok, response.status);
if (!response.ok) return undefined; if (!response.ok) return undefined;
const data = await response.arrayBuffer(); const data = await response.arrayBuffer();
console.log('[fetchAndExtractColor] Downloaded', data.byteLength, 'bytes'); return extractColorFromImage(data);
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;
} catch (error) { } catch (error) {
console.error('[fetchAndExtractColor] Error fetching/extracting color:', error); console.error('[fetchAndExtractColor] Error fetching/extracting color:', error);
return undefined; return undefined;
@@ -422,7 +399,6 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string
// Check cache first // Check cache first
const cached = avatarMetadataCache.get(url); const cached = avatarMetadataCache.get(url);
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) { if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
console.log('[fetchAndExtractMetadata] Using cached metadata for', url, ':', cached.metadata);
return cached.metadata; return cached.metadata;
} }
@@ -430,12 +406,9 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string
const inflightKey = `${url}:${accessToken || ''}`; const inflightKey = `${url}:${accessToken || ''}`;
const existingFetch = inflightMetadataFetches.get(inflightKey); const existingFetch = inflightMetadataFetches.get(inflightKey);
if (existingFetch) { if (existingFetch) {
console.log('[fetchAndExtractMetadata] Reusing in-flight fetch for', url);
return existingFetch; return existingFetch;
} }
console.log('[fetchAndExtractMetadata] Fetching metadata from', url);
const fetchPromise = (async () => { const fetchPromise = (async () => {
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@@ -443,20 +416,14 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
}); });
console.log('[fetchAndExtractMetadata] Fetch response status:', response.ok, response.status);
if (!response.ok) return {}; if (!response.ok) return {};
const data = await response.arrayBuffer(); const data = await response.arrayBuffer();
console.log('[fetchAndExtractMetadata] Downloaded', data.byteLength, 'bytes');
const metadata = extractMetadataFromImage(data); const metadata = extractMetadataFromImage(data);
console.log('[fetchAndExtractMetadata] Extracted metadata:', metadata);
// Cache the result
avatarMetadataCache.set(url, { avatarMetadataCache.set(url, {
metadata, metadata,
timestamp: Date.now() timestamp: Date.now(),
}); });
return metadata; return metadata;

View File

@@ -38,43 +38,20 @@ export const withViewTransition = async (callback: () => void | Promise<void>):
}; };
/** /**
* Enable view transitions for router navigation * Enable light view transitions for browser back/forward only.
* Call this in your app initialization *
* Do NOT intercept in-app <a>/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 => { export const enableViewTransitionsForNavigation = (): void => {
if (!supportsViewTransitions()) { if (!supportsViewTransitions()) {
return; return;
} }
// Intercept link clicks and navigation to add view transitions window.addEventListener('popstate', () => {
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 = () => {
withViewTransition(() => { 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);
}; };