Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 7 additions & 23 deletions src/app/hooks/useCall.ts
Original file line number Diff line number Diff line change
@@ -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[] => {
Expand Down
80 changes: 80 additions & 0 deletions src/app/hooks/useMatrixRTCSession.tsx
Original file line number Diff line number Diff line change
@@ -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<string>;

const MatrixRTCSessionContext = createContext<ActiveRTCSessionIds>(new Set());

export function MatrixRTCSessionProvider({ children }: { children: ReactNode }): ReactElement {
const mx = useMatrixClient();
const [activeRoomIds, setActiveRoomIds] = useState<ActiveRTCSessionIds>(() => {
// 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<string>();
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 (
<MatrixRTCSessionContext.Provider value={activeRoomIds}>
{children}
</MatrixRTCSessionContext.Provider>
);
}

/**
* 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);
}
5 changes: 3 additions & 2 deletions src/app/pages/client/ClientNonUIFeatures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1004,7 +1005,7 @@ function NativeNotificationClickRouting() {
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
useIncomingCallSignaling();
return (
<>
<MatrixRTCSessionProvider>
<SettingsSyncFeature />
<SystemEmojiFeature />
<PageZoomFeature />
Expand Down Expand Up @@ -1032,6 +1033,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<HealthMonitor />
<ShareTargetFeature />
<IconSizesProvider>{children}</IconSizesProvider>
</>
</MatrixRTCSessionProvider>
);
}
122 changes: 122 additions & 0 deletions src/client/slidingSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() ────────────────────────────────────────────────────────────────
Expand Down
46 changes: 46 additions & 0 deletions src/client/slidingSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,14 @@ export class SlidingSyncManager {

private readonly activeRoomSubscriptions = new Set<string>();

/**
* 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<string>();

private readonly sidebarRoomSubscriptions = new Set<string>();

private readonly spaceSubscriptions = new Set<string>();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -575,6 +584,7 @@ export class SlidingSyncManager {
});

this.pendingRoomDataListeners.clear();
this.optimisticallyJoinedRoomIds.clear();
this.responseProcessing = false;
this.responseSettledListeners.clear();
this.roomDataAwaitingSyncCompletion.clear();
Expand Down Expand Up @@ -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);
Expand Down
Loading