diff --git a/.changeset/fix-sliding-sync-stale-display-names.md b/.changeset/fix-sliding-sync-stale-display-names.md new file mode 100644 index 000000000..2b7b401c0 --- /dev/null +++ b/.changeset/fix-sliding-sync-stale-display-names.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix outdated user display names in chats under sliding sync by force-refreshing room member state when the global profile suggests the per-room name is stale. diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 12f15fd3c..01636c402 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -493,7 +493,7 @@ function MessageInternal( // Avatars // Prefer the room-scoped member avatar (m.room.member) over the global profile // avatar so per-room avatar overrides are respected in the timeline. - useRoomMemberHydration(room, senderId, mEvent.sender !== null); + useRoomMemberHydration(room, senderId, mEvent.sender !== null, profile.displayName); const memberAvatarMxc = mEvent.sender?.getMxcAvatarUrl() ?? getMemberAvatarMxc(room, senderId); const avatarUrl = useMemo(() => { const mxc = pmp?.avatar_url || memberAvatarMxc || profile.avatarUrl; diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.test.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.test.tsx index bff6757b3..bc511289d 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.test.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.test.tsx @@ -51,7 +51,15 @@ vi.mock('$hooks/useSableCosmetics', () => ({ })); vi.mock('$hooks/useRoomMemberHydration', () => ({ - useRoomMemberHydration: vi.fn<(room: unknown, userId: string) => void>(), + useRoomMemberHydration: + vi.fn< + ( + room: unknown, + userId: string, + hasTimelineMember?: boolean, + profileDisplayName?: string + ) => void + >(), })); vi.mock('$state/hooks/userRoomProfile', () => ({ diff --git a/src/app/hooks/useRoomMemberHydration.ts b/src/app/hooks/useRoomMemberHydration.ts index fb736f023..9942d69bf 100644 --- a/src/app/hooks/useRoomMemberHydration.ts +++ b/src/app/hooks/useRoomMemberHydration.ts @@ -5,15 +5,11 @@ import { useMatrixClient } from './useMatrixClient'; import { useIsInactivePanel } from './useRoom'; import { useTimelineScrolling } from './useTimelineScrollActivity'; -/** - * Under sliding sync, m.room.member events only arrive for senders in the - * lazy-loaded sync window. Fetch the member on demand and bump local state so - * the caller re-reads room member state (avatar, display name) once hydrated. - */ export const useRoomMemberHydration = ( room: Room, userId: string, - hasTimelineMember = false + hasTimelineMember = false, + profileDisplayName?: string ): number => { const mx = useMatrixClient(); const [version, setVersion] = useState(0); @@ -21,22 +17,32 @@ export const useRoomMemberHydration = ( const timelineScrolling = useTimelineScrolling(); useEffect(() => { - if ( - isInactivePanel || - timelineScrolling || - hasTimelineMember || - !userId.startsWith('@') || - room.getMember(userId) - ) - return undefined; - let disposed = false; - void hydrateRoomMember(mx, room.roomId, userId).then(() => { - if (!disposed && room.getMember(userId)) setVersion((v) => v + 1); - }); - return () => { - disposed = true; - }; - }, [mx, room, userId, hasTimelineMember, isInactivePanel, timelineScrolling]); + if (isInactivePanel || timelineScrolling || !userId.startsWith('@')) return undefined; + + const member = room.getMember(userId); + + if (!member && !hasTimelineMember) { + let disposed = false; + void hydrateRoomMember(mx, room.roomId, userId).then(() => { + if (!disposed && room.getMember(userId)) setVersion((v) => v + 1); + }); + return () => { + disposed = true; + }; + } + + if (member && profileDisplayName && profileDisplayName !== member.rawDisplayName) { + let disposed = false; + void hydrateRoomMember(mx, room.roomId, userId, true).then(() => { + if (!disposed && room.getMember(userId)) setVersion((v) => v + 1); + }); + return () => { + disposed = true; + }; + } + + return undefined; + }, [mx, room, userId, hasTimelineMember, isInactivePanel, timelineScrolling, profileDisplayName]); return version; }; diff --git a/src/client/roomMemberHydration.test.ts b/src/client/roomMemberHydration.test.ts index a29e57f6e..7a6c4ebd4 100644 --- a/src/client/roomMemberHydration.test.ts +++ b/src/client/roomMemberHydration.test.ts @@ -116,6 +116,41 @@ describe('hydrateRoomMember', () => { }); }); +describe('hydrateRoomMember (force)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('re-fetches and injects state when force=true even if the member exists', async () => { + const { mx, getStateEvent, setStateEvents, getMember } = makeFakes(); + getMember.mockReturnValue({ rawDisplayName: 'OldName' } as RoomMember); + + await hydrateRoomMember(mx, ROOM_ID, USER_ID, true); + + expect(getStateEvent).toHaveBeenCalledTimes(1); + expect(setStateEvents).toHaveBeenCalledTimes(1); + }); + + it('respects the refresh cooldown so repeated force calls do not refetch', async () => { + 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); + + expect(getStateEvent).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(10 * 60_000 + 1); + await hydrateRoomMember(mx, ROOM_ID, USER_ID, true); + + expect(getStateEvent).toHaveBeenCalledTimes(2); + }); +}); + 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 22fb5fb6e..cc4374566 100644 --- a/src/client/roomMemberHydration.ts +++ b/src/client/roomMemberHydration.ts @@ -11,6 +11,9 @@ const failedAt = new WeakMap>(); const activeRequests = new WeakMap(); const requestQueues = new WeakMap void>>(); +const REFRESH_TTL_MS = 10 * 60_000; +const refreshedAt = new WeakMap>(); + const scheduleRequest = (mx: MatrixClient, task: () => Promise): Promise => new Promise((resolve, reject) => { const run = () => { @@ -38,12 +41,21 @@ const scheduleRequest = (mx: MatrixClient, task: () => Promise): Promise => { const room = mx.getRoom(roomId); - if (!room || room.getMember(userId)) return Promise.resolve(); + if (!room) return Promise.resolve(); + if (!force && room.getMember(userId)) return Promise.resolve(); const key = `${roomId}\u0000${userId}`; + + if (force) { + const lastRefreshed = refreshedAt.get(mx)?.get(key); + if (lastRefreshed !== undefined && Date.now() - lastRefreshed < REFRESH_TTL_MS) + return Promise.resolve(); + } + const failedTs = failedAt.get(mx)?.get(key); if (failedTs !== undefined && Date.now() - failedTs < FAILURE_TTL_MS) return Promise.resolve(); @@ -56,10 +68,12 @@ export const hydrateRoomMember = ( // A request may have waited in the queue while another event supplied the // member state. Avoid issuing a redundant network request in that case. const requestRoom = mx.getRoom(roomId); - if (!requestRoom || requestRoom.getMember(userId)) return; + if (!requestRoom) return; + if (!force && requestRoom.getMember(userId)) return; const content = await mx.getStateEvent(roomId, EventType.RoomMember, userId); const currentRoom = mx.getRoom(roomId); - if (!currentRoom || currentRoom.getMember(userId)) return; + if (!currentRoom) return; + if (!force && currentRoom.getMember(userId)) return; currentRoom.currentState.setStateEvents([ new MatrixEvent({ type: EventType.RoomMember, @@ -72,6 +86,11 @@ export const hydrateRoomMember = ( }) .then(() => { failedAt.get(mx)?.delete(key); + if (force) { + const refreshMap = refreshedAt.get(mx) ?? new Map(); + refreshedAt.set(mx, refreshMap); + refreshMap.set(key, Date.now()); + } }) .catch(() => { const failures = failedAt.get(mx) ?? new Map();