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 React, { useEffect } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
|
import { pendingRoomNavigationAtom } from '../state/pendingRoomNavigation';
|
||||||
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
|
import { IsDirectRoomProvider, RoomProvider } from '../hooks/useRoom';
|
||||||
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
|
import { RoomFrame } from '../features/room/Room';
|
||||||
|
import * as css from './AnimatedOutlet.css';
|
||||||
|
|
||||||
|
/** Chrome-only shell for the press→navigate gap (no timeline — avoids scroll fights). */
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div className={css.PendingOverlay} aria-busy aria-label={`Opening ${room.name}`}>
|
||||||
key={location.pathname}
|
<RoomProvider key={room.roomId} value={room}>
|
||||||
data-route-transition="true"
|
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>
|
||||||
style={{
|
<RoomFrame room={room} pending />
|
||||||
flex: 1,
|
</IsDirectRoomProvider>
|
||||||
minWidth: 0,
|
</RoomProvider>
|
||||||
minHeight: 0,
|
</div>
|
||||||
overflow: 'hidden',
|
);
|
||||||
display: 'flex',
|
}
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
/** Optimistic room navigation: brief pending chrome until the route settles. */
|
||||||
>
|
export function AnimatedOutlet() {
|
||||||
<Outlet />
|
const selectedRoomId = useSelectedRoom();
|
||||||
|
const [pendingRoomId, setPendingRoomId] = useAtom(pendingRoomNavigationAtom);
|
||||||
|
|
||||||
|
// Safety: if navigation never lands, don't leave pending forever.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingRoomId) return undefined;
|
||||||
|
const t = window.setTimeout(() => setPendingRoomId(null), 4000);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [pendingRoomId, setPendingRoomId]);
|
||||||
|
|
||||||
|
// Only cover the gap before the target room route is active.
|
||||||
|
const showOverlay =
|
||||||
|
typeof pendingRoomId === 'string' &&
|
||||||
|
pendingRoomId.length > 0 &&
|
||||||
|
pendingRoomId !== selectedRoomId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minWidth: 0, minHeight: 0, position: 'relative', display: 'flex' }}>
|
||||||
|
{showOverlay && <PendingRoomShell roomId={pendingRoomId} />}
|
||||||
|
{/*
|
||||||
|
Do NOT key this by pathname — room switches share CachedRooms keep-alive.
|
||||||
|
Remounting here wiped the cache and made warm rooms feel as slow as cold ones.
|
||||||
|
*/}
|
||||||
|
<div className={css.RoutePane}>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import {
|
|||||||
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
MATRIX_SPOILER_REASON_PROPERTY_NAME,
|
||||||
} from '../../../types/matrix/common';
|
} from '../../../types/matrix/common';
|
||||||
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||||
import { parseGeoUri, scaleYDimension, fitMediaSize } from '../../utils/common';
|
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||||
import { getMediaDimensions } from '../../state/mediaDimensionCache';
|
import { resolveAttachmentBoxSize } from '../../state/mediaDimensionCache';
|
||||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||||
|
|
||||||
@@ -206,18 +206,11 @@ type MImageProps = {
|
|||||||
outlined?: boolean;
|
outlined?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ATTACHMENT_MAX = 400;
|
|
||||||
|
|
||||||
const resolveMediaBoxSize = (
|
const resolveMediaBoxSize = (
|
||||||
mxcUrl: string,
|
mxcUrl: string,
|
||||||
w?: number,
|
w?: number,
|
||||||
h?: number
|
h?: number
|
||||||
): { width: number; height: number } => {
|
): { width: number; height: number } => resolveAttachmentBoxSize(mxcUrl, w, h);
|
||||||
const cached = (!w || !h) ? getMediaDimensions(mxcUrl) : undefined;
|
|
||||||
const naturalW = w && w > 0 ? w : cached?.w;
|
|
||||||
const naturalH = h && h > 0 ? h : cached?.h;
|
|
||||||
return fitMediaSize(naturalW ?? 0, naturalH ?? 0, ATTACHMENT_MAX, ATTACHMENT_MAX);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||||
const imgInfo = content?.info;
|
const imgInfo = content?.info;
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
setShowPlaceholder(true);
|
setShowPlaceholder(true);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const timer = window.setTimeout(() => setShowPlaceholder(false), 300);
|
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
setShowPlaceholder(true);
|
setShowPlaceholder(true);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const timer = window.setTimeout(() => setShowPlaceholder(false), 300);
|
const timer = window.setTimeout(() => setShowPlaceholder(false), 540);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ export const MediaSkeleton = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const blurhashFadeIn = keyframes({
|
||||||
|
from: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/** Soft blurhash layer that fills the reserved media box. */
|
/** Soft blurhash layer that fills the reserved media box. */
|
||||||
export const BlurhashPlaceholder = style([
|
export const BlurhashPlaceholder = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
@@ -90,6 +99,8 @@ export const BlurhashPlaceholder = style([
|
|||||||
// Soften the decode so it reads as color blobs; slight scale hides blur edges.
|
// Soften the decode so it reads as color blobs; slight scale hides blur edges.
|
||||||
filter: 'blur(14px)',
|
filter: 'blur(14px)',
|
||||||
transform: 'scale(1.06)',
|
transform: 'scale(1.06)',
|
||||||
|
opacity: 0,
|
||||||
|
animation: `${blurhashFadeIn} 560ms cubic-bezier(0.22, 1, 0.36, 1) forwards`,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -97,7 +108,7 @@ export const BlurhashPlaceholder = style([
|
|||||||
export const MediaFadeIn = style({
|
export const MediaFadeIn = style({
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
transition: 'opacity 280ms ease-out',
|
transition: 'opacity 520ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MediaFadeInLoaded = style({
|
export const MediaFadeInLoaded = style({
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
|
import React, { ComponentProps, MutableRefObject, ReactNode, Suspense, lazy } from 'react';
|
||||||
import { Box, Header, Line, Scroll, Text, as } from 'folds';
|
import { Box, Header, Line, Scroll, Text, as } from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import * as css from './style.css';
|
import * as css from './style.css';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { SidebarDockedCallPanel } from '../../features/call/SidebarDockedCallPanel';
|
|
||||||
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
import { useShowCompactMasterView } from '../../hooks/useCompactNav';
|
||||||
|
|
||||||
|
const SidebarDockedCallPanel = lazy(() =>
|
||||||
|
import('../../features/call/SidebarDockedCallPanel').then((module) => ({
|
||||||
|
default: module.SidebarDockedCallPanel,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
type PageRootProps = {
|
type PageRootProps = {
|
||||||
nav: ReactNode;
|
nav: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -46,7 +51,9 @@ export function PageNav({
|
|||||||
>
|
>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
{children}
|
{children}
|
||||||
<SidebarDockedCallPanel />
|
<Suspense fallback={null}>
|
||||||
|
<SidebarDockedCallPanel />
|
||||||
|
</Suspense>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
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
|
* Hook to check if a call is active in a specific room
|
||||||
*/
|
*/
|
||||||
|
/** Persists across keep-alive remounts so room switches don't re-hit well-known. */
|
||||||
|
const roomCallSupportCache = new Map<string, boolean>();
|
||||||
|
|
||||||
export function useRoomCall(roomId: string) {
|
export function useRoomCall(roomId: string) {
|
||||||
const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall();
|
const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [roomCallSupported, setRoomCallSupported] = useState(false);
|
const [roomCallSupported, setRoomCallSupported] = useState(
|
||||||
const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(true);
|
() => roomCallSupportCache.get(roomId) ?? false
|
||||||
|
);
|
||||||
|
const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(
|
||||||
|
() => !roomCallSupportCache.has(roomId)
|
||||||
|
);
|
||||||
|
|
||||||
// Check if calls are supported for this specific room by checking all priority homeservers
|
// Check if calls are supported for this specific room by checking all priority homeservers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const cached = roomCallSupportCache.get(roomId);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
setRoomCallSupported(cached);
|
||||||
|
setRoomCallSupportLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const checkRoomCallSupport = async () => {
|
const checkRoomCallSupport = async () => {
|
||||||
setRoomCallSupportLoading(true);
|
setRoomCallSupportLoading(true);
|
||||||
|
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) {
|
if (!room) {
|
||||||
setRoomCallSupported(false);
|
if (!cancelled) {
|
||||||
setRoomCallSupportLoading(false);
|
setRoomCallSupported(false);
|
||||||
|
setRoomCallSupportLoading(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get homeserver priority list for this room
|
|
||||||
const homeserverPriority = getLiveKitHomeserverPriority(
|
const homeserverPriority = getLiveKitHomeserverPriority(
|
||||||
room,
|
room,
|
||||||
mx.getHomeserverUrl(),
|
mx.getHomeserverUrl(),
|
||||||
mx.getSafeUserId()
|
mx.getSafeUserId()
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Checking call support for room ${roomId}, homeserver priority:`, homeserverPriority);
|
|
||||||
|
|
||||||
// Check each homeserver in priority order
|
|
||||||
for (const homeserver of homeserverPriority) {
|
for (const homeserver of homeserverPriority) {
|
||||||
try {
|
try {
|
||||||
console.log(`Fetching well-known from ${homeserver}...`);
|
|
||||||
const wellKnown = await fetchWellKnownWithRTC(homeserver);
|
const wellKnown = await fetchWellKnownWithRTC(homeserver);
|
||||||
console.log(`Well-known response from ${homeserver}:`, wellKnown);
|
|
||||||
|
|
||||||
const livekitFocus = getLiveKitFocus(wellKnown);
|
const livekitFocus = getLiveKitFocus(wellKnown);
|
||||||
console.log(`LiveKit focus from ${homeserver}:`, livekitFocus);
|
|
||||||
|
|
||||||
if (livekitFocus) {
|
if (livekitFocus) {
|
||||||
console.log(`Call support found for room ${roomId} on homeserver: ${homeserver}`);
|
roomCallSupportCache.set(roomId, true);
|
||||||
setRoomCallSupported(true);
|
if (!cancelled) {
|
||||||
setRoomCallSupportLoading(false);
|
setRoomCallSupported(true);
|
||||||
|
setRoomCallSupportLoading(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.warn(`Failed to check call support on ${homeserver}:`, error);
|
|
||||||
// Continue to next homeserver
|
// Continue to next homeserver
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No homeserver supports calls
|
roomCallSupportCache.set(roomId, false);
|
||||||
console.log(`No call support found for room ${roomId}`);
|
if (!cancelled) {
|
||||||
setRoomCallSupported(false);
|
setRoomCallSupported(false);
|
||||||
setRoomCallSupportLoading(false);
|
setRoomCallSupportLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
checkRoomCallSupport();
|
checkRoomCallSupport();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [roomId, mx]);
|
}, [roomId, mx]);
|
||||||
|
|
||||||
const isInCall = activeCall?.roomId === roomId;
|
const isInCall = activeCall?.roomId === roomId;
|
||||||
|
|||||||
@@ -1,115 +1,12 @@
|
|||||||
/* eslint-disable no-console */
|
import { useRoomCallMembersFromRegistry } from '../../state/activeCallRoomsRegistry';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
|
|
||||||
/** The Matrix state event type for call membership (MSC3401) */
|
export type { CallMember } from './callMemberUtils';
|
||||||
const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member';
|
export { getActiveCallMembers, CALL_MEMBER_EVENT_TYPE } from './callMemberUtils';
|
||||||
|
|
||||||
/** Represents an active call member in a room */
|
|
||||||
export interface CallMember {
|
|
||||||
userId: string;
|
|
||||||
deviceId: string;
|
|
||||||
sessionId: string;
|
|
||||||
expiresTs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts active call members from a room's state events
|
* Hook to get the list of users currently in a call in a room.
|
||||||
* @param room - The Matrix room to check
|
* Backed by a shared registry — one state listener for all rooms.
|
||||||
* @returns Array of active call members
|
|
||||||
*/
|
*/
|
||||||
export function getActiveCallMembers(room: Room): CallMember[] {
|
export function useRoomCallMembers(roomId: string, enabled = true) {
|
||||||
const members: CallMember[] = [];
|
return useRoomCallMembersFromRegistry(roomId, enabled);
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stateEvents = room.currentState.getStateEvents(CALL_MEMBER_EVENT_TYPE);
|
|
||||||
|
|
||||||
stateEvents.forEach((event) => {
|
|
||||||
const content = event.getContent();
|
|
||||||
const userId = event.getStateKey();
|
|
||||||
|
|
||||||
if (!userId || !content['m.calls']) return;
|
|
||||||
|
|
||||||
const calls = content['m.calls'];
|
|
||||||
if (!Array.isArray(calls)) return;
|
|
||||||
|
|
||||||
calls.forEach((call) => {
|
|
||||||
const devices = call['m.devices'];
|
|
||||||
if (!Array.isArray(devices)) return;
|
|
||||||
|
|
||||||
devices.forEach((device) => {
|
|
||||||
const expiresTs = device.expires_ts || 0;
|
|
||||||
|
|
||||||
// Check if the call membership is still valid (not expired)
|
|
||||||
if (expiresTs > now) {
|
|
||||||
members.push({
|
|
||||||
userId,
|
|
||||||
deviceId: device.device_id,
|
|
||||||
sessionId: device.session_id,
|
|
||||||
expiresTs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error reading call member events:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return members;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get the list of users currently in a call in a room
|
|
||||||
* @param roomId - The Matrix room ID to monitor
|
|
||||||
* @returns Object with call members and whether a call is active
|
|
||||||
*/
|
|
||||||
export function useRoomCallMembers(roomId: string) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const [callMembers, setCallMembers] = useState<CallMember[]>([]);
|
|
||||||
|
|
||||||
const updateCallMembers = useCallback(() => {
|
|
||||||
const room = mx.getRoom(roomId);
|
|
||||||
if (!room) {
|
|
||||||
setCallMembers([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const members = getActiveCallMembers(room);
|
|
||||||
setCallMembers(members);
|
|
||||||
}, [mx, roomId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateCallMembers();
|
|
||||||
|
|
||||||
// Listen for state event changes
|
|
||||||
const handleStateEvent = (event: MatrixEvent) => {
|
|
||||||
if (event.getRoomId() === roomId && event.getType() === CALL_MEMBER_EVENT_TYPE) {
|
|
||||||
updateCallMembers();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mx.on(RoomStateEvent.Events, handleStateEvent);
|
|
||||||
|
|
||||||
// Also refresh periodically to handle expired memberships
|
|
||||||
const interval = setInterval(updateCallMembers, 30000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mx.off(RoomStateEvent.Events, handleStateEvent);
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [mx, roomId, updateCallMembers]);
|
|
||||||
|
|
||||||
const isCallActive = callMembers.length > 0;
|
|
||||||
const myUserId = mx.getUserId();
|
|
||||||
const othersInCall = callMembers.filter((m) => m.userId !== myUserId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
callMembers,
|
|
||||||
isCallActive,
|
|
||||||
othersInCall,
|
|
||||||
callMemberCount: callMembers.length,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import { getActiveCallMembers } from './useRoomCallMembers';
|
|||||||
* Get room server from room ID (format: !roomid:server.com)
|
* Get room server from room ID (format: !roomid:server.com)
|
||||||
*/
|
*/
|
||||||
function getRoomServer(roomId: string): string | undefined {
|
function getRoomServer(roomId: string): string | undefined {
|
||||||
const server = getMxIdServer(roomId);
|
return getMxIdServer(roomId);
|
||||||
console.log(`[getRoomServer] Room ID: ${roomId} -> Server: ${server}`);
|
|
||||||
return server;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,31 +29,24 @@ export function getLiveKitHomeserverPriority(
|
|||||||
const servers: string[] = [];
|
const servers: string[] = [];
|
||||||
const memberCount = room.getMembers().length;
|
const memberCount = room.getMembers().length;
|
||||||
const isDM = memberCount <= 2;
|
const isDM = memberCount <= 2;
|
||||||
|
|
||||||
console.log(`[getLiveKitHomeserverPriority] Room: ${room.roomId}`);
|
|
||||||
console.log(`[getLiveKitHomeserverPriority] Member count: ${memberCount}, isDM: ${isDM}`);
|
|
||||||
console.log(`[getLiveKitHomeserverPriority] User homeserver: ${userHomeserver}`);
|
|
||||||
|
|
||||||
// Check for active call members
|
// Check for active call members
|
||||||
const activeCallMembers = getActiveCallMembers(room);
|
const activeCallMembers = getActiveCallMembers(room);
|
||||||
const hasActiveCall = activeCallMembers.length > 0;
|
const hasActiveCall = activeCallMembers.length > 0;
|
||||||
|
|
||||||
console.log(`[getLiveKitHomeserverPriority] Active call members: ${activeCallMembers.length}, hasActiveCall: ${hasActiveCall}`);
|
|
||||||
|
|
||||||
if (hasActiveCall) {
|
if (hasActiveCall) {
|
||||||
// Joining existing call - prioritize room's homeserver (where call is hosted)
|
// Joining existing call - prioritize room's homeserver (where call is hosted)
|
||||||
const roomServer = getRoomServer(room.roomId);
|
const roomServer = getRoomServer(room.roomId);
|
||||||
console.log(`[getLiveKitHomeserverPriority] Room server (active call): ${roomServer}`);
|
|
||||||
if (roomServer) {
|
if (roomServer) {
|
||||||
servers.push(`https://${roomServer}`);
|
servers.push(`https://${roomServer}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user's homeserver as fallback
|
// Add user's homeserver as fallback
|
||||||
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
|
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
|
||||||
if (!roomServer || roomServer !== userDomain) {
|
if (!roomServer || roomServer !== userDomain) {
|
||||||
servers.push(userHomeserver);
|
servers.push(userHomeserver);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For DMs, also try other member's homeserver
|
// For DMs, also try other member's homeserver
|
||||||
if (isDM) {
|
if (isDM) {
|
||||||
const otherUserId = guessDmRoomUserId(room, myUserId);
|
const otherUserId = guessDmRoomUserId(room, myUserId);
|
||||||
@@ -66,42 +57,31 @@ export function getLiveKitHomeserverPriority(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (isDM) {
|
||||||
// Starting new call - use original priority logic
|
// For DMs: user's homeserver first, then other member's homeserver
|
||||||
if (isDM) {
|
servers.push(userHomeserver);
|
||||||
console.log(`[getLiveKitHomeserverPriority] DM path - user homeserver first`);
|
|
||||||
// For DMs: user's homeserver first, then other member's homeserver
|
const otherUserId = guessDmRoomUserId(room, myUserId);
|
||||||
servers.push(userHomeserver);
|
if (otherUserId && otherUserId !== myUserId) {
|
||||||
|
const otherServer = getMxIdServer(otherUserId);
|
||||||
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
|
|
||||||
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
|
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
|
||||||
console.log(`[getLiveKitHomeserverPriority] User domain: ${userDomain}, room server: ${roomServer}`);
|
if (otherServer && otherServer !== userDomain) {
|
||||||
if (!roomServer || roomServer !== userDomain) {
|
servers.push(`https://${otherServer}`);
|
||||||
servers.push(userHomeserver);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// For channels: room's homeserver first, then user's homeserver
|
||||||
|
const roomServer = getRoomServer(room.roomId);
|
||||||
|
if (roomServer) {
|
||||||
|
servers.push(`https://${roomServer}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
|
||||||
|
if (!roomServer || roomServer !== userDomain) {
|
||||||
|
servers.push(userHomeserver);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[getLiveKitHomeserverPriority] Final server list:`, servers);
|
|
||||||
return servers;
|
return servers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,46 +92,46 @@ export function getLiveKitHomeserverPriority(
|
|||||||
*/
|
*/
|
||||||
export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined {
|
export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined {
|
||||||
const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci'];
|
const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci'];
|
||||||
console.log(`[getLiveKitFocus] rtcFoci from well-known:`, rtcFoci);
|
|
||||||
|
|
||||||
if (!rtcFoci || !Array.isArray(rtcFoci)) {
|
if (!rtcFoci || !Array.isArray(rtcFoci)) {
|
||||||
console.log(`[getLiveKitFocus] No rtc_foci found or not an array`);
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const livekitFocus = rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit');
|
return rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit');
|
||||||
console.log(`[getLiveKitFocus] Found LiveKit focus:`, livekitFocus);
|
|
||||||
return livekitFocus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the well-known client configuration from a homeserver
|
* Fetches the well-known client configuration from a homeserver.
|
||||||
* @param baseUrl - The homeserver base URL
|
* Results are cached — room switches must not re-hit the network.
|
||||||
* @returns The well-known response with RTC configuration
|
|
||||||
*/
|
*/
|
||||||
|
const wellKnownCache = new Map<string, Promise<WellKnownWithRTC>>();
|
||||||
|
|
||||||
export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> {
|
export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> {
|
||||||
// In development mode (localhost), use CORS proxy to avoid CORS issues
|
const cacheKey = baseUrl.replace(/\/$/, '');
|
||||||
|
const cached = wellKnownCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
|
||||||
let wellKnownUrl: string;
|
let wellKnownUrl: string;
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
// Extract hostname from baseUrl for proxy
|
const hostname = cacheKey.replace('https://', '').replace('http://', '');
|
||||||
const hostname = baseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, '');
|
|
||||||
wellKnownUrl = `/_matrix-cors-proxy/${hostname}/.well-known/matrix/client`;
|
wellKnownUrl = `/_matrix-cors-proxy/${hostname}/.well-known/matrix/client`;
|
||||||
} else {
|
} else {
|
||||||
wellKnownUrl = `${baseUrl}/.well-known/matrix/client`;
|
wellKnownUrl = `${cacheKey}/.well-known/matrix/client`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[fetchWellKnownWithRTC] Fetching from: ${wellKnownUrl}`);
|
const fetchPromise = (async () => {
|
||||||
|
const response = await fetch(wellKnownUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
wellKnownCache.delete(cacheKey);
|
||||||
|
throw new Error(`Failed to fetch well-known: ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as WellKnownWithRTC;
|
||||||
|
})();
|
||||||
|
|
||||||
const response = await fetch(wellKnownUrl);
|
wellKnownCache.set(cacheKey, fetchPromise);
|
||||||
if (!response.ok) {
|
return fetchPromise;
|
||||||
throw new Error(`Failed to fetch well-known: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log(`[fetchWellKnownWithRTC] Response data:`, data);
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { PageRoot } from '../../components/page';
|
import { PageRoot } from '../../components/page';
|
||||||
import { AnimatedOutlet } from '../../components/AnimatedOutlet';
|
|
||||||
import { MobileFriendlyPageNav } from '../../pages/MobileFriendly';
|
import { MobileFriendlyPageNav } from '../../pages/MobileFriendly';
|
||||||
import { SPACE_PATH } from '../../pages/paths';
|
import { SPACE_PATH } from '../../pages/paths';
|
||||||
import { Space } from '../../pages/client/space/Space';
|
import { Space } from '../../pages/client/space/Space';
|
||||||
|
import { SpaceRoomLayout } from '../../pages/client/space/SpaceRoomLayout';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useSpace } from '../../hooks/useSpace';
|
import { useSpace } from '../../hooks/useSpace';
|
||||||
@@ -11,7 +11,7 @@ import { isForumContainer, shouldShowForumLobby } from '../../utils/room';
|
|||||||
import { ForumBoardProvider } from './ForumBoardContext';
|
import { ForumBoardProvider } from './ForumBoardContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Space sidebar + outlet. Forum spaces render ForumFeedSidebar in Space.tsx.
|
* Space sidebar + room keep-alive host. Forum spaces render ForumFeedSidebar in Space.tsx.
|
||||||
*/
|
*/
|
||||||
export function ForumAwareSpaceLayout() {
|
export function ForumAwareSpaceLayout() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -33,7 +33,7 @@ export function ForumAwareSpaceLayout() {
|
|||||||
</MobileFriendlyPageNav>
|
</MobileFriendlyPageNav>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AnimatedOutlet />
|
<SpaceRoomLayout />
|
||||||
</PageRoot>
|
</PageRoot>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|||||||
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||||
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||||
import { joiningProgressAtom } from '../../state/joiningProgress';
|
import { joiningProgressAtom } from '../../state/joiningProgress';
|
||||||
|
import {
|
||||||
|
getCachedHierarchyRooms,
|
||||||
|
hierarchyRoomsToMap,
|
||||||
|
} from '../../state/spaceHierarchyCache';
|
||||||
|
|
||||||
const useCanDropLobbyItem = (
|
const useCanDropLobbyItem = (
|
||||||
space: Room,
|
space: Room,
|
||||||
@@ -166,7 +170,6 @@ function SpaceCardLobby() {
|
|||||||
const isForum = shouldShowForumLobby(space);
|
const isForum = shouldShowForumLobby(space);
|
||||||
const spacePowerLevels = usePowerLevels(space);
|
const spacePowerLevels = usePowerLevels(space);
|
||||||
const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []);
|
const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []);
|
||||||
const members = useRoomMembers(mx, space.roomId);
|
|
||||||
const joiningProgress = useAtomValue(joiningProgressAtom);
|
const joiningProgress = useAtomValue(joiningProgressAtom);
|
||||||
const currentJoiningProgress = joiningProgress.get(space.roomId);
|
const currentJoiningProgress = joiningProgress.get(space.roomId);
|
||||||
|
|
||||||
@@ -174,7 +177,8 @@ function SpaceCardLobby() {
|
|||||||
const heroSectionRef = useRef<HTMLDivElement>(null);
|
const heroSectionRef = useRef<HTMLDivElement>(null);
|
||||||
const [heroSectionHeight, setHeroSectionHeight] = useState<number>();
|
const [heroSectionHeight, setHeroSectionHeight] = useState<number>();
|
||||||
const [spaceRooms, setSpaceRooms] = useAtom(spaceRoomsAtom);
|
const [spaceRooms, setSpaceRooms] = useAtom(spaceRoomsAtom);
|
||||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [isDrawer] = useSetting(settingsAtom, 'peopleDrawerInRooms');
|
||||||
|
const members = useRoomMembers(mx, space.roomId, isDrawer);
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const [onTop, setOnTop] = useState(true);
|
const [onTop, setOnTop] = useState(true);
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
|
||||||
@@ -190,7 +194,10 @@ function SpaceCardLobby() {
|
|||||||
return new Set(sideSpaces);
|
return new Set(sideSpaces);
|
||||||
}, [sidebarItems]);
|
}, [sidebarItems]);
|
||||||
|
|
||||||
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
|
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => {
|
||||||
|
const cached = getCachedHierarchyRooms(space.roomId, 3);
|
||||||
|
return cached ? hierarchyRoomsToMap(cached) : new Map();
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -202,11 +209,7 @@ function SpaceCardLobby() {
|
|||||||
type: 'PUT',
|
type: 'PUT',
|
||||||
roomIds: all.filter(isHierarchySpaceRoom).map((r) => r.room_id),
|
roomIds: all.filter(isHierarchySpaceRoom).map((r) => r.room_id),
|
||||||
});
|
});
|
||||||
setSpacesItem((current) =>
|
setSpacesItem(hierarchyRoomsToMap(all));
|
||||||
produce(current, (draft) => {
|
|
||||||
all.forEach((r) => draft.set(r.room_id, r));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
// hierarchy may be unavailable for some memberships
|
// hierarchy may be unavailable for some memberships
|
||||||
}
|
}
|
||||||
@@ -246,20 +249,33 @@ function SpaceCardLobby() {
|
|||||||
});
|
});
|
||||||
const vItems = virtualizer.getVirtualItems();
|
const vItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
const roomsPowerLevels = useRoomsPowerLevels(
|
const powerLevelRoomIds = useMemo(() => {
|
||||||
useMemo(
|
const roomIds = new Set<string>([space.roomId]);
|
||||||
() =>
|
vItems.forEach((vItem) => {
|
||||||
hierarchy
|
const item = hierarchy[vItem.index];
|
||||||
.flatMap((i) => {
|
if (!item) return;
|
||||||
const childRooms = Array.isArray(i.rooms) ? i.rooms.map((r) => getRoom(r.roomId)) : [];
|
roomIds.add(item.space.roomId);
|
||||||
|
if (item.space.parentId) roomIds.add(item.space.parentId);
|
||||||
|
item.rooms?.forEach((room) => roomIds.add(room.roomId));
|
||||||
|
});
|
||||||
|
if (draggingItem) {
|
||||||
|
roomIds.add(draggingItem.roomId);
|
||||||
|
if (draggingItem.parentId) roomIds.add(draggingItem.parentId);
|
||||||
|
}
|
||||||
|
return Array.from(roomIds).sort().join('\0');
|
||||||
|
}, [vItems, hierarchy, space.roomId, draggingItem]);
|
||||||
|
|
||||||
return [getRoom(i.space.roomId), ...childRooms];
|
const powerLevelRooms = useMemo(
|
||||||
})
|
() =>
|
||||||
.filter((r) => !!r) as Room[],
|
powerLevelRoomIds
|
||||||
[hierarchy, getRoom]
|
.split('\0')
|
||||||
)
|
.map((roomId) => getRoom(roomId))
|
||||||
|
.filter((room): room is Room => !!room),
|
||||||
|
[powerLevelRoomIds, getRoom]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const roomsPowerLevels = useRoomsPowerLevels(powerLevelRooms);
|
||||||
|
|
||||||
const canDrop: CanDropCallback = useCanDropLobbyItem(space, roomsPowerLevels, getRoom);
|
const canDrop: CanDropCallback = useCanDropLobbyItem(space, roomsPowerLevels, getRoom);
|
||||||
|
|
||||||
const [reorderSpaceState, reorderSpace] = useAsyncCallback(
|
const [reorderSpaceState, reorderSpace] = useAsyncCallback(
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export function LobbyHeader({ showProfile, showSpaceToolbar, powerLevels }: Lobb
|
|||||||
const space = useSpace();
|
const space = useSpace();
|
||||||
const creators = useRoomCreators(space);
|
const creators = useRoomCreators(space);
|
||||||
const spacePermissions = useRoomPermissions(creators, powerLevels);
|
const spacePermissions = useRoomPermissions(creators, powerLevels);
|
||||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
const setPeopleDrawer = useSetSetting(settingsAtom, 'peopleDrawerInRooms');
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [addExisting, setAddExisting] = useState<OpenAddExistingOptions | null>(null);
|
const [addExisting, setAddExisting] = useState<OpenAddExistingOptions | null>(null);
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { SequenceCard } from '../../components/sequence-card';
|
|||||||
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||||
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||||
import { PaarrotSubRoomsContent } from '../../hooks/useRoomSubRooms';
|
import { PaarrotSubRoomsContent } from '../../hooks/useRoomSubRooms';
|
||||||
|
import { useGlobalSubRoomIds } from '../../state/globalSubRoomIds';
|
||||||
|
|
||||||
type SpaceHierarchyProps = {
|
type SpaceHierarchyProps = {
|
||||||
summary: IHierarchyRoom | undefined;
|
summary: IHierarchyRoom | undefined;
|
||||||
@@ -71,7 +72,14 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||||||
) => {
|
) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(spaceItem.roomId, true);
|
// Lobby bulk-fetches hierarchy once; per-category fetch duplicated N×50 API pages.
|
||||||
|
const parentHasSummaries = hierarchySummaries.size > 0;
|
||||||
|
const enableFetch = !closed && !parentHasSummaries;
|
||||||
|
const { fetching, error, rooms: fetchedRooms } = useFetchSpaceHierarchyLevel(
|
||||||
|
spaceItem.roomId,
|
||||||
|
enableFetch
|
||||||
|
);
|
||||||
|
const rooms = parentHasSummaries ? hierarchySummaries : fetchedRooms;
|
||||||
|
|
||||||
const subspaces = useMemo(() => {
|
const subspaces = useMemo(() => {
|
||||||
const s: Map<string, IHierarchyRoom> = new Map();
|
const s: Map<string, IHierarchyRoom> = new Map();
|
||||||
@@ -105,15 +113,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||||||
|
|
||||||
// Build a global set of all sub-room IDs by checking ALL joined rooms
|
// Build a global set of all sub-room IDs by checking ALL joined rooms
|
||||||
// This ensures sub-rooms are hidden even if their parent isn't in this space
|
// This ensures sub-rooms are hidden even if their parent isn't in this space
|
||||||
const globalSubRoomIds = useMemo(() => {
|
const globalSubRoomIds = useGlobalSubRoomIds();
|
||||||
const subRoomIds = new Set<string>();
|
|
||||||
mx.getRooms().forEach((room) => {
|
|
||||||
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
|
|
||||||
const content = subRoomsEvent?.getContent<PaarrotSubRoomsContent>();
|
|
||||||
content?.children?.forEach((childId: string) => subRoomIds.add(childId));
|
|
||||||
});
|
|
||||||
return subRoomIds;
|
|
||||||
}, [mx, allJoinedRooms]);
|
|
||||||
|
|
||||||
// Build a map of parent room -> sub-room IDs for nested rendering (only for rooms in this space)
|
// Build a map of parent room -> sub-room IDs for nested rendering (only for rooms in this space)
|
||||||
const subRoomsMap = useMemo(() => {
|
const subRoomsMap = useMemo(() => {
|
||||||
|
|||||||
@@ -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 { Avatar, Box, Text, config } from 'folds';
|
||||||
import { Icon, Icons } from '../../components/icons';
|
import { Icon, Icons } from '../../components/icons';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomCallMembers } from '../call/useRoomCallMembers';
|
|
||||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
import { mxcUrlToHttp, getMxIdLocalPart } from '../../utils/matrix';
|
import { mxcUrlToHttp, getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { nameInitials } from '../../utils/common';
|
import { nameInitials } from '../../utils/common';
|
||||||
import { useCall } from '../call/useCall';
|
import { useCall } from '../call/useCall';
|
||||||
|
import { useRoomHasActiveCall } from '../../state/activeCallRoomsRegistry';
|
||||||
|
import { useRoomCallMembers } from '../call/useRoomCallMembers';
|
||||||
import * as css from './CallParticipantsIndicator.css';
|
import * as css from './CallParticipantsIndicator.css';
|
||||||
|
|
||||||
const MAX_VISIBLE_PARTICIPANTS = 8;
|
const MAX_VISIBLE_PARTICIPANTS = 8;
|
||||||
@@ -17,6 +18,12 @@ type CallParticipantsIndicatorProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorProps) {
|
export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorProps) {
|
||||||
|
const hasCall = useRoomHasActiveCall(roomId);
|
||||||
|
if (!hasCall) return null;
|
||||||
|
return <CallParticipantsIndicatorActive roomId={roomId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CallParticipantsIndicatorActive({ roomId }: CallParticipantsIndicatorProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
@@ -41,20 +48,14 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
|
|||||||
return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a participant is currently speaking
|
|
||||||
*/
|
|
||||||
const isParticipantSpeaking = (userId: string): boolean => {
|
const isParticipantSpeaking = (userId: string): boolean => {
|
||||||
if (!activeCall) return false;
|
if (!activeCall) return false;
|
||||||
|
|
||||||
// Direct check for Matrix user ID (works for local user)
|
|
||||||
if (activeCall.activeSpeakers.has(userId)) return true;
|
if (activeCall.activeSpeakers.has(userId)) return true;
|
||||||
|
|
||||||
// For remote users, activeSpeakers contains LiveKit identities (deviceId_timestamp)
|
|
||||||
const member = callMembers.find((m) => m.userId === userId);
|
const member = callMembers.find((m) => m.userId === userId);
|
||||||
if (!member) return false;
|
if (!member) return false;
|
||||||
|
|
||||||
// Check if any active speaker has this user's deviceId
|
|
||||||
return Array.from(activeCall.activeSpeakers).some((speakerId) => {
|
return Array.from(activeCall.activeSpeakers).some((speakerId) => {
|
||||||
const underscoreIndex = speakerId.lastIndexOf('_');
|
const underscoreIndex = speakerId.lastIndexOf('_');
|
||||||
const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId;
|
const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId;
|
||||||
@@ -62,20 +63,14 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a participant is currently muted
|
|
||||||
*/
|
|
||||||
const isParticipantMuted = (userId: string): boolean => {
|
const isParticipantMuted = (userId: string): boolean => {
|
||||||
if (!activeCall) return false;
|
if (!activeCall) return false;
|
||||||
|
|
||||||
// For local user, check activeCall.isMuted
|
|
||||||
if (userId === myUserId) return activeCall.isMuted;
|
if (userId === myUserId) return activeCall.isMuted;
|
||||||
|
|
||||||
// For remote users, mutedParticipants contains LiveKit identities (deviceId_timestamp)
|
|
||||||
const member = callMembers.find((m) => m.userId === userId);
|
const member = callMembers.find((m) => m.userId === userId);
|
||||||
if (!member) return false;
|
if (!member) return false;
|
||||||
|
|
||||||
// Check if any muted participant has this user's deviceId
|
|
||||||
return Array.from(activeCall.mutedParticipants).some((mutedId) => {
|
return Array.from(activeCall.mutedParticipants).some((mutedId) => {
|
||||||
const underscoreIndex = mutedId.lastIndexOf('_');
|
const underscoreIndex = mutedId.lastIndexOf('_');
|
||||||
const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId;
|
const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId;
|
||||||
@@ -83,17 +78,11 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the local user is deafened
|
|
||||||
*/
|
|
||||||
const isLocalUserDeafened = (userId: string): boolean => {
|
const isLocalUserDeafened = (userId: string): boolean => {
|
||||||
if (!activeCall || userId !== myUserId) return false;
|
if (!activeCall || userId !== myUserId) return false;
|
||||||
return activeCall.isDeafened;
|
return activeCall.isDeafened;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the appropriate audio icon for a participant
|
|
||||||
*/
|
|
||||||
const getAudioIcon = (userId: string): string => {
|
const getAudioIcon = (userId: string): string => {
|
||||||
if (isLocalUserDeafened(userId)) {
|
if (isLocalUserDeafened(userId)) {
|
||||||
return Icons.VolumeMute;
|
return Icons.VolumeMute;
|
||||||
@@ -115,7 +104,7 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
|
|||||||
const isSpeaking = isParticipantSpeaking(member.userId);
|
const isSpeaking = isParticipantSpeaking(member.userId);
|
||||||
const isMuted = isParticipantMuted(member.userId);
|
const isMuted = isParticipantMuted(member.userId);
|
||||||
const isDeafened = isLocalUserDeafened(member.userId);
|
const isDeafened = isLocalUserDeafened(member.userId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={member.userId}
|
key={member.userId}
|
||||||
@@ -125,8 +114,8 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
|
|||||||
opacity: config.opacity.P300,
|
opacity: config.opacity.P300,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
size="200"
|
size="200"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
className={isSpeaking ? css.ParticipantSpeaking : undefined}
|
className={isSpeaking ? css.ParticipantSpeaking : undefined}
|
||||||
>
|
>
|
||||||
@@ -148,13 +137,13 @@ export function CallParticipantsIndicator({ roomId }: CallParticipantsIndicatorP
|
|||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box alignItems="Center" gap="100" grow="Yes">
|
<Box alignItems="Center" gap="100" grow="Yes">
|
||||||
<Icon
|
<Icon
|
||||||
src={getAudioIcon(member.userId)}
|
src={getAudioIcon(member.userId)}
|
||||||
size="50"
|
size="50"
|
||||||
style={{
|
style={{
|
||||||
opacity: config.opacity.P500,
|
opacity: config.opacity.P500,
|
||||||
color: isDeafened ? 'var(--color-Critical-Main)' : isMuted ? 'var(--color-Critical-Main)' : undefined
|
color: isDeafened ? 'var(--color-Critical-Main)' : isMuted ? 'var(--color-Critical-Main)' : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text as="span" size="T200" truncate>
|
<Text as="span" size="T200" truncate>
|
||||||
{getParticipantName(member.userId)}
|
{getParticipantName(member.userId)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
|
import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react';
|
||||||
import { Room, EventTimeline } from 'matrix-js-sdk';
|
import { Room, EventTimeline } from 'matrix-js-sdk';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import { Avatar, Box, IconButton, Text, Menu, MenuItem, config, PopOut, toRem, Line, RectCords, Badge, Spinner, Tooltip, TooltipProvider } from 'folds';
|
import { Avatar, Box, IconButton, Text, Menu, MenuItem, config, PopOut, toRem, Line, RectCords, Badge, Spinner, Tooltip, TooltipProvider } from 'folds';
|
||||||
import { Icon, Icons } from '../../components/icons';
|
import { Icon, Icons } from '../../components/icons';
|
||||||
import { useFocusWithin, useHover } from 'react-aria';
|
import { useFocusWithin, useHover } from 'react-aria';
|
||||||
@@ -48,6 +49,9 @@ import { mDirectAtom } from '../../state/mDirectList';
|
|||||||
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
|
import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal';
|
||||||
import { PresenceAvatar } from '../../components/presence';
|
import { PresenceAvatar } from '../../components/presence';
|
||||||
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
|
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
|
||||||
|
import { pendingRoomNavigationAtom, roomNavLoadingAtom } from '../../state/pendingRoomNavigation';
|
||||||
|
import * as roomNavCss from './styles.css';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -287,6 +291,14 @@ export function RoomNavItem({
|
|||||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
|
const pendingRoomId = useAtomValue(pendingRoomNavigationAtom);
|
||||||
|
const setPendingRoomId = useSetAtom(pendingRoomNavigationAtom);
|
||||||
|
const loadingRoomId = useAtomValue(roomNavLoadingAtom);
|
||||||
|
const setLoadingRoomId = useSetAtom(roomNavLoadingAtom);
|
||||||
|
// Instant highlight on press — don't wait for the route to settle.
|
||||||
|
const isPending = pendingRoomId === room.roomId;
|
||||||
|
const isLoading = loadingRoomId === room.roomId;
|
||||||
|
const effectivelySelected = selected || isPending || isLoading;
|
||||||
|
|
||||||
const avatarUrl = direct
|
const avatarUrl = direct
|
||||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
@@ -368,6 +380,21 @@ export function RoomNavItem({
|
|||||||
if (shouldScroll) {
|
if (shouldScroll) {
|
||||||
navigate(linkPath, { replace: true, state: { scrollToLatest: Date.now() } });
|
navigate(linkPath, { replace: true, state: { scrollToLatest: Date.now() } });
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always mark pending so the nav loading bar shows until the room view settles.
|
||||||
|
setPendingRoomId(room.roomId);
|
||||||
|
setLoadingRoomId(room.roomId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomPointerDown: MouseEventHandler<HTMLAnchorElement> = () => {
|
||||||
|
if (!selected) {
|
||||||
|
// Paint highlight + loading bar before click navigation blocks the main thread.
|
||||||
|
flushSync(() => {
|
||||||
|
setPendingRoomId(room.roomId);
|
||||||
|
setLoadingRoomId(room.roomId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -376,17 +403,32 @@ export function RoomNavItem({
|
|||||||
return (
|
return (
|
||||||
<Box direction="Column" style={{ width: '100%' }}>
|
<Box direction="Column" style={{ width: '100%' }}>
|
||||||
<NavItem
|
<NavItem
|
||||||
className={!shouldShowIcon ? css.ThreadItem : undefined}
|
className={classNames(
|
||||||
|
!shouldShowIcon && css.ThreadItem,
|
||||||
|
isLoading && roomNavCss.RoomNavItemLoading
|
||||||
|
)}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="400"
|
radii="400"
|
||||||
highlight={unread !== undefined}
|
highlight={unread !== undefined}
|
||||||
aria-selected={selected}
|
aria-selected={effectivelySelected}
|
||||||
|
aria-busy={isLoading || undefined}
|
||||||
data-hover={!!menuAnchor}
|
data-hover={!!menuAnchor}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
{...hoverProps}
|
{...hoverProps}
|
||||||
{...focusWithinProps}
|
{...focusWithinProps}
|
||||||
>
|
>
|
||||||
<NavLink to={linkPath} onClick={handleRoomClick}>
|
{isLoading && (
|
||||||
|
<>
|
||||||
|
<div className={roomNavCss.RoomNavLoadingBg} aria-hidden />
|
||||||
|
<div className={roomNavCss.RoomNavLoadingBar} aria-hidden />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<NavLink
|
||||||
|
className={isLoading ? roomNavCss.RoomNavLoadingContent : undefined}
|
||||||
|
to={linkPath}
|
||||||
|
onClick={handleRoomClick}
|
||||||
|
onPointerDown={handleRoomPointerDown}
|
||||||
|
>
|
||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
<Box as="span" grow="Yes" alignItems="Center" gap={shouldShowIcon ? "200" : "100"}>
|
||||||
{shouldShowIcon &&
|
{shouldShowIcon &&
|
||||||
@@ -413,7 +455,7 @@ export function RoomNavItem({
|
|||||||
) : (
|
) : (
|
||||||
<RoomIcon
|
<RoomIcon
|
||||||
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
||||||
filled={selected}
|
filled={effectivelySelected}
|
||||||
size="100"
|
size="100"
|
||||||
joinRule={room.getJoinRule()}
|
joinRule={room.getJoinRule()}
|
||||||
/>
|
/>
|
||||||
@@ -436,7 +478,7 @@ export function RoomNavItem({
|
|||||||
) : (
|
) : (
|
||||||
<RoomIcon
|
<RoomIcon
|
||||||
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
style={{ opacity: unread ? config.opacity.P500 : config.opacity.P300 }}
|
||||||
filled={selected}
|
filled={effectivelySelected}
|
||||||
size="100"
|
size="100"
|
||||||
joinRule={room.getJoinRule()}
|
joinRule={room.getJoinRule()}
|
||||||
/>
|
/>
|
||||||
@@ -470,7 +512,7 @@ export function RoomNavItem({
|
|||||||
</NavItemContent>
|
</NavItemContent>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{optionsVisible && (
|
{optionsVisible && (
|
||||||
<NavItemOptions>
|
<NavItemOptions className={isLoading ? roomNavCss.RoomNavLoadingContent : undefined}>
|
||||||
<Box alignItems="Center" gap="100">
|
<Box alignItems="Center" gap="100">
|
||||||
{parentSpaceInfo && (
|
{parentSpaceInfo && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
import { config } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const CategoryButton = style({
|
export const CategoryButton = style({
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
@@ -7,3 +7,68 @@ export const CategoryButton = style({
|
|||||||
export const CategoryButtonIcon = style({
|
export const CategoryButtonIcon = style({
|
||||||
opacity: config.opacity.P400,
|
opacity: config.opacity.P400,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const indeterminate = keyframes({
|
||||||
|
'0%': {
|
||||||
|
transform: 'translateX(-100%)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'translateX(200%)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pulse = keyframes({
|
||||||
|
'0%, 100%': {
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
|
'50%': {
|
||||||
|
opacity: 0.55,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Nav item is in a loading/opening state. */
|
||||||
|
export const RoomNavItemLoading = style({
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Soft primary wash over the whole room button. */
|
||||||
|
export const RoomNavLoadingBg = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
animation: `${pulse} 1.2s ease-in-out infinite`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Classic indeterminate bar along the bottom edge. */
|
||||||
|
export const RoomNavLoadingBar = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: toRem(3),
|
||||||
|
borderRadius: `0 0 ${config.radii.R400} ${config.radii.R400}`,
|
||||||
|
backgroundColor: color.Primary.ContainerActive,
|
||||||
|
overflow: 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 2,
|
||||||
|
selectors: {
|
||||||
|
'&::after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '45%',
|
||||||
|
backgroundColor: color.Primary.Main,
|
||||||
|
animation: `${indeterminate} 1s ease-in-out infinite`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Keep nav content above the loading wash. */
|
||||||
|
export const RoomNavLoadingContent = style({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
});
|
||||||
|
|||||||
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 { TypingIndicator } from '../../components/typing-indicator';
|
||||||
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
|
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { useSetSetting, useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { millify } from '../../plugins/millify';
|
import { millify } from '../../plugins/millify';
|
||||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||||
@@ -41,12 +42,14 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
|||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
|
|
||||||
type MemberDrawerHeaderProps = {
|
type MemberDrawerHeaderProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
};
|
};
|
||||||
function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
|
function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
|
||||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
const isDirect = useIsDirectRoom();
|
||||||
|
const [, setPeopleDrawer] = usePeopleDrawerSetting(isDirect);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header className={css.MembersDrawerHeader} variant="Background" size="600">
|
<Header className={css.MembersDrawerHeader} variant="Background" size="600">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { memo, useCallback, useLayoutEffect } from 'react';
|
||||||
import { Box, Line } from 'folds';
|
import { Box, Line } from 'folds';
|
||||||
|
import { Room as MatrixRoom } from 'matrix-js-sdk';
|
||||||
import { decodeRouteParam } from '../../pages/pathUtils';
|
import { decodeRouteParam } from '../../pages/pathUtils';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
@@ -8,9 +9,10 @@ import { RoomView } from './RoomView';
|
|||||||
import { MembersDrawer } from './MembersDrawer';
|
import { MembersDrawer } from './MembersDrawer';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
@@ -19,25 +21,64 @@ import { activeRoomIdAtom } from '../../state/activeRoom';
|
|||||||
import { isForum } from '../../utils/room';
|
import { isForum } from '../../utils/room';
|
||||||
import { ForumRoomView } from './ForumRoomView';
|
import { ForumRoomView } from './ForumRoomView';
|
||||||
|
|
||||||
export function Room() {
|
type RoomFrameProps = {
|
||||||
const { eventId: rawEventId } = useParams();
|
room: MatrixRoom;
|
||||||
const eventId = decodeRouteParam(rawEventId);
|
eventId?: string;
|
||||||
const room = useRoom();
|
/** Optimistic shell only — no live timeline. */
|
||||||
const mx = useMatrixClient();
|
pending?: boolean;
|
||||||
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
/**
|
||||||
|
* Visible keep-alive slot. Drives composer mount immediately (Slate cannot
|
||||||
|
* stay mounted in hidden rooms). Timeline ignores this via memo.
|
||||||
|
*/
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
/** Shared room column layout used by the live room and the pending-nav shell. */
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
export const RoomFrame = memo(function RoomFrame({
|
||||||
|
room,
|
||||||
|
eventId,
|
||||||
|
pending = false,
|
||||||
|
active = true,
|
||||||
|
}: RoomFrameProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const isDirect = useIsDirectRoom();
|
||||||
|
const [isDrawer] = usePeopleDrawerSetting(isDirect);
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const members = useRoomMembers(mx, room.roomId);
|
|
||||||
const markAsRead = useMarkAsRead(mx);
|
|
||||||
const forumRoom = isForum(room);
|
const forumRoom = isForum(room);
|
||||||
|
// Mount whenever the account pref is on — including inactive keep-alive slots —
|
||||||
|
// so members are already loaded when you switch rooms (no pop-in).
|
||||||
|
const showDrawer = !pending && screenSize === ScreenSize.Desktop && isDrawer;
|
||||||
|
const members = useRoomMembers(mx, room.roomId, showDrawer);
|
||||||
|
|
||||||
// Update titlebar with current room ID
|
return (
|
||||||
useEffect(() => {
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
|
<Box grow="Yes">
|
||||||
|
{forumRoom && !pending ? (
|
||||||
|
<ForumRoomView room={room} eventId={eventId} />
|
||||||
|
) : (
|
||||||
|
<RoomView room={room} eventId={eventId} pending={pending} active={active} />
|
||||||
|
)}
|
||||||
|
{showDrawer && (
|
||||||
|
<>
|
||||||
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</PowerLevelsContextProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Titlebar + escape-to-mark-read — only for the visible cached room. */
|
||||||
|
export function ActiveRoomEffects({ room }: { room: MatrixRoom }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
||||||
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const markAsRead = useMarkAsRead(mx);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
setActiveRoomId(room.roomId);
|
setActiveRoomId(room.roomId);
|
||||||
return () => setActiveRoomId(undefined);
|
|
||||||
}, [room.roomId, setActiveRoomId]);
|
}, [room.roomId, setActiveRoomId]);
|
||||||
|
|
||||||
useKeyDown(
|
useKeyDown(
|
||||||
@@ -52,17 +93,19 @@ export function Room() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Prefer CachedRooms for route outlets — kept for single-room mounts. */
|
||||||
|
export function Room() {
|
||||||
|
const { eventId: rawEventId } = useParams();
|
||||||
|
const eventId = decodeRouteParam(rawEventId);
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<>
|
||||||
<Box grow="Yes">
|
<ActiveRoomEffects room={room} />
|
||||||
{forumRoom ? <ForumRoomView room={room} eventId={eventId} /> : <RoomView room={room} eventId={eventId} />}
|
<RoomFrame room={room} eventId={eventId} />
|
||||||
{screenSize === ScreenSize.Desktop && isDrawer && (
|
</>
|
||||||
<>
|
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
|
||||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</PowerLevelsContextProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
memo,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
Direction,
|
Direction,
|
||||||
@@ -117,6 +118,8 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
|
|||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
import { useRoomVisibilityRef } from './RoomViewActiveContext';
|
||||||
|
import { roomNavLoadingAtom } from '../../state/pendingRoomNavigation';
|
||||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
import {
|
import {
|
||||||
@@ -553,8 +556,15 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
|
export const RoomTimeline = memo(function RoomTimeline({
|
||||||
|
room,
|
||||||
|
eventId,
|
||||||
|
roomInputRef,
|
||||||
|
editor,
|
||||||
|
}: RoomTimelineProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const visibilityRef = useRoomVisibilityRef();
|
||||||
|
const setLoadingRoomId = useSetAtom(roomNavLoadingAtom);
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||||
@@ -716,6 +726,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
[]
|
[]
|
||||||
),
|
),
|
||||||
onEnd: handleTimelinePagination,
|
onEnd: handleTimelinePagination,
|
||||||
|
enabledRef: visibilityRef,
|
||||||
|
roomId: room.roomId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadEventTimeline = useEventTimelineLoader(
|
const loadEventTimeline = useEventTimelineLoader(
|
||||||
@@ -759,6 +771,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
|
||||||
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);
|
||||||
|
|
||||||
|
// Hidden keep-alive: only slide the virtual window when stuck to bottom.
|
||||||
|
// Shifting range while scrolled-up remaps scrollTop onto different messages.
|
||||||
|
if (!visibilityRef.current) {
|
||||||
|
if (shouldStickToBottom) {
|
||||||
|
setTimeline((ct) => ({
|
||||||
|
...ct,
|
||||||
|
range: {
|
||||||
|
start: ct.range.start + 1,
|
||||||
|
end: ct.range.end + 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Always update timeline with new message
|
// Always update timeline with new message
|
||||||
setTimeline((ct) => ({
|
setTimeline((ct) => ({
|
||||||
...ct,
|
...ct,
|
||||||
@@ -771,7 +798,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
// Handle own messages and focus
|
// Handle own messages and focus
|
||||||
if (isOwnMessage) {
|
if (isOwnMessage) {
|
||||||
setLatestUnreadMessage(null);
|
setLatestUnreadMessage(null);
|
||||||
if (document.hasFocus() && (!unreadInfo)) {
|
if (document.hasFocus() && !unreadInfo) {
|
||||||
const roomId = mEvt.getRoomId();
|
const roomId = mEvt.getRoomId();
|
||||||
if (roomId) {
|
if (roomId) {
|
||||||
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
requestAnimationFrame(() => markAsRead(roomId, hideActivity));
|
||||||
@@ -803,7 +830,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, room, unreadInfo, hideActivity, markAsRead]
|
[visibilityRef, mx, room, unreadInfo, hideActivity, markAsRead]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -840,10 +867,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
useLiveTimelineRefresh(
|
useLiveTimelineRefresh(
|
||||||
room,
|
room,
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
if (!visibilityRef.current) return;
|
||||||
if (liveTimelineLinked) {
|
if (liveTimelineLinked) {
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
}
|
}
|
||||||
}, [room, liveTimelineLinked])
|
}, [visibilityRef, room, liveTimelineLinked])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stay at bottom when room editor resize
|
// Stay at bottom when room editor resize
|
||||||
@@ -856,6 +884,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
mounted = true;
|
mounted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!visibilityRef.current) return;
|
||||||
if (!roomInputRef.current) return;
|
if (!roomInputRef.current) return;
|
||||||
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
|
const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
|
||||||
const scrollElement = getScrollElement();
|
const scrollElement = getScrollElement();
|
||||||
@@ -865,29 +894,37 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
autoScrollToBottom(scrollElement, false, true);
|
autoScrollToBottom(scrollElement, false, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [getScrollElement, roomInputRef]),
|
}, [getScrollElement, roomInputRef, visibilityRef]),
|
||||||
useCallback(() => roomInputRef.current, [roomInputRef])
|
useCallback(() => roomInputRef.current, [roomInputRef])
|
||||||
);
|
);
|
||||||
|
|
||||||
useResizeObserver(
|
useResizeObserver(
|
||||||
useCallback((entries) => {
|
useCallback(
|
||||||
const timelineContent = timelineContentRef.current;
|
(entries) => {
|
||||||
const scrollElement = getScrollElement();
|
if (!visibilityRef.current) return;
|
||||||
if (!timelineContent || !scrollElement) return;
|
const timelineContent = timelineContentRef.current;
|
||||||
|
const scrollElement = getScrollElement();
|
||||||
|
if (!timelineContent || !scrollElement) return;
|
||||||
|
|
||||||
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
|
const timelineEntry = getResizeObserverEntry(timelineContent, entries);
|
||||||
if (!timelineEntry) return;
|
if (!timelineEntry) return;
|
||||||
|
|
||||||
const nextHeight = timelineEntry.contentRect.height;
|
const nextHeight = timelineEntry.contentRect.height;
|
||||||
const previousHeight = timelineContentHeightRef.current;
|
const previousHeight = timelineContentHeightRef.current;
|
||||||
timelineContentHeightRef.current = nextHeight;
|
timelineContentHeightRef.current = nextHeight;
|
||||||
|
|
||||||
if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
|
if (
|
||||||
return;
|
previousHeight === 0 ||
|
||||||
}
|
nextHeight <= previousHeight ||
|
||||||
|
!isScrolledToBottom(scrollElement)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
autoScrollToBottom(scrollElement, false, true);
|
autoScrollToBottom(scrollElement, false, true);
|
||||||
}, [getScrollElement]),
|
},
|
||||||
|
[getScrollElement, visibilityRef]
|
||||||
|
),
|
||||||
useCallback(() => timelineContentRef.current, [])
|
useCallback(() => timelineContentRef.current, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -915,6 +952,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
useIntersectionObserver(
|
useIntersectionObserver(
|
||||||
useCallback(
|
useCallback(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
|
if (!visibilityRef.current) return;
|
||||||
const target = atBottomAnchorRef.current;
|
const target = atBottomAnchorRef.current;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const targetEntry = getIntersectionObserverEntry(target, entries);
|
const targetEntry = getIntersectionObserverEntry(target, entries);
|
||||||
@@ -927,7 +965,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[debounceSetAtBottom, tryAutoMarkAsRead]
|
[visibilityRef, debounceSetAtBottom, tryAutoMarkAsRead]
|
||||||
),
|
),
|
||||||
useCallback(
|
useCallback(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -965,6 +1003,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
window,
|
window,
|
||||||
useCallback(
|
useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
|
if (!visibilityRef.current) return;
|
||||||
if (
|
if (
|
||||||
isKeyHotkey('arrowup', evt) &&
|
isKeyHotkey('arrowup', evt) &&
|
||||||
editableActiveElement() &&
|
editableActiveElement() &&
|
||||||
@@ -980,7 +1019,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, room, editor]
|
[visibilityRef, mx, room, editor]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1011,6 +1050,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Drop the room-nav loading bar once this timeline has painted (or after a
|
||||||
|
// short idle). flushSync paints the bar before this heavy mount work.
|
||||||
|
useEffect(() => {
|
||||||
|
let cleared = false;
|
||||||
|
const clear = () => {
|
||||||
|
if (cleared) return;
|
||||||
|
cleared = true;
|
||||||
|
setLoadingRoomId((id) => (id === room.roomId ? null : id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const idleId =
|
||||||
|
typeof window.requestIdleCallback === 'function'
|
||||||
|
? window.requestIdleCallback(clear, { timeout: 2000 })
|
||||||
|
: undefined;
|
||||||
|
const timeoutId = window.setTimeout(clear, idleId === undefined ? 400 : 2500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleared = true;
|
||||||
|
if (idleId !== undefined && typeof window.cancelIdleCallback === 'function') {
|
||||||
|
window.cancelIdleCallback(idleId);
|
||||||
|
}
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [room.roomId, setLoadingRoomId]);
|
||||||
|
|
||||||
// if live timeline is linked and unreadInfo change
|
// if live timeline is linked and unreadInfo change
|
||||||
// Scroll to last read message
|
// Scroll to last read message
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -1053,6 +1117,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const scrollToBottomCount = scrollToBottomRef.current.count;
|
const scrollToBottomCount = scrollToBottomRef.current.count;
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (scrollToBottomCount > 0) {
|
if (scrollToBottomCount > 0) {
|
||||||
|
if (!visibilityRef.current) return;
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (scrollEl) {
|
if (scrollEl) {
|
||||||
autoScrollToBottom(
|
autoScrollToBottom(
|
||||||
@@ -1062,7 +1127,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [scrollToBottomCount]);
|
}, [scrollToBottomCount, visibilityRef]);
|
||||||
|
|
||||||
// Remove unreadInfo on mark as read
|
// Remove unreadInfo on mark as read
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2291,7 +2356,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</Chip>
|
</Chip>
|
||||||
</TimelineFloat>
|
</TimelineFloat>
|
||||||
)}
|
)}
|
||||||
<Scroll ref={scrollRef} visibility="Hover">
|
<Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
|
||||||
<Box
|
<Box
|
||||||
ref={timelineContentRef}
|
ref={timelineContentRef}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
@@ -2420,4 +2485,4 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useCallback, useRef } from 'react';
|
import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Box, Text, config } from 'folds';
|
import { Box, Text, config } from 'folds';
|
||||||
import { EventType, Room } from 'matrix-js-sdk';
|
import { EventType, Room } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue, useSetAtom, getDefaultStore } from 'jotai';
|
||||||
|
import { Editor } from 'slate';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
@@ -24,6 +25,8 @@ import { useSetting } from '../../state/hooks/settings';
|
|||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||||
|
import { pendingRoomNavigationAtom } from '../../state/pendingRoomNavigation';
|
||||||
|
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||||
import { ThreadView } from './ThreadView';
|
import { ThreadView } from './ThreadView';
|
||||||
|
|
||||||
const FN_KEYS_REGEX = /^F\d+$/;
|
const FN_KEYS_REGEX = /^F\d+$/;
|
||||||
@@ -33,10 +36,8 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// do not focus on F keys
|
|
||||||
if (FN_KEYS_REGEX.test(code)) return false;
|
if (FN_KEYS_REGEX.test(code)) return false;
|
||||||
|
|
||||||
// do not focus on numlock/scroll lock
|
|
||||||
if (
|
if (
|
||||||
code.startsWith('OS') ||
|
code.startsWith('OS') ||
|
||||||
code.startsWith('Meta') ||
|
code.startsWith('Meta') ||
|
||||||
@@ -59,7 +60,99 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
/**
|
||||||
|
* Slate must not stay mounted in hidden keep-alive rooms — multiple Editable
|
||||||
|
* trees crash with findPath. Timeline stays warm; only the composer remounts.
|
||||||
|
*/
|
||||||
|
function RoomComposerSlot({
|
||||||
|
room,
|
||||||
|
roomId,
|
||||||
|
editor,
|
||||||
|
canMessage,
|
||||||
|
active,
|
||||||
|
roomViewRef,
|
||||||
|
roomInputRef,
|
||||||
|
}: {
|
||||||
|
room: Room;
|
||||||
|
roomId: string;
|
||||||
|
editor: Editor;
|
||||||
|
canMessage: boolean;
|
||||||
|
active: boolean;
|
||||||
|
roomViewRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
roomInputRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}) {
|
||||||
|
const [mountComposer, setMountComposer] = useState(active);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
setMountComposer(false);
|
||||||
|
try {
|
||||||
|
ReactEditor.blur(editor);
|
||||||
|
} catch {
|
||||||
|
// Editor may already be disconnected.
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const raf = window.requestAnimationFrame(() => setMountComposer(true));
|
||||||
|
return () => window.cancelAnimationFrame(raf);
|
||||||
|
}, [active, editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) return undefined;
|
||||||
|
try {
|
||||||
|
ReactEditor.blur(editor);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [active, editor]);
|
||||||
|
|
||||||
|
if (!canMessage) {
|
||||||
|
return (
|
||||||
|
<RoomInputPlaceholder
|
||||||
|
style={{ padding: config.space.S200 }}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
>
|
||||||
|
<Text align="Center">You do not have permission to post in this room</Text>
|
||||||
|
</RoomInputPlaceholder>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate on `active` in render (not only state) so Slate unmounts the same
|
||||||
|
// commit the slot hides — waiting for useLayoutEffect is one frame too late.
|
||||||
|
if (active && mountComposer) {
|
||||||
|
return (
|
||||||
|
<RoomInput
|
||||||
|
room={room}
|
||||||
|
editor={editor}
|
||||||
|
roomId={roomId}
|
||||||
|
fileDropContainerRef={roomViewRef}
|
||||||
|
ref={roomInputRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={roomInputRef}>
|
||||||
|
<RoomInputPlaceholder style={{ padding: config.space.S200 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoomView = memo(function RoomView({
|
||||||
|
room,
|
||||||
|
eventId,
|
||||||
|
pending = false,
|
||||||
|
active = true,
|
||||||
|
}: {
|
||||||
|
room: Room;
|
||||||
|
eventId?: string;
|
||||||
|
/** Optimistic shell only — no live timeline (used for press→navigate gap). */
|
||||||
|
pending?: boolean;
|
||||||
|
/** False for hidden keep-alive slots — unmounts Slate only. */
|
||||||
|
active?: boolean;
|
||||||
|
}) {
|
||||||
const roomInputRef = useRef<HTMLDivElement>(null);
|
const roomInputRef = useRef<HTMLDivElement>(null);
|
||||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -69,6 +162,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const setPendingRoomId = useSetAtom(pendingRoomNavigationAtom);
|
||||||
|
|
||||||
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
||||||
const powerLevels = usePowerLevelsContext();
|
const powerLevels = usePowerLevelsContext();
|
||||||
@@ -78,11 +172,20 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
|
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
|
||||||
|
|
||||||
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
|
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
|
||||||
|
const showThread = !pending && !!activeThreadId;
|
||||||
|
const composerActive = active && !pending;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!composerActive) return;
|
||||||
|
setPendingRoomId((id) => (id === roomId ? null : id));
|
||||||
|
}, [composerActive, roomId, setPendingRoomId]);
|
||||||
|
|
||||||
useKeyDown(
|
useKeyDown(
|
||||||
window,
|
window,
|
||||||
useCallback(
|
useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
|
if (!composerActive) return;
|
||||||
|
if (getDefaultStore().get(activeRoomIdAtom) !== roomId) return;
|
||||||
if (editableActiveElement()) return;
|
if (editableActiveElement()) return;
|
||||||
const portalContainer = document.getElementById('portalContainer');
|
const portalContainer = document.getElementById('portalContainer');
|
||||||
if (portalContainer && portalContainer.children.length > 0) {
|
if (portalContainer && portalContainer.children.length > 0) {
|
||||||
@@ -90,33 +193,45 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
}
|
}
|
||||||
if (shouldFocusMessageField(evt)) {
|
if (shouldFocusMessageField(evt)) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
ReactEditor.focus(editor);
|
try {
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (evt.key.length === 1) {
|
if (evt.key.length === 1) {
|
||||||
editor.insertText(evt.key);
|
editor.insertText(evt.key);
|
||||||
}
|
}
|
||||||
} else if (isKeyHotkey('mod+v', evt)) {
|
} else if (isKeyHotkey('mod+v', evt)) {
|
||||||
ReactEditor.focus(editor);
|
try {
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
} catch {
|
||||||
|
// Composer may not be mounted yet.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editor]
|
[composerActive, editor, roomId]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page ref={roomViewRef}>
|
<Page ref={roomViewRef}>
|
||||||
<RoomViewHeader />
|
<RoomViewHeader />
|
||||||
{activeThreadId ? (
|
{showThread ? (
|
||||||
<ThreadView room={room} threadRootId={activeThreadId} />
|
<ThreadView room={room} threadRootId={activeThreadId!} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column" style={{ position: 'relative', minHeight: 0 }}>
|
||||||
<RoomTimeline
|
{!pending && (
|
||||||
key={roomId}
|
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
room={room}
|
<RoomTimeline
|
||||||
eventId={eventId}
|
key={roomId}
|
||||||
roomInputRef={roomInputRef}
|
room={room}
|
||||||
editor={editor}
|
eventId={eventId}
|
||||||
/>
|
roomInputRef={roomInputRef}
|
||||||
|
editor={editor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<RoomViewTyping room={room} />
|
<RoomViewTyping room={room} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" direction="Column">
|
<Box shrink="No" direction="Column">
|
||||||
@@ -128,26 +243,15 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<RoomComposerSlot
|
||||||
{canMessage && (
|
room={room}
|
||||||
<RoomInput
|
roomId={roomId}
|
||||||
room={room}
|
editor={editor}
|
||||||
editor={editor}
|
canMessage={canMessage}
|
||||||
roomId={roomId}
|
active={composerActive}
|
||||||
fileDropContainerRef={roomViewRef}
|
roomViewRef={roomViewRef}
|
||||||
ref={roomInputRef}
|
roomInputRef={roomInputRef}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{!canMessage && (
|
|
||||||
<RoomInputPlaceholder
|
|
||||||
style={{ padding: config.space.S200 }}
|
|
||||||
alignItems="Center"
|
|
||||||
justifyContent="Center"
|
|
||||||
>
|
|
||||||
<Text align="Center">You do not have permission to post in this room</Text>
|
|
||||||
</RoomInputPlaceholder>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||||
@@ -156,4 +260,4 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
|||||||
)}
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
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 { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom, useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
|
import { useRoomViewActive } from './RoomViewActiveContext';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { usePeopleDrawerSetting } from '../../state/hooks/peopleDrawer';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||||
@@ -358,12 +360,6 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
|
|||||||
callSupportLoading,
|
callSupportLoading,
|
||||||
} = useRoomCall(roomId);
|
} = useRoomCall(roomId);
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
React.useEffect(() => {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('RoomCallButtons render:', { callSupportLoading, callSupported, roomId });
|
|
||||||
}, [callSupportLoading, callSupported, roomId]);
|
|
||||||
|
|
||||||
if (callSupportLoading || !callSupported) {
|
if (callSupportLoading || !callSupported) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -465,6 +461,8 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const { showInPageHeader, goBack } = useBackRoute();
|
const { showInPageHeader, goBack } = useBackRoute();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
|
const isDirect = useIsDirectRoom();
|
||||||
|
const roomActive = useRoomViewActive();
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||||
@@ -480,7 +478,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [peopleDrawer, setPeopleDrawer] = usePeopleDrawerSetting(isDirect);
|
||||||
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
|
const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId));
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
@@ -579,8 +577,8 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" alignItems="Center" gap="100">
|
<Box shrink="No" alignItems="Center" gap="100">
|
||||||
<CallIndicator roomId={room.roomId} />
|
{roomActive && <CallIndicator roomId={room.roomId} />}
|
||||||
<RoomCallButtons roomId={room.roomId} />
|
{roomActive && <RoomCallButtons roomId={room.roomId} />}
|
||||||
{!ecryptedRoom && (
|
{!ecryptedRoom && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './Room';
|
export * from './Room';
|
||||||
|
export * from './CachedRooms';
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ interface PluginLoaderProps {
|
|||||||
* Should be placed high in the component tree after MatrixClient is available.
|
* Should be placed high in the component tree after MatrixClient is available.
|
||||||
*/
|
*/
|
||||||
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
||||||
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadPlugins = async () => {
|
const loadPlugins = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
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';
|
} from './useRoomsNotificationPreferences';
|
||||||
import { updateJoiningProgressAtom } from '../state/joiningProgress';
|
import { updateJoiningProgressAtom } from '../state/joiningProgress';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { fetchAllHierarchyRooms } from './useSpaceHierarchy';
|
||||||
|
|
||||||
const PER_PAGE_COUNT = 100;
|
|
||||||
const MAX_PAGES = 50;
|
|
||||||
const JOIN_DELAY_MS = 500;
|
const JOIN_DELAY_MS = 500;
|
||||||
|
|
||||||
/** Join rules that allow automatic joining */
|
/** Join rules that allow automatic joining */
|
||||||
const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted'];
|
const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted'];
|
||||||
|
|
||||||
|
/** Spaces we've already kicked off auto-join for this session (avoid repeat on revisit). */
|
||||||
|
const autoJoinStartedForSpace = new Set<string>();
|
||||||
|
|
||||||
|
/** Spaces currently running auto-join (avoid concurrent runs). */
|
||||||
const processingSpaces = new Set<string>();
|
const processingSpaces = new Set<string>();
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the server name from a Matrix room ID
|
|
||||||
* @param roomId - The room ID (e.g., "!abc123:matrix.org")
|
|
||||||
* @returns The server name (e.g., "matrix.org")
|
|
||||||
*/
|
|
||||||
function getServerFromRoomId(roomId: string): string {
|
function getServerFromRoomId(roomId: string): string {
|
||||||
const parts = roomId.split(':');
|
const parts = roomId.split(':');
|
||||||
return parts.length > 1 ? parts[1] : '';
|
return parts.length > 1 ? parts[1] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a room/space can be automatically joined based on its join rule
|
|
||||||
* @param hierarchyRoom - The room info from the space hierarchy
|
|
||||||
* @returns Whether the room/space should be auto-joined
|
|
||||||
*/
|
|
||||||
function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean {
|
function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean {
|
||||||
const { join_rule: joinRule } = hierarchyRoom;
|
const { join_rule: joinRule } = hierarchyRoom;
|
||||||
|
|
||||||
// Only join public or restricted rooms/spaces
|
|
||||||
// Restricted ones are joinable if you're a member of the parent space
|
|
||||||
if (!joinRule) {
|
if (!joinRule) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return AUTO_JOINABLE_RULES.includes(joinRule);
|
return AUTO_JOINABLE_RULES.includes(joinRule);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a hierarchy item is a space (including m.forum category spaces)
|
|
||||||
* @param hierarchyRoom - The room info from the space hierarchy
|
|
||||||
* @returns Whether the item is a space
|
|
||||||
*/
|
|
||||||
function isHierarchySpace(hierarchyRoom: IHierarchyRoom): boolean {
|
function isHierarchySpace(hierarchyRoom: IHierarchyRoom): boolean {
|
||||||
return hierarchyRoom.room_type === 'm.space' || hierarchyRoom.room_type === 'm.forum';
|
return hierarchyRoom.room_type === 'm.space' || hierarchyRoom.room_type === 'm.forum';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches all rooms from a space hierarchy
|
|
||||||
* @param mx - Matrix client
|
|
||||||
* @param spaceId - The space room ID
|
|
||||||
* @returns Array of hierarchy rooms
|
|
||||||
*/
|
|
||||||
async function fetchSpaceHierarchy(
|
|
||||||
mx: MatrixClient,
|
|
||||||
spaceId: string
|
|
||||||
): Promise<IHierarchyRoom[]> {
|
|
||||||
const allRooms: IHierarchyRoom[] = [];
|
|
||||||
let nextBatch: string | undefined;
|
|
||||||
let pageCount = 0;
|
|
||||||
|
|
||||||
while (pageCount < MAX_PAGES) {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
const result = await mx.getRoomHierarchy(spaceId, PER_PAGE_COUNT, undefined, false, nextBatch);
|
|
||||||
allRooms.push(...result.rooms);
|
|
||||||
nextBatch = result.next_batch;
|
|
||||||
pageCount += 1;
|
|
||||||
|
|
||||||
if (!nextBatch) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allRooms;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delays execution for a specified time
|
|
||||||
* @param ms - Milliseconds to delay
|
|
||||||
*/
|
|
||||||
function delay(ms: number): Promise<void> {
|
function delay(ms: number): Promise<void> {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
setTimeout(resolve, ms);
|
setTimeout(resolve, ms);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Callback for reporting join progress */
|
|
||||||
type ProgressCallback = (current: number, total: number) => void;
|
type ProgressCallback = (current: number, total: number) => void;
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts to join rooms/spaces with rate limiting and sets notification to Mentions & Keywords for rooms
|
|
||||||
* @param mx - Matrix client
|
|
||||||
* @param rooms - Array of hierarchy rooms to join
|
|
||||||
* @param spaceId - The parent space ID (for via servers)
|
|
||||||
* @param onProgress - Callback to report progress
|
|
||||||
*/
|
|
||||||
async function joinRoomsSequentially(
|
async function joinRoomsSequentially(
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
rooms: IHierarchyRoom[],
|
rooms: IHierarchyRoom[],
|
||||||
@@ -119,29 +58,23 @@ async function joinRoomsSequentially(
|
|||||||
const spaceServer = getServerFromRoomId(spaceId);
|
const spaceServer = getServerFromRoomId(spaceId);
|
||||||
|
|
||||||
const itemsToJoin = rooms.filter((room) => {
|
const itemsToJoin = rooms.filter((room) => {
|
||||||
// Check if already joined
|
|
||||||
const existingRoom = mx.getRoom(room.room_id);
|
const existingRoom = mx.getRoom(room.room_id);
|
||||||
if (existingRoom?.getMyMembership() === Membership.Join) {
|
if (existingRoom?.getMyMembership() === Membership.Join) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if can auto-join
|
|
||||||
return canAutoJoin(room);
|
return canAutoJoin(room);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Separate spaces and rooms - join spaces first (rooms may depend on space membership)
|
|
||||||
const spacesToJoin = itemsToJoin.filter(isHierarchySpace);
|
const spacesToJoin = itemsToJoin.filter(isHierarchySpace);
|
||||||
const roomsToJoin = itemsToJoin.filter((r) => !isHierarchySpace(r));
|
const roomsToJoin = itemsToJoin.filter((r) => !isHierarchySpace(r));
|
||||||
|
|
||||||
const total = itemsToJoin.length;
|
const total = itemsToJoin.length;
|
||||||
let current = 0;
|
let current = 0;
|
||||||
|
|
||||||
// Report initial progress
|
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
onProgress?.(current, total);
|
onProgress?.(current, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process item (space or room)
|
|
||||||
const joinItem = async (item: IHierarchyRoom, isItemSpace: boolean): Promise<void> => {
|
const joinItem = async (item: IHierarchyRoom, isItemSpace: boolean): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const itemServer = getServerFromRoomId(item.room_id);
|
const itemServer = getServerFromRoomId(item.room_id);
|
||||||
@@ -149,7 +82,6 @@ async function joinRoomsSequentially(
|
|||||||
|
|
||||||
await mx.joinRoom(item.room_id, { viaServers });
|
await mx.joinRoom(item.room_id, { viaServers });
|
||||||
|
|
||||||
// Only set notification preferences for rooms, not spaces
|
|
||||||
if (!isItemSpace) {
|
if (!isItemSpace) {
|
||||||
await setRoomNotificationPreference(
|
await setRoomNotificationPreference(
|
||||||
mx,
|
mx,
|
||||||
@@ -168,21 +100,18 @@ async function joinRoomsSequentially(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Join spaces first, then rooms
|
|
||||||
await spacesToJoin.reduce(
|
await spacesToJoin.reduce(
|
||||||
(promise, space) => promise.then(() => joinItem(space, true)),
|
(promise, item) => promise.then(() => joinItem(item, true)),
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
);
|
);
|
||||||
|
|
||||||
await roomsToJoin.reduce(
|
await roomsToJoin.reduce(
|
||||||
(promise, room) => promise.then(() => joinItem(room, false)),
|
(promise, item) => promise.then(() => joinItem(item, false)),
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Joins all auto-joinable children of a space (and nested spaces/rooms). */
|
||||||
* Joins all auto-joinable children of a space (and nested spaces/rooms).
|
|
||||||
*/
|
|
||||||
export async function autoJoinSpaceChildren(
|
export async function autoJoinSpaceChildren(
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
@@ -195,7 +124,7 @@ export async function autoJoinSpaceChildren(
|
|||||||
processingSpaces.add(spaceId);
|
processingSpaces.add(spaceId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hierarchyRooms = await fetchSpaceHierarchy(mx, spaceId);
|
const hierarchyRooms = await fetchAllHierarchyRooms(mx, spaceId, undefined);
|
||||||
const childRooms = hierarchyRooms.filter((r) => r.room_id !== spaceId);
|
const childRooms = hierarchyRooms.filter((r) => r.room_id !== spaceId);
|
||||||
await joinRoomsSequentially(mx, childRooms, spaceId, onProgress);
|
await joinRoomsSequentially(mx, childRooms, spaceId, onProgress);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -203,10 +132,7 @@ export async function autoJoinSpaceChildren(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Hook that automatically joins all accessible rooms when joining a space. */
|
||||||
* Hook that automatically joins all accessible rooms when joining a space
|
|
||||||
* @param mx - Matrix client instance
|
|
||||||
*/
|
|
||||||
export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
|
export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const setJoiningProgress = useSetAtom(updateJoiningProgressAtom);
|
const setJoiningProgress = useSetAtom(updateJoiningProgressAtom);
|
||||||
@@ -216,13 +142,9 @@ export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
|
|||||||
if (!settings.autoJoinSpaceRooms) {
|
if (!settings.autoJoinSpaceRooms) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process spaces (includes m.forum containers)
|
|
||||||
if (!isSpace(room)) {
|
if (!isSpace(room)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process when joining (not invites or leaves)
|
|
||||||
if (room.getMyMembership() !== Membership.Join) {
|
if (room.getMyMembership() !== Membership.Join) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -256,7 +178,8 @@ export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-joins space children when visiting an already-joined space (e.g. forum spaces).
|
* Auto-joins space children when visiting an already-joined space.
|
||||||
|
* Deferred so opening a space stays responsive.
|
||||||
*/
|
*/
|
||||||
export function useAutoJoinOnSpaceVisit(space: Room | undefined): void {
|
export function useAutoJoinOnSpaceVisit(space: Room | undefined): void {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -265,34 +188,57 @@ export function useAutoJoinOnSpaceVisit(space: Room | undefined): void {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!space || !settings.autoJoinSpaceRooms) {
|
if (!space || !settings.autoJoinSpaceRooms) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!isSpace(space)) {
|
if (!isSpace(space)) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (space.getMyMembership() !== Membership.Join) {
|
if (space.getMyMembership() !== Membership.Join) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spaceId = space.roomId;
|
const spaceId = space.roomId;
|
||||||
let cancelled = false;
|
if (autoJoinStartedForSpace.has(spaceId)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
void (async () => {
|
let cancelled = false;
|
||||||
try {
|
let idleId: number | undefined;
|
||||||
await autoJoinSpaceChildren(mx, spaceId, (current, total) => {
|
let timeoutId: number | undefined;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
if (cancelled || autoJoinStartedForSpace.has(spaceId)) return;
|
||||||
|
autoJoinStartedForSpace.add(spaceId);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await autoJoinSpaceChildren(mx, spaceId, (current, total) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setJoiningProgress({ spaceId, progress: { current, total } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setJoiningProgress({ spaceId, progress: { current, total } });
|
setJoiningProgress({ spaceId, progress: null });
|
||||||
}
|
}
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setJoiningProgress({ spaceId, progress: null });
|
|
||||||
}
|
}
|
||||||
}
|
})();
|
||||||
})();
|
};
|
||||||
|
|
||||||
|
if (typeof window.requestIdleCallback === 'function') {
|
||||||
|
idleId = window.requestIdleCallback(run, { timeout: 4000 });
|
||||||
|
} else {
|
||||||
|
timeoutId = window.setTimeout(run, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (idleId !== undefined && typeof window.cancelIdleCallback === 'function') {
|
||||||
|
window.cancelIdleCallback(idleId);
|
||||||
|
}
|
||||||
|
if (timeoutId !== undefined) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [space, settings.autoJoinSpaceRooms, mx, setJoiningProgress]);
|
}, [space, settings.autoJoinSpaceRooms, mx, setJoiningProgress]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import { useStateEvent } from './useStateEvent';
|
import { useStateEvent } from './useStateEvent';
|
||||||
import { StateEvent } from '../../types/matrix/room';
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
@@ -87,18 +87,35 @@ export const usePowerLevelsContext = (): IPowerLevels => {
|
|||||||
|
|
||||||
export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> => {
|
export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const getRoomsPowerLevels = useCallback(() => {
|
const roomIdsKey = useMemo(
|
||||||
|
() => rooms.map((room) => room.roomId).sort().join('\0'),
|
||||||
|
[rooms]
|
||||||
|
);
|
||||||
|
const roomById = useMemo(() => {
|
||||||
|
const map = new Map<string, Room>();
|
||||||
|
rooms.forEach((room) => map.set(room.roomId, room));
|
||||||
|
return map;
|
||||||
|
}, [roomIdsKey, rooms]);
|
||||||
|
|
||||||
|
const [overrides, setOverrides] = useState<Map<string, IPowerLevels>>(() => new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOverrides(new Map());
|
||||||
|
}, [roomIdsKey]);
|
||||||
|
|
||||||
|
const roomToPowerLevels = useMemo(() => {
|
||||||
const rToPl = new Map<string, IPowerLevels>();
|
const rToPl = new Map<string, IPowerLevels>();
|
||||||
|
roomById.forEach((room, roomId) => {
|
||||||
rooms.forEach((room) => {
|
const override = overrides.get(roomId);
|
||||||
|
if (override) {
|
||||||
|
rToPl.set(roomId, override);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mEvent = getStateEvent(room, StateEvent.RoomPowerLevels, '');
|
const mEvent = getStateEvent(room, StateEvent.RoomPowerLevels, '');
|
||||||
rToPl.set(room.roomId, getPowersLevelFromMatrixEvent(mEvent));
|
rToPl.set(roomId, getPowersLevelFromMatrixEvent(mEvent));
|
||||||
});
|
});
|
||||||
|
|
||||||
return rToPl;
|
return rToPl;
|
||||||
}, [rooms]);
|
}, [roomById, overrides]);
|
||||||
|
|
||||||
const [roomToPowerLevels, setRoomToPowerLevels] = useState(() => getRoomsPowerLevels());
|
|
||||||
|
|
||||||
useStateEventCallback(
|
useStateEventCallback(
|
||||||
mx,
|
mx,
|
||||||
@@ -107,14 +124,19 @@ export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> =>
|
|||||||
const roomId = event.getRoomId();
|
const roomId = event.getRoomId();
|
||||||
if (
|
if (
|
||||||
roomId &&
|
roomId &&
|
||||||
|
roomById.has(roomId) &&
|
||||||
event.getType() === StateEvent.RoomPowerLevels &&
|
event.getType() === StateEvent.RoomPowerLevels &&
|
||||||
event.getStateKey() === '' &&
|
event.getStateKey() === ''
|
||||||
rooms.find((r) => r.roomId === roomId)
|
|
||||||
) {
|
) {
|
||||||
setRoomToPowerLevels(getRoomsPowerLevels());
|
const pl = getPowersLevelFromMatrixEvent(event);
|
||||||
|
setOverrides((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(roomId, pl);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[rooms, getRoomsPowerLevels]
|
[roomById]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from 'matrix-js-sdk';
|
import { MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from 'matrix-js-sdk';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => {
|
export const useRoomMembers = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
enabled = true
|
||||||
|
): RoomMember[] => {
|
||||||
const [members, setMembers] = useState<RoomMember[]>([]);
|
const [members, setMembers] = useState<RoomMember[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return undefined;
|
||||||
|
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
let loadingMembers = true;
|
let loadingMembers = true;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -31,7 +37,7 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] =
|
|||||||
mx.removeListener(RoomMemberEvent.Membership, updateMemberList);
|
mx.removeListener(RoomMemberEvent.Membership, updateMemberList);
|
||||||
mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList);
|
mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList);
|
||||||
};
|
};
|
||||||
}, [mx, roomId]);
|
}, [mx, roomId, enabled]);
|
||||||
|
|
||||||
return members;
|
return members;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { isRoomId } from '../utils/matrix';
|
|||||||
import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
|
import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort';
|
||||||
import { useStateEventCallback } from './useStateEventCallback';
|
import { useStateEventCallback } from './useStateEventCallback';
|
||||||
import { ErrorCode } from '../cs-errorcode';
|
import { ErrorCode } from '../cs-errorcode';
|
||||||
|
import {
|
||||||
|
getCachedHierarchyRooms,
|
||||||
|
setCachedHierarchyRooms,
|
||||||
|
} from '../state/spaceHierarchyCache';
|
||||||
|
|
||||||
export type HierarchyItemSpace = {
|
export type HierarchyItemSpace = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -65,8 +69,14 @@ export function isHierarchySpaceRoom(room: IHierarchyRoom | undefined): boolean
|
|||||||
export async function fetchAllHierarchyRooms(
|
export async function fetchAllHierarchyRooms(
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
maxDepth = 3
|
maxDepth: number | undefined = 3,
|
||||||
|
options?: { skipCache?: boolean }
|
||||||
): Promise<IHierarchyRoom[]> {
|
): Promise<IHierarchyRoom[]> {
|
||||||
|
if (!options?.skipCache) {
|
||||||
|
const cached = getCachedHierarchyRooms(spaceId, maxDepth);
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
const allRooms: IHierarchyRoom[] = [];
|
const allRooms: IHierarchyRoom[] = [];
|
||||||
let nextBatch: string | undefined;
|
let nextBatch: string | undefined;
|
||||||
|
|
||||||
@@ -77,6 +87,7 @@ export async function fetchAllHierarchyRooms(
|
|||||||
if (!nextBatch) break;
|
if (!nextBatch) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCachedHierarchyRooms(spaceId, allRooms, maxDepth);
|
||||||
return allRooms;
|
return allRooms;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +437,7 @@ export const useFetchSpaceHierarchyLevel = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const queryResponse = useInfiniteQuery({
|
const queryResponse = useInfiniteQuery({
|
||||||
|
enabled: enable,
|
||||||
refetchOnMount: enable,
|
refetchOnMount: enable,
|
||||||
queryKey: [roomId, 'hierarchy_level'],
|
queryKey: [roomId, 'hierarchy_level'],
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
@@ -450,6 +462,7 @@ export const useFetchSpaceHierarchyLevel = (
|
|||||||
const { data, isLoading, isFetchingNextPage, error, fetchNextPage, hasNextPage } = queryResponse;
|
const { data, isLoading, isFetchingNextPage, error, fetchNextPage, hasNextPage } = queryResponse;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enable) return;
|
||||||
if (
|
if (
|
||||||
hasNextPage &&
|
hasNextPage &&
|
||||||
pageNoRef.current <= MAX_AUTO_PAGE_COUNT &&
|
pageNoRef.current <= MAX_AUTO_PAGE_COUNT &&
|
||||||
@@ -460,7 +473,7 @@ export const useFetchSpaceHierarchyLevel = (
|
|||||||
pageNoRef.current += 1;
|
pageNoRef.current += 1;
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}, [fetchNextPage, hasNextPage, data, error]);
|
}, [enable, fetchNextPage, hasNextPage, data, error]);
|
||||||
|
|
||||||
const rooms: Map<string, IHierarchyRoom> = useMemo(() => {
|
const rooms: Map<string, IHierarchyRoom> = useMemo(() => {
|
||||||
const roomsMap: Map<string, IHierarchyRoom> = new Map();
|
const roomsMap: Map<string, IHierarchyRoom> = new Map();
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { MatrixClient, MatrixEvent, RoomState, RoomStateEvent } from 'matrix-js-sdk';
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { subscribeStateEvents } from '../state/stateEventDemux';
|
||||||
|
import type { StateEventCallback } from './stateEventCallback.types';
|
||||||
|
|
||||||
export type StateEventCallback = (
|
export type { StateEventCallback } from './stateEventCallback.types';
|
||||||
event: MatrixEvent,
|
|
||||||
state: RoomState,
|
|
||||||
lastStateEvent: MatrixEvent | null
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
export const useStateEventCallback = (mx: MatrixClient, onStateEvent: StateEventCallback) => {
|
export const useStateEventCallback = (mx: MatrixClient, onStateEvent: StateEventCallback) => {
|
||||||
useEffect(() => {
|
useEffect(() => subscribeStateEvents(mx, onStateEvent), [mx, onStateEvent]);
|
||||||
mx.on(RoomStateEvent.Events, onStateEvent);
|
|
||||||
return () => {
|
|
||||||
mx.removeListener(RoomStateEvent.Events, onStateEvent);
|
|
||||||
};
|
|
||||||
}, [mx, onStateEvent]);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -252,64 +252,66 @@ const userColorCache = new LRUCache<string, { color: string | undefined; timesta
|
|||||||
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get another user's chosen profile color from their avatar metadata
|
* Hook to get another user's chosen profile color from their avatar metadata.
|
||||||
* @param userId - The user ID to get color for
|
* Cache hits are synchronous (no setState / no logs) so timeline remounts stay cheap.
|
||||||
* @param avatarMxc - The user's avatar MXC URL
|
|
||||||
* @returns The user's chosen color or undefined
|
|
||||||
*/
|
*/
|
||||||
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
|
export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
// Check cache synchronously BEFORE initializing state to avoid flash
|
|
||||||
const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : '';
|
const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : '';
|
||||||
const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined;
|
const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined;
|
||||||
const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS
|
const freshCached =
|
||||||
? cachedEntry.color
|
cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS ? cachedEntry : undefined;
|
||||||
: undefined;
|
|
||||||
|
// Only used while a color is being fetched for an uncached avatar.
|
||||||
const [color, setColor] = useState<string | undefined>(cachedColor);
|
const [fetchedColor, setFetchedColor] = useState<string | undefined>();
|
||||||
|
const [fetchedKey, setFetchedKey] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!avatarMxc) {
|
if (!avatarMxc) return undefined;
|
||||||
console.log('[useOtherUserColor] No avatarMxc provided for', userId);
|
|
||||||
setColor(undefined);
|
const key = `${userId}:${avatarMxc}`;
|
||||||
return;
|
const cached = userColorCache.get(key);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache first
|
let cancelled = false;
|
||||||
const cacheKey = `${userId}:${avatarMxc}`;
|
|
||||||
const cached = userColorCache.get(cacheKey);
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
||||||
console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color);
|
|
||||||
setColor(cached.color);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadColor = async () => {
|
const loadColor = async () => {
|
||||||
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication);
|
||||||
console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl);
|
|
||||||
if (!httpUrl) {
|
if (!httpUrl) {
|
||||||
console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc);
|
userColorCache.set(key, { color: undefined, timestamp: Date.now() });
|
||||||
setColor(undefined);
|
if (!cancelled) {
|
||||||
|
setFetchedKey(key);
|
||||||
|
setFetchedColor(undefined);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const accessToken = getCurrentAccessToken();
|
const accessToken = getCurrentAccessToken();
|
||||||
const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null);
|
const extractedColor = await fetchAndExtractColor(
|
||||||
|
httpUrl,
|
||||||
console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor);
|
useAuthentication ? accessToken : null
|
||||||
|
);
|
||||||
// Cache the result
|
|
||||||
userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() });
|
userColorCache.set(key, { color: extractedColor, timestamp: Date.now() });
|
||||||
|
if (!cancelled) {
|
||||||
setColor(extractedColor);
|
setFetchedKey(key);
|
||||||
|
setFetchedColor(extractedColor);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadColor();
|
loadColor();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [mx, useAuthentication, avatarMxc, userId]);
|
}, [mx, useAuthentication, avatarMxc, userId]);
|
||||||
|
|
||||||
return color;
|
if (!avatarMxc) return undefined;
|
||||||
|
if (freshCached) return freshCached.color;
|
||||||
|
if (fetchedKey === cacheKey) return fetchedColor;
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||||
import { User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
import { User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
|
||||||
@@ -22,36 +22,125 @@ const getUserPresence = (user: User): UserPresence => ({
|
|||||||
lastActiveTs: user.getLastActiveTs(),
|
lastActiveTs: user.getLastActiveTs(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const presenceEqual = (a: UserPresence | undefined, b: UserPresence | undefined): boolean => {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return (
|
||||||
|
a.presence === b.presence &&
|
||||||
|
a.status === b.status &&
|
||||||
|
a.active === b.active &&
|
||||||
|
a.lastActiveTs === b.lastActiveTs
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type PresenceEntry = {
|
||||||
|
/** Stable reference — only replaced when presence data changes. */
|
||||||
|
presence: UserPresence;
|
||||||
|
listeners: Set<() => void>;
|
||||||
|
user: User;
|
||||||
|
onChange: UserEventHandlerMap[UserEvent.Presence];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One EventEmitter subscription per userId, shared across all hook consumers. */
|
||||||
|
const presenceEntries = new Map<string, PresenceEntry>();
|
||||||
|
|
||||||
|
const subscribeNoop = () => () => undefined;
|
||||||
|
|
||||||
|
const bindUser = (userId: string, user: User): PresenceEntry => {
|
||||||
|
const existing = presenceEntries.get(userId);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.user === user) return existing;
|
||||||
|
existing.user.removeListener(UserEvent.Presence, existing.onChange);
|
||||||
|
existing.user.removeListener(UserEvent.CurrentlyActive, existing.onChange);
|
||||||
|
existing.user.removeListener(UserEvent.LastPresenceTs, existing.onChange);
|
||||||
|
presenceEntries.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: PresenceEntry = {
|
||||||
|
presence: getUserPresence(user),
|
||||||
|
listeners: new Set(),
|
||||||
|
user,
|
||||||
|
onChange: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
entry.onChange = (_event, u) => {
|
||||||
|
const current = presenceEntries.get(userId);
|
||||||
|
if (!current || u.userId !== current.user.userId) return;
|
||||||
|
const next = getUserPresence(current.user);
|
||||||
|
if (presenceEqual(current.presence, next)) return;
|
||||||
|
current.presence = next;
|
||||||
|
current.listeners.forEach((listener) => listener());
|
||||||
|
};
|
||||||
|
|
||||||
|
user.on(UserEvent.Presence, entry.onChange);
|
||||||
|
user.on(UserEvent.CurrentlyActive, entry.onChange);
|
||||||
|
user.on(UserEvent.LastPresenceTs, entry.onChange);
|
||||||
|
presenceEntries.set(userId, entry);
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseIfUnused = (userId: string) => {
|
||||||
|
const entry = presenceEntries.get(userId);
|
||||||
|
if (!entry || entry.listeners.size > 0) return;
|
||||||
|
entry.user.removeListener(UserEvent.Presence, entry.onChange);
|
||||||
|
entry.user.removeListener(UserEvent.CurrentlyActive, entry.onChange);
|
||||||
|
entry.user.removeListener(UserEvent.LastPresenceTs, entry.onChange);
|
||||||
|
presenceEntries.delete(userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live presence for a Matrix user. Safe to call from every message row —
|
||||||
|
* listeners are demuxed to one EventEmitter subscription per userId.
|
||||||
|
*/
|
||||||
export const useUserPresence = (userId: string): UserPresence | undefined => {
|
export const useUserPresence = (userId: string): UserPresence | undefined => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const user = userId ? mx.getUser(userId) : null;
|
// Cache getSnapshot result so useSyncExternalStore doesn't loop.
|
||||||
|
const snapshotRef = useRef<UserPresence | undefined>(undefined);
|
||||||
|
|
||||||
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
const subscribe = useCallback(
|
||||||
|
(onStoreChange: () => void) => {
|
||||||
|
if (!userId) return subscribeNoop();
|
||||||
|
|
||||||
useEffect(() => {
|
const user = mx.getUser(userId);
|
||||||
if (!user) {
|
if (!user) return subscribeNoop();
|
||||||
setPresence(undefined);
|
|
||||||
|
const entry = bindUser(userId, user);
|
||||||
|
snapshotRef.current = entry.presence;
|
||||||
|
entry.listeners.add(onStoreChange);
|
||||||
|
return () => {
|
||||||
|
entry.listeners.delete(onStoreChange);
|
||||||
|
releaseIfUnused(userId);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[mx, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSnapshot = useCallback(() => {
|
||||||
|
if (!userId) {
|
||||||
|
snapshotRef.current = undefined;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const cached = presenceEntries.get(userId);
|
||||||
|
if (cached) {
|
||||||
|
snapshotRef.current = cached.presence;
|
||||||
|
return cached.presence;
|
||||||
|
}
|
||||||
|
const user = mx.getUser(userId);
|
||||||
|
if (!user) {
|
||||||
|
snapshotRef.current = undefined;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Not subscribed yet — return a stable fallback without allocating each read
|
||||||
|
// once we have a previous snapshot that still matches.
|
||||||
|
const next = getUserPresence(user);
|
||||||
|
if (presenceEqual(snapshotRef.current, next)) {
|
||||||
|
return snapshotRef.current;
|
||||||
|
}
|
||||||
|
snapshotRef.current = next;
|
||||||
|
return next;
|
||||||
|
}, [mx, userId]);
|
||||||
|
|
||||||
setPresence(getUserPresence(user));
|
return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
|
||||||
|
|
||||||
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => {
|
|
||||||
if (u.userId === user.userId) {
|
|
||||||
setPresence(getUserPresence(user));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
user.on(UserEvent.Presence, updatePresence);
|
|
||||||
user.on(UserEvent.CurrentlyActive, updatePresence);
|
|
||||||
user.on(UserEvent.LastPresenceTs, updatePresence);
|
|
||||||
return () => {
|
|
||||||
user.removeListener(UserEvent.Presence, updatePresence);
|
|
||||||
user.removeListener(UserEvent.CurrentlyActive, updatePresence);
|
|
||||||
user.removeListener(UserEvent.LastPresenceTs, updatePresence);
|
|
||||||
};
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
return presence;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePresenceLabel = (): Record<Presence, string> =>
|
export const usePresenceLabel = (): Record<Presence, string> =>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
import { MutableRefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||||
import { OnIntersectionCallback, useIntersectionObserver } from './useIntersectionObserver';
|
import { OnIntersectionCallback, useIntersectionObserver } from './useIntersectionObserver';
|
||||||
import {
|
import {
|
||||||
canFitInScrollView,
|
canFitInScrollView,
|
||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
isIntersectingScrollView,
|
isIntersectingScrollView,
|
||||||
scrollToPosition,
|
scrollToPosition,
|
||||||
} from '../utils/dom';
|
} from '../utils/dom';
|
||||||
|
import {
|
||||||
|
armRoomTimelineNetworkPaginate,
|
||||||
|
canRoomTimelineNetworkPaginate,
|
||||||
|
} from '../state/roomTimelineVisibilityGate';
|
||||||
|
|
||||||
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
|
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
|
||||||
|
|
||||||
@@ -55,6 +59,15 @@ type VirtualPaginatorOptions<TScrollElement extends HTMLElement> = {
|
|||||||
getScrollElement: () => TScrollElement | null;
|
getScrollElement: () => TScrollElement | null;
|
||||||
getItemElement: (index: number) => HTMLElement | undefined;
|
getItemElement: (index: number) => HTMLElement | undefined;
|
||||||
onEnd?: (back: boolean) => void;
|
onEnd?: (back: boolean) => void;
|
||||||
|
/** When false, skip observe/paginate (keep-alive inactive rooms). */
|
||||||
|
enabled?: boolean;
|
||||||
|
/**
|
||||||
|
* Mutable gate for keep-alive visibility. Prefer this over flipping `enabled`
|
||||||
|
* so parent re-renders don't tear down the timeline tree.
|
||||||
|
*/
|
||||||
|
enabledRef?: MutableRefObject<boolean>;
|
||||||
|
/** Room id for keep-alive network-paginate suppress across CSS-only switches. */
|
||||||
|
roomId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VirtualPaginator = {
|
type VirtualPaginator = {
|
||||||
@@ -163,9 +176,42 @@ const useObserveAnchorHandle = (
|
|||||||
export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
||||||
options: VirtualPaginatorOptions<TScrollElement>
|
options: VirtualPaginatorOptions<TScrollElement>
|
||||||
): VirtualPaginator => {
|
): VirtualPaginator => {
|
||||||
const { count, limit, range, onRangeChange, getScrollElement, getItemElement, onEnd } = options;
|
const {
|
||||||
|
count,
|
||||||
|
limit,
|
||||||
|
range,
|
||||||
|
onRangeChange,
|
||||||
|
getScrollElement,
|
||||||
|
getItemElement,
|
||||||
|
onEnd,
|
||||||
|
enabled = true,
|
||||||
|
enabledRef,
|
||||||
|
roomId,
|
||||||
|
} = options;
|
||||||
|
|
||||||
const initialRenderRef = useRef(true);
|
const initialRenderRef = useRef(true);
|
||||||
|
/** After keep-alive re-show, don't network-paginate until the user scrolls. */
|
||||||
|
const allowNetworkPaginateRef = useRef(true);
|
||||||
|
const enabledRefStable = enabledRef;
|
||||||
|
|
||||||
|
const isPaginatorEnabled = useCallback(() => {
|
||||||
|
if (!enabled) return false;
|
||||||
|
if (enabledRefStable && !enabledRefStable.current) return false;
|
||||||
|
return true;
|
||||||
|
}, [enabled, enabledRefStable]);
|
||||||
|
|
||||||
|
const canNetworkPaginate = useCallback(() => {
|
||||||
|
if (!allowNetworkPaginateRef.current) return false;
|
||||||
|
if (roomId && !canRoomTimelineNetworkPaginate(roomId)) return false;
|
||||||
|
return true;
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
|
// Arm suppress while hidden so re-show can't race intersection → paginate/decrypt.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
allowNetworkPaginateRef.current = false;
|
||||||
|
}
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
const restoreScrollRef = useRef<{
|
const restoreScrollRef = useRef<{
|
||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
@@ -269,6 +315,10 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
|
|
||||||
const paginate = useCallback(
|
const paginate = useCallback(
|
||||||
(direction: Direction) => {
|
(direction: Direction) => {
|
||||||
|
if (!isPaginatorEnabled()) {
|
||||||
|
allowNetworkPaginateRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const scrollEl = getScrollElement();
|
const scrollEl = getScrollElement();
|
||||||
const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current;
|
const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current;
|
||||||
let { start, end } = currentRange;
|
let { start, end } = currentRange;
|
||||||
@@ -276,7 +326,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
if (direction === Direction.Backward) {
|
if (direction === Direction.Backward) {
|
||||||
restoreScrollRef.current = undefined;
|
restoreScrollRef.current = undefined;
|
||||||
if (start === 0) {
|
if (start === 0) {
|
||||||
onEnd?.(true);
|
if (canNetworkPaginate()) onEnd?.(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (scrollEl) {
|
if (scrollEl) {
|
||||||
@@ -294,7 +344,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
if (direction === Direction.Forward) {
|
if (direction === Direction.Forward) {
|
||||||
restoreScrollRef.current = undefined;
|
restoreScrollRef.current = undefined;
|
||||||
if (end === currentCount) {
|
if (end === currentCount) {
|
||||||
onEnd?.(false);
|
if (canNetworkPaginate()) onEnd?.(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (scrollEl) {
|
if (scrollEl) {
|
||||||
@@ -315,11 +365,15 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
end,
|
end,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[getScrollElement, getItemElement, onEnd, onRangeChange]
|
[getScrollElement, getItemElement, onEnd, onRangeChange, isPaginatorEnabled, canNetworkPaginate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePaginatorElIntersection: OnIntersectionCallback = useCallback(
|
const handlePaginatorElIntersection: OnIntersectionCallback = useCallback(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
|
if (!isPaginatorEnabled()) {
|
||||||
|
allowNetworkPaginateRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const anchorB = entries.find(
|
const anchorB = entries.find(
|
||||||
(entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Backward
|
(entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Backward
|
||||||
);
|
);
|
||||||
@@ -333,7 +387,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
paginate(Direction.Forward);
|
paginate(Direction.Forward);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[paginate]
|
[paginate, isPaginatorEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
const intersectionObserver = useIntersectionObserver(
|
const intersectionObserver = useIntersectionObserver(
|
||||||
@@ -346,6 +400,7 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Keep observers attached for warm keep-alive rooms; gate in handlers via enabledRef.
|
||||||
const observeBackAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Backward);
|
const observeBackAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Backward);
|
||||||
const observeFrontAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Forward);
|
const observeFrontAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Forward);
|
||||||
|
|
||||||
@@ -390,9 +445,13 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
// check if pagination anchor are in visible view height
|
// check if pagination anchor are in visible view height
|
||||||
// and trigger pagination
|
// and trigger pagination
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialRenderRef.current) {
|
if (!isPaginatorEnabled()) {
|
||||||
// Do not trigger pagination on initial render
|
allowNetworkPaginateRef.current = false;
|
||||||
// anchor intersection observable will trigger pagination on mount
|
return;
|
||||||
|
}
|
||||||
|
// Skip the first cycle after mount OR while network paginate is suppressed
|
||||||
|
// (keep-alive re-show) — otherwise intersecting anchors re-trigger decrypt.
|
||||||
|
if (initialRenderRef.current || !canNetworkPaginate()) {
|
||||||
initialRenderRef.current = false;
|
initialRenderRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -412,7 +471,20 @@ export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
|
|||||||
if (frontAnchor && isIntersectingScrollView(scrollElement, frontAnchor)) {
|
if (frontAnchor && isIntersectingScrollView(scrollElement, frontAnchor)) {
|
||||||
paginate(Direction.Forward);
|
paginate(Direction.Forward);
|
||||||
}
|
}
|
||||||
}, [range, getScrollElement, paginate]);
|
}, [range, getScrollElement, paginate, isPaginatorEnabled, canNetworkPaginate]);
|
||||||
|
|
||||||
|
// Re-arm network pagination after the user scrolls (keep-alive re-show safe).
|
||||||
|
useEffect(() => {
|
||||||
|
const el = getScrollElement();
|
||||||
|
if (!el) return undefined;
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!isPaginatorEnabled()) return;
|
||||||
|
allowNetworkPaginateRef.current = true;
|
||||||
|
if (roomId) armRoomTimelineNetworkPaginate(roomId);
|
||||||
|
};
|
||||||
|
el.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener('scroll', onScroll);
|
||||||
|
}, [isPaginatorEnabled, getScrollElement, range, roomId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getItems,
|
getItems,
|
||||||
|
|||||||
@@ -40,15 +40,14 @@ import {
|
|||||||
getOriginBaseUrl,
|
getOriginBaseUrl,
|
||||||
} from './pathUtils';
|
} from './pathUtils';
|
||||||
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
||||||
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
import { Home, HomeLayout, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
||||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
import { Direct, DirectCreate, DirectLayout, DirectRouteRoomProvider } from './client/direct';
|
||||||
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
|
import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch, SpaceIndexRedirect } from './client/space';
|
||||||
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
|
import { ForumAwareSpaceLayout } from '../features/forum/ForumAwareSpaceLayout';
|
||||||
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
|
import { ForumFeedPage } from '../features/forum/ForumFeedPage';
|
||||||
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
||||||
import { Notifications, Inbox, Invites } from './client/inbox';
|
import { Notifications, Inbox, Invites } from './client/inbox';
|
||||||
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
|
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
|
||||||
import { Room } from '../features/room';
|
|
||||||
import { Lobby } from '../features/lobby';
|
import { Lobby } from '../features/lobby';
|
||||||
import { WelcomePage } from './client/WelcomePage';
|
import { WelcomePage } from './client/WelcomePage';
|
||||||
import { SidebarNav } from './client/SidebarNav';
|
import { SidebarNav } from './client/SidebarNav';
|
||||||
@@ -190,7 +189,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
</MobileFriendlyPageNav>
|
</MobileFriendlyPageNav>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AnimatedOutlet />
|
<HomeLayout />
|
||||||
</PageRoot>
|
</PageRoot>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -198,14 +197,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
<Route path={_CREATE_PATH} element={<HomeCreateRoom />} />
|
<Route path={_CREATE_PATH} element={<HomeCreateRoom />} />
|
||||||
<Route path={_JOIN_PATH} element={<p>join</p>} />
|
<Route path={_JOIN_PATH} element={<p>join</p>} />
|
||||||
<Route path={_SEARCH_PATH} element={<HomeSearch />} />
|
<Route path={_SEARCH_PATH} element={<HomeSearch />} />
|
||||||
<Route
|
<Route path={_ROOM_PATH} element={<HomeRouteRoomProvider />} />
|
||||||
path={_ROOM_PATH}
|
|
||||||
element={
|
|
||||||
<HomeRouteRoomProvider>
|
|
||||||
<Room />
|
|
||||||
</HomeRouteRoomProvider>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path={DIRECT_PATH}
|
path={DIRECT_PATH}
|
||||||
@@ -217,7 +209,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
</MobileFriendlyPageNav>
|
</MobileFriendlyPageNav>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AnimatedOutlet />
|
<DirectLayout />
|
||||||
</PageRoot>
|
</PageRoot>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -225,14 +217,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
<Route path={_CREATE_PATH} element={<DirectCreate />} />
|
||||||
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
<Route path={_NOTIFICATIONS_PATH} element={<Notifications />} />
|
||||||
<Route path={_INVITES_PATH} element={<Invites />} />
|
<Route path={_INVITES_PATH} element={<Invites />} />
|
||||||
<Route
|
<Route path={_ROOM_PATH} element={<DirectRouteRoomProvider />} />
|
||||||
path={_ROOM_PATH}
|
|
||||||
element={
|
|
||||||
<DirectRouteRoomProvider>
|
|
||||||
<Room />
|
|
||||||
</DirectRouteRoomProvider>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path={SPACE_PATH}
|
path={SPACE_PATH}
|
||||||
@@ -246,14 +231,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
<Route path={_FEED_PATH} element={<ForumFeedPage />} />
|
<Route path={_FEED_PATH} element={<ForumFeedPage />} />
|
||||||
<Route path={_LOBBY_PATH} element={<Lobby />} />
|
<Route path={_LOBBY_PATH} element={<Lobby />} />
|
||||||
<Route path={_SEARCH_PATH} element={<SpaceSearch />} />
|
<Route path={_SEARCH_PATH} element={<SpaceSearch />} />
|
||||||
<Route
|
<Route path={_ROOM_PATH} element={<SpaceRouteRoomProvider />} />
|
||||||
path={_ROOM_PATH}
|
|
||||||
element={
|
|
||||||
<SpaceRouteRoomProvider>
|
|
||||||
<Room />
|
|
||||||
</SpaceRouteRoomProvider>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path={EXPLORE_PATH}
|
path={EXPLORE_PATH}
|
||||||
|
|||||||
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 { useParams } from 'react-router-dom';
|
||||||
import { decodeRouteParam } from '../../pathUtils';
|
import { decodeRouteParam } from '../../pathUtils';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||||
import { useDirectRooms } from './useDirectRooms';
|
import { useDirectRooms } from './useDirectRooms';
|
||||||
|
|
||||||
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
|
/**
|
||||||
|
* Room-route gate only. The actual room UI is rendered by DirectLayout's
|
||||||
|
* CachedRooms host so keep-alive survives room id changes.
|
||||||
|
*/
|
||||||
|
export function DirectRouteRoomProvider() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const rooms = useDirectRooms();
|
const rooms = useDirectRooms();
|
||||||
|
|
||||||
@@ -26,9 +29,6 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Valid room — DirectLayout / CachedRooms owns the UI.
|
||||||
<RoomProvider key={room.roomId} value={room}>
|
return null;
|
||||||
<IsDirectRoomProvider value>{children}</IsDirectRoomProvider>
|
|
||||||
</RoomProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './Direct';
|
export * from './Direct';
|
||||||
|
export * from './DirectLayout';
|
||||||
export * from './RoomProvider';
|
export * from './RoomProvider';
|
||||||
export * from './DirectCreate';
|
export * from './DirectCreate';
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
|||||||
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
|
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
|
||||||
import { _RoomSearchParams } from '../../paths';
|
import { _RoomSearchParams } from '../../paths';
|
||||||
import { StateEvent } from '../../../../types/matrix/room';
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { useGlobalSubRoomIds } from '../../../state/globalSubRoomIds';
|
||||||
|
|
||||||
type HomeMenuProps = {
|
type HomeMenuProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -256,6 +257,8 @@ export function Home() {
|
|||||||
return items;
|
return items;
|
||||||
}, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
|
}, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
|
||||||
|
|
||||||
|
const globalSubRoomIds = useGlobalSubRoomIds();
|
||||||
|
|
||||||
// Flatten rooms and their sub-rooms into a hierarchical list
|
// Flatten rooms and their sub-rooms into a hierarchical list
|
||||||
const listItems = useMemo(() => {
|
const listItems = useMemo(() => {
|
||||||
const items: RoomListItem[] = [];
|
const items: RoomListItem[] = [];
|
||||||
@@ -285,26 +288,15 @@ export function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build set of all sub-room IDs to identify top-level rooms
|
|
||||||
const allSubRoomIds = new Set<string>();
|
|
||||||
sortedRooms.forEach(roomId => {
|
|
||||||
const room = mx.getRoom(roomId);
|
|
||||||
if (room) {
|
|
||||||
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
|
|
||||||
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
|
|
||||||
subRooms.forEach(id => allSubRoomIds.add(id));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only add top-level rooms (rooms that aren't sub-rooms of another)
|
// Only add top-level rooms (rooms that aren't sub-rooms of another)
|
||||||
sortedRooms.forEach(roomId => {
|
sortedRooms.forEach(roomId => {
|
||||||
if (!allSubRoomIds.has(roomId)) {
|
if (!globalSubRoomIds.has(roomId)) {
|
||||||
addRoomAndSubRooms(roomId);
|
addRoomAndSubRooms(roomId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [mx, sortedRooms]);
|
}, [mx, sortedRooms, globalSubRoomIds]);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: listItems.length,
|
count: listItems.length,
|
||||||
|
|||||||
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 { useParams } from 'react-router-dom';
|
||||||
import { decodeRouteParam } from '../../pathUtils';
|
import { decodeRouteParam } from '../../pathUtils';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||||
import { useHomeRooms } from './useHomeRooms';
|
import { useHomeRooms } from './useHomeRooms';
|
||||||
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
||||||
|
|
||||||
export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
|
/**
|
||||||
|
* Room-route gate only. HomeLayout's CachedRooms owns the UI.
|
||||||
|
*/
|
||||||
|
export function HomeRouteRoomProvider() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const rooms = useHomeRooms();
|
const rooms = useHomeRooms();
|
||||||
|
|
||||||
@@ -29,9 +31,5 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return null;
|
||||||
<RoomProvider key={room.roomId} value={room}>
|
|
||||||
<IsDirectRoomProvider value={false}>{children}</IsDirectRoomProvider>
|
|
||||||
</RoomProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './Home';
|
export * from './Home';
|
||||||
|
export * from './HomeLayout';
|
||||||
export * from './Search';
|
export * from './Search';
|
||||||
export * from './RoomProvider';
|
export * from './RoomProvider';
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
|||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { useOrphanRooms } from '../../../state/hooks/roomList';
|
import { useOrphanRooms } from '../../../state/hooks/roomList';
|
||||||
import { RoomType, StateEvent } from '../../../../types/matrix/room';
|
import { RoomType, StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { useGlobalSubRoomIds } from '../../../state/globalSubRoomIds';
|
||||||
|
|
||||||
export const useHomeRooms = () => {
|
export const useHomeRooms = () => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
|
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
|
||||||
|
const globalSubRoomIds = useGlobalSubRoomIds();
|
||||||
|
|
||||||
// Filter out rooms that are sub-rooms of ANY room (not just orphan rooms)
|
// Filter out rooms that are sub-rooms of ANY room (not just orphan rooms)
|
||||||
const rooms = useMemo(() => {
|
const rooms = useMemo(() => {
|
||||||
@@ -34,17 +36,10 @@ export const useHomeRooms = () => {
|
|||||||
return roomType === RoomType.Space || roomType === RoomType.Forum;
|
return roomType === RoomType.Space || roomType === RoomType.Forum;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a set of all sub-room IDs from ALL joined rooms
|
return orphanRooms.filter(
|
||||||
const allSubRoomIds = new Set<string>();
|
(roomId) => !globalSubRoomIds.has(roomId) && !isSpaceLikeRoom(roomId)
|
||||||
mx.getRooms().forEach((room) => {
|
);
|
||||||
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
|
}, [mx, orphanRooms, globalSubRoomIds]);
|
||||||
const subRooms = subRoomsEvent?.getContent<{ children: string[] }>()?.children ?? [];
|
|
||||||
subRooms.forEach((id) => allSubRoomIds.add(id));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return only rooms that are not sub-rooms of another room
|
|
||||||
return orphanRooms.filter((roomId) => !allSubRoomIds.has(roomId) && !isSpaceLikeRoom(roomId));
|
|
||||||
}, [mx, orphanRooms]);
|
|
||||||
|
|
||||||
return rooms;
|
return rooms;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { decodeRouteParam } from '../../pathUtils';
|
import { decodeRouteParam } from '../../pathUtils';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||||
import { useSpace } from '../../../hooks/useSpace';
|
import { useSpace } from '../../../hooks/useSpace';
|
||||||
@@ -11,16 +10,17 @@ import { getAllParents, getSpaceChildren, isSpace } from '../../../utils/room';
|
|||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
|
||||||
export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
/**
|
||||||
|
* Room-route gate only. SpaceRoomLayout's CachedRooms owns the UI.
|
||||||
|
*/
|
||||||
|
export function SpaceRouteRoomProvider() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const space = useSpace();
|
const space = useSpace();
|
||||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom);
|
const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
|
||||||
const allRooms = useAtomValue(allRoomsAtom);
|
const allRooms = useAtomValue(allRoomsAtom);
|
||||||
|
|
||||||
const { roomIdOrAlias: rawRoomIdOrAlias, eventId: rawEventId } = useParams();
|
const { roomIdOrAlias: rawRoomIdOrAlias, eventId: rawEventId } = useParams();
|
||||||
@@ -31,7 +31,6 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
if (!room || !allRooms.includes(room.roomId)) {
|
if (!room || !allRooms.includes(room.roomId)) {
|
||||||
// room is not joined
|
|
||||||
return (
|
return (
|
||||||
<JoinBeforeNavigate
|
<JoinBeforeNavigate
|
||||||
roomIdOrAlias={roomIdOrAlias ?? rawRoomIdOrAlias!}
|
roomIdOrAlias={roomIdOrAlias ?? rawRoomIdOrAlias!}
|
||||||
@@ -42,17 +41,11 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (developerTools && isSpace(room) && room.roomId === space.roomId) {
|
if (developerTools && isSpace(room) && room.roomId === space.roomId) {
|
||||||
// allow to view space timeline
|
return null;
|
||||||
return (
|
|
||||||
<RoomProvider key={room.roomId} value={room}>
|
|
||||||
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
|
|
||||||
</RoomProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
|
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
|
||||||
if (getSpaceChildren(space).includes(room.roomId)) {
|
if (getSpaceChildren(space).includes(room.roomId)) {
|
||||||
// fill missing roomToParent mapping
|
|
||||||
setRoomToParents({
|
setRoomToParents({
|
||||||
type: 'PUT',
|
type: 'PUT',
|
||||||
parent: space.roomId,
|
parent: space.roomId,
|
||||||
@@ -69,9 +62,5 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return null;
|
||||||
<RoomProvider key={room.roomId} value={room}>
|
|
||||||
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
|
|
||||||
</RoomProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { Membership, StateEvent } from '../../../../types/matrix/room';
|
|||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
|
import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot';
|
||||||
import { getMatrixToRoom } from '../../../plugins/matrix-to';
|
import { getMatrixToRoom } from '../../../plugins/matrix-to';
|
||||||
|
import { useGlobalSubRoomIds } from '../../../state/globalSubRoomIds';
|
||||||
import { getViaServers } from '../../../plugins/via-servers';
|
import { getViaServers } from '../../../plugins/via-servers';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
@@ -255,17 +256,7 @@ export function Space() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build a global set of all sub-room IDs to filter from hierarchy
|
const globalSubRoomIds = useGlobalSubRoomIds();
|
||||||
// This prevents sub-rooms from appearing both as top-level entries AND nested under parents
|
|
||||||
const globalSubRoomIds = useMemo(() => {
|
|
||||||
const subRoomIds = new Set<string>();
|
|
||||||
mx.getRooms().forEach((room) => {
|
|
||||||
const subRoomsEvent = room.currentState.getStateEvents(StateEvent.PaarrotSubRooms, '');
|
|
||||||
const content = subRoomsEvent?.getContent<{ children: string[] }>();
|
|
||||||
content?.children?.forEach((childId: string) => subRoomIds.add(childId));
|
|
||||||
});
|
|
||||||
return subRoomIds;
|
|
||||||
}, [mx, allJoinedRooms, hierarchy]);
|
|
||||||
|
|
||||||
// Filter hierarchy to exclude sub-rooms (they'll be shown nested under their parent)
|
// Filter hierarchy to exclude sub-rooms (they'll be shown nested under their parent)
|
||||||
const filteredHierarchy = useMemo(() => {
|
const filteredHierarchy = useMemo(() => {
|
||||||
|
|||||||
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 './SpaceIndexRedirect';
|
||||||
export * from './SpaceProvider';
|
export * from './SpaceProvider';
|
||||||
export * from './Space';
|
export * from './Space';
|
||||||
|
export * from './SpaceRoomLayout';
|
||||||
export * from './Search';
|
export * from './Search';
|
||||||
export * from './RoomProvider';
|
export * from './RoomProvider';
|
||||||
|
|||||||
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 { encodeBlurHash, validBlurHash } from '../utils/blurHash';
|
||||||
|
import { fitMediaSize } from '../utils/common';
|
||||||
|
|
||||||
const STORAGE_KEY = 'paarrot.media.meta';
|
const STORAGE_KEY = 'paarrot.media.meta';
|
||||||
const LEGACY_DIMENSIONS_KEY = 'paarrot.media.dimensions';
|
const LEGACY_DIMENSIONS_KEY = 'paarrot.media.dimensions';
|
||||||
const MAX_ENTRIES = 500;
|
const MAX_ENTRIES = 500;
|
||||||
|
|
||||||
|
/** Same max box as MImage / MVideo attachment rendering. */
|
||||||
|
export const ATTACHMENT_MAX_SIZE = 400;
|
||||||
|
|
||||||
export type MediaMeta = {
|
export type MediaMeta = {
|
||||||
w: number;
|
w: number;
|
||||||
h: number;
|
h: number;
|
||||||
@@ -88,6 +92,19 @@ export const getMediaDimensions = (mxcUrl: string): MediaDimensions | undefined
|
|||||||
return { w: entry.w, h: entry.h };
|
return { w: entry.w, h: entry.h };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Fit attachment box size using event info, then local dimension cache. */
|
||||||
|
export const resolveAttachmentBoxSize = (
|
||||||
|
mxcUrl: string | undefined,
|
||||||
|
w?: number,
|
||||||
|
h?: number,
|
||||||
|
maxSize: number = ATTACHMENT_MAX_SIZE
|
||||||
|
): { width: number; height: number } => {
|
||||||
|
const cached = mxcUrl && (!w || !h) ? getMediaDimensions(mxcUrl) : undefined;
|
||||||
|
const naturalW = w && w > 0 ? w : cached?.w;
|
||||||
|
const naturalH = h && h > 0 ? h : cached?.h;
|
||||||
|
return fitMediaSize(naturalW ?? 0, naturalH ?? 0, maxSize, maxSize);
|
||||||
|
};
|
||||||
|
|
||||||
export const setMediaDimensions = (mxcUrl: string, w: number, h: number): void => {
|
export const setMediaDimensions = (mxcUrl: string, w: number, h: number): void => {
|
||||||
if (!mxcUrl || w <= 0 || h <= 0) return;
|
if (!mxcUrl || w <= 0 || h <= 0) return;
|
||||||
touchEntry(mxcUrl, { w, h });
|
touchEntry(mxcUrl, { w, h });
|
||||||
|
|||||||
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;
|
pageZoom: number;
|
||||||
hideActivity: boolean;
|
hideActivity: boolean;
|
||||||
|
|
||||||
isPeopleDrawer: boolean;
|
/** Last open/closed state of the members panel in non-DM rooms. */
|
||||||
|
peopleDrawerInRooms: boolean;
|
||||||
|
/** Last open/closed state of the members panel in DMs. */
|
||||||
|
peopleDrawerInDirects: boolean;
|
||||||
isCallPanelDocked: boolean;
|
isCallPanelDocked: boolean;
|
||||||
callPanelDockPosition: CallPanelDockPosition;
|
callPanelDockPosition: CallPanelDockPosition;
|
||||||
memberSortFilterIndex: number;
|
memberSortFilterIndex: number;
|
||||||
@@ -79,7 +82,8 @@ const defaultSettings: Settings = {
|
|||||||
pageZoom: 100,
|
pageZoom: 100,
|
||||||
hideActivity: false,
|
hideActivity: false,
|
||||||
|
|
||||||
isPeopleDrawer: true,
|
peopleDrawerInRooms: true,
|
||||||
|
peopleDrawerInDirects: false,
|
||||||
isCallPanelDocked: false,
|
isCallPanelDocked: false,
|
||||||
callPanelDockPosition: 'right',
|
callPanelDockPosition: 'right',
|
||||||
memberSortFilterIndex: 0,
|
memberSortFilterIndex: 0,
|
||||||
@@ -112,15 +116,29 @@ const defaultSettings: Settings = {
|
|||||||
export const getSettings = () => {
|
export const getSettings = () => {
|
||||||
const settings = localStorage.getItem(STORAGE_KEY);
|
const settings = localStorage.getItem(STORAGE_KEY);
|
||||||
if (settings === null) return defaultSettings;
|
if (settings === null) return defaultSettings;
|
||||||
|
|
||||||
const parsed = JSON.parse(settings) as Settings & { twitterEmoji?: boolean };
|
const parsed = JSON.parse(settings) as Settings & {
|
||||||
|
twitterEmoji?: boolean;
|
||||||
|
isPeopleDrawer?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// Migrate old twitterEmoji boolean to new emojiStyle enum
|
// Migrate old twitterEmoji boolean to new emojiStyle enum
|
||||||
if ('twitterEmoji' in parsed && parsed.emojiStyle === undefined) {
|
if ('twitterEmoji' in parsed && parsed.emojiStyle === undefined) {
|
||||||
parsed.emojiStyle = parsed.twitterEmoji ? EmojiStyle.Twemoji : EmojiStyle.Apple;
|
parsed.emojiStyle = parsed.twitterEmoji ? EmojiStyle.Twemoji : EmojiStyle.Apple;
|
||||||
delete parsed.twitterEmoji;
|
delete parsed.twitterEmoji;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate single people-drawer flag into room/DM prefs.
|
||||||
|
if (
|
||||||
|
typeof parsed.isPeopleDrawer === 'boolean' &&
|
||||||
|
parsed.peopleDrawerInRooms === undefined &&
|
||||||
|
parsed.peopleDrawerInDirects === undefined
|
||||||
|
) {
|
||||||
|
parsed.peopleDrawerInRooms = parsed.isPeopleDrawer;
|
||||||
|
parsed.peopleDrawerInDirects = parsed.isPeopleDrawer;
|
||||||
|
}
|
||||||
|
delete parsed.isPeopleDrawer;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
...parsed,
|
...parsed,
|
||||||
|
|||||||
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 {
|
export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
||||||
const format = detectImageFormat(imageData);
|
const format = detectImageFormat(imageData);
|
||||||
|
|
||||||
console.log('[extractColorFromImage] Detected format:', format);
|
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'png':
|
case 'png':
|
||||||
return extractColorFromPNG(imageData);
|
return extractColorFromPNG(imageData);
|
||||||
@@ -182,7 +180,6 @@ export function extractColorFromImage(imageData: ArrayBuffer | Uint8Array): stri
|
|||||||
case 'gif':
|
case 'gif':
|
||||||
return extractColorFromGIF(imageData);
|
return extractColorFromGIF(imageData);
|
||||||
default:
|
default:
|
||||||
console.warn('[extractColorFromImage] Unknown image format');
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,24 +209,18 @@ export function extractBannerFromImage(imageData: ArrayBuffer | Uint8Array): str
|
|||||||
*/
|
*/
|
||||||
export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
|
export function extractMetadataFromImage(imageData: ArrayBuffer | Uint8Array): ImageMetadata {
|
||||||
const format = detectImageFormat(imageData);
|
const format = detectImageFormat(imageData);
|
||||||
|
|
||||||
console.log('[extractMetadataFromImage] Detected format:', format);
|
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'png':
|
case 'png':
|
||||||
const pngMetadata = extractMetadataFromPNG(imageData);
|
return extractMetadataFromPNG(imageData);
|
||||||
console.log('[extractMetadataFromImage] PNG metadata:', pngMetadata);
|
|
||||||
return pngMetadata;
|
|
||||||
// For other formats, extract color only for now
|
// For other formats, extract color only for now
|
||||||
case 'webp':
|
case 'webp':
|
||||||
case 'jpeg':
|
case 'jpeg':
|
||||||
case 'gif': {
|
case 'gif': {
|
||||||
const color = extractColorFromImage(imageData);
|
const color = extractColorFromImage(imageData);
|
||||||
console.log('[extractMetadataFromImage] Non-PNG color:', color);
|
|
||||||
return color ? { color } : {};
|
return color ? { color } : {};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.warn('[extractMetadataFromImage] Unknown image format');
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,7 +356,6 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
|
|||||||
// Check if full metadata is cached
|
// Check if full metadata is cached
|
||||||
const cached = avatarMetadataCache.get(url);
|
const cached = avatarMetadataCache.get(url);
|
||||||
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
|
||||||
console.log('[fetchAndExtractColor] Using cached color for', url, ':', cached.metadata.color);
|
|
||||||
return cached.metadata.color;
|
return cached.metadata.color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,33 +363,20 @@ export async function fetchAndExtractColor(url: string, accessToken?: string | n
|
|||||||
const inflightKey = `${url}:${accessToken || ''}`;
|
const inflightKey = `${url}:${accessToken || ''}`;
|
||||||
const existingFetch = inflightColorFetches.get(inflightKey);
|
const existingFetch = inflightColorFetches.get(inflightKey);
|
||||||
if (existingFetch) {
|
if (existingFetch) {
|
||||||
console.log('[fetchAndExtractColor] Reusing in-flight fetch for', url);
|
|
||||||
return existingFetch;
|
return existingFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[fetchAndExtractColor] Fetching and extracting color from', url);
|
|
||||||
|
|
||||||
const fetchPromise = (async () => {
|
const fetchPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[fetchAndExtractColor] Fetch response status:', response.ok, response.status);
|
|
||||||
|
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) return undefined;
|
||||||
|
|
||||||
const data = await response.arrayBuffer();
|
const data = await response.arrayBuffer();
|
||||||
console.log('[fetchAndExtractColor] Downloaded', data.byteLength, 'bytes');
|
return extractColorFromImage(data);
|
||||||
|
|
||||||
const color = extractColorFromImage(data);
|
|
||||||
console.log('[fetchAndExtractColor] Extracted color:', color);
|
|
||||||
|
|
||||||
// Don't cache here - fetchAndExtractMetadata will cache full metadata
|
|
||||||
// This avoids cache collision where color-only fetch overwrites full metadata
|
|
||||||
|
|
||||||
return color;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[fetchAndExtractColor] Error fetching/extracting color:', error);
|
console.error('[fetchAndExtractColor] Error fetching/extracting color:', error);
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -422,7 +399,6 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string
|
|||||||
// Check cache first
|
// Check cache first
|
||||||
const cached = avatarMetadataCache.get(url);
|
const cached = avatarMetadataCache.get(url);
|
||||||
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.timestamp < METADATA_CACHE_TTL_MS) {
|
||||||
console.log('[fetchAndExtractMetadata] Using cached metadata for', url, ':', cached.metadata);
|
|
||||||
return cached.metadata;
|
return cached.metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,35 +406,26 @@ export async function fetchAndExtractMetadata(url: string, accessToken?: string
|
|||||||
const inflightKey = `${url}:${accessToken || ''}`;
|
const inflightKey = `${url}:${accessToken || ''}`;
|
||||||
const existingFetch = inflightMetadataFetches.get(inflightKey);
|
const existingFetch = inflightMetadataFetches.get(inflightKey);
|
||||||
if (existingFetch) {
|
if (existingFetch) {
|
||||||
console.log('[fetchAndExtractMetadata] Reusing in-flight fetch for', url);
|
|
||||||
return existingFetch;
|
return existingFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[fetchAndExtractMetadata] Fetching metadata from', url);
|
|
||||||
|
|
||||||
const fetchPromise = (async () => {
|
const fetchPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[fetchAndExtractMetadata] Fetch response status:', response.ok, response.status);
|
|
||||||
|
|
||||||
if (!response.ok) return {};
|
if (!response.ok) return {};
|
||||||
|
|
||||||
const data = await response.arrayBuffer();
|
const data = await response.arrayBuffer();
|
||||||
console.log('[fetchAndExtractMetadata] Downloaded', data.byteLength, 'bytes');
|
|
||||||
|
|
||||||
const metadata = extractMetadataFromImage(data);
|
const metadata = extractMetadataFromImage(data);
|
||||||
console.log('[fetchAndExtractMetadata] Extracted metadata:', metadata);
|
|
||||||
|
avatarMetadataCache.set(url, {
|
||||||
// Cache the result
|
metadata,
|
||||||
avatarMetadataCache.set(url, {
|
timestamp: Date.now(),
|
||||||
metadata,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return metadata;
|
return metadata;
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -38,43 +38,20 @@ export const withViewTransition = async (callback: () => void | Promise<void>):
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable view transitions for router navigation
|
* Enable light view transitions for browser back/forward only.
|
||||||
* Call this in your app initialization
|
*
|
||||||
|
* Do NOT intercept in-app <a>/NavLink clicks: wrapping room switches in
|
||||||
|
* document.startViewTransition keeps the previous room painted until the new
|
||||||
|
* Room tree finishes mounting (often ~1s), which feels like a dead click.
|
||||||
*/
|
*/
|
||||||
export const enableViewTransitionsForNavigation = (): void => {
|
export const enableViewTransitionsForNavigation = (): void => {
|
||||||
if (!supportsViewTransitions()) {
|
if (!supportsViewTransitions()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intercept link clicks and navigation to add view transitions
|
window.addEventListener('popstate', () => {
|
||||||
const handleLinkClick = (event: MouseEvent) => {
|
|
||||||
const target = (event.target as HTMLElement).closest('a');
|
|
||||||
|
|
||||||
if (!target) return;
|
|
||||||
if (target.target === '_blank') return;
|
|
||||||
if (target.origin !== window.location.origin) return;
|
|
||||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
|
||||||
|
|
||||||
const url = new URL(target.href);
|
|
||||||
|
|
||||||
// Only handle same-origin navigation
|
|
||||||
if (url.origin === window.location.origin) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
withViewTransition(() => {
|
|
||||||
window.history.pushState({}, '', target.href);
|
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle back/forward navigation
|
|
||||||
const handlePopState = () => {
|
|
||||||
withViewTransition(() => {
|
withViewTransition(() => {
|
||||||
// The URL has already changed, just trigger re-render
|
// URL already changed; transition just softens the re-render.
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
|
|
||||||
window.addEventListener('click', handleLinkClick);
|
|
||||||
window.addEventListener('popstate', handlePopState);
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user