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-sliding-sync-stale-display-names.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion src/app/features/room/message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,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;
Expand Down
10 changes: 9 additions & 1 deletion src/app/hooks/timeline/useTimelineEventRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
50 changes: 28 additions & 22 deletions src/app/hooks/useRoomMemberHydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,44 @@ 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);
const isInactivePanel = useIsInactivePanel();
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;
};
35 changes: 35 additions & 0 deletions src/client/roomMemberHydration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
27 changes: 23 additions & 4 deletions src/client/roomMemberHydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const failedAt = new WeakMap<MatrixClient, Map<string, number>>();
const activeRequests = new WeakMap<MatrixClient, number>();
const requestQueues = new WeakMap<MatrixClient, Array<() => void>>();

const REFRESH_TTL_MS = 10 * 60_000;
const refreshedAt = new WeakMap<MatrixClient, Map<string, number>>();

const scheduleRequest = <T>(mx: MatrixClient, task: () => Promise<T>): Promise<T> =>
new Promise<T>((resolve, reject) => {
const run = () => {
Expand Down Expand Up @@ -38,12 +41,21 @@ const scheduleRequest = <T>(mx: MatrixClient, task: () => Promise<T>): Promise<T
export const hydrateRoomMember = (
mx: MatrixClient,
roomId: string,
userId: string
userId: string,
force = false
): Promise<void> => {
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();

Expand All @@ -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,
Expand All @@ -72,6 +86,11 @@ export const hydrateRoomMember = (
})
.then(() => {
failedAt.get(mx)?.delete(key);
if (force) {
const refreshMap = refreshedAt.get(mx) ?? new Map<string, number>();
refreshedAt.set(mx, refreshMap);
refreshMap.set(key, Date.now());
}
})
.catch(() => {
const failures = failedAt.get(mx) ?? new Map<string, number>();
Expand Down
Loading