From a8547c81990752e03a523dd44258c63622a2c69e Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 18:47:15 +0200 Subject: [PATCH] fix(call): hydrate the member roster before the call widget reads it --- .../call/CallWidgetDriver.roster.test.ts | 45 +++++++++++++++++++ src/app/plugins/call/CallWidgetDriver.ts | 28 ++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 src/app/plugins/call/CallWidgetDriver.roster.test.ts diff --git a/src/app/plugins/call/CallWidgetDriver.roster.test.ts b/src/app/plugins/call/CallWidgetDriver.roster.test.ts new file mode 100644 index 0000000000..54fdb6ef4f --- /dev/null +++ b/src/app/plugins/call/CallWidgetDriver.roster.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Room } from '$types/matrix-sdk'; +import { hydrateWidgetRoster } from './CallWidgetDriver'; + +type RoomStub = { + loadMembersIfNeeded: () => Promise; + joinedInState?: number; + joinedCount?: number; +}; + +const makeRoom = ({ loadMembersIfNeeded, joinedInState = 4, joinedCount = 4 }: RoomStub): Room => + ({ + roomId: '!room:example.org', + loadMembersIfNeeded, + getMembersWithMembership: () => Array.from({ length: joinedInState }, () => ({})), + getJoinedMemberCount: () => joinedCount, + }) as unknown as Room; + +describe('hydrateWidgetRoster', () => { + it('fetches the roster before the widget reads member state', async () => { + const loadMembersIfNeeded = vi.fn<() => Promise>().mockResolvedValue(true); + + await hydrateWidgetRoster(makeRoom({ loadMembersIfNeeded })); + + expect(loadMembersIfNeeded).toHaveBeenCalledOnce(); + }); + + it('still serves state when the roster request fails', async () => { + const loadMembersIfNeeded = vi + .fn<() => Promise>() + .mockRejectedValue(new Error('roster request failed')); + + await expect(hydrateWidgetRoster(makeRoom({ loadMembersIfNeeded }))).resolves.toBeUndefined(); + }); + + it('does not throw when the hydrated roster is still short', async () => { + const room = makeRoom({ + loadMembersIfNeeded: () => Promise.resolve(false), + joinedInState: 2, + joinedCount: 7, + }); + + await expect(hydrateWidgetRoster(room)).resolves.toBeUndefined(); + }); +}); diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts index 5d71098b52..4ded2826f3 100644 --- a/src/app/plugins/call/CallWidgetDriver.ts +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -16,8 +16,10 @@ import type { MatrixClient } from '$types/matrix-sdk'; import { EventType, type IContent, + KnownMembership, MatrixError, type MatrixEvent, + type Room, Direction, type SendDelayedEventResponse, type StateEvents, @@ -29,6 +31,29 @@ import { createDebugLogger } from '../../utils/debugLogger'; const debugLog = createDebugLogger('CallWidgetDriver'); +export const hydrateWidgetRoster = async (room: Room): Promise => { + try { + await room.loadMembersIfNeeded(); + } catch (error) { + // A partial roster still beats failing the read and stalling state sync. + debugLog.warn('call', 'Failed to load room members for the call widget', { + roomId: room.roomId, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + const joinedInState = room.getMembersWithMembership(KnownMembership.Join).length; + const joinedCount = room.getJoinedMemberCount(); + if (joinedInState < joinedCount) { + debugLog.warn('call', 'Call widget roster is short of the joined member count', { + roomId: room.roomId, + joinedInState, + joinedCount, + }); + } +}; + export class CallWidgetDriver extends WidgetDriver { private allowedCapabilities: Set; @@ -295,6 +320,9 @@ export class CallWidgetDriver extends WidgetDriver { ): Promise { const room = this.mx.getRoom(roomId); if (room === null) return []; + + if (eventType === (EventType.RoomMember as string)) await hydrateWidgetRoster(room); + const state = room.getLiveTimeline().getState(Direction.Forward); if (state === undefined) return [];