diff --git a/matrixgw_frontend/src/api/matrix/MatrixApiRoom.ts b/matrixgw_frontend/src/api/matrix/MatrixApiRoom.ts index 8cfbcbc..9809143 100644 --- a/matrixgw_frontend/src/api/matrix/MatrixApiRoom.ts +++ b/matrixgw_frontend/src/api/matrix/MatrixApiRoom.ts @@ -15,6 +15,12 @@ export interface Room { latest_event?: MatrixEvent; } +export interface Receipt { + user: string; + event_id: string; + ts: number; +} + /** * Find main member of room */ @@ -53,4 +59,16 @@ export class MatrixApiRoom { }) ).data; } + + /** + * Get a room receipts + */ + static async RoomReceipts(room: Room): Promise { + return ( + await APIClient.exec({ + method: "GET", + uri: `/matrix/room/${room.id}/receipts`, + }) + ).data; + } } diff --git a/matrixgw_frontend/src/utils/DateUtils.ts b/matrixgw_frontend/src/utils/DateUtils.ts index c44c16f..7c680b9 100644 --- a/matrixgw_frontend/src/utils/DateUtils.ts +++ b/matrixgw_frontend/src/utils/DateUtils.ts @@ -6,3 +6,12 @@ export function time(): number { return Math.floor(new Date().getTime() / 1000); } + +/** + * Get UNIX time + * + * @returns Number of milliseconds since Epoch + */ +export function timeMs(): number { + return new Date().getTime(); +} diff --git a/matrixgw_frontend/src/utils/RoomEventsManager.ts b/matrixgw_frontend/src/utils/RoomEventsManager.ts index 08a5a95..d324ec5 100644 --- a/matrixgw_frontend/src/utils/RoomEventsManager.ts +++ b/matrixgw_frontend/src/utils/RoomEventsManager.ts @@ -5,8 +5,9 @@ import type { MatrixEventsList, MessageType, } from "../api/matrix/MatrixApiEvent"; -import type { Room } from "../api/matrix/MatrixApiRoom"; +import type { Receipt, Room } from "../api/matrix/MatrixApiRoom"; import type { WsMessage } from "../api/WsApi"; +import { timeMs } from "./DateUtils"; export interface MessageReaction { event_id: string; @@ -29,15 +30,23 @@ export interface Message { export class RoomEventsManager { readonly room: Room; private events: MatrixEvent[]; + private receipts: Receipt[]; messages: Message[]; endToken?: string; typingUsers: string[]; + receiptsEventsMap: Map; - constructor(room: Room, initialMessages: MatrixEventsList) { + constructor( + room: Room, + initialMessages: MatrixEventsList, + receipts: Receipt[] + ) { this.room = room; this.events = []; + this.receipts = receipts; this.messages = []; this.typingUsers = []; + this.receiptsEventsMap = new Map(); this.processNewEvents(initialMessages); } @@ -90,9 +99,30 @@ export class RoomEventsManager { file: m.data.file, }, }; + } else if (m.type === "ReceiptEvent") { + for (const r of m.receipts) { + const prevReceipt = this.receipts.find( + (needle) => r.user === needle.user + ); + // Create new receipt + if (!prevReceipt) + this.receipts.push({ + user: r.user, + event_id: r.event, + ts: r.ts ?? timeMs(), + }); + // Update receipt + else { + prevReceipt.event_id = r.event; + prevReceipt.ts = r.ts ?? timeMs(); + } + } + + this.rebuildMessagesList(); + return true; // Emphemeral event } else if (m.type === "TypingEvent") { this.typingUsers = m.user_ids; - return true; + return true; // Not a real event } else { // Ignore event console.info("Event not supported => ignored"); @@ -117,6 +147,12 @@ export class RoomEventsManager { // Sorts events list to process oldest events first this.events.sort((a, b) => a.time - b.time); + // Process receipts (users map) + const receiptsUsersMap = new Map(); + for (const r of this.receipts) { + receiptsUsersMap.set(r.user, { ...r }); + } + // First, process redactions to skip redacted events let redacted = new Set( this.events @@ -144,6 +180,24 @@ export class RoomEventsManager { continue; } + // Else it is a new message; update receipts if needed + else { + let userReceipt = receiptsUsersMap.get(evt.sender); + + // Create fake receipt if none is available + if (!userReceipt) + receiptsUsersMap.set(evt.sender, { + event_id: evt.id, + ts: evt.time, + user: evt.sender, + }); + // If the message is more recent than user receipt, replace the receipt + else if (userReceipt.ts < evt.time) { + userReceipt.event_id = evt.id; + userReceipt.ts = evt.time; + } + } + this.messages.push({ event_id: evt.id, account: evt.sender, @@ -175,5 +229,13 @@ export class RoomEventsManager { }); } } + + // Adapt receipts to be event-indexed + this.receiptsEventsMap.clear(); + for (const [_userId, receipt] of receiptsUsersMap) { + if (!this.receiptsEventsMap.has(receipt.event_id)) + this.receiptsEventsMap.set(receipt.event_id, [receipt]); + else this.receiptsEventsMap.get(receipt.event_id)!.push(receipt); + } } } diff --git a/matrixgw_frontend/src/widgets/messages/AccountIcon.tsx b/matrixgw_frontend/src/widgets/messages/AccountIcon.tsx index 3c6e23a..e4fb748 100644 --- a/matrixgw_frontend/src/widgets/messages/AccountIcon.tsx +++ b/matrixgw_frontend/src/widgets/messages/AccountIcon.tsx @@ -2,12 +2,16 @@ import { Avatar } from "@mui/material"; import type { UserProfile } from "../../api/matrix/MatrixApiProfile"; import { MatrixApiMedia } from "../../api/matrix/MatrixApiMedia"; -export function AccountIcon(p: { user: UserProfile }): React.ReactElement { +export function AccountIcon(p: { + user: UserProfile; + size?: number; +}): React.ReactElement { return ( {p.user.display_name?.slice(0, 1)} diff --git a/matrixgw_frontend/src/widgets/messages/MainMessagesWidget.tsx b/matrixgw_frontend/src/widgets/messages/MainMessagesWidget.tsx index 05bc37f..d5c60a8 100644 --- a/matrixgw_frontend/src/widgets/messages/MainMessagesWidget.tsx +++ b/matrixgw_frontend/src/widgets/messages/MainMessagesWidget.tsx @@ -89,7 +89,8 @@ function _MainMessageWidget(p: { return; } const messages = await MatrixApiEvent.GetRoomEvents(currentRoom); - const mgr = new RoomEventsManager(currentRoom!, messages); + const receipts = await MatrixApiRoom.RoomReceipts(currentRoom); + const mgr = new RoomEventsManager(currentRoom!, messages, receipts); setRoomMgr(mgr); }; diff --git a/matrixgw_frontend/src/widgets/messages/RoomMessagesList.tsx b/matrixgw_frontend/src/widgets/messages/RoomMessagesList.tsx index 480adc9..ca50878 100644 --- a/matrixgw_frontend/src/widgets/messages/RoomMessagesList.tsx +++ b/matrixgw_frontend/src/widgets/messages/RoomMessagesList.tsx @@ -21,7 +21,7 @@ import EmojiPicker, { EmojiStyle, Theme } from "emoji-picker-react"; import React from "react"; import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent"; import type { UsersMap } from "../../api/matrix/MatrixApiProfile"; -import type { Room } from "../../api/matrix/MatrixApiRoom"; +import type { Receipt, Room } from "../../api/matrix/MatrixApiRoom"; import { useAlert } from "../../hooks/contexts_provider/AlertDialogProvider"; import { useConfirm } from "../../hooks/contexts_provider/ConfirmDialogProvider"; import { useLoadingMessage } from "../../hooks/contexts_provider/LoadingMessageProvider"; @@ -73,6 +73,7 @@ export function RoomMessagesList(p: { m.time_sent_dayjs.startOf("day").unix() != p.manager.messages[idx - 1].time_sent_dayjs.startOf("day").unix() } + receipts={p.manager.receiptsEventsMap.get(m.event_id)} /> ))} @@ -87,6 +88,7 @@ function RoomMessage(p: { message: Message; previousFromSamePerson: boolean; firstMessageOfDay: boolean; + receipts?: Receipt[]; }): React.ReactElement { const theme = useTheme(); const user = useUserInfo(); @@ -220,7 +222,7 @@ function RoomMessage(p: { {/** Message itself */} -
+
{/* Image */} {p.message.type === "m.image" && ( {p.message.content}
)}
+ + {/* Read receipts */} +
+ {(p.receipts ?? []).map((r) => { + const u = p.users.get(r.user); + + if (!u || u.user_id === user.info.matrix_user_id) return <>; + + return ; + })} +
+ + {/** Button bar */} - {/* Reaction */} + {/* Reactions */} {[...p.message.reactions.keys()].map((r) => { const reactions = p.message.reactions.get(r)!;