feat: per-message read receipt avatars showing each user s last-read position
This commit is contained in:
@@ -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);
|
||||||
@@ -89,6 +89,8 @@ import { useSetting } from '../../state/hooks/settings';
|
|||||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
import { MessageLayout, settingsAtom } from '../../state/settings';
|
||||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||||
import { Reactions, Message, Event, EncryptedContent } from './message';
|
import { Reactions, Message, Event, EncryptedContent } from './message';
|
||||||
|
import { ReadPositionsContext } from './ReadPositionsContext';
|
||||||
|
import { useRoomReadPositions } from '../../hooks/useRoomReadPositions';
|
||||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||||
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
||||||
import { RoomIntro } from '../../components/room-intro';
|
import { RoomIntro } from '../../components/room-intro';
|
||||||
@@ -441,6 +443,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
const direct = useIsDirectRoom();
|
const direct = useIsDirectRoom();
|
||||||
|
const readPositions = useRoomReadPositions(room);
|
||||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
@@ -1837,6 +1840,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ReadPositionsContext.Provider value={readPositions}>
|
||||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
<Box grow="Yes" style={{ position: 'relative' }}>
|
||||||
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
|
||||||
<TimelineFloat position="Top">
|
<TimelineFloat position="Top">
|
||||||
@@ -1962,5 +1966,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</TimelineFloat>
|
</TimelineFloat>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</ReadPositionsContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { EventReaders } from '../../../components/event-readers';
|
import { EventReaders } from '../../../components/event-readers';
|
||||||
|
import { ReadReceiptAvatars } from '../../../components/read-receipt-avatars';
|
||||||
|
import { useReadPositions } from '../ReadPositionsContext';
|
||||||
import { TextViewer } from '../../../components/text-viewer';
|
import { TextViewer } from '../../../components/text-viewer';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { EmojiBoard } from '../../../components/emoji-board';
|
import { EmojiBoard } from '../../../components/emoji-board';
|
||||||
@@ -721,6 +723,10 @@ export const Message = as<'div', MessageProps>(
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const senderId = mEvent.getSender() ?? '';
|
const senderId = mEvent.getSender() ?? '';
|
||||||
|
const readPositions = useReadPositions();
|
||||||
|
const readReceiptUsers = hideReadReceipts
|
||||||
|
? []
|
||||||
|
: (readPositions.get(mEvent.getId() ?? '') ?? []);
|
||||||
|
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||||
@@ -833,6 +839,13 @@ export const Message = as<'div', MessageProps>(
|
|||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
{reactions}
|
{reactions}
|
||||||
|
{readReceiptUsers.length > 0 && (
|
||||||
|
<ReadReceiptAvatars
|
||||||
|
room={room}
|
||||||
|
eventId={mEvent.getId() ?? ''}
|
||||||
|
userIds={readReceiptUsers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user