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 { Outlet, useLocation } from 'react-router-dom';
import React, { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useAtom, useAtomValue } from 'jotai';
import { pendingRoomNavigationAtom } from '../state/pendingRoomNavigation';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { IsDirectRoomProvider, RoomProvider } from '../hooks/useRoom';
import { mDirectAtom } from '../state/mDirectList';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { RoomFrame } from '../features/room/Room';
import * as css from './AnimatedOutlet.css';
/** Chrome-only shell for the press→navigate gap (no timeline — avoids scroll fights). */
function PendingRoomShell({ roomId }: { roomId: string }) {
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const room = mx.getRoom(roomId);
if (!room) return null;
/**
* Wrapper for Outlet that adds route-based animation
* Forces remount on route change by using location as key
*/
export function AnimatedOutlet() {
const location = useLocation();
return (
<div
key={location.pathname}
data-route-transition="true"
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Outlet />
<div className={css.PendingOverlay} aria-busy aria-label={`Opening ${room.name}`}>
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>
<RoomFrame room={room} pending />
</IsDirectRoomProvider>
</RoomProvider>
</div>
);
}
/** Optimistic room navigation: brief pending chrome until the route settles. */
export function AnimatedOutlet() {
const selectedRoomId = useSelectedRoom();
const [pendingRoomId, setPendingRoomId] = useAtom(pendingRoomNavigationAtom);
// Safety: if navigation never lands, don't leave pending forever.
useEffect(() => {
if (!pendingRoomId) return undefined;
const t = window.setTimeout(() => setPendingRoomId(null), 4000);
return () => window.clearTimeout(t);
}, [pendingRoomId, setPendingRoomId]);
// Only cover the gap before the target room route is active.
const showOverlay =
typeof pendingRoomId === 'string' &&
pendingRoomId.length > 0 &&
pendingRoomId !== selectedRoomId;
return (
<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>
);
}

View File

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

View File

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

View File

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

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. */
export const BlurhashPlaceholder = style([
DefaultReset,
@@ -90,6 +99,8 @@ export const BlurhashPlaceholder = style([
// Soften the decode so it reads as color blobs; slight scale hides blur edges.
filter: 'blur(14px)',
transform: 'scale(1.06)',
opacity: 0,
animation: `${blurhashFadeIn} 560ms cubic-bezier(0.22, 1, 0.36, 1) forwards`,
},
]);
@@ -97,7 +108,7 @@ export const BlurhashPlaceholder = style([
export const MediaFadeIn = style({
zIndex: 1,
opacity: 0,
transition: 'opacity 280ms ease-out',
transition: 'opacity 520ms cubic-bezier(0.22, 1, 0.36, 1)',
});
export const MediaFadeInLoaded = style({

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 classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
const SidebarDockedCallPanel = lazy(() =>
import('../../features/call/SidebarDockedCallPanel').then((module) => ({
default: module.SidebarDockedCallPanel,
}))
);
type PageRootProps = {
nav: ReactNode;
children: ReactNode;
@@ -46,7 +51,9 @@ export function PageNav({
>
<Box grow="Yes" direction="Column">
{children}
<SidebarDockedCallPanel />
<Suspense fallback={null}>
<SidebarDockedCallPanel />
</Suspense>
</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
*/
/** 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) {
const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall();
const mx = useMatrixClient();
const [roomCallSupported, setRoomCallSupported] = useState(false);
const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(true);
const [roomCallSupported, setRoomCallSupported] = useState(
() => roomCallSupportCache.get(roomId) ?? false
);
const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(
() => !roomCallSupportCache.has(roomId)
);
// Check if calls are supported for this specific room by checking all priority homeservers
useEffect(() => {
const cached = roomCallSupportCache.get(roomId);
if (cached !== undefined) {
setRoomCallSupported(cached);
setRoomCallSupportLoading(false);
return undefined;
}
let cancelled = false;
const checkRoomCallSupport = async () => {
setRoomCallSupportLoading(true);
const room = mx.getRoom(roomId);
if (!room) {
setRoomCallSupported(false);
setRoomCallSupportLoading(false);
if (!cancelled) {
setRoomCallSupported(false);
setRoomCallSupportLoading(false);
}
return;
}
// Get homeserver priority list for this room
const homeserverPriority = getLiveKitHomeserverPriority(
room,
mx.getHomeserverUrl(),
mx.getSafeUserId()
);
console.log(`Checking call support for room ${roomId}, homeserver priority:`, homeserverPriority);
// Check each homeserver in priority order
for (const homeserver of homeserverPriority) {
try {
console.log(`Fetching well-known from ${homeserver}...`);
const wellKnown = await fetchWellKnownWithRTC(homeserver);
console.log(`Well-known response from ${homeserver}:`, wellKnown);
const livekitFocus = getLiveKitFocus(wellKnown);
console.log(`LiveKit focus from ${homeserver}:`, livekitFocus);
if (livekitFocus) {
console.log(`Call support found for room ${roomId} on homeserver: ${homeserver}`);
setRoomCallSupported(true);
setRoomCallSupportLoading(false);
roomCallSupportCache.set(roomId, true);
if (!cancelled) {
setRoomCallSupported(true);
setRoomCallSupportLoading(false);
}
return;
}
} catch (error) {
console.warn(`Failed to check call support on ${homeserver}:`, error);
} catch {
// Continue to next homeserver
}
}
// No homeserver supports calls
console.log(`No call support found for room ${roomId}`);
setRoomCallSupported(false);
setRoomCallSupportLoading(false);
roomCallSupportCache.set(roomId, false);
if (!cancelled) {
setRoomCallSupported(false);
setRoomCallSupportLoading(false);
}
};
checkRoomCallSupport();
return () => {
cancelled = true;
};
}, [roomId, mx]);
const isInCall = activeCall?.roomId === roomId;

View File

@@ -1,115 +1,12 @@
/* eslint-disable no-console */
import { useCallback, useEffect, useState } from 'react';
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomCallMembersFromRegistry } from '../../state/activeCallRoomsRegistry';
/** The Matrix state event type for call membership (MSC3401) */
const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member';
/** Represents an active call member in a room */
export interface CallMember {
userId: string;
deviceId: string;
sessionId: string;
expiresTs: number;
}
export type { CallMember } from './callMemberUtils';
export { getActiveCallMembers, CALL_MEMBER_EVENT_TYPE } from './callMemberUtils';
/**
* Extracts active call members from a room's state events
* @param room - The Matrix room to check
* @returns Array of active call members
* Hook to get the list of users currently in a call in a room.
* Backed by a shared registry — one state listener for all rooms.
*/
export function getActiveCallMembers(room: Room): CallMember[] {
const members: CallMember[] = [];
const now = Date.now();
try {
const stateEvents = room.currentState.getStateEvents(CALL_MEMBER_EVENT_TYPE);
stateEvents.forEach((event) => {
const content = event.getContent();
const userId = event.getStateKey();
if (!userId || !content['m.calls']) return;
const calls = content['m.calls'];
if (!Array.isArray(calls)) return;
calls.forEach((call) => {
const devices = call['m.devices'];
if (!Array.isArray(devices)) return;
devices.forEach((device) => {
const expiresTs = device.expires_ts || 0;
// Check if the call membership is still valid (not expired)
if (expiresTs > now) {
members.push({
userId,
deviceId: device.device_id,
sessionId: device.session_id,
expiresTs,
});
}
});
});
});
} catch (e) {
console.error('Error reading call member events:', e);
}
return members;
}
/**
* Hook to get the list of users currently in a call in a room
* @param roomId - The Matrix room ID to monitor
* @returns Object with call members and whether a call is active
*/
export function useRoomCallMembers(roomId: string) {
const mx = useMatrixClient();
const [callMembers, setCallMembers] = useState<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,
};
export function useRoomCallMembers(roomId: string, enabled = true) {
return useRoomCallMembersFromRegistry(roomId, enabled);
}

View File

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

View File

@@ -1,9 +1,9 @@
import React, { useMemo } from 'react';
import { PageRoot } from '../../components/page';
import { AnimatedOutlet } from '../../components/AnimatedOutlet';
import { MobileFriendlyPageNav } from '../../pages/MobileFriendly';
import { SPACE_PATH } from '../../pages/paths';
import { Space } from '../../pages/client/space/Space';
import { SpaceRoomLayout } from '../../pages/client/space/SpaceRoomLayout';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useSpace } from '../../hooks/useSpace';
@@ -11,7 +11,7 @@ import { isForumContainer, shouldShowForumLobby } from '../../utils/room';
import { ForumBoardProvider } from './ForumBoardContext';
/**
* Space sidebar + outlet. Forum spaces render ForumFeedSidebar in Space.tsx.
* Space sidebar + room keep-alive host. Forum spaces render ForumFeedSidebar in Space.tsx.
*/
export function ForumAwareSpaceLayout() {
const mx = useMatrixClient();
@@ -33,7 +33,7 @@ export function ForumAwareSpaceLayout() {
</MobileFriendlyPageNav>
}
>
<AnimatedOutlet />
<SpaceRoomLayout />
</PageRoot>
);

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ import { SequenceCard } from '../../components/sequence-card';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { PaarrotSubRoomsContent } from '../../hooks/useRoomSubRooms';
import { useGlobalSubRoomIds } from '../../state/globalSubRoomIds';
type SpaceHierarchyProps = {
summary: IHierarchyRoom | undefined;
@@ -71,7 +72,14 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
) => {
const mx = useMatrixClient();
const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(spaceItem.roomId, true);
// Lobby bulk-fetches hierarchy once; per-category fetch duplicated N×50 API pages.
const parentHasSummaries = hierarchySummaries.size > 0;
const enableFetch = !closed && !parentHasSummaries;
const { fetching, error, rooms: fetchedRooms } = useFetchSpaceHierarchyLevel(
spaceItem.roomId,
enableFetch
);
const rooms = parentHasSummaries ? hierarchySummaries : fetchedRooms;
const subspaces = useMemo(() => {
const s: Map<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
// This ensures sub-rooms are hidden even if their parent isn't in this space
const globalSubRoomIds = useMemo(() => {
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]);
const globalSubRoomIds = useGlobalSubRoomIds();
// Build a map of parent room -> sub-room IDs for nested rendering (only for rooms in this space)
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 { Icon, Icons } from '../../components/icons';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomCallMembers } from '../call/useRoomCallMembers';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { mxcUrlToHttp, getMxIdLocalPart } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { nameInitials } from '../../utils/common';
import { useCall } from '../call/useCall';
import { useRoomHasActiveCall } from '../../state/activeCallRoomsRegistry';
import { useRoomCallMembers } from '../call/useRoomCallMembers';
import * as css from './CallParticipantsIndicator.css';
const MAX_VISIBLE_PARTICIPANTS = 8;
@@ -17,6 +18,12 @@ type CallParticipantsIndicatorProps = {
};
export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorProps) {
const hasCall = useRoomHasActiveCall(roomId);
if (!hasCall) return null;
return <CallParticipantsIndicatorActive roomId={roomId} />;
}
function CallParticipantsIndicatorActive({ roomId }: CallParticipantsIndicatorProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(roomId);
@@ -41,20 +48,14 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
};
/**
* Check if a participant is currently speaking
*/
const isParticipantSpeaking = (userId: string): boolean => {
if (!activeCall) return false;
// Direct check for Matrix user ID (works for local user)
if (activeCall.activeSpeakers.has(userId)) return true;
// For remote users, activeSpeakers contains LiveKit identities (deviceId_timestamp)
const member = callMembers.find((m) => m.userId === userId);
if (!member) return false;
// Check if any active speaker has this user's deviceId
return Array.from(activeCall.activeSpeakers).some((speakerId) => {
const underscoreIndex = speakerId.lastIndexOf('_');
const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId;
@@ -62,20 +63,14 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
});
};
/**
* Check if a participant is currently muted
*/
const isParticipantMuted = (userId: string): boolean => {
if (!activeCall) return false;
// For local user, check activeCall.isMuted
if (userId === myUserId) return activeCall.isMuted;
// For remote users, mutedParticipants contains LiveKit identities (deviceId_timestamp)
const member = callMembers.find((m) => m.userId === userId);
if (!member) return false;
// Check if any muted participant has this user's deviceId
return Array.from(activeCall.mutedParticipants).some((mutedId) => {
const underscoreIndex = mutedId.lastIndexOf('_');
const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId;
@@ -83,17 +78,11 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
});
};
/**
* Check if the local user is deafened
*/
const isLocalUserDeafened = (userId: string): boolean => {
if (!activeCall || userId !== myUserId) return false;
return activeCall.isDeafened;
};
/**
* Get the appropriate audio icon for a participant
*/
const getAudioIcon = (userId: string): string => {
if (isLocalUserDeafened(userId)) {
return Icons.VolumeMute;
@@ -115,7 +104,7 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
const isSpeaking = isParticipantSpeaking(member.userId);
const isMuted = isParticipantMuted(member.userId);
const isDeafened = isLocalUserDeafened(member.userId);
return (
<Box
key={member.userId}
@@ -125,8 +114,8 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
opacity: config.opacity.P300,
}}
>
<Avatar
size="200"
<Avatar
size="200"
radii="Pill"
className={isSpeaking ? css.ParticipantSpeaking : undefined}
>
@@ -148,13 +137,13 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
)}
</Avatar>
<Box alignItems="Center" gap="100" grow="Yes">
<Icon
src={getAudioIcon(member.userId)}
size="50"
style={{
<Icon
src={getAudioIcon(member.userId)}
size="50"
style={{
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>
{getParticipantName(member.userId)}

View File

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

View File

@@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { keyframes, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const CategoryButton = style({
flexGrow: 1,
@@ -7,3 +7,68 @@ export const CategoryButton = style({
export const CategoryButtonIcon = style({
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 { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { useSetSetting, useSetting } from '../../state/hooks/settings';
import { useSetting } from '../../state/hooks/settings';
import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer';
import { settingsAtom } from '../../state/settings';
import { millify } from '../../plugins/millify';
import { ScrollTopContainer } from '../../components/scroll-top-container';
@@ -41,12 +42,14 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useIsDirectRoom } from '../../hooks/useRoom';
type MemberDrawerHeaderProps = {
room: Room;
};
function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
const isDirect = useIsDirectRoom();
const [, setPeopleDrawer] = usePeopleDrawerSetting(isDirect);
return (
<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 { Room as MatrixRoom } from 'matrix-js-sdk';
import { decodeRouteParam } from '../../pages/pathUtils';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
@@ -8,9 +9,10 @@ import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings';
import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer';
import { settingsAtom } from '../../state/settings';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -19,25 +21,64 @@ import { activeRoomIdAtom } from '../../state/activeRoom';
import { isForum } from '../../utils/room';
import { ForumRoomView } from './ForumRoomView';
export function Room() {
const { eventId: rawEventId } = useParams();
const eventId = decodeRouteParam(rawEventId);
const room = useRoom();
const mx = useMatrixClient();
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
type RoomFrameProps = {
room: MatrixRoom;
eventId?: string;
/** Optimistic shell only — no live timeline. */
pending?: boolean;
/**
* Visible keep-alive slot. Drives composer mount immediately (Slate cannot
* stay mounted in hidden rooms). Timeline ignores this via memo.
*/
active?: boolean;
};
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
/** Shared room column layout used by the live room and the pending-nav shell. */
export const RoomFrame = memo(function RoomFrame({
room,
eventId,
pending = false,
active = true,
}: RoomFrameProps) {
const mx = useMatrixClient();
const isDirect = useIsDirectRoom();
const [isDrawer] = usePeopleDrawerSetting(isDirect);
const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const markAsRead = useMarkAsRead(mx);
const forumRoom = isForum(room);
// Mount whenever the account pref is on — including inactive keep-alive slots —
// so members are already loaded when you switch rooms (no pop-in).
const showDrawer = !pending && screenSize === ScreenSize.Desktop && isDrawer;
const members = useRoomMembers(mx, room.roomId, showDrawer);
// Update titlebar with current room ID
useEffect(() => {
return (
<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);
return () => setActiveRoomId(undefined);
}, [room.roomId, setActiveRoomId]);
useKeyDown(
@@ -52,17 +93,19 @@ export function Room() {
)
);
return null;
}
/** @deprecated Prefer CachedRooms for route outlets — kept for single-room mounts. */
export function Room() {
const { eventId: rawEventId } = useParams();
const eventId = decodeRouteParam(rawEventId);
const room = useRoom();
return (
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView 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>
<>
<ActiveRoomEffects room={room} />
<RoomFrame room={room} eventId={eventId} />
</>
);
}

View File

@@ -10,6 +10,7 @@ import React, {
useMemo,
useRef,
useState,
memo,
} from 'react';
import {
Direction,
@@ -117,6 +118,8 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useTheme } from '../../hooks/useTheme';
import { useRoomVisibilityRef } from './RoomViewActiveContext';
import { roomNavLoadingAtom } from '../../state/pendingRoomNavigation';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import {
@@ -553,8 +556,15 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
};
};
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
export const RoomTimeline = memo(function RoomTimeline({
room,
eventId,
roomInputRef,
editor,
}: RoomTimelineProps) {
const mx = useMatrixClient();
const visibilityRef = useRoomVisibilityRef();
const setLoadingRoomId = useSetAtom(roomNavLoadingAtom);
const useAuthentication = useMediaAuthentication();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
@@ -716,6 +726,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[]
),
onEnd: handleTimelinePagination,
enabledRef: visibilityRef,
roomId: room.roomId,
});
const loadEventTimeline = useEventTimelineLoader(
@@ -759,6 +771,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
// Hidden keep-alive: only slide the virtual window when stuck to bottom.
// Shifting range while scrolled-up remaps scrollTop onto different messages.
if (!visibilityRef.current) {
if (shouldStickToBottom) {
setTimeline((ct) => ({
...ct,
range: {
start: ct.range.start + 1,
end: ct.range.end + 1,
},
}));
}
return;
}
// Always update timeline with new message
setTimeline((ct) => ({
...ct,
@@ -771,7 +798,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Handle own messages and focus
if (isOwnMessage) {
setLatestUnreadMessage(null);
if (document.hasFocus() && (!unreadInfo)) {
if (document.hasFocus() && !unreadInfo) {
const roomId = mEvt.getRoomId();
if (roomId) {
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
@@ -803,7 +830,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
});
}
},
[mx, room, unreadInfo, hideActivity, markAsRead]
[visibilityRef, mx, room, unreadInfo, hideActivity, markAsRead]
)
);
@@ -840,10 +867,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useLiveTimelineRefresh(
room,
useCallback(() => {
if (!visibilityRef.current) return;
if (liveTimelineLinked) {
setTimeline(getInitialTimeline(room));
}
}, [room, liveTimelineLinked])
}, [visibilityRef, room, liveTimelineLinked])
);
// Stay at bottom when room editor resize
@@ -856,6 +884,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
mounted = true;
return;
}
if (!visibilityRef.current) return;
if (!roomInputRef.current) return;
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
const scrollElement = getScrollElement();
@@ -865,29 +894,37 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
autoScrollToBottom(scrollElement, false, true);
}
};
}, [getScrollElement, roomInputRef]),
}, [getScrollElement, roomInputRef, visibilityRef]),
useCallback(() => roomInputRef.current, [roomInputRef])
);
useResizeObserver(
useCallback((entries) => {
const timelineContent = timelineContentRef.current;
const scrollElement = getScrollElement();
if (!timelineContent || !scrollElement) return;
useCallback(
(entries) => {
if (!visibilityRef.current) return;
const timelineContent = timelineContentRef.current;
const scrollElement = getScrollElement();
if (!timelineContent || !scrollElement) return;
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
if (!timelineEntry) return;
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
if (!timelineEntry) return;
const nextHeight = timelineEntry.contentRect.height;
const previousHeight = timelineContentHeightRef.current;
timelineContentHeightRef.current = nextHeight;
const nextHeight = timelineEntry.contentRect.height;
const previousHeight = timelineContentHeightRef.current;
timelineContentHeightRef.current = nextHeight;
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
return;
}
if (
previousHeight === 0 ||
nextHeight <= previousHeight ||
!isScrolledToBottom(scrollElement)
) {
return;
}
autoScrollToBottom(scrollElement, false, true);
}, [getScrollElement]),
autoScrollToBottom(scrollElement, false, true);
},
[getScrollElement, visibilityRef]
),
useCallback(() => timelineContentRef.current, [])
);
@@ -915,6 +952,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useIntersectionObserver(
useCallback(
(entries) => {
if (!visibilityRef.current) return;
const target = atBottomAnchorRef.current;
if (!target) return;
const targetEntry = getIntersectionObserverEntry(target, entries);
@@ -927,7 +965,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
}
},
[debounceSetAtBottom, tryAutoMarkAsRead]
[visibilityRef, debounceSetAtBottom, tryAutoMarkAsRead]
),
useCallback(
() => ({
@@ -965,6 +1003,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
window,
useCallback(
(evt) => {
if (!visibilityRef.current) return;
if (
isKeyHotkey('arrowup', evt) &&
editableActiveElement() &&
@@ -980,7 +1019,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
evt.preventDefault();
}
},
[mx, room, editor]
[visibilityRef, mx, room, editor]
)
);
@@ -1011,6 +1050,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
}, []);
// Drop the room-nav loading bar once this timeline has painted (or after a
// short idle). flushSync paints the bar before this heavy mount work.
useEffect(() => {
let cleared = false;
const clear = () => {
if (cleared) return;
cleared = true;
setLoadingRoomId((id) => (id === room.roomId ? null : id));
};
const idleId =
typeof window.requestIdleCallback === 'function'
? window.requestIdleCallback(clear, { timeout: 2000 })
: undefined;
const timeoutId = window.setTimeout(clear, idleId === undefined ? 400 : 2500);
return () => {
cleared = true;
if (idleId !== undefined && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(idleId);
}
window.clearTimeout(timeoutId);
};
}, [room.roomId, setLoadingRoomId]);
// if live timeline is linked and unreadInfo change
// Scroll to last read message
useLayoutEffect(() => {
@@ -1053,6 +1117,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => {
if (scrollToBottomCount > 0) {
if (!visibilityRef.current) return;
const scrollEl = scrollRef.current;
if (scrollEl) {
autoScrollToBottom(
@@ -1062,7 +1127,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
);
}
}
}, [scrollToBottomCount]);
}, [scrollToBottomCount, visibilityRef]);
// Remove unreadInfo on mark as read
useEffect(() => {
@@ -2291,7 +2356,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Chip>
</TimelineFloat>
)}
<Scroll ref={scrollRef} visibility="Hover">
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
<Box
ref={timelineContentRef}
direction="Column"
@@ -2420,4 +2485,4 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)}
</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 { EventType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai';
import { useAtomValue, useSetAtom, getDefaultStore } from 'jotai';
import { Editor } from 'slate';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
@@ -24,6 +25,8 @@ import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
import { pendingRoomNavigationAtom } from '../../state/pendingRoomNavigation';
import { activeRoomIdAtom } from '../../state/activeRoom';
import { ThreadView } from './ThreadView';
const FN_KEYS_REGEX = /^F\d+$/;
@@ -33,10 +36,8 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
return false;
}
// do not focus on F keys
if (FN_KEYS_REGEX.test(code)) return false;
// do not focus on numlock/scroll lock
if (
code.startsWith('OS') ||
code.startsWith('Meta') ||
@@ -59,7 +60,99 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
return true;
};
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
/**
* Slate must not stay mounted in hidden keep-alive rooms — multiple Editable
* trees crash with findPath. Timeline stays warm; only the composer remounts.
*/
function RoomComposerSlot({
room,
roomId,
editor,
canMessage,
active,
roomViewRef,
roomInputRef,
}: {
room: Room;
roomId: string;
editor: Editor;
canMessage: boolean;
active: boolean;
roomViewRef: React.RefObject<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 roomViewRef = useRef<HTMLDivElement>(null);
@@ -69,6 +162,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const editor = useEditor();
const mx = useMatrixClient();
const setPendingRoomId = useSetAtom(pendingRoomNavigationAtom);
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
const powerLevels = usePowerLevelsContext();
@@ -78,11 +172,20 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
const showThread = !pending && !!activeThreadId;
const composerActive = active && !pending;
useLayoutEffect(() => {
if (!composerActive) return;
setPendingRoomId((id) => (id === roomId ? null : id));
}, [composerActive, roomId, setPendingRoomId]);
useKeyDown(
window,
useCallback(
(evt) => {
if (!composerActive) return;
if (getDefaultStore().get(activeRoomIdAtom) !== roomId) return;
if (editableActiveElement()) return;
const portalContainer = document.getElementById('portalContainer');
if (portalContainer && portalContainer.children.length > 0) {
@@ -90,33 +193,45 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
}
if (shouldFocusMessageField(evt)) {
evt.preventDefault();
ReactEditor.focus(editor);
try {
ReactEditor.focus(editor);
} catch {
return;
}
if (evt.key.length === 1) {
editor.insertText(evt.key);
}
} else if (isKeyHotkey('mod+v', evt)) {
ReactEditor.focus(editor);
try {
ReactEditor.focus(editor);
} catch {
// Composer may not be mounted yet.
}
}
},
[editor]
[composerActive, editor, roomId]
)
);
return (
<Page ref={roomViewRef}>
<RoomViewHeader />
{activeThreadId ? (
<ThreadView room={room} threadRootId={activeThreadId} />
{showThread ? (
<ThreadView room={room} threadRootId={activeThreadId!} />
) : (
<>
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
<Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
{!pending && (
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
</div>
)}
<RoomViewTyping room={room} />
</Box>
<Box shrink="No" direction="Column">
@@ -128,26 +243,15 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={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>
)}
</>
<RoomComposerSlot
room={room}
roomId={roomId}
editor={editor}
canMessage={canMessage}
active={composerActive}
roomViewRef={roomViewRef}
roomInputRef={roomInputRef}
/>
)}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
@@ -156,4 +260,4 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
)}
</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 { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
import { useRoom, useIsDirectRoom } from '../../hooks/useRoom';
import { useRoomViewActive } from './RoomViewActiveContext';
import { useSetting } from '../../state/hooks/settings';
import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer';
import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
@@ -358,12 +360,6 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
callSupportLoading,
} = useRoomCall(roomId);
// Debug logging
React.useEffect(() => {
// eslint-disable-next-line no-console
console.log('RoomCallButtons render:', { callSupportLoading, callSupported, roomId });
}, [callSupportLoading, callSupported, roomId]);
if (callSupportLoading || !callSupported) {
return null;
}
@@ -465,6 +461,8 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
const screenSize = useScreenSizeContext();
const { showInPageHeader, goBack } = useBackRoute();
const room = useRoom();
const isDirect = useIsDirectRoom();
const roomActive = useRoomViewActive();
const space = useSpaceOptionally();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
@@ -480,7 +478,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [peopleDrawer, setPeopleDrawer] = usePeopleDrawerSetting(isDirect);
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
const handleSearchClick = () => {
@@ -579,8 +577,8 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
</Box>
</Box>
<Box shrink="No" alignItems="Center" gap="100">
<CallIndicator roomId={room.roomId} />
<RoomCallButtons roomId={room.roomId} />
{roomActive && <CallIndicator roomId={room.roomId} />}
{roomActive && <RoomCallButtons roomId={room.roomId} />}
{!ecryptedRoom && (
<TooltipProvider
position="Bottom"

View File

@@ -1 +1,2 @@
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.
*/
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
useEffect(() => {
const loadPlugins = async () => {
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';
import { updateJoiningProgressAtom } from '../state/joiningProgress';
import { useMatrixClient } from './useMatrixClient';
import { fetchAllHierarchyRooms } from './useSpaceHierarchy';
const PER_PAGE_COUNT = 100;
const MAX_PAGES = 50;
const JOIN_DELAY_MS = 500;
/** Join rules that allow automatic joining */
const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted'];
/** Spaces we've already kicked off auto-join for this session (avoid repeat on revisit). */
const autoJoinStartedForSpace = new Set<string>();
/** Spaces currently running auto-join (avoid concurrent runs). */
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 {
const parts = roomId.split(':');
return parts.length > 1 ? parts[1] : '';
}
/**
* Checks if a room/space can be automatically joined based on its join rule
* @param hierarchyRoom - The room info from the space hierarchy
* @returns Whether the room/space should be auto-joined
*/
function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean {
const { join_rule: joinRule } = hierarchyRoom;
// Only join public or restricted rooms/spaces
// Restricted ones are joinable if you're a member of the parent space
if (!joinRule) {
return false;
}
return AUTO_JOINABLE_RULES.includes(joinRule);
}
/**
* Checks if a hierarchy item is a space (including m.forum category spaces)
* @param hierarchyRoom - The room info from the space hierarchy
* @returns Whether the item is a space
*/
function isHierarchySpace(hierarchyRoom: IHierarchyRoom): boolean {
return hierarchyRoom.room_type === 'm.space' || hierarchyRoom.room_type === 'm.forum';
}
/**
* Fetches all rooms from a space hierarchy
* @param mx - Matrix client
* @param spaceId - The space room ID
* @returns Array of hierarchy rooms
*/
async function fetchSpaceHierarchy(
mx: MatrixClient,
spaceId: string
): Promise<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> {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
/** Callback for reporting join progress */
type ProgressCallback = (current: number, total: number) => void;
/**
* Attempts to join rooms/spaces with rate limiting and sets notification to Mentions & Keywords for rooms
* @param mx - Matrix client
* @param rooms - Array of hierarchy rooms to join
* @param spaceId - The parent space ID (for via servers)
* @param onProgress - Callback to report progress
*/
async function joinRoomsSequentially(
mx: MatrixClient,
rooms: IHierarchyRoom[],
@@ -119,29 +58,23 @@ async function joinRoomsSequentially(
const spaceServer = getServerFromRoomId(spaceId);
const itemsToJoin = rooms.filter((room) => {
// Check if already joined
const existingRoom = mx.getRoom(room.room_id);
if (existingRoom?.getMyMembership() === Membership.Join) {
return false;
}
// Check if can auto-join
return canAutoJoin(room);
});
// Separate spaces and rooms - join spaces first (rooms may depend on space membership)
const spacesToJoin = itemsToJoin.filter(isHierarchySpace);
const roomsToJoin = itemsToJoin.filter((r) => !isHierarchySpace(r));
const total = itemsToJoin.length;
let current = 0;
// Report initial progress
if (total > 0) {
onProgress?.(current, total);
}
// Process item (space or room)
const joinItem = async (item: IHierarchyRoom, isItemSpace: boolean): Promise<void> => {
try {
const itemServer = getServerFromRoomId(item.room_id);
@@ -149,7 +82,6 @@ async function joinRoomsSequentially(
await mx.joinRoom(item.room_id, { viaServers });
// Only set notification preferences for rooms, not spaces
if (!isItemSpace) {
await setRoomNotificationPreference(
mx,
@@ -168,21 +100,18 @@ async function joinRoomsSequentially(
}
};
// Join spaces first, then rooms
await spacesToJoin.reduce(
(promise, space) => promise.then(() => joinItem(space, true)),
(promise, item) => promise.then(() => joinItem(item, true)),
Promise.resolve()
);
await roomsToJoin.reduce(
(promise, room) => promise.then(() => joinItem(room, false)),
(promise, item) => promise.then(() => joinItem(item, false)),
Promise.resolve()
);
}
/**
* Joins all auto-joinable children of a space (and nested spaces/rooms).
*/
/** Joins all auto-joinable children of a space (and nested spaces/rooms). */
export async function autoJoinSpaceChildren(
mx: MatrixClient,
spaceId: string,
@@ -195,7 +124,7 @@ export async function autoJoinSpaceChildren(
processingSpaces.add(spaceId);
try {
const hierarchyRooms = await fetchSpaceHierarchy(mx, spaceId);
const hierarchyRooms = await fetchAllHierarchyRooms(mx, spaceId, undefined);
const childRooms = hierarchyRooms.filter((r) => r.room_id !== spaceId);
await joinRoomsSequentially(mx, childRooms, spaceId, onProgress);
} finally {
@@ -203,10 +132,7 @@ export async function autoJoinSpaceChildren(
}
}
/**
* Hook that automatically joins all accessible rooms when joining a space
* @param mx - Matrix client instance
*/
/** Hook that automatically joins all accessible rooms when joining a space. */
export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
const settings = useAtomValue(settingsAtom);
const setJoiningProgress = useSetAtom(updateJoiningProgressAtom);
@@ -216,13 +142,9 @@ export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
if (!settings.autoJoinSpaceRooms) {
return;
}
// Only process spaces (includes m.forum containers)
if (!isSpace(room)) {
return;
}
// Only process when joining (not invites or leaves)
if (room.getMyMembership() !== Membership.Join) {
return;
}
@@ -256,7 +178,8 @@ export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
}
/**
* Auto-joins space children when visiting an already-joined space (e.g. forum spaces).
* Auto-joins space children when visiting an already-joined space.
* Deferred so opening a space stays responsive.
*/
export function useAutoJoinOnSpaceVisit(space: Room | undefined): void {
const mx = useMatrixClient();
@@ -265,34 +188,57 @@ export function useAutoJoinOnSpaceVisit(space: Room | undefined): void {
useEffect(() => {
if (!space || !settings.autoJoinSpaceRooms) {
return;
return undefined;
}
if (!isSpace(space)) {
return;
return undefined;
}
if (space.getMyMembership() !== Membership.Join) {
return;
return undefined;
}
const spaceId = space.roomId;
let cancelled = false;
if (autoJoinStartedForSpace.has(spaceId)) {
return undefined;
}
void (async () => {
try {
await autoJoinSpaceChildren(mx, spaceId, (current, total) => {
let cancelled = false;
let idleId: number | undefined;
let timeoutId: number | undefined;
const run = () => {
if (cancelled || autoJoinStartedForSpace.has(spaceId)) return;
autoJoinStartedForSpace.add(spaceId);
void (async () => {
try {
await autoJoinSpaceChildren(mx, spaceId, (current, total) => {
if (!cancelled) {
setJoiningProgress({ spaceId, progress: { current, total } });
}
});
} finally {
if (!cancelled) {
setJoiningProgress({ spaceId, progress: { current, total } });
setJoiningProgress({ spaceId, progress: null });
}
});
} finally {
if (!cancelled) {
setJoiningProgress({ spaceId, progress: null });
}
}
})();
})();
};
if (typeof window.requestIdleCallback === 'function') {
idleId = window.requestIdleCallback(run, { timeout: 4000 });
} else {
timeoutId = window.setTimeout(run, 2000);
}
return () => {
cancelled = true;
if (idleId !== undefined && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(idleId);
}
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}, [space, settings.autoJoinSpaceRooms, mx, setJoiningProgress]);
}
}

View File

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

View File

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

View File

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

View File

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

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 { useMatrixClient } from './useMatrixClient';
@@ -22,36 +22,125 @@ const getUserPresence = (user: User): UserPresence => ({
lastActiveTs: user.getLastActiveTs(),
});
const presenceEqual = (a: UserPresence | undefined, b: UserPresence | undefined): boolean => {
if (a === b) return true;
if (!a || !b) return false;
return (
a.presence === b.presence &&
a.status === b.status &&
a.active === b.active &&
a.lastActiveTs === b.lastActiveTs
);
};
type PresenceEntry = {
/** Stable reference — only replaced when presence data changes. */
presence: UserPresence;
listeners: Set<() => void>;
user: User;
onChange: UserEventHandlerMap[UserEvent.Presence];
};
/** One EventEmitter subscription per userId, shared across all hook consumers. */
const presenceEntries = new Map<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 => {
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(() => {
if (!user) {
setPresence(undefined);
const user = mx.getUser(userId);
if (!user) return subscribeNoop();
const entry = bindUser(userId, user);
snapshotRef.current = entry.presence;
entry.listeners.add(onStoreChange);
return () => {
entry.listeners.delete(onStoreChange);
releaseIfUnused(userId);
};
},
[mx, userId]
);
const getSnapshot = useCallback(() => {
if (!userId) {
snapshotRef.current = undefined;
return undefined;
}
const cached = presenceEntries.get(userId);
if (cached) {
snapshotRef.current = cached.presence;
return cached.presence;
}
const user = mx.getUser(userId);
if (!user) {
snapshotRef.current = undefined;
return undefined;
}
// Not subscribed yet — return a stable fallback without allocating each read
// once we have a previous snapshot that still matches.
const next = getUserPresence(user);
if (presenceEqual(snapshotRef.current, next)) {
return snapshotRef.current;
}
snapshotRef.current = next;
return next;
}, [mx, userId]);
setPresence(getUserPresence(user));
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => {
if (u.userId === user.userId) {
setPresence(getUserPresence(user));
}
};
user.on(UserEvent.Presence, updatePresence);
user.on(UserEvent.CurrentlyActive, updatePresence);
user.on(UserEvent.LastPresenceTs, updatePresence);
return () => {
user.removeListener(UserEvent.Presence, updatePresence);
user.removeListener(UserEvent.CurrentlyActive, updatePresence);
user.removeListener(UserEvent.LastPresenceTs, updatePresence);
};
}, [user]);
return presence;
return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
};
export const usePresenceLabel = (): Record<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 {
canFitInScrollView,
@@ -7,6 +7,10 @@ import {
isIntersectingScrollView,
scrollToPosition,
} from '../utils/dom';
import {
armRoomTimelineNetworkPaginate,
canRoomTimelineNetworkPaginate,
} from '../state/roomTimelineVisibilityGate';
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
@@ -55,6 +59,15 @@ type VirtualPaginatorOptions<TScrollElement extends HTMLElement> = {
getScrollElement: () => TScrollElement | null;
getItemElement: (index: number) => HTMLElement | undefined;
onEnd?: (back: boolean) => void;
/** When false, skip observe/paginate (keep-alive inactive rooms). */
enabled?: boolean;
/**
* Mutable gate for keep-alive visibility. Prefer this over flipping `enabled`
* so parent re-renders don't tear down the timeline tree.
*/
enabledRef?: MutableRefObject<boolean>;
/** Room id for keep-alive network-paginate suppress across CSS-only switches. */
roomId?: string;
};
type VirtualPaginator = {
@@ -163,9 +176,42 @@ const useObserveAnchorHandle = (
export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
options: VirtualPaginatorOptions<TScrollElement>
): VirtualPaginator => {
const { count, limit, range, onRangeChange, getScrollElement, getItemElement, onEnd } = options;
const {
count,
limit,
range,
onRangeChange,
getScrollElement,
getItemElement,
onEnd,
enabled = true,
enabledRef,
roomId,
} = options;
const initialRenderRef = useRef(true);
/** After keep-alive re-show, don't network-paginate until the user scrolls. */
const allowNetworkPaginateRef = useRef(true);
const enabledRefStable = enabledRef;
const isPaginatorEnabled = useCallback(() => {
if (!enabled) return false;
if (enabledRefStable && !enabledRefStable.current) return false;
return true;
}, [enabled, enabledRefStable]);
const canNetworkPaginate = useCallback(() => {
if (!allowNetworkPaginateRef.current) return false;
if (roomId && !canRoomTimelineNetworkPaginate(roomId)) return false;
return true;
}, [roomId]);
// Arm suppress while hidden so re-show can't race intersection → paginate/decrypt.
useLayoutEffect(() => {
if (!enabled) {
allowNetworkPaginateRef.current = false;
}
}, [enabled]);
const restoreScrollRef = useRef<{
scrollTop: number;
@@ -269,6 +315,10 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
const paginate = useCallback(
(direction: Direction) => {
if (!isPaginatorEnabled()) {
allowNetworkPaginateRef.current = false;
return;
}
const scrollEl = getScrollElement();
const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current;
let { start, end } = currentRange;
@@ -276,7 +326,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
if (direction === Direction.Backward) {
restoreScrollRef.current = undefined;
if (start === 0) {
onEnd?.(true);
if (canNetworkPaginate()) onEnd?.(true);
return;
}
if (scrollEl) {
@@ -294,7 +344,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
if (direction === Direction.Forward) {
restoreScrollRef.current = undefined;
if (end === currentCount) {
onEnd?.(false);
if (canNetworkPaginate()) onEnd?.(false);
return;
}
if (scrollEl) {
@@ -315,11 +365,15 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
end,
});
},
[getScrollElement, getItemElement, onEnd, onRangeChange]
[getScrollElement, getItemElement, onEnd, onRangeChange, isPaginatorEnabled, canNetworkPaginate]
);
const handlePaginatorElIntersection: OnIntersectionCallback = useCallback(
(entries) => {
if (!isPaginatorEnabled()) {
allowNetworkPaginateRef.current = false;
return;
}
const anchorB = entries.find(
(entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Backward
);
@@ -333,7 +387,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
paginate(Direction.Forward);
}
},
[paginate]
[paginate, isPaginatorEnabled]
);
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 observeFrontAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Forward);
@@ -390,9 +445,13 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
// check if pagination anchor are in visible view height
// and trigger pagination
useEffect(() => {
if (initialRenderRef.current) {
// Do not trigger pagination on initial render
// anchor intersection observable will trigger pagination on mount
if (!isPaginatorEnabled()) {
allowNetworkPaginateRef.current = false;
return;
}
// Skip the first cycle after mount OR while network paginate is suppressed
// (keep-alive re-show) — otherwise intersecting anchors re-trigger decrypt.
if (initialRenderRef.current || !canNetworkPaginate()) {
initialRenderRef.current = false;
return;
}
@@ -412,7 +471,20 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
if (frontAnchor && isIntersectingScrollView(scrollElement, frontAnchor)) {
paginate(Direction.Forward);
}
}, [range, getScrollElement, paginate]);
}, [range, getScrollElement, paginate, isPaginatorEnabled, canNetworkPaginate]);
// Re-arm network pagination after the user scrolls (keep-alive re-show safe).
useEffect(() => {
const el = getScrollElement();
if (!el) return undefined;
const onScroll = () => {
if (!isPaginatorEnabled()) return;
allowNetworkPaginateRef.current = true;
if (roomId) armRoomTimelineNetworkPaginate(roomId);
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [isPaginatorEnabled, getScrollElement, range, roomId]);
return {
getItems,

View File

@@ -40,15 +40,14 @@ import {
getOriginBaseUrl,
} from './pathUtils';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { Home, HomeLayout, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectLayout, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
import { Notifications, Inbox, Invites } from './client/inbox';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { Room } from '../features/room';
import { Lobby } from '../features/lobby';
import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav';
@@ -190,7 +189,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</MobileFriendlyPageNav>
}
>
<AnimatedOutlet />
<HomeLayout />
</PageRoot>
}
>
@@ -198,14 +197,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_CREATE_PATH} element={<HomeCreateRoom />} />
<Route path={_JOIN_PATH} element={<p>join</p>} />
<Route path={_SEARCH_PATH} element={<HomeSearch />} />
<Route
path={_ROOM_PATH}
element={
<HomeRouteRoomProvider>
<Room />
</HomeRouteRoomProvider>
}
/>
<Route path={_ROOM_PATH} element={<HomeRouteRoomProvider />} />
</Route>
<Route
path={DIRECT_PATH}
@@ -217,7 +209,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</MobileFriendlyPageNav>
}
>
<AnimatedOutlet />
<DirectLayout />
</PageRoot>
}
>
@@ -225,14 +217,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_CREATE_PATH} element={<DirectCreate />} />
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
<Route path={_INVITES_PATH} element={<Invites />} />
<Route
path={_ROOM_PATH}
element={
<DirectRouteRoomProvider>
<Room />
</DirectRouteRoomProvider>
}
/>
<Route path={_ROOM_PATH} element={<DirectRouteRoomProvider />} />
</Route>
<Route
path={SPACE_PATH}
@@ -246,14 +231,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_FEED_PATH} element={<ForumFeedPage />} />
<Route path={_LOBBY_PATH} element={<Lobby />} />
<Route path={_SEARCH_PATH} element={<SpaceSearch />} />
<Route
path={_ROOM_PATH}
element={
<SpaceRouteRoomProvider>
<Room />
</SpaceRouteRoomProvider>
}
/>
<Route path={_ROOM_PATH} element={<SpaceRouteRoomProvider />} />
</Route>
<Route
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 { decodeRouteParam } from '../../pathUtils';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useDirectRooms } from './useDirectRooms';
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
/**
* Room-route gate only. The actual room UI is rendered by DirectLayout's
* CachedRooms host so keep-alive survives room id changes.
*/
export function DirectRouteRoomProvider() {
const mx = useMatrixClient();
const rooms = useDirectRooms();
@@ -26,9 +29,6 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
);
}
return (
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value>{children}</IsDirectRoomProvider>
</RoomProvider>
);
// Valid room — DirectLayout / CachedRooms owns the UI.
return null;
}

View File

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

View File

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

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 { decodeRouteParam } from '../../pathUtils';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useHomeRooms } from './useHomeRooms';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
/**
* Room-route gate only. HomeLayout's CachedRooms owns the UI.
*/
export function HomeRouteRoomProvider() {
const mx = useMatrixClient();
const rooms = useHomeRooms();
@@ -29,9 +31,5 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
);
}
return (
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value={false}>{children}</IsDirectRoomProvider>
</RoomProvider>
);
return null;
}

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,7 @@ import { Membership, StateEvent } from '../../../../types/matrix/room';
import { stopPropagation } from '../../../utils/keyboard';
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { useGlobalSubRoomIds } from '../../../state/globalSubRoomIds';
import { getViaServers } from '../../../plugins/via-servers';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
@@ -255,17 +256,7 @@ export function Space() {
)
);
// Build a global set of all sub-room IDs to filter from hierarchy
// This prevents sub-rooms from appearing both as top-level entries AND nested under parents
const globalSubRoomIds = useMemo(() => {
const subRoomIds = new Set<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]);
const globalSubRoomIds = useGlobalSubRoomIds();
// Filter hierarchy to exclude sub-rooms (they'll be shown nested under their parent)
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 './SpaceProvider';
export * from './Space';
export * from './SpaceRoomLayout';
export * from './Search';
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 { fitMediaSize } from '../utils/common';
const STORAGE_KEY = 'paarrot.media.meta';
const LEGACY_DIMENSIONS_KEY = 'paarrot.media.dimensions';
const MAX_ENTRIES = 500;
/** Same max box as MImage / MVideo attachment rendering. */
export const ATTACHMENT_MAX_SIZE = 400;
export type MediaMeta = {
w: number;
h: number;
@@ -88,6 +92,19 @@ export const getMediaDimensions = (mxcUrl: string): MediaDimensions | undefined
return { w: entry.w, h: entry.h };
};
/** Fit attachment box size using event info, then local dimension cache. */
export const resolveAttachmentBoxSize = (
mxcUrl: string | undefined,
w?: number,
h?: number,
maxSize: number = ATTACHMENT_MAX_SIZE
): { width: number; height: number } => {
const cached = mxcUrl && (!w || !h) ? getMediaDimensions(mxcUrl) : undefined;
const naturalW = w && w > 0 ? w : cached?.w;
const naturalH = h && h > 0 ? h : cached?.h;
return fitMediaSize(naturalW ?? 0, naturalH ?? 0, maxSize, maxSize);
};
export const setMediaDimensions = (mxcUrl: string, w: number, h: number): void => {
if (!mxcUrl || w <= 0 || h <= 0) return;
touchEntry(mxcUrl, { w, h });

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;
hideActivity: boolean;
isPeopleDrawer: boolean;
/** Last open/closed state of the members panel in non-DM rooms. */
peopleDrawerInRooms: boolean;
/** Last open/closed state of the members panel in DMs. */
peopleDrawerInDirects: boolean;
isCallPanelDocked: boolean;
callPanelDockPosition: CallPanelDockPosition;
memberSortFilterIndex: number;
@@ -79,7 +82,8 @@ const defaultSettings: Settings = {
pageZoom: 100,
hideActivity: false,
isPeopleDrawer: true,
peopleDrawerInRooms: true,
peopleDrawerInDirects: false,
isCallPanelDocked: false,
callPanelDockPosition: 'right',
memberSortFilterIndex: 0,
@@ -112,15 +116,29 @@ const defaultSettings: Settings = {
export const getSettings = () => {
const settings = localStorage.getItem(STORAGE_KEY);
if (settings === null) return defaultSettings;
const parsed = JSON.parse(settings) as Settings & { twitterEmoji?: boolean };
const parsed = JSON.parse(settings) as Settings & {
twitterEmoji?: boolean;
isPeopleDrawer?: boolean;
};
// Migrate old twitterEmoji boolean to new emojiStyle enum
if ('twitterEmoji' in parsed && parsed.emojiStyle === undefined) {
parsed.emojiStyle = parsed.twitterEmoji ? EmojiStyle.Twemoji : EmojiStyle.Apple;
delete parsed.twitterEmoji;
}
// Migrate single people-drawer flag into room/DM prefs.
if (
typeof parsed.isPeopleDrawer === 'boolean' &&
parsed.peopleDrawerInRooms === undefined &&
parsed.peopleDrawerInDirects === undefined
) {
parsed.peopleDrawerInRooms = parsed.isPeopleDrawer;
parsed.peopleDrawerInDirects = parsed.isPeopleDrawer;
}
delete parsed.isPeopleDrawer;
return {
...defaultSettings,
...parsed,

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

@@ -169,9 +169,7 @@ export function detectImageFormat(data: ArrayBuffer | Uint8Array): ImageFormat {
*/
export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined {
const format = detectImageFormat(imageData);
console.log('[extractColorFromImage] Detected format:', format);
switch (format) {
case 'png':
return extractColorFromPNG(imageData);
@@ -182,7 +180,6 @@ export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): stri
case 'gif':
return extractColorFromGIF(imageData);
default:
console.warn('[extractColorFromImage] Unknown image format');
return undefined;
}
}
@@ -212,24 +209,18 @@ export function extractBannerFromImage(imageData: ArrayBuffer | Uint8Array): str
*/
export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
const format = detectImageFormat(imageData);
console.log('[extractMetadataFromImage] Detected format:', format);
switch (format) {
case 'png':
const pngMetadata = extractMetadataFromPNG(imageData);
console.log('[extractMetadataFromImage] PNG metadata:', pngMetadata);
return pngMetadata;
return extractMetadataFromPNG(imageData);
// For other formats, extract color only for now
case 'webp':
case 'jpeg':
case 'gif': {
const color = extractColorFromImage(imageData);
console.log('[extractMetadataFromImage] Non-PNG color:', color);
return color ? { color } : {};
}
default:
console.warn('[extractMetadataFromImage] Unknown image format');
return {};
}
}
@@ -365,7 +356,6 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
// Check if full metadata is cached
const cached = avatarMetadataCache.get(url);
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
console.log('[fetchAndExtractColor] Using cached color for', url, ':', cached.metadata.color);
return cached.metadata.color;
}
@@ -373,33 +363,20 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
const inflightKey = `${url}:${accessToken || ''}`;
const existingFetch = inflightColorFetches.get(inflightKey);
if (existingFetch) {
console.log('[fetchAndExtractColor] Reusing in-flight fetch for', url);
return existingFetch;
}
console.log('[fetchAndExtractColor] Fetching and extracting color from', url);
const fetchPromise = (async () => {
try {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
console.log('[fetchAndExtractColor] Fetch response status:', response.ok, response.status);
if (!response.ok) return undefined;
const data = await response.arrayBuffer();
console.log('[fetchAndExtractColor] Downloaded', data.byteLength, 'bytes');
const color = extractColorFromImage(data);
console.log('[fetchAndExtractColor] Extracted color:', color);
// Don't cache here - fetchAndExtractMetadata will cache full metadata
// This avoids cache collision where color-only fetch overwrites full metadata
return color;
return extractColorFromImage(data);
} catch (error) {
console.error('[fetchAndExtractColor] Error fetching/extracting color:', error);
return undefined;
@@ -422,7 +399,6 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string
// Check cache first
const cached = avatarMetadataCache.get(url);
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
console.log('[fetchAndExtractMetadata] Using cached metadata for', url, ':', cached.metadata);
return cached.metadata;
}
@@ -430,35 +406,26 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string
const inflightKey = `${url}:${accessToken || ''}`;
const existingFetch = inflightMetadataFetches.get(inflightKey);
if (existingFetch) {
console.log('[fetchAndExtractMetadata] Reusing in-flight fetch for', url);
return existingFetch;
}
console.log('[fetchAndExtractMetadata] Fetching metadata from', url);
const fetchPromise = (async () => {
try {
const response = await fetch(url, {
method: 'GET',
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
console.log('[fetchAndExtractMetadata] Fetch response status:', response.ok, response.status);
if (!response.ok) return {};
const data = await response.arrayBuffer();
console.log('[fetchAndExtractMetadata] Downloaded', data.byteLength, 'bytes');
const metadata = extractMetadataFromImage(data);
console.log('[fetchAndExtractMetadata] Extracted metadata:', metadata);
// Cache the result
avatarMetadataCache.set(url, {
metadata,
timestamp: Date.now()
avatarMetadataCache.set(url, {
metadata,
timestamp: Date.now(),
});
return metadata;
} catch {
return {};

View File

@@ -38,43 +38,20 @@ export const withViewTransition = async (callback: () => void | Promise<void>):
};
/**
* Enable view transitions for router navigation
* Call this in your app initialization
* Enable light view transitions for browser back/forward only.
*
* Do NOT intercept in-app <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 => {
if (!supportsViewTransitions()) {
return;
}
// Intercept link clicks and navigation to add view transitions
const handleLinkClick = (event: MouseEvent) => {
const target = (event.target as HTMLElement).closest('a');
if (!target) return;
if (target.target === '_blank') return;
if (target.origin !== window.location.origin) return;
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
const url = new URL(target.href);
// Only handle same-origin navigation
if (url.origin === window.location.origin) {
event.preventDefault();
withViewTransition(() => {
window.history.pushState({}, '', target.href);
window.dispatchEvent(new PopStateEvent('popstate'));
});
}
};
// Handle back/forward navigation
const handlePopState = () => {
window.addEventListener('popstate', () => {
withViewTransition(() => {
// The URL has already changed, just trigger re-render
// URL already changed; transition just softens the re-render.
});
};
window.addEventListener('click', handleLinkClick);
window.addEventListener('popstate', handlePopState);
});
};