diff --git a/.changeset/fix-sliding-sync-member-profiles.md b/.changeset/fix-sliding-sync-member-profiles.md new file mode 100644 index 0000000000..ff8c00a00d --- /dev/null +++ b/.changeset/fix-sliding-sync-member-profiles.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix display names for read receipts, event relations, and state events when using sliding sync with the member drawer closed. diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 5f41ce7646..d5f529518a 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -1,6 +1,6 @@ import { Box, Button, color, config, Menu, MenuItem, Scroll, Text, toRem } from 'folds'; import type { CSSProperties, SyntheticEvent } from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAtomValue } from 'jotai'; import type { Opts as LinkifyOpts } from 'linkifyjs'; @@ -59,6 +59,7 @@ import { PowerChip } from './PowerChip'; import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; import { UserHero, UserHeroName } from './UserHero'; import { KnownMembership } from '$types/matrix-sdk'; +import { hydrateRoomMember } from '$client/roomMemberHydration'; import * as css from './styles.css'; import * as prefix from '$unstable/prefixes'; @@ -436,9 +437,6 @@ export function UserRoomProfile({ userId, initialProfile }: Readonly { + if (room.getMember(userId)) return undefined; + let disposed = false; + void hydrateRoomMember(mx, room.roomId, userId).then(() => { + if (!disposed) refreshMemberProfile((version) => version + 1); + }); + return () => { + disposed = true; + }; + }, [mx, room, userId]); + + const avatarMxc = getMemberAvatarMxc(room, userId) ?? extendedProfile.avatarUrl; + const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + const parsedBanner = typeof extendedProfile.bannerUrl === 'string' ? extendedProfile.bannerUrl.replaceAll(/^"|"$/g, '') diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index 2d70a1444b..3140c39b33 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -25,6 +25,10 @@ vi.mock('$utils/timeline', async (importOriginal) => { }; }); +vi.mock('$utils/notifications', () => ({ + markAsRead: vi.fn<() => Promise>().mockResolvedValue(undefined), +})); + type FakeTimeline = { getEvents: () => unknown[]; getNeighbouringTimeline: () => undefined; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index fc29e68260..657d2a4b96 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -26,6 +26,7 @@ import type { SlidingSyncDiagnostics } from './slidingSync'; import { scopeEphemeralExtensions, SlidingSyncManager } from './slidingSync'; import { PresenceSyncManager } from './presenceSync'; import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; +import { hydrateRoomMember } from './roomMemberHydration'; const log = createLogger('initMatrix'); const debugLog = createDebugLogger('initMatrix'); @@ -171,7 +172,12 @@ function installStartupFetchRoomEventPatch( mxWritable.fetchRoomEvent = (roomId: string, eventId: string) => { if (slidingSyncManager.isRoomActive(roomId)) { - return origFetchRoomEvent(roomId, eventId); + return origFetchRoomEvent(roomId, eventId).then((event) => { + if (typeof event.sender === 'string') { + void hydrateRoomMember(mx, roomId, event.sender); + } + return event; + }); } const cachedEvent = mx.getRoom(roomId)?.findEventById(eventId); const payload: FetchRoomEventResult = cachedEvent?.event ?? { diff --git a/src/client/roomMemberHydration.ts b/src/client/roomMemberHydration.ts new file mode 100644 index 0000000000..f6b2622118 --- /dev/null +++ b/src/client/roomMemberHydration.ts @@ -0,0 +1,54 @@ +import type { MatrixClient, Room } from '$types/matrix-sdk'; +import { EventType, MatrixEvent } from '$types/matrix-sdk'; + +const inFlight = new WeakMap>>(); + +export const hydrateRoomMember = ( + mx: MatrixClient, + roomId: string, + userId: string +): Promise => { + const room = mx.getRoom(roomId); + if (!room || room.getMember(userId)) return Promise.resolve(); + + const key = `${roomId}\u0000${userId}`; + const pending = inFlight.get(mx) ?? new Map>(); + inFlight.set(mx, pending); + const existing = pending.get(key); + if (existing) return existing; + + const request = mx + .getStateEvent(roomId, EventType.RoomMember, userId) + .then((content) => { + const currentRoom = mx.getRoom(roomId); + if (!currentRoom || currentRoom.getMember(userId)) return; + currentRoom.currentState.setStateEvents([ + new MatrixEvent({ + type: EventType.RoomMember, + state_key: userId, + room_id: roomId, + sender: userId, + content, + }), + ]); + }) + .catch(() => undefined) + .finally(() => pending.delete(key)); + + pending.set(key, request); + return request; +}; + +export const hydrateRoomMembers = ( + mx: MatrixClient, + roomId: string, + userIds: Iterable +): Promise => + Promise.all( + [...new Set(userIds)] + .filter((userId) => userId.startsWith('@')) + .map((userId) => hydrateRoomMember(mx, roomId, userId)) + ); + +export const getRoomMemberAvatarMxc = (room: Room, userId: string): string | undefined => + room.getMember(userId)?.getMxcAvatarUrl(); diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index fc7d846d2f..b70dfc2482 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -112,6 +112,7 @@ describe('SlidingSyncManager initial request', () => { const updates = lists.get('updates'); const defaultSubscription = mocks.slidingSyncConstructorArgs?.[2] as { timeline_limit: number; + required_state: string[][]; }; expect(joined?.ranges).toEqual([[0, 29]]); @@ -126,6 +127,8 @@ describe('SlidingSyncManager initial request', () => { filters: { is_invite: false }, }); expect(defaultSubscription.timeline_limit).toBe(30); + expect(defaultSubscription.required_state).toContainEqual([EventType.RoomMember, '$LAZY']); + expect(defaultSubscription.required_state).not.toContainEqual([EventType.RoomMember, '*']); expect(mocks.slidingSyncConstructorArgs?.[4]).toBe(45000); }); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index eba55eea16..1f3d2bf78f 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -24,6 +24,7 @@ import { createDebugLogger } from '$utils/debugLogger'; import { CustomStateEvent } from '$types/matrix/room'; import * as Sentry from '@sentry/react'; import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; +import { hydrateRoomMembers } from './roomMemberHydration'; const log = createLogger('slidingSync'); const debugLog = createDebugLogger('slidingSync'); @@ -381,6 +382,7 @@ export class SlidingSyncManager { if (err || !resp || state !== SlidingSyncState.Complete) return; this.recordServerMembershipRooms(resp); + this.hydrateReferencedMembers(resp); this.roomDataAwaitingSyncCompletion.forEach((roomId) => this.notifyRoomSubscriptionStatus(roomId, false) @@ -604,6 +606,42 @@ export class SlidingSyncManager { }); } + private hydrateReferencedMembers(response: MSC3575SlidingSyncResponse): void { + const userIdsByRoom = new Map>(); + const add = (roomId: string, userId: unknown) => { + if (!this.activeRoomSubscriptions.has(roomId) || typeof userId !== 'string') return; + const userIds = userIdsByRoom.get(roomId) ?? new Set(); + userIds.add(userId); + userIdsByRoom.set(roomId, userIds); + }; + + Object.entries(response.rooms ?? {}).forEach(([roomId, roomData]) => { + [...(roomData.timeline ?? []), ...(roomData.required_state ?? [])].forEach((event) => { + add(roomId, event.sender); + }); + }); + + const receipts = response.extensions?.receipts as + | { + rooms?: Record< + string, + { content?: Record>> } + >; + } + | undefined; + Object.entries(receipts?.rooms ?? {}).forEach(([roomId, event]) => { + Object.values(event.content ?? {}).forEach((receiptTypes) => { + Object.values(receiptTypes).forEach((users) => { + Object.keys(users).forEach((userId) => add(roomId, userId)); + }); + }); + }); + + userIdsByRoom.forEach((userIds, roomId) => { + void hydrateRoomMembers(this.mx, roomId, userIds); + }); + } + private reconcileSidebarCacheMembership(): void { if (this.sidebarCacheReconciled || this.sidebarCacheReconciliationQueued) return; this.sidebarCacheReconciliationQueued = true;