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';
@@ -0,0 +1,4 @@
import { createContext, useContext } from 'react';
export const ReadPositionsContext = createContext<Map<string, string[]>>(new Map());
export const useReadPositions = () => useContext(ReadPositionsContext);
+5
View File
@@ -89,6 +89,8 @@ import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message';
import { ReadPositionsContext } from './ReadPositionsContext';
import { useRoomReadPositions } from '../../hooks/useRoomReadPositions';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
import * as customHtmlCss from '../../styles/CustomHtml.css';
import { RoomIntro } from '../../components/room-intro';
@@ -441,6 +443,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const direct = useIsDirectRoom();
const readPositions = useRoomReadPositions(room);
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
@@ -1837,6 +1840,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
};
return (
<ReadPositionsContext.Provider value={readPositions}>
<Box grow="Yes" style={{ position: 'relative' }}>
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
<TimelineFloat position="Top">
@@ -1962,5 +1966,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</TimelineFloat>
)}
</Box>
</ReadPositionsContext.Provider>
);
}
+13
View File
@@ -63,6 +63,8 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import * as css from './styles.css';
import { EventReaders } from '../../../components/event-readers';
import { ReadReceiptAvatars } from '../../../components/read-receipt-avatars';
import { useReadPositions } from '../ReadPositionsContext';
import { TextViewer } from '../../../components/text-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { EmojiBoard } from '../../../components/emoji-board';
@@ -721,6 +723,10 @@ export const Message = as<'div', MessageProps>(
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const senderId = mEvent.getSender() ?? '';
const readPositions = useReadPositions();
const readReceiptUsers = hideReadReceipts
? []
: (readPositions.get(mEvent.getId() ?? '') ?? []);
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
@@ -833,6 +839,13 @@ export const Message = as<'div', MessageProps>(
children
)}
{reactions}
{readReceiptUsers.length > 0 && (
<ReadReceiptAvatars
room={room}
eventId={mEvent.getId() ?? ''}
userIds={readReceiptUsers}
/>
)}
</Box>
);
+33
View File
@@ -0,0 +1,33 @@
import { Room, RoomEvent } from 'matrix-js-sdk';
import { useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
function computePositions(room: Room, myUserId: string): Map<string, string[]> {
const map = new Map<string, string[]>();
for (const member of room.getJoinedMembers()) {
if (member.userId === myUserId) continue;
const evtId = room.getEventReadUpTo(member.userId);
if (!evtId) continue;
const arr = map.get(evtId);
if (arr) arr.push(member.userId);
else map.set(evtId, [member.userId]);
}
return map;
}
export function useRoomReadPositions(room: Room): Map<string, string[]> {
const mx = useMatrixClient();
const myUserId = mx.getUserId() ?? '';
const [positions, setPositions] = useState(() => computePositions(room, myUserId));
useEffect(() => {
setPositions(computePositions(room, myUserId));
const onReceipt = () => setPositions(computePositions(room, myUserId));
room.on(RoomEvent.Receipt, onReceipt);
return () => {
room.removeListener(RoomEvent.Receipt, onReceipt);
};
}, [room, myUserId]);
return positions;
}