diff --git a/src/app/hooks/useCall.ts b/src/app/hooks/useCall.ts index 792d39e016..500b96233f 100644 --- a/src/app/hooks/useCall.ts +++ b/src/app/hooks/useCall.ts @@ -1,33 +1,17 @@ import type { Room } from '$types/matrix-sdk'; import { MatrixRTCSession, MatrixRTCSessionEvent } from '$types/matrix-sdk'; import type { CallMembership } from '$types/matrix-sdk'; -import { useEffect, useState } from 'react'; -import { MatrixRTCSessionManagerEvents } from '$types/matrix-sdk'; +import { useEffect, useMemo, useState } from 'react'; import { useMatrixClient } from './useMatrixClient'; +import { useActiveRTCSessionIds } from './useMatrixRTCSession'; export const useCallSession = (room: Room): MatrixRTCSession => { const mx = useMatrixClient(); - - const [session, setSession] = useState(mx.matrixRTC.getRoomSession(room)); - - useEffect(() => { - const start = (roomId: string) => { - if (roomId !== room.roomId) return; - setSession(mx.matrixRTC.getRoomSession(room)); - }; - const end = (roomId: string) => { - if (roomId !== room.roomId) return; - setSession(mx.matrixRTC.getRoomSession(room)); - }; - mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, start); - mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, end); - return () => { - mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, start); - mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, end); - }; - }, [mx, room]); - - return session; + const activeRoomIds = useActiveRTCSessionIds(); + // Re-derive the session whenever the active-session set changes. The returned + // session object is stable across renders unless the set changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => mx.matrixRTC.getRoomSession(room), [mx, room, activeRoomIds]); }; export const useCallMembers = (room: Room, session: MatrixRTCSession): CallMembership[] => { diff --git a/src/app/hooks/useMatrixRTCSession.tsx b/src/app/hooks/useMatrixRTCSession.tsx new file mode 100644 index 0000000000..c7255ab5a9 --- /dev/null +++ b/src/app/hooks/useMatrixRTCSession.tsx @@ -0,0 +1,80 @@ +import type { ReactElement, ReactNode } from 'react'; +import { createContext, useContext, useEffect, useState } from 'react'; +import { MatrixRTCSessionManagerEvents } from '$types/matrix-sdk'; +import { useMatrixClient } from './useMatrixClient'; + +/** + * The set of room IDs that currently have an active MatrixRTC session. + * Updated by a single global subscription to `mx.matrixRTC`, so N consumers + * share one listener pair instead of each adding their own (which exceeded + * Node's default EventEmitter limit of 10 when 11+ rooms were visible). + */ +type ActiveRTCSessionIds = ReadonlySet; + +const MatrixRTCSessionContext = createContext(new Set()); + +export function MatrixRTCSessionProvider({ children }: { children: ReactNode }): ReactElement { + const mx = useMatrixClient(); + const [activeRoomIds, setActiveRoomIds] = useState(() => { + // Seed with rooms that currently have an active session. + // There is no public `getRoomSessions()` bulk method on MatrixRTCSessionManager, + // so we iterate all client rooms and check each one individually. + const ids = new Set(); + try { + for (const room of mx.getRooms()) { + try { + const session = mx.matrixRTC.getRoomSession(room); + if (session.memberships.length > 0) { + ids.add(room.roomId); + } + } catch { + // Individual room session lookup failure is non-fatal. + } + } + } catch { + // Best-effort seed; the subscription below will keep it accurate. + } + return ids; + }); + + useEffect(() => { + const handleStart = (roomId: string) => { + setActiveRoomIds((prev) => { + if (prev.has(roomId)) return prev; + const next = new Set(prev); + next.add(roomId); + return next; + }); + }; + const handleEnd = (roomId: string) => { + setActiveRoomIds((prev) => { + if (!prev.has(roomId)) return prev; + const next = new Set(prev); + next.delete(roomId); + return next; + }); + }; + + mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, handleStart); + mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleEnd); + + return () => { + mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, handleStart); + mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleEnd); + }; + }, [mx]); + + return ( + + {children} + + ); +} + +/** + * Returns the set of room IDs that currently have an active MatrixRTC session. + * Backed by a single global subscription (see MatrixRTCSessionProvider). + */ +export function useActiveRTCSessionIds(): ActiveRTCSessionIds { + return useContext(MatrixRTCSessionContext); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index b39ec15123..1881ba3c40 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -65,6 +65,7 @@ import { NotificationBanner } from '$components/notification-banner'; import { ThemeMigrationBanner } from '$components/theme/ThemeMigrationBanner'; import { TelemetryConsentBanner } from '$components/telemetry-consent'; import { useIncomingCallSignaling } from '$hooks/useCallSignaling'; +import { MatrixRTCSessionProvider } from '$hooks/useMatrixRTCSession'; import { lastVisitedRoomAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; import { resolveIncomingCallFromNotificationData } from '$features/call/callNotificationBridge'; @@ -1004,7 +1005,7 @@ function NativeNotificationClickRouting() { export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useIncomingCallSignaling(); return ( - <> + @@ -1032,6 +1033,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { {children} - + ); } diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 10eb46b455..c1a65d8d68 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -696,6 +696,128 @@ describe('SlidingSyncManager local membership reconciliation', () => { expect(reconcileRooms).not.toHaveBeenCalled(); }); + + it('subscribes an optimistically joined room and tracks it for re-assertion', () => { + const updateMyMembership = vi.fn<() => void>(); + const manager = makeManager( + makeMockMx({ + getRoom: vi.fn<() => { updateMyMembership: typeof updateMyMembership }>().mockReturnValue({ + updateMyMembership, + }), + }) + ); + + manager.reconcileRoomMembership('!invite:example.com', KnownMembership.Join); + + expect(updateMyMembership).toHaveBeenCalledWith(KnownMembership.Join); + // Room is now an active subscription so the next sync pulls real joined state + expect(manager.isRoomActive('!invite:example.com')).toBe(true); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith( + new Set(['!invite:example.com']) + ); + }); + + it('re-asserts join after a sync cycle when the SDK reverted to invite', () => { + let myMembership: string = KnownMembership.Invite; + const updateMyMembership = vi.fn<(m: string) => void>().mockImplementation((m) => { + myMembership = m; + }); + const room = { + getMyMembership: vi.fn<() => string>().mockImplementation(() => myMembership), + updateMyMembership, + }; + const manager = makeManager( + makeMockMx({ + getRoom: vi.fn<() => typeof room>().mockReturnValue(room), + }) + ); + manager.attach(); + + // Optimistic join + manager.reconcileRoomMembership('!invite:example.com', KnownMembership.Join); + expect(myMembership).toBe(KnownMembership.Join); + + // Simulate the SDK reverting to invite during a sync cycle, then fire Complete. + // (room.recalculate() in the SDK would set this back to invite via invite_state) + myMembership = KnownMembership.Invite; + + fireLifecycle(SlidingSyncState.Complete, { + rooms: { + '!invite:example.com': { + required_state: [], + invite_state: [ + { + type: EventType.RoomMember, + state_key: '@user:example.com', + sender: '@inviter:example.com', + content: { membership: KnownMembership.Invite }, + }, + ], + }, + }, + }); + + // The manager re-asserted join + expect(updateMyMembership).toHaveBeenLastCalledWith(KnownMembership.Join); + expect(myMembership).toBe(KnownMembership.Join); + }); + + it('stops tracking a room once the server confirms join (no invite_state)', () => { + let myMembership: string = KnownMembership.Join; + const updateMyMembership = vi.fn<(m: string) => void>().mockImplementation((m) => { + myMembership = m; + }); + const room = { + getMyMembership: vi.fn<() => string>().mockImplementation(() => myMembership), + updateMyMembership, + }; + const manager = makeManager( + makeMockMx({ + getRoom: vi.fn<() => typeof room>().mockReturnValue(room), + }) + ); + manager.attach(); + + manager.reconcileRoomMembership('!invite:example.com', KnownMembership.Join); + + // Server caught up: room is joined, no invite_state in the response. + fireLifecycle(SlidingSyncState.Complete, { + rooms: { + '!invite:example.com': { + required_state: [ + { + type: EventType.RoomMember, + state_key: '@user:example.com', + sender: '@user:example.com', + content: { membership: KnownMembership.Join }, + }, + ], + timeline: [], + }, + }, + }); + + // Room is no longer tracked; a subsequent revert would not be re-asserted. + expect(updateMyMembership).toHaveBeenCalledTimes(1); // only the initial optimistic join + }); + + it('clears optimistic join tracking on leave', () => { + const updateMyMembership = vi.fn<() => void>(); + const manager = makeManager( + makeMockMx({ + getRoom: vi.fn<() => { updateMyMembership: typeof updateMyMembership }>().mockReturnValue({ + updateMyMembership, + }), + }) + ); + manager.subscribeToRoom('!room:example.com'); + + manager.reconcileRoomMembership('!room:example.com', KnownMembership.Join); + manager.reconcileRoomMembership('!room:example.com', KnownMembership.Leave); + + expect(updateMyMembership).toHaveBeenCalledWith(KnownMembership.Leave); + expect(manager.isRoomActive('!room:example.com')).toBe(false); + }); }); // ── dispose() ──────────────────────────────────────────────────────────────── diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index d8ef816bf0..0c29ed10ba 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -235,6 +235,14 @@ export class SlidingSyncManager { private readonly activeRoomSubscriptions = new Set(); + /** + * Room IDs joined locally via reconcileRoomMembership(Join) but not yet + * confirmed as joined by the server's sliding-sync response. The SDK can + * revert these to "invite" when the server still sends invite_state after a + * join; we re-assert join after each sync until the server catches up. + */ + private readonly optimisticallyJoinedRoomIds = new Set(); + private readonly sidebarRoomSubscriptions = new Set(); private readonly spaceSubscriptions = new Set(); @@ -405,6 +413,7 @@ export class SlidingSyncManager { if (err || !resp || state !== SlidingSyncState.Complete) return; this.recordServerMembershipRooms(resp); + this.reassertOptimisticJoins(); this.roomDataAwaitingSyncCompletion.forEach((roomId) => this.notifyRoomSubscriptionStatus(roomId, false) @@ -575,6 +584,7 @@ export class SlidingSyncManager { }); this.pendingRoomDataListeners.clear(); + this.optimisticallyJoinedRoomIds.clear(); this.responseProcessing = false; this.responseSettledListeners.clear(); this.roomDataAwaitingSyncCompletion.clear(); @@ -987,13 +997,49 @@ export class SlidingSyncManager { return () => this.responseSettledListeners.delete(listener); } + /** + * Re-assert join for rooms the SDK reverted to "invite" because the server's + * sliding-sync proxy still sent invite_state after a successful join. Runs + * after the SDK has finished processing all room data for the cycle (Complete + * fires post-processing) and before the responseSettled microtask that drives + * unread computation, so the app sees the corrected membership. + */ + private reassertOptimisticJoins(): void { + if (this.optimisticallyJoinedRoomIds.size === 0) return; + for (const roomId of this.optimisticallyJoinedRoomIds) { + const room = this.mx.getRoom(roomId); + if (!room) { + this.optimisticallyJoinedRoomIds.delete(roomId); + continue; + } + if (room.getMyMembership() === (KnownMembership.Join as string)) { + // Server has caught up: the room is genuinely joined now. Stop tracking. + this.optimisticallyJoinedRoomIds.delete(roomId); + } else { + // SDK reverted to invite (or another state). Re-assert join. + room.updateMyMembership(KnownMembership.Join); + } + } + } + public reconcileRoomMembership( roomId: string, membership: KnownMembership.Join | KnownMembership.Leave ): void { this.mx.getRoom(roomId)?.updateMyMembership(membership); + if (membership === KnownMembership.Join) { + // Track the room so we can re-assert join if the SDK reverts it while the + // server's sliding-sync proxy still reports the room in the invite list. + this.optimisticallyJoinedRoomIds.add(roomId); + // Subscribe so the next sync pulls real joined member state into the + // room's current state, letting recalculate() see "join" not "invite". + this.subscribeToRoom(roomId); + return; + } + if (membership === KnownMembership.Leave) { + this.optimisticallyJoinedRoomIds.delete(roomId); this.sidebarCache.removeRoom(roomId); const removedSpaceSubscription = this.spaceSubscriptions.delete(roomId); const removedSidebarSubscription = this.sidebarRoomSubscriptions.delete(roomId);