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 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 23:01:39 -04:00
parent 8c0e2b4250
commit e545706c3b
3 changed files with 154 additions and 94 deletions
@@ -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<HTMLButtonElement>;
};
function EventReaderItem({
room,
readerId,
hour24Clock,
lotusTerminal,
onSelect,
}: EventReaderItemProps) {
const { name, avatarUrl } = useMemberAvatar(room, readerId, 100, 100);
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
return (
<MenuItem
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={onSelect}
before={
<Avatar size="200">
<UserAvatar
userId={readerId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Box direction="Column" grow="Yes">
<Text size="T400" truncate>
{name}
</Text>
{receiptTs !== undefined && (
<Text
size="T200"
priority="300"
style={
lotusTerminal
? {
color: 'var(--lt-accent-amber)',
textShadow: 'var(--lt-glow-amber)',
}
: undefined
}
>
{formatReadTs(receiptTs, hour24Clock)}
</Text>
)}
</Box>
</MenuItem>
);
}
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 (
<Box
className={classNames(css.EventReaders, className)}
@@ -100,21 +152,14 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box grow="Yes">
<Scroll visibility="Hover" hideTrack size="300">
<Box className={css.Content} direction="Column">
{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 (
<MenuItem
{latestEventReaders.map((readerId) => (
<EventReaderItem
key={readerId}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={(event) => {
room={room}
readerId={readerId}
hour24Clock={hour24Clock}
lotusTerminal={lotusTerminal}
onSelect={(event) => {
openProfile(
room.roomId,
space?.roomId,
@@ -123,41 +168,8 @@ export const EventReaders = as<'div', EventReadersProps>(
'Bottom',
);
}}
before={
<Avatar size="200">
<UserAvatar
userId={readerId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Box direction="Column" grow="Yes">
<Text size="T400" truncate>
{name}
</Text>
{receiptTs !== undefined && (
<Text
size="T200"
priority="300"
style={
lotusTerminal
? {
color: 'var(--lt-accent-amber)',
textShadow: 'var(--lt-glow-amber)',
}
: undefined
}
>
{formatReadTs(receiptTs, hour24Clock)}
</Text>
)}
</Box>
</MenuItem>
);
})}
))}
</Box>
</Scroll>
</Box>
@@ -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 (
<StackedAvatar title={name} variant="SurfaceVariant" size="200" radii="Pill">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
}
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 (
<StackedAvatar
key={userId}
title={name}
variant="SurfaceVariant"
size="200"
radii="Pill"
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
})}
{displayed.map((userId) => (
<ReceiptStackedAvatar key={userId} room={room} userId={userId} />
))}
{extra > 0 && (
<Text
size="T200"
+56
View File
@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { Room, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useMediaAuthentication } from './useMediaAuthentication';
import { useForceUpdate } from './useForceUpdate';
import { getMemberName } from '../utils/room';
import { mxcUrlToHttp } from '../utils/matrix';
export type MemberAvatar = {
name: string;
avatarUrl: string | undefined;
};
/**
* Resolve a room member's display name and avatar http url, staying reactive to
* that member's profile (name/avatar/membership) changes.
*
* Mirrors the reactivity pattern in ReadReceiptAvatars (N6): subscribe to
* `RoomStateEvent.Members` filtered to this room + userId and force a re-render,
* since `RoomMemberEvent` has no dedicated Avatar signal.
*/
export const useMemberAvatar = (
room: Room,
userId: string,
width = 32,
height = 32,
resizeMethod = 'crop',
): MemberAvatar => {
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 };
};