Handle read receipts on web ui

This commit is contained in:
2025-12-01 18:30:22 +01:00
parent 30e63bfdb4
commit 7356a66e4a
6 changed files with 117 additions and 8 deletions

View File

@@ -15,6 +15,12 @@ export interface Room {
latest_event?: MatrixEvent; latest_event?: MatrixEvent;
} }
export interface Receipt {
user: string;
event_id: string;
ts: number;
}
/** /**
* Find main member of room * Find main member of room
*/ */
@@ -53,4 +59,16 @@ export class MatrixApiRoom {
}) })
).data; ).data;
} }
/**
* Get a room receipts
*/
static async RoomReceipts(room: Room): Promise<Receipt[]> {
return (
await APIClient.exec({
method: "GET",
uri: `/matrix/room/${room.id}/receipts`,
})
).data;
}
} }

View File

@@ -6,3 +6,12 @@
export function time(): number { export function time(): number {
return Math.floor(new Date().getTime() / 1000); return Math.floor(new Date().getTime() / 1000);
} }
/**
* Get UNIX time
*
* @returns Number of milliseconds since Epoch
*/
export function timeMs(): number {
return new Date().getTime();
}

View File

@@ -5,8 +5,9 @@ import type {
MatrixEventsList, MatrixEventsList,
MessageType, MessageType,
} from "../api/matrix/MatrixApiEvent"; } 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 type { WsMessage } from "../api/WsApi";
import { timeMs } from "./DateUtils";
export interface MessageReaction { export interface MessageReaction {
event_id: string; event_id: string;
@@ -29,15 +30,23 @@ export interface Message {
export class RoomEventsManager { export class RoomEventsManager {
readonly room: Room; readonly room: Room;
private events: MatrixEvent[]; private events: MatrixEvent[];
private receipts: Receipt[];
messages: Message[]; messages: Message[];
endToken?: string; endToken?: string;
typingUsers: string[]; typingUsers: string[];
receiptsEventsMap: Map<string, Receipt[]>;
constructor(room: Room, initialMessages: MatrixEventsList) { constructor(
room: Room,
initialMessages: MatrixEventsList,
receipts: Receipt[]
) {
this.room = room; this.room = room;
this.events = []; this.events = [];
this.receipts = receipts;
this.messages = []; this.messages = [];
this.typingUsers = []; this.typingUsers = [];
this.receiptsEventsMap = new Map();
this.processNewEvents(initialMessages); this.processNewEvents(initialMessages);
} }
@@ -90,9 +99,30 @@ export class RoomEventsManager {
file: m.data.file, 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") { } else if (m.type === "TypingEvent") {
this.typingUsers = m.user_ids; this.typingUsers = m.user_ids;
return true; return true; // Not a real event
} else { } else {
// Ignore event // Ignore event
console.info("Event not supported => ignored"); console.info("Event not supported => ignored");
@@ -117,6 +147,12 @@ export class RoomEventsManager {
// Sorts events list to process oldest events first // Sorts events list to process oldest events first
this.events.sort((a, b) => a.time - b.time); this.events.sort((a, b) => a.time - b.time);
// Process receipts (users map)
const receiptsUsersMap = new Map<string, Receipt>();
for (const r of this.receipts) {
receiptsUsersMap.set(r.user, { ...r });
}
// First, process redactions to skip redacted events // First, process redactions to skip redacted events
let redacted = new Set( let redacted = new Set(
this.events this.events
@@ -144,6 +180,24 @@ export class RoomEventsManager {
continue; 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({ this.messages.push({
event_id: evt.id, event_id: evt.id,
account: evt.sender, 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);
}
} }
} }

View File

@@ -2,12 +2,16 @@ import { Avatar } from "@mui/material";
import type { UserProfile } from "../../api/matrix/MatrixApiProfile"; import type { UserProfile } from "../../api/matrix/MatrixApiProfile";
import { MatrixApiMedia } from "../../api/matrix/MatrixApiMedia"; 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 ( return (
<Avatar <Avatar
src={ src={
p.user.avatar ? MatrixApiMedia.MediaURL(p.user.avatar, true) : undefined p.user.avatar ? MatrixApiMedia.MediaURL(p.user.avatar, true) : undefined
} }
sx={{ width: p.size, height: p.size }}
> >
{p.user.display_name?.slice(0, 1)} {p.user.display_name?.slice(0, 1)}
</Avatar> </Avatar>

View File

@@ -89,7 +89,8 @@ function _MainMessageWidget(p: {
return; return;
} }
const messages = await MatrixApiEvent.GetRoomEvents(currentRoom); 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); setRoomMgr(mgr);
}; };

View File

@@ -21,7 +21,7 @@ import EmojiPicker, { EmojiStyle, Theme } from "emoji-picker-react";
import React from "react"; import React from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent"; import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile"; 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 { useAlert } from "../../hooks/contexts_provider/AlertDialogProvider";
import { useConfirm } from "../../hooks/contexts_provider/ConfirmDialogProvider"; import { useConfirm } from "../../hooks/contexts_provider/ConfirmDialogProvider";
import { useLoadingMessage } from "../../hooks/contexts_provider/LoadingMessageProvider"; import { useLoadingMessage } from "../../hooks/contexts_provider/LoadingMessageProvider";
@@ -73,6 +73,7 @@ export function RoomMessagesList(p: {
m.time_sent_dayjs.startOf("day").unix() != m.time_sent_dayjs.startOf("day").unix() !=
p.manager.messages[idx - 1].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; message: Message;
previousFromSamePerson: boolean; previousFromSamePerson: boolean;
firstMessageOfDay: boolean; firstMessageOfDay: boolean;
receipts?: Receipt[];
}): React.ReactElement { }): React.ReactElement {
const theme = useTheme(); const theme = useTheme();
const user = useUserInfo(); const user = useUserInfo();
@@ -220,7 +222,7 @@ function RoomMessage(p: {
</Typography> </Typography>
{/** Message itself */} {/** Message itself */}
<div style={{ marginLeft: "15px", whiteSpace: "pre-wrap" }}> <div style={{ marginLeft: "15px", whiteSpace: "pre-wrap", flex: 1 }}>
{/* Image */} {/* Image */}
{p.message.type === "m.image" && ( {p.message.type === "m.image" && (
<img <img
@@ -284,6 +286,19 @@ function RoomMessage(p: {
<div style={{ margin: "2px 0px" }}>{p.message.content}</div> <div style={{ margin: "2px 0px" }}>{p.message.content}</div>
)} )}
</div> </div>
{/* Read receipts */}
<div>
{(p.receipts ?? []).map((r) => {
const u = p.users.get(r.user);
if (!u || u.user_id === user.info.matrix_user_id) return <></>;
return <AccountIcon key={u.user_id} user={u} size={16} />;
})}
</div>
{/** Button bar */}
<ButtonGroup <ButtonGroup
className="buttons" className="buttons"
size="small" size="small"
@@ -319,7 +334,7 @@ function RoomMessage(p: {
</ButtonGroup> </ButtonGroup>
</Box> </Box>
{/* Reaction */} {/* Reactions */}
<Box sx={{ marginLeft: "50px" }}> <Box sx={{ marginLeft: "50px" }}>
{[...p.message.reactions.keys()].map((r) => { {[...p.message.reactions.keys()].map((r) => {
const reactions = p.message.reactions.get(r)!; const reactions = p.message.reactions.get(r)!;