fix(receipts): refresh read-receipt avatars/names on member changes (N6)

ReadReceiptAvatars read avatar + display-name from room member state at render
time, so they only refreshed when the parent Message re-rendered (on a receipt
change). A reader changing their avatar/display-name left a stale avatar/name
until an unrelated re-render. Subscribe to RoomStateEvent.Members (the only signal
that fires for name, avatar AND membership changes — RoomMemberEvent has no Avatar
event) scoped to the displayed readers, and force a re-render so the pill updates
live. Listener is cleaned up; userIds identity is stable (memoized in the read-
positions map) so no subscribe churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 20:58:10 -04:00
parent 1b2142e6c4
commit 3f9fea1cf2
@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Room } from 'matrix-js-sdk'; import { Room, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
import { import {
Icon, Icon,
Icons, Icons,
@@ -23,6 +23,7 @@ import { StackedAvatar } from '../stacked-avatar';
import { EventReaders } from '../event-readers'; import { EventReaders } from '../event-readers';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle'; import { useModalStyle } from '../../hooks/useModalStyle';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import * as css from './ReadReceiptAvatars.css'; import * as css from './ReadReceiptAvatars.css';
const MAX_DISPLAY = 5; const MAX_DISPLAY = 5;
@@ -41,6 +42,28 @@ export function ReadReceiptAvatars({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const modalStyle = useModalStyle(360); const modalStyle = useModalStyle(360);
const [, forceUpdate] = useForceUpdate();
// The avatars + names below are read from room member state at render time, so
// they only refresh when this component re-renders (which the timeline does on a
// receipt change). Re-render on a member-state change of any displayed reader so
// an avatar/display-name update shows live. RoomStateEvent.Members fires for name,
// avatar AND membership changes (RoomMemberEvent has no Avatar signal).
useEffect(() => {
const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
event,
_state,
member
) => {
if (event.getRoomId() === room.roomId && userIds.includes(member.userId)) {
forceUpdate();
}
};
mx.on(RoomStateEvent.Members, handleMembers);
return () => {
mx.removeListener(RoomStateEvent.Members, handleMembers);
};
}, [mx, room.roomId, userIds, forceUpdate]);
if (userIds.length === 0) return null; if (userIds.length === 0) return null;