feat: per-message read receipt avatars showing each user s last-read position

This commit is contained in:
root
2026-05-15 18:56:17 -04:00
parent cb2d730cca
commit 7d57396de8
6 changed files with 170 additions and 0 deletions
@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { Avatar, Icon, Icons, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { UserAvatar } from '../user-avatar';
import { EventReaders } from '../event-readers';
import { stopPropagation } from '../../utils/keyboard';
const MAX_DISPLAY = 5;
export function ReadReceiptAvatars({
room,
eventId,
userIds,
}: {
room: Room;
eventId: string;
userIds: string[];
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [open, setOpen] = useState(false);
if (userIds.length === 0) return null;
const displayed = userIds.slice(0, MAX_DISPLAY);
const extra = userIds.length - MAX_DISPLAY;
const tooltipNames = userIds
.slice(0, 5)
.map((id) => getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id)
.join(', ') + (extra > 0 ? ` +${extra} more` : '');
return (
<>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
<button
type="button"
onClick={() => setOpen(true)}
title={tooltipNames}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '1px 0 0',
display: 'flex',
alignItems: 'center',
gap: '3px',
marginLeft: 'auto',
}}
>
<span style={{ display: 'flex', alignItems: 'center' }}>
{displayed.map((userId, i) => {
const name =
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? mx.mxcUrlToHttp(avatarMxc, 32, 32, 'crop', undefined, false, useAuthentication) ??
undefined
: undefined;
return (
<span
key={userId}
title={name}
style={{
marginLeft: i === 0 ? 0 : -5,
display: 'block',
width: 16,
height: 16,
borderRadius: '50%',
border: '1.5px solid var(--bg-surface)',
overflow: 'hidden',
flexShrink: 0,
}}
>
<Avatar size="100">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</span>
);
})}
</span>
{extra > 0 && (
<Text size="T100" style={{ opacity: 0.6 }}>
+{extra}
</Text>
)}
</button>
</>
);
}
@@ -0,0 +1 @@
export { ReadReceiptAvatars } from './ReadReceiptAvatars';