feat(matrix-web): add read receipt indicators

- Add ReadReceipt type for tracking who read messages
- Track read receipts via Matrix SDK receipt events
- Display single checkmark for sent messages
- Display blue double checkmarks when message is read
- Tooltip shows names of readers
- Auto-refresh timeline when receipts are received

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 17:24:03 +01:00
parent 84fca4036e
commit 840f6d7ff3
3 changed files with 85 additions and 4 deletions

View file

@ -16,6 +16,8 @@
Lock,
Warning,
Smiley,
Check,
Checks,
} from '@manacore/shared-icons';
interface Props {
@ -486,11 +488,27 @@
</div>
{/if}
<!-- Time (shown on hover) -->
<!-- Time and read status -->
<div
class="flex items-center gap-2 mt-1.5 px-1 opacity-0 group-hover:opacity-100 transition-opacity"
class="flex items-center gap-1.5 mt-1.5 px-1 {message.isOwn ? 'justify-end' : ''}"
>
<span class="text-xs text-muted-foreground">{formattedTime()}</span>
<span class="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">{formattedTime()}</span>
<!-- Read receipt indicator (for own messages) -->
{#if message.isOwn}
{#if message.readBy && message.readBy.length > 0}
<Checks
class="h-4 w-4 text-blue-500"
weight="bold"
title="Gelesen von: {message.readBy.map(r => r.userName).join(', ')}"
/>
{:else}
<Check
class="h-4 w-4 text-muted-foreground/50"
weight="bold"
title="Gesendet"
/>
{/if}
{/if}
</div>
<!-- Message actions (hover) -->

View file

@ -7,6 +7,7 @@ import type {
SimpleMessage,
MessageType,
MessageReaction,
ReadReceipt,
RoomMember,
VerificationStatus,
DeviceInfo,
@ -300,6 +301,14 @@ class MatrixStore {
// Also trigger room list update for DMs
this._rooms = this._client!.getRooms();
});
// Read receipt updates
this._client.on(sdk.RoomEvent.Receipt, (event, room) => {
// Update timeline if we're in this room to refresh read receipts
if (room.roomId === this._currentRoomId) {
this._timeline = [...(room.getLiveTimeline().getEvents() || [])];
}
});
}
/**
@ -1328,6 +1337,10 @@ class MatrixStore {
// Collect reactions for this message
const reactions = this.getReactionsForEvent(event.getId() || '', timeline);
// Get read receipts for this message (only for own messages)
const isOwn = event.getSender() === this._client?.getUserId();
const readBy = isOwn ? this.getReadReceiptsForEvent(event) : undefined;
return {
id: event.getId() || '',
sender: event.getSender() || '',
@ -1336,13 +1349,14 @@ class MatrixStore {
formattedBody: content.formatted_body,
timestamp: event.getTs(),
type: msgtype as MessageType,
isOwn: event.getSender() === this._client?.getUserId(),
isOwn,
replyTo: replyToId,
replyToBody,
edited: !!event.replacingEvent(),
redacted: isRedacted,
media,
reactions: reactions.length > 0 ? reactions : undefined,
readBy: readBy && readBy.length > 0 ? readBy : undefined,
};
}
@ -1398,6 +1412,44 @@ class MatrixStore {
return reactions.sort((a, b) => b.count - a.count);
}
/**
* Get read receipts for a specific event
*/
private getReadReceiptsForEvent(event: MatrixEvent): ReadReceipt[] {
const eventId = event.getId();
const roomId = event.getRoomId();
if (!eventId || !roomId || !this._client) return [];
const room = this._client.getRoom(roomId);
if (!room) return [];
const myUserId = this._client.getUserId();
const receipts: ReadReceipt[] = [];
// Get all members who have read up to or past this event
const members = room.getJoinedMembers();
for (const member of members) {
// Skip self
if (member.userId === myUserId) continue;
// Get the user's read receipt
const receiptEvent = room.getEventReadUpTo(member.userId);
if (!receiptEvent) continue;
// Check if their read receipt is at or after this event
const receiptEventObj = room.findEventById(receiptEvent);
if (receiptEventObj && receiptEventObj.getTs() >= event.getTs()) {
receipts.push({
userId: member.userId,
userName: member.name || member.userId.split(':')[0].substring(1),
timestamp: receiptEventObj.getTs(),
});
}
}
return receipts;
}
/**
* Get display name for message sender
*/

View file

@ -39,6 +39,15 @@ export interface MessageReaction {
includesMe: boolean;
}
/**
* Read receipt info for a message
*/
export interface ReadReceipt {
userId: string;
userName: string;
timestamp: number;
}
/**
* Simplified message for UI rendering
*/
@ -57,6 +66,8 @@ export interface SimpleMessage {
redacted?: boolean;
media?: MediaInfo;
reactions?: MessageReaction[];
// Read receipts
readBy?: ReadReceipt[];
}
export type MessageType =