Skip to content
Closed
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.
2 changes: 1 addition & 1 deletion src/app/hooks/useRoomMemberHydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const useRoomMemberHydration = (

if (member && profileDisplayName && profileDisplayName !== member.rawDisplayName) {
let disposed = false;
void hydrateRoomMember(mx, room.roomId, userId, true).then(() => {
void hydrateRoomMember(mx, room.roomId, userId, { force: true }).then(() => {
if (!disposed && room.getMember(userId)) setVersion((v) => v + 1);
});
return () => {
Expand Down
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
131 changes: 126 additions & 5 deletions 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 @@ -129,7 +133,7 @@ describe('hydrateRoomMember (force)', () => {
const { mx, getStateEvent, setStateEvents, getMember } = makeFakes();
getMember.mockReturnValue({ rawDisplayName: 'OldName' } as RoomMember);

await hydrateRoomMember(mx, ROOM_ID, USER_ID, true);
await hydrateRoomMember(mx, ROOM_ID, USER_ID, { force: true });

expect(getStateEvent).toHaveBeenCalledTimes(1);
expect(setStateEvents).toHaveBeenCalledTimes(1);
Expand All @@ -139,19 +143,136 @@ describe('hydrateRoomMember (force)', () => {
const { mx, getStateEvent, getMember } = makeFakes();
getMember.mockReturnValue({ rawDisplayName: 'OldName' } as RoomMember);

await hydrateRoomMember(mx, ROOM_ID, USER_ID, true);
await hydrateRoomMember(mx, ROOM_ID, USER_ID, true);
await hydrateRoomMember(mx, ROOM_ID, USER_ID, { force: true });
await hydrateRoomMember(mx, ROOM_ID, USER_ID, { force: true });

expect(getStateEvent).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(10 * 60_000 + 1);
await hydrateRoomMember(mx, ROOM_ID, USER_ID, true);
await hydrateRoomMember(mx, ROOM_ID, USER_ID, { force: true });

expect(getStateEvent).toHaveBeenCalledTimes(2);
});

it('can bypass the refresh cooldown when current state becomes stale again', async () => {
const { mx, getStateEvent, getMember } = makeFakes();
getMember.mockReturnValue({ rawDisplayName: 'OldName' } as RoomMember);

await hydrateRoomMember(mx, ROOM_ID, USER_ID, { force: true });
await hydrateRoomMember(mx, ROOM_ID, USER_ID, {
force: true,
bypassRefreshCooldown: true,
});

expect(getStateEvent).toHaveBeenCalledTimes(2);
});
});

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('forwards force=true to each member hydration', async () => {
const { mx, getStateEvent, getMember } = makeFakes();
getMember.mockReturnValue({ rawDisplayName: 'OldName' } as RoomMember);

await hydrateRoomMembers(mx, ROOM_ID, [USER_ID], { force: true });

expect(getStateEvent).toHaveBeenCalledOnce();
});

it('dedups user ids and filters non-user ids', async () => {
const { mx, getStateEvent } = makeFakes();

Expand Down
62 changes: 56 additions & 6 deletions 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 All @@ -14,6 +14,11 @@ const requestQueues = new WeakMap<MatrixClient, Array<() => void>>();
const REFRESH_TTL_MS = 10 * 60_000;
const refreshedAt = new WeakMap<MatrixClient, Map<string, number>>();

export type RoomMemberHydrationOptions = {
force?: boolean;
bypassRefreshCooldown?: boolean;
};

const scheduleRequest = <T>(mx: MatrixClient, task: () => Promise<T>): Promise<T> =>
new Promise<T>((resolve, reject) => {
const run = () => {
Expand Down Expand Up @@ -42,15 +47,15 @@ export const hydrateRoomMember = (
mx: MatrixClient,
roomId: string,
userId: string,
force = false
{ force = false, bypassRefreshCooldown = false }: RoomMemberHydrationOptions = {}
): Promise<void> => {
const room = mx.getRoom(roomId);
if (!room) return Promise.resolve();
if (!force && room.getMember(userId)) return Promise.resolve();

const key = `${roomId}\u0000${userId}`;

if (force) {
if (force && !bypassRefreshCooldown) {
const lastRefreshed = refreshedAt.get(mx)?.get(key);
if (lastRefreshed !== undefined && Date.now() - lastRefreshed < REFRESH_TTL_MS)
return Promise.resolve();
Expand Down Expand Up @@ -86,7 +91,7 @@ export const hydrateRoomMember = (
})
.then(() => {
failedAt.get(mx)?.delete(key);
if (force) {
if (force && !bypassRefreshCooldown) {
const refreshMap = refreshedAt.get(mx) ?? new Map<string, number>();
refreshedAt.set(mx, refreshMap);
refreshMap.set(key, Date.now());
Expand All @@ -103,13 +108,58 @@ 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,
userIds: Iterable<string>
userIds: Iterable<string>,
options: RoomMemberHydrationOptions = {}
): Promise<void[]> =>
Promise.all(
[...new Set(userIds)]
.filter((userId) => userId.startsWith('@'))
.map((userId) => hydrateRoomMember(mx, roomId, userId))
.map((userId) => hydrateRoomMember(mx, roomId, userId, options))
);
Loading