diff --git a/src/app/components/read-receipt-avatars/ReadReceiptAvatars.tsx b/src/app/components/read-receipt-avatars/ReadReceiptAvatars.tsx new file mode 100644 index 000000000..03bea0d70 --- /dev/null +++ b/src/app/components/read-receipt-avatars/ReadReceiptAvatars.tsx @@ -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 ( + <> + }> + + setOpen(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + setOpen(false)} /> + + + + + + + ); +} diff --git a/src/app/components/read-receipt-avatars/index.ts b/src/app/components/read-receipt-avatars/index.ts new file mode 100644 index 000000000..5fedc3f27 --- /dev/null +++ b/src/app/components/read-receipt-avatars/index.ts @@ -0,0 +1 @@ +export { ReadReceiptAvatars } from './ReadReceiptAvatars'; diff --git a/src/app/features/room/ReadPositionsContext.ts b/src/app/features/room/ReadPositionsContext.ts new file mode 100644 index 000000000..b90eed7b9 --- /dev/null +++ b/src/app/features/room/ReadPositionsContext.ts @@ -0,0 +1,4 @@ +import { createContext, useContext } from 'react'; + +export const ReadPositionsContext = createContext>(new Map()); +export const useReadPositions = () => useContext(ReadPositionsContext); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 4e29b9908..cc2f29261 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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 ( + {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && ( @@ -1962,5 +1966,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli )} + ); } diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 0c2b0e25b..03c9c6927 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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 && ( + + )} ); diff --git a/src/app/hooks/useRoomReadPositions.ts b/src/app/hooks/useRoomReadPositions.ts new file mode 100644 index 000000000..9fbb3c6b2 --- /dev/null +++ b/src/app/hooks/useRoomReadPositions.ts @@ -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 { + const map = new Map(); + 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 { + 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; +}