diff --git a/.changeset/fix-lazy-loaded-member-roster.md b/.changeset/fix-lazy-loaded-member-roster.md new file mode 100644 index 0000000000..c577a11956 --- /dev/null +++ b/.changeset/fix-lazy-loaded-member-roster.md @@ -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. diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index daa4bf1622..2ea6ec7a1b 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -22,7 +22,7 @@ import { useNavigate } from 'react-router-dom'; import { NavButton, NavItem, NavItemContent, NavItemOptions } from '$components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import { RoomAvatar, RoomIcon } from '$components/room-avatar'; -import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '$utils/room/display'; +import { getDirectRoomAvatarUrl, getDmOtherMember, getRoomAvatarUrl } from '$utils/room/display'; import { roomHaveUnread } from '$utils/room/unread'; import { nameInitials } from '$utils/common'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -335,7 +335,7 @@ export function RoomNavItem({ const [roomIconOverlay] = useSetting(settingsAtom, 'roomIconOverlay'); const nicknames = useAtomValue(nicknamesAtom); - const dmUserId = direct ? room.getAvatarFallbackMember()?.userId : undefined; + const dmUserId = direct ? getDmOtherMember(mx, room)?.userId : undefined; const matrixRoomName = useRoomName(room); const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName; const presence = useUserPresence(dmUserId ?? ''); diff --git a/src/app/hooks/useGroupDMMembers.ts b/src/app/hooks/useGroupDMMembers.ts index da5532e7df..f8015727b7 100644 --- a/src/app/hooks/useGroupDMMembers.ts +++ b/src/app/hooks/useGroupDMMembers.ts @@ -1,5 +1,5 @@ import type { MatrixClient, Room } from '$types/matrix-sdk'; -import { getMemberDisplayName } from '$utils/room/display'; +import { getMemberDisplayName, isBridgeBot } from '$utils/room/display'; export type GroupMemberInfo = { userId: string; @@ -7,18 +7,6 @@ export type GroupMemberInfo = { avatarUrl?: string; }; -// Filter out bridge bots (not bridged users) -const isBridgeBot = (userId: string): boolean => { - const localpart = userId.split(':')[0]?.substring(1) ?? ''; - const lowerLocalpart = localpart.toLowerCase(); - - // Only filter out users ending with 'bot' (e.g., discordbot, blueskybot) - // Don't filter bridge users with IDs like discord_378405164077547520 - if (lowerLocalpart.endsWith('bot')) return true; - - return false; -}; - /** * Fetches member information for a group DM. * Gets all joined members from room state and fetches their profiles. diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index 31e04867f5..4469680d61 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -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([]); @@ -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) => { diff --git a/src/app/hooks/useRoomMeta.test.tsx b/src/app/hooks/useRoomMeta.test.tsx index 7721813f2f..1150b55b47 100644 --- a/src/app/hooks/useRoomMeta.test.tsx +++ b/src/app/hooks/useRoomMeta.test.tsx @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { EventEmitter } from 'events'; -import type { MatrixEvent, Room } from '$types/matrix-sdk'; +import type { PropsWithChildren } from 'react'; +import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; import { RoomStateEvent } from '$types/matrix-sdk'; +import { MatrixClientProvider } from './useMatrixClient'; import { useRoomAvatar } from './useRoomMeta'; const AVATAR_MXC = 'mxc://server/abc'; @@ -28,6 +30,11 @@ const makeRoom = (roomId: string) => { return { room, client, + wrapper: ({ children }: PropsWithChildren) => ( + + {children} + + ), setAvatarEvent: (event: MatrixEvent) => { avatarEvent = event; }, @@ -36,14 +43,14 @@ const makeRoom = (roomId: string) => { describe('useRoomAvatar', () => { it('returns undefined when no avatar state is loaded', () => { - const { room } = makeRoom('!space:server'); - const { result } = renderHook(() => useRoomAvatar(room)); + const { room, wrapper } = makeRoom('!space:server'); + const { result } = renderHook(() => useRoomAvatar(room), { wrapper }); expect(result.current).toBeUndefined(); }); it('updates when the avatar state event arrives after mount', () => { - const { room, client, setAvatarEvent } = makeRoom('!space:server'); - const { result } = renderHook(() => useRoomAvatar(room)); + const { room, client, setAvatarEvent, wrapper } = makeRoom('!space:server'); + const { result } = renderHook(() => useRoomAvatar(room), { wrapper }); expect(result.current).toBeUndefined(); const avatarEvent = makeAvatarEvent('!space:server', AVATAR_MXC); @@ -56,8 +63,8 @@ describe('useRoomAvatar', () => { }); it('ignores avatar state events from other rooms', () => { - const { room, client } = makeRoom('!space:server'); - const { result } = renderHook(() => useRoomAvatar(room)); + const { room, client, wrapper } = makeRoom('!space:server'); + const { result } = renderHook(() => useRoomAvatar(room), { wrapper }); act(() => { client.emit(RoomStateEvent.Events, makeAvatarEvent('!other:server', AVATAR_MXC)); diff --git a/src/app/hooks/useRoomMeta.ts b/src/app/hooks/useRoomMeta.ts index 1c7387a546..fdf8d8d93e 100644 --- a/src/app/hooks/useRoomMeta.ts +++ b/src/app/hooks/useRoomMeta.ts @@ -4,6 +4,8 @@ import type { RoomJoinRulesEventContent, Room } from '$types/matrix-sdk'; import { RoomEvent, RoomStateEvent, EventType } from '$types/matrix-sdk'; import { mDirectAtom } from '$state/mDirectList'; +import { getDmOtherMember, getMemberDisplayName } from '$utils/room/display'; +import { useMatrixClient } from './useMatrixClient'; import { useStateEvent } from './useStateEvent'; import { useNickname } from './useNickname'; @@ -11,18 +13,21 @@ const getRoomDisplayName = ( roomName: string, stateName: unknown, isDmTagged: boolean, - dmNickname?: string + dmNickname?: string, + dmOtherMemberName?: string ): string => { if (isDmTagged && dmNickname) return dmNickname; if (typeof stateName === 'string' && stateName) return stateName; + if (isDmTagged && dmOtherMemberName) return dmOtherMemberName; return roomName; }; export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => { + const mx = useMatrixClient(); const avatarEvent = useStateEvent(room, EventType.RoomAvatar); if (dm) { - return room.getAvatarFallbackMember()?.getMxcAvatarUrl(); + return getDmOtherMember(mx, room)?.getMxcAvatarUrl(); } const content = avatarEvent?.getContent(); const avatarMxc = content && typeof content.url === 'string' ? content.url : undefined; @@ -31,6 +36,7 @@ export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => { }; export const useRoomName = (room: Room): string => { + const mx = useMatrixClient(); const dmUserId = room.guessDMUserId(); const dmNickname = useNickname(dmUserId || ''); const mDirects = useAtomValue(mDirectAtom); @@ -45,7 +51,18 @@ export const useRoomName = (room: Room): string => { room.recalculate(); } - const nextName = getRoomDisplayName(room.name, stateName, isDmTagged, dmNickname); + const otherMember = isDmTagged ? getDmOtherMember(mx, room) : undefined; + const dmOtherMemberName = otherMember + ? (getMemberDisplayName(room, otherMember.userId) ?? otherMember.userId) + : undefined; + + const nextName = getRoomDisplayName( + room.name, + stateName, + isDmTagged, + dmNickname, + dmOtherMemberName + ); setName((prev) => (prev !== nextName ? nextName : prev)); }; @@ -58,7 +75,7 @@ export const useRoomName = (room: Room): string => { room.removeListener(RoomEvent.Name, updateName); room.removeListener(RoomStateEvent.Members, updateName); }; - }, [room, stateName, dmNickname, isDmTagged]); + }, [room, mx, stateName, dmNickname, isDmTagged]); return name; }; diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index ac836775e1..e2ded23c24 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -21,6 +21,7 @@ import { nameInitials } from '$utils/common'; import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix'; import { useSelectedOrLastRoom } from '$hooks/router/useSelectedRoom'; import { useGroupDMMembers } from '$hooks/useGroupDMMembers'; +import { useRoomName } from '$hooks/useRoomMeta'; import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; import * as css from './DirectDMsList.css'; @@ -41,24 +42,16 @@ function DMItem({ room, selected }: DMItemProps) { navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, room.roomId))); }; - // Check if this is a group DM (more than 2 members) - const isGroupDM = room.getJoinedMemberCount() > 2; + const roomName = useRoomName(room); // Use already-synced room state only; sidebar rendering must not trigger member/profile requests. const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS); + const isGroupDM = groupMembers.length > 1; + // Get unread info for badge const unread = roomToUnread.get(room.roomId); - // Determine avatar src for single group DM member to avoid nested ternary - const getSingleMemberAvatarSrc = () => { - const member = groupMembers[0]; - if (groupMembers.length !== 1 || !member?.avatarUrl) { - return undefined; - } - return mxcUrlToHttp(mx, member.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined; - }; - // Render appropriate avatar based on DM type const renderAvatar = () => { if (!isGroupDM) { @@ -71,29 +64,10 @@ function DMItem({ room, selected }: DMItemProps) { getRoomAvatarUrl(mx, room, 96, useAuthentication) || getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) } - alt={room.name} - renderFallback={() => ( - - {nameInitials(room.name)} - - )} - /> - - ); - } - - if (groupMembers.length === 1) { - const member = groupMembers[0]; - if (!member) return null; - return ( - - ( - {nameInitials(member.displayName || member.userId)} + {nameInitials(roomName)} )} /> @@ -132,7 +106,7 @@ function DMItem({ room, selected }: DMItemProps) { return ( - + {(triggerRef) => ( {renderAvatar()} diff --git a/src/app/utils/room/display.ts b/src/app/utils/room/display.ts index 5bbaffa64e..afe92e0b95 100644 --- a/src/app/utils/room/display.ts +++ b/src/app/utils/room/display.ts @@ -24,13 +24,27 @@ export const getRoomAvatarUrl = ( useAuthentication = false ): string | undefined => getAvatarUrl(mx, room.getMxcAvatarUrl(), size, useAuthentication); +// Bridges add a persistent bot member to 1:1 DM portals that the SDK counts as a real participant. +export const isBridgeBot = (userId: string): boolean => { + const localpart = userId.split(':')[0]?.substring(1) ?? ''; + return localpart.toLowerCase().endsWith('bot'); +}; + +export const getDmOtherMember = (mx: MatrixClient, room: Room): RoomMember | undefined => { + const currentUserId = mx.getUserId(); + const others = room + .getJoinedMembers() + .filter((member) => member.userId !== currentUserId && !isBridgeBot(member.userId)); + return others.length === 1 ? others[0] : undefined; +}; + export const getDirectRoomAvatarUrl = ( mx: MatrixClient, room: Room, size: 32 | 96 = 32, useAuthentication = false ): string | undefined => { - const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl(); + const mxcUrl = getDmOtherMember(mx, room)?.getMxcAvatarUrl(); if (!mxcUrl) { return getRoomAvatarUrl(mx, room, size, useAuthentication); diff --git a/src/client/roomMemberHydration.test.ts b/src/client/roomMemberHydration.test.ts index 7a6c4ebd4e..d447e5bae4 100644 --- a/src/client/roomMemberHydration.test.ts +++ b/src/client/roomMemberHydration.test.ts @@ -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'; @@ -151,6 +155,101 @@ describe('hydrateRoomMember (force)', () => { }); }); +type BulkFakeSetup = { + mx: MatrixClient; + members: ReturnType; + setStateEvents: ReturnType; +}; + +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>(() => + 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(); diff --git a/src/client/roomMemberHydration.ts b/src/client/roomMemberHydration.ts index cc43745669..ef5493d27a 100644 --- a/src/client/roomMemberHydration.ts +++ b/src/client/roomMemberHydration.ts @@ -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>>(); @@ -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>>(); +const bulkAttemptedAt = new WeakMap>(); + +export const hydrateAllRoomMembers = (mx: MatrixClient, roomId: string): Promise => { + 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>(); + bulkInFlight.set(mx, pending); + const existing = pending.get(roomId); + if (existing) return existing; + + const attempts = bulkAttemptedAt.get(mx) ?? new Map(); + 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,