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:
@@ -15,12 +15,10 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
|
||||||
import * as css from './EventReaders.css';
|
import * as css from './EventReaders.css';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { UserAvatar } from '../user-avatar';
|
import { UserAvatar } from '../user-avatar';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { getMouseEventCords } from '../../utils/dom';
|
import { getMouseEventCords } from '../../utils/dom';
|
||||||
@@ -38,6 +36,64 @@ function formatReadTs(ts: number, hour24Clock: boolean): string {
|
|||||||
: `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`;
|
: `${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 = {
|
export type EventReadersProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
@@ -46,7 +102,6 @@ export type EventReadersProps = {
|
|||||||
export const EventReaders = as<'div', EventReadersProps>(
|
export const EventReaders = as<'div', EventReadersProps>(
|
||||||
({ className, room, eventId, requestClose, ...props }, ref) => {
|
({ className, room, eventId, requestClose, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const myUserId = mx.getUserId();
|
const myUserId = mx.getUserId();
|
||||||
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
|
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
|
||||||
const openProfile = useOpenUserRoomProfile();
|
const openProfile = useOpenUserRoomProfile();
|
||||||
@@ -54,9 +109,6 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
|
||||||
const getName = (userId: string) =>
|
|
||||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className={classNames(css.EventReaders, className)}
|
className={classNames(css.EventReaders, className)}
|
||||||
@@ -100,21 +152,14 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Scroll visibility="Hover" hideTrack size="300">
|
<Scroll visibility="Hover" hideTrack size="300">
|
||||||
<Box className={css.Content} direction="Column">
|
<Box className={css.Content} direction="Column">
|
||||||
{latestEventReaders.map((readerId) => {
|
{latestEventReaders.map((readerId) => (
|
||||||
const name = getName(readerId);
|
<EventReaderItem
|
||||||
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
|
|
||||||
key={readerId}
|
key={readerId}
|
||||||
style={{ padding: `0 ${config.space.S200}` }}
|
room={room}
|
||||||
radii="400"
|
readerId={readerId}
|
||||||
onClick={(event) => {
|
hour24Clock={hour24Clock}
|
||||||
|
lotusTerminal={lotusTerminal}
|
||||||
|
onSelect={(event) => {
|
||||||
openProfile(
|
openProfile(
|
||||||
room.roomId,
|
room.roomId,
|
||||||
space?.roomId,
|
space?.roomId,
|
||||||
@@ -123,41 +168,8 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
'Bottom',
|
'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>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -15,19 +15,32 @@ import FocusTrap from 'focus-trap-react';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { getMemberName } from '../../utils/room';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
|
||||||
import { UserAvatar } from '../user-avatar';
|
import { UserAvatar } from '../user-avatar';
|
||||||
import { StackedAvatar } from '../stacked-avatar';
|
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 { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
|
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
|
||||||
import * as css from './ReadReceiptAvatars.css';
|
import * as css from './ReadReceiptAvatars.css';
|
||||||
|
|
||||||
const MAX_DISPLAY = 5;
|
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({
|
export function ReadReceiptAvatars({
|
||||||
room,
|
room,
|
||||||
eventId,
|
eventId,
|
||||||
@@ -38,7 +51,6 @@ export function ReadReceiptAvatars({
|
|||||||
userIds: string[];
|
userIds: string[];
|
||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
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);
|
||||||
@@ -53,7 +65,7 @@ export function ReadReceiptAvatars({
|
|||||||
const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
|
const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
|
||||||
event,
|
event,
|
||||||
_state,
|
_state,
|
||||||
member
|
member,
|
||||||
) => {
|
) => {
|
||||||
if (event.getRoomId() === room.roomId && userIds.includes(member.userId)) {
|
if (event.getRoomId() === room.roomId && userIds.includes(member.userId)) {
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
@@ -72,7 +84,7 @@ export function ReadReceiptAvatars({
|
|||||||
const tooltipNames =
|
const tooltipNames =
|
||||||
userIds
|
userIds
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map((id) => getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id)
|
.map((id) => getMemberName(room, id))
|
||||||
.join(', ') + (extra > 0 ? ` +${extra} more` : '');
|
.join(', ') + (extra > 0 ? ` +${extra} more` : '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -117,29 +129,9 @@ export function ReadReceiptAvatars({
|
|||||||
gap: '0px',
|
gap: '0px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{displayed.map((userId) => {
|
{displayed.map((userId) => (
|
||||||
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
<ReceiptStackedAvatar key={userId} room={room} 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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{extra > 0 && (
|
{extra > 0 && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user