Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-lazy-loaded-member-roster.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Refill the room member list from the server when sliding sync only delivered lazy-loaded members, so mention autocomplete and the members panel are complete.
7 changes: 5 additions & 2 deletions src/app/hooks/useRoomMembers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { MatrixClient, MatrixEvent, RoomMember } from '$types/matrix-sdk';
import { EventType, RoomMemberEvent, RoomStateEvent } from '$types/matrix-sdk';
import { useEffect, useState } from 'react';
import { hydrateAllRoomMembers } from '$client/roomMemberHydration';

export const useRoomMembers = (mx: MatrixClient, roomId: string, enabled = true): RoomMember[] => {
const [members, setMembers] = useState<RoomMember[]>([]);
Expand All @@ -23,11 +24,13 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string, enabled = true)

if (room) {
setMembers(room.getMembers());
room.loadMembersIfNeeded().then(() => {
const stopLoading = () => {
loadingMembers = false;
if (disposed) return;
updateMemberList();
});
void hydrateAllRoomMembers(mx, roomId).then(() => updateMemberList());
};
room.loadMembersIfNeeded().then(stopLoading, stopLoading);
}

const handleStateEvent = (event: MatrixEvent) => {
Expand Down
101 changes: 100 additions & 1 deletion src/client/roomMemberHydration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { MatrixClient, Room, RoomMember } from '$types/matrix-sdk';
import { EventType } from '$types/matrix-sdk';

import { hydrateRoomMember, hydrateRoomMembers } from './roomMemberHydration';
import {
hydrateAllRoomMembers,
hydrateRoomMember,
hydrateRoomMembers,
} from './roomMemberHydration';

const ROOM_ID = '!room:server';
const USER_ID = '@ghost:server';
Expand Down Expand Up @@ -151,6 +155,101 @@ describe('hydrateRoomMember (force)', () => {
});
});

type BulkFakeSetup = {
mx: MatrixClient;
members: ReturnType<typeof vi.fn>;
setStateEvents: ReturnType<typeof vi.fn>;
};

const makeBulkFakes = (
joinedMembers: number,
joinedCount: number,
knownMemberIds: string[] = []
): BulkFakeSetup => {
const setStateEvents = vi.fn<() => void>();
const room = {
roomId: ROOM_ID,
getJoinedMembers: () => Array.from({ length: joinedMembers }, () => ({}) as RoomMember),
getJoinedMemberCount: () => joinedCount,
getMember: (userId: string) => (knownMemberIds.includes(userId) ? ({} as RoomMember) : null),
currentState: { setStateEvents },
} as unknown as Room;
const members = vi.fn<() => Promise<object>>(() =>
Promise.resolve({
chunk: [
{
type: EventType.RoomMember,
state_key: USER_ID,
room_id: ROOM_ID,
sender: USER_ID,
content: { membership: 'join' },
},
],
})
);
const mx = {
getRoom: () => room,
members,
} as unknown as MatrixClient;
return { mx, members, setStateEvents };
};

describe('hydrateAllRoomMembers', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('fetches the full member list when the roster is short of the joined count', async () => {
const { mx, members, setStateEvents } = makeBulkFakes(2, 20);

await hydrateAllRoomMembers(mx, ROOM_ID);

expect(members).toHaveBeenCalledWith(ROOM_ID, undefined, 'leave');
const [events] = setStateEvents.mock.calls[0] as [Array<{ getType: () => string }>];
expect(events[0]?.getType()).toBe(EventType.RoomMember);
});

it('skips members the room already knows', async () => {
const { mx, setStateEvents } = makeBulkFakes(2, 20, [USER_ID]);

await hydrateAllRoomMembers(mx, ROOM_ID);

expect(setStateEvents).not.toHaveBeenCalled();
});

it('does nothing when the roster already matches the joined count', async () => {
const { mx, members } = makeBulkFakes(20, 20);

await hydrateAllRoomMembers(mx, ROOM_ID);

expect(members).not.toHaveBeenCalled();
});

it('does not refetch within the TTL and retries after it', async () => {
const { mx, members } = makeBulkFakes(2, 20);

await hydrateAllRoomMembers(mx, ROOM_ID);
await hydrateAllRoomMembers(mx, ROOM_ID);
expect(members).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(5 * 60_000 + 1);
await hydrateAllRoomMembers(mx, ROOM_ID);
expect(members).toHaveBeenCalledTimes(2);
});

it('swallows a failed fetch', async () => {
const { mx, members, setStateEvents } = makeBulkFakes(2, 20);
members.mockRejectedValueOnce(new Error('403'));

await expect(hydrateAllRoomMembers(mx, ROOM_ID)).resolves.toBeUndefined();
expect(setStateEvents).not.toHaveBeenCalled();
});
});

describe('hydrateRoomMembers', () => {
it('dedups user ids and filters non-user ids', async () => {
const { mx, getStateEvent } = makeFakes();
Expand Down
46 changes: 45 additions & 1 deletion src/client/roomMemberHydration.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MatrixClient } from '$types/matrix-sdk';
import { EventType, MatrixEvent } from '$types/matrix-sdk';
import { EventType, KnownMembership, MatrixEvent } from '$types/matrix-sdk';

const inFlight = new WeakMap<MatrixClient, Map<string, Promise<void>>>();

Expand Down Expand Up @@ -103,6 +103,50 @@ export const hydrateRoomMember = (
return request;
};

// The SDK only fetches /members when the sync store holds no out-of-band member
// set for the room. Sliding sync sends $LAZY members, so a room whose stored set
// predates most joins keeps a short roster forever. Refill it from the server.
const BULK_TTL_MS = 5 * 60_000;
const bulkInFlight = new WeakMap<MatrixClient, Map<string, Promise<void>>>();
const bulkAttemptedAt = new WeakMap<MatrixClient, Map<string, number>>();

export const hydrateAllRoomMembers = (mx: MatrixClient, roomId: string): Promise<void> => {
const room = mx.getRoom(roomId);
if (!room) return Promise.resolve();
if (room.getJoinedMembers().length >= room.getJoinedMemberCount()) return Promise.resolve();

const attemptedTs = bulkAttemptedAt.get(mx)?.get(roomId);
if (attemptedTs !== undefined && Date.now() - attemptedTs < BULK_TTL_MS) return Promise.resolve();

const pending = bulkInFlight.get(mx) ?? new Map<string, Promise<void>>();
bulkInFlight.set(mx, pending);
const existing = pending.get(roomId);
if (existing) return existing;

const attempts = bulkAttemptedAt.get(mx) ?? new Map<string, number>();
bulkAttemptedAt.set(mx, attempts);
attempts.set(roomId, Date.now());

const request = mx
.members(roomId, undefined, KnownMembership.Leave)
.then(({ chunk }) => {
const currentRoom = mx.getRoom(roomId);
if (!currentRoom || !chunk) return;
// The response is current state, which may be ahead of our sync position,
// so only fill in members we are missing rather than overwriting known ones.
const missing = chunk.filter(
(event) => event.state_key && !currentRoom.getMember(event.state_key)
);
if (missing.length === 0) return;
currentRoom.currentState.setStateEvents(missing.map((event) => new MatrixEvent(event)));
})
.catch(() => undefined)
.finally(() => pending.delete(roomId));

pending.set(roomId, request);
return request;
};

export const hydrateRoomMembers = (
mx: MatrixClient,
roomId: string,
Expand Down
Loading