From e545706c3b79fb63a9e9337ef24cdfa3db18b1f4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 7 Jul 2026 23:01:39 -0400 Subject: [PATCH] DP18: add useMemberAvatar hook and adopt it in reader avatars Add a reactive `useMemberAvatar(room, userId, w?, h?, resize?)` hook returning { name, avatarUrl }, standardizing the member name + avatar trio and the RoomStateEvent.Members reactivity pattern (N6). Convert ReadReceiptAvatars and EventReaders to render per-user avatars via a small child component using the hook, preserving exact sizes, fallback rendering, TDS pill/tooltip styling and behavior. Co-Authored-By: Claude Opus 4.8 --- .../components/event-readers/EventReaders.tsx | 142 ++++++++++-------- .../ReadReceiptAvatars.tsx | 50 +++--- src/app/hooks/useMemberAvatar.ts | 56 +++++++ 3 files changed, 154 insertions(+), 94 deletions(-) create mode 100644 src/app/hooks/useMemberAvatar.ts diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index 89991935d..796866057 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -15,12 +15,10 @@ import { } from 'folds'; import { Room } from 'matrix-js-sdk'; import { useRoomEventReaders } from '../../hooks/useRoomEventReaders'; -import { getMemberDisplayName } from '../../utils/room'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import * as css from './EventReaders.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { UserAvatar } from '../user-avatar'; -import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useMemberAvatar } from '../../hooks/useMemberAvatar'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getMouseEventCords } from '../../utils/dom'; @@ -38,6 +36,64 @@ function formatReadTs(ts: number, hour24Clock: boolean): string { : `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`; } +type EventReaderItemProps = { + room: Room; + readerId: string; + hour24Clock: boolean; + lotusTerminal: boolean; + onSelect: React.MouseEventHandler; +}; +function EventReaderItem({ + room, + readerId, + hour24Clock, + lotusTerminal, + onSelect, +}: EventReaderItemProps) { + const { name, avatarUrl } = useMemberAvatar(room, readerId, 100, 100); + const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts; + + return ( + + } + /> + + } + > + + + {name} + + {receiptTs !== undefined && ( + + {formatReadTs(receiptTs, hour24Clock)} + + )} + + + ); +} + export type EventReadersProps = { room: Room; eventId: string; @@ -46,7 +102,6 @@ export type EventReadersProps = { export const EventReaders = as<'div', EventReadersProps>( ({ className, room, eventId, requestClose, ...props }, ref) => { const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); const myUserId = mx.getUserId(); const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId); const openProfile = useOpenUserRoomProfile(); @@ -54,9 +109,6 @@ export const EventReaders = as<'div', EventReadersProps>( const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); - const getName = (userId: string) => - getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; - return ( ( - {latestEventReaders.map((readerId) => { - const name = getName(readerId); - const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl(); - const avatarUrl = avatarMxcUrl - ? (mxcUrlToHttp(mx, avatarMxcUrl, useAuthentication, 100, 100, 'crop') ?? - undefined) - : undefined; - const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts; - - return ( - { - openProfile( - room.roomId, - space?.roomId, - readerId, - getMouseEventCords(event.nativeEvent), - 'Bottom', - ); - }} - before={ - - } - /> - - } - > - - - {name} - - {receiptTs !== undefined && ( - - {formatReadTs(receiptTs, hour24Clock)} - - )} - - - ); - })} + {latestEventReaders.map((readerId) => ( + { + openProfile( + room.roomId, + space?.roomId, + readerId, + getMouseEventCords(event.nativeEvent), + 'Bottom', + ); + }} + /> + ))} diff --git a/src/app/components/read-receipt-avatars/ReadReceiptAvatars.tsx b/src/app/components/read-receipt-avatars/ReadReceiptAvatars.tsx index beda4ed8c..2c217fa08 100644 --- a/src/app/components/read-receipt-avatars/ReadReceiptAvatars.tsx +++ b/src/app/components/read-receipt-avatars/ReadReceiptAvatars.tsx @@ -15,19 +15,32 @@ import FocusTrap from 'focus-trap-react'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; -import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { getMemberDisplayName } from '../../utils/room'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { getMemberName } from '../../utils/room'; import { UserAvatar } from '../user-avatar'; import { StackedAvatar } from '../stacked-avatar'; import { EventReaders } from '../event-readers'; import { stopPropagation } from '../../utils/keyboard'; import { useModalStyle } from '../../hooks/useModalStyle'; import { useForceUpdate } from '../../hooks/useForceUpdate'; +import { useMemberAvatar } from '../../hooks/useMemberAvatar'; import * as css from './ReadReceiptAvatars.css'; const MAX_DISPLAY = 5; +function ReceiptStackedAvatar({ room, userId }: { room: Room; userId: string }) { + const { name, avatarUrl } = useMemberAvatar(room, userId); + return ( + + } + /> + + ); +} + export function ReadReceiptAvatars({ room, eventId, @@ -38,7 +51,6 @@ export function ReadReceiptAvatars({ userIds: string[]; }) { const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); const [open, setOpen] = useState(false); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const modalStyle = useModalStyle(360); @@ -53,7 +65,7 @@ export function ReadReceiptAvatars({ const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = ( event, _state, - member + member, ) => { if (event.getRoomId() === room.roomId && userIds.includes(member.userId)) { forceUpdate(); @@ -72,7 +84,7 @@ export function ReadReceiptAvatars({ const tooltipNames = userIds .slice(0, 5) - .map((id) => getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id) + .map((id) => getMemberName(room, id)) .join(', ') + (extra > 0 ? ` +${extra} more` : ''); return ( @@ -117,29 +129,9 @@ export function ReadReceiptAvatars({ gap: '0px', }} > - {displayed.map((userId) => { - const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; - const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl(); - const avatarUrl = avatarMxc - ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined) - : undefined; - return ( - - } - /> - - ); - })} + {displayed.map((userId) => ( + + ))} {extra > 0 && ( { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [, forceUpdate] = useForceUpdate(); + + useEffect(() => { + const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = ( + event, + _state, + member, + ) => { + if (event.getRoomId() === room.roomId && member.userId === userId) { + forceUpdate(); + } + }; + mx.on(RoomStateEvent.Members, handleMembers); + return () => { + mx.removeListener(RoomStateEvent.Members, handleMembers); + }; + }, [mx, room.roomId, userId, forceUpdate]); + + const name = getMemberName(room, userId); + const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl(); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, width, height, resizeMethod) ?? undefined) + : undefined; + + return { name, avatarUrl }; +};