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:
21
src/app/components/AnimatedOutlet.css.ts
Normal file
21
src/app/components/AnimatedOutlet.css.ts
Normal 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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
60
src/app/features/call/callMemberUtils.ts
Normal file
60
src/app/features/call/callMemberUtils.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './Lobby';
|
||||
export { Lobby } from './Lobby';
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
199
src/app/features/room/CachedRooms.tsx
Normal file
199
src/app/features/room/CachedRooms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
29
src/app/features/room/RoomViewActiveContext.ts
Normal file
29
src/app/features/room/RoomViewActiveContext.ts
Normal 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);
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './Room';
|
||||
export * from './CachedRooms';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
7
src/app/hooks/stateEventCallback.types.ts
Normal file
7
src/app/hooks/stateEventCallback.types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { MatrixEvent, RoomState } from 'matrix-js-sdk';
|
||||
|
||||
export type StateEventCallback = (
|
||||
event: MatrixEvent,
|
||||
state: RoomState,
|
||||
lastStateEvent: MatrixEvent | null
|
||||
) => void;
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
69
src/app/pages/client/direct/DirectLayout.tsx
Normal file
69
src/app/pages/client/direct/DirectLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Direct';
|
||||
export * from './DirectLayout';
|
||||
export * from './RoomProvider';
|
||||
export * from './DirectCreate';
|
||||
|
||||
@@ -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,
|
||||
|
||||
66
src/app/pages/client/home/HomeLayout.tsx
Normal file
66
src/app/pages/client/home/HomeLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Home';
|
||||
export * from './HomeLayout';
|
||||
export * from './Search';
|
||||
export * from './RoomProvider';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
78
src/app/pages/client/space/SpaceRoomLayout.tsx
Normal file
78
src/app/pages/client/space/SpaceRoomLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './SpaceIndexRedirect';
|
||||
export * from './SpaceProvider';
|
||||
export * from './Space';
|
||||
export * from './SpaceRoomLayout';
|
||||
export * from './Search';
|
||||
export * from './RoomProvider';
|
||||
|
||||
146
src/app/state/activeCallRoomsRegistry.ts
Normal file
146
src/app/state/activeCallRoomsRegistry.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
77
src/app/state/globalSubRoomIds.ts
Normal file
77
src/app/state/globalSubRoomIds.ts
Normal 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
|
||||
);
|
||||
};
|
||||
11
src/app/state/hooks/peopleDrawer.ts
Normal file
11
src/app/state/hooks/peopleDrawer.ts
Normal 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;
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
7
src/app/state/pendingRoomNavigation.ts
Normal file
7
src/app/state/pendingRoomNavigation.ts
Normal 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);
|
||||
22
src/app/state/roomTimelineVisibilityGate.ts
Normal file
22
src/app/state/roomTimelineVisibilityGate.ts
Normal 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);
|
||||
};
|
||||
20
src/app/state/roomViewCache.ts
Normal file
20
src/app/state/roomViewCache.ts
Normal 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 };
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
40
src/app/state/spaceHierarchyCache.ts
Normal file
40
src/app/state/spaceHierarchyCache.ts
Normal 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;
|
||||
};
|
||||
38
src/app/state/stateEventDemux.ts
Normal file
38
src/app/state/stateEventDemux.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user