1
0
Fork 0
forked from Mirrors/gomuks

hicli,web: add support for read receipts

Fixes #514
This commit is contained in:
Tulir Asokan 2024-12-18 03:27:54 +02:00
parent 158409db2f
commit 0fbf76af98
21 changed files with 317 additions and 27 deletions

View file

@ -8,7 +8,7 @@ require github.com/wailsapp/wails/v3 v3.0.0-alpha.7
require (
go.mau.fi/gomuks v0.3.1
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86
)
require (

View file

@ -160,8 +160,8 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc h1:55919oheHZLQktzYU2z9KSJ5KHXq34dYedoGFfdUcsg=
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

2
go.mod
View file

@ -17,7 +17,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/yuin/goldmark v1.7.8
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86
go.mau.fi/zeroconfig v0.1.3
golang.org/x/crypto v0.31.0
golang.org/x/image v0.23.0

4
go.sum
View file

@ -63,8 +63,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc h1:55919oheHZLQktzYU2z9KSJ5KHXq34dYedoGFfdUcsg=
go.mau.fi/util v0.8.4-0.20241217203137-4aa8973d6dbc/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86 h1:pIeLc83N03ect2L06lDg+4MQ11oH0phEzRZ/58FdHMo=
go.mau.fi/util v0.8.4-0.20241217231624-e3dc7ee01c86/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=

View file

@ -8,7 +8,9 @@ package database
import (
"context"
"fmt"
"slices"
"strings"
"time"
"go.mau.fi/util/dbutil"
@ -25,6 +27,7 @@ const (
SET event_id = excluded.event_id,
timestamp = excluded.timestamp
`
getReadReceiptsQuery = `SELECT room_id, user_id, receipt_type, thread_id, event_id, timestamp FROM receipt WHERE room_id = $1 AND receipt_type='m.read' AND event_id IN ($2)`
)
var receiptMassInserter = dbutil.NewMassInsertBuilder[*Receipt, [1]any](upsertReceiptQuery, "($1, $%d, $%d, $%d, $%d, $%d)")
@ -53,11 +56,29 @@ func (rq *ReceiptQuery) PutMany(ctx context.Context, roomID id.RoomID, receipts
return rq.Exec(ctx, query, params...)
}
func (rq *ReceiptQuery) GetManyRead(ctx context.Context, roomID id.RoomID, eventIDs []id.EventID) (map[id.EventID][]*Receipt, error) {
args := make([]any, len(eventIDs)+1)
placeholders := make([]string, len(eventIDs)+1)
args[0] = roomID
placeholders[0] = "?1"
for i, evtID := range eventIDs {
args[i+1] = evtID
placeholders[i+1] = fmt.Sprintf("?%d", i+2)
}
query := strings.Replace(getReadReceiptsQuery, "$2", strings.Join(placeholders, ", "), 1)
output := make(map[id.EventID][]*Receipt)
err := rq.QueryManyIter(ctx, query, args...).Iter(func(receipt *Receipt) (bool, error) {
output[receipt.EventID] = append(output[receipt.EventID], receipt)
return true, nil
})
return output, err
}
type Receipt struct {
RoomID id.RoomID `json:"room_id"`
RoomID id.RoomID `json:"room_id,omitempty"`
UserID id.UserID `json:"user_id"`
ReceiptType event.ReceiptType `json:"receipt_type"`
ThreadID event.ThreadID `json:"thread_id"`
ThreadID event.ThreadID `json:"thread_id,omitempty"`
EventID id.EventID `json:"event_id"`
Timestamp jsontime.UnixMilli `json:"timestamp"`
}

View file

@ -22,6 +22,7 @@ type SyncRoom struct {
Events []*database.Event `json:"events"`
Reset bool `json:"reset"`
Notifications []SyncNotification `json:"notifications"`
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
}
type SyncNotification struct {

View file

@ -19,6 +19,7 @@ func (h *HiClient) getInitialSyncRoom(ctx context.Context, room *database.Room)
Timeline: make([]database.TimelineRowTuple, 0),
State: map[event.Type]map[string]database.EventRowID{},
Notifications: make([]SyncNotification, 0),
Receipts: make(map[id.EventID][]*database.Receipt),
}
ad, err := h.DB.AccountData.GetAllRoom(ctx, h.Account.UserID, room.ID)
if err != nil {

View file

@ -118,6 +118,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *getSpecificRoomStateParams) ([]*database.Event, error) {
return h.DB.CurrentState.GetMany(ctx, params.Keys)
})
case "get_receipts":
return unmarshalAndCall(req.Data, func(params *getReceiptsParams) (map[id.EventID][]*database.Receipt, error) {
return h.GetReceipts(ctx, params.RoomID, params.EventIDs)
})
case "paginate":
return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) {
return h.Paginate(ctx, params.RoomID, params.MaxTimelineID, params.Limit)
@ -310,3 +314,8 @@ type paginateParams struct {
MaxTimelineID database.TimelineRowID `json:"max_timeline_id"`
Limit int `json:"limit"`
}
type getReceiptsParams struct {
RoomID id.RoomID `json:"room_id"`
EventIDs []id.EventID `json:"event_ids"`
}

View file

@ -162,6 +162,7 @@ func (h *HiClient) processGetRoomState(ctx context.Context, roomID id.RoomID, fe
Events: make([]*database.Event, 0),
Reset: false,
Notifications: make([]SyncNotification, 0),
Receipts: make(map[id.EventID][]*database.Receipt),
},
},
AccountData: make(map[event.Type]*database.AccountData),
@ -196,22 +197,69 @@ func (h *HiClient) GetRoomState(ctx context.Context, roomID id.RoomID, includeMe
}
type PaginationResponse struct {
Events []*database.Event `json:"events"`
HasMore bool `json:"has_more"`
Events []*database.Event `json:"events"`
Receipts map[id.EventID][]*database.Receipt `json:"receipts"`
HasMore bool `json:"has_more"`
}
func (h *HiClient) Paginate(ctx context.Context, roomID id.RoomID, maxTimelineID database.TimelineRowID, limit int) (*PaginationResponse, error) {
evts, err := h.DB.Timeline.Get(ctx, roomID, limit, maxTimelineID)
if err != nil {
return nil, err
} else if len(evts) > 0 {
}
var resp *PaginationResponse
if len(evts) > 0 {
for _, evt := range evts {
h.ReprocessExistingEvent(ctx, evt)
}
return &PaginationResponse{Events: evts, HasMore: true}, nil
resp = &PaginationResponse{Events: evts, HasMore: true}
} else {
return h.PaginateServer(ctx, roomID, limit)
resp, err = h.PaginateServer(ctx, roomID, limit)
if err != nil {
return nil, err
}
}
eventIDs := make([]id.EventID, len(resp.Events))
for i, evt := range resp.Events {
eventIDs[i] = evt.ID
}
resp.Receipts, err = h.GetReceipts(ctx, roomID, eventIDs)
if err != nil {
return nil, fmt.Errorf("failed to get receipts: %w", err)
}
return resp, nil
}
func (h *HiClient) GetReceipts(ctx context.Context, roomID id.RoomID, eventIDs []id.EventID) (map[id.EventID][]*database.Receipt, error) {
receipts, err := h.DB.Receipt.GetManyRead(ctx, roomID, eventIDs)
if err != nil {
return nil, err
}
encounteredUsers := map[id.UserID]struct{}{
// Never include own receipts
h.Account.UserID: {},
}
// If there are multiple receipts (e.g. due to threads), only keep the one for the latest event (first in the array)
// The input event IDs are already sorted in reverse chronological order
for _, evtID := range eventIDs {
receiptArr := receipts[evtID]
i := 0
for _, receipt := range receiptArr {
_, alreadyEncountered := encounteredUsers[receipt.UserID]
if alreadyEncountered {
continue
}
// Clear room ID for efficiency
receipt.RoomID = ""
encounteredUsers[receipt.UserID] = struct{}{}
receiptArr[i] = receipt
i++
}
if len(receiptArr) > 0 && i < len(receiptArr) {
receipts[evtID] = receiptArr[:i]
}
}
return receipts, nil
}
func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit int) (*PaginationResponse, error) {

View file

@ -180,6 +180,9 @@ func (h *HiClient) receiptsToList(content *event.ReceiptEventContent) ([]*databa
if userID == h.Account.UserID {
newOwnReceipts = append(newOwnReceipts, eventID)
}
if receiptInfo.ThreadID == event.ReadReceiptThreadMain {
receiptInfo.ThreadID = ""
}
receiptList = append(receiptList, &database.Receipt{
UserID: userID,
ReceiptType: receiptType,
@ -718,15 +721,38 @@ func (h *HiClient) processStateAndTimeline(
setNewState(evt.Type, *evt.StateKey, rowID)
}
var timelineRowTuples []database.TimelineRowTuple
receiptMap := make(map[id.EventID][]*database.Receipt)
for _, receipt := range receipts {
if receipt.UserID != h.Account.UserID {
receiptMap[receipt.EventID] = append(receiptMap[receipt.EventID], receipt)
}
}
var err error
if len(timeline.Events) > 0 {
timelineIDs := make([]database.EventRowID, len(timeline.Events))
encounteredReceiptUsers := make(map[id.UserID]struct{})
readUpToIndex := -1
for i := len(timeline.Events) - 1; i >= 0; i-- {
evt := timeline.Events[i]
for _, receipt := range receiptMap[evt.ID] {
encounteredReceiptUsers[receipt.UserID] = struct{}{}
}
isRead := slices.Contains(newOwnReceipts, evt.ID)
isOwnEvent := evt.Sender == h.Account.UserID
if isRead || isOwnEvent {
_, alreadyEncountered := encounteredReceiptUsers[evt.Sender]
if !isOwnEvent && !alreadyEncountered {
encounteredReceiptUsers[evt.Sender] = struct{}{}
injectedReceipt := &database.Receipt{
RoomID: room.ID,
UserID: evt.Sender,
ReceiptType: event.ReceiptTypeRead,
EventID: evt.ID,
Timestamp: jsontime.UM(time.UnixMilli(evt.Timestamp)),
}
receipts = append(receipts, injectedReceipt)
receiptMap[evt.ID] = append(receiptMap[evt.ID], injectedReceipt)
}
if readUpToIndex == -1 && (isRead || isOwnEvent) {
readUpToIndex = i
// Reset unread counts if we see our own read receipt in the timeline.
// It'll be updated with new unreads (if any) at the end.
@ -741,7 +767,6 @@ func (h *HiClient) processStateAndTimeline(
})
newOwnReceipts = append(newOwnReceipts, evt.ID)
}
break
}
}
for i, evt := range timeline.Events {
@ -841,7 +866,10 @@ func (h *HiClient) processStateAndTimeline(
}
}
// TODO why is *old* unread count sometimes zero when processing the read receipt that is making it zero?
if roomChanged || len(accountData) > 0 || len(newOwnReceipts) > 0 || len(timelineRowTuples) > 0 || len(allNewEvents) > 0 {
if roomChanged || len(accountData) > 0 || len(newOwnReceipts) > 0 || len(receipts) > 0 || len(timelineRowTuples) > 0 || len(allNewEvents) > 0 {
for _, receipt := range receipts {
receipt.RoomID = ""
}
ctx.Value(syncContextKey).(*syncContext).evt.Rooms[room.ID] = &SyncRoom{
Meta: room,
Timeline: timelineRowTuples,
@ -850,6 +878,7 @@ func (h *HiClient) processStateAndTimeline(
Reset: timeline.Limited,
Events: allNewEvents,
Notifications: newNotifications,
Receipts: receiptMap,
}
}
return nil

View file

@ -316,7 +316,7 @@ export default class Client {
throw new Error("Timeline changed while loading history")
}
room.hasMoreHistory = resp.has_more
room.applyPagination(resp.events)
room.applyPagination(resp.events, resp.receipts)
} finally {
room.paginating = false
}

View file

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"
import type { CustomEmojiPack } from "@/util/emoji"
import type { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types"
import type { EventID, EventType, MemDBEvent, MemReceipt, UnknownEventContent } from "../types"
import { Preferences, preferences } from "../types/preferences"
import type { StateStore } from "./main.ts"
import type { AutocompleteMemberEntry, RoomStateStore } from "./room.ts"
@ -31,6 +31,13 @@ export function useRoomTyping(room: RoomStateStore): string[] {
return useSyncExternalStore(room.typingSub.subscribe, () => room.typing)
}
export function useReadReceipts(room: RoomStateStore, evtID: EventID): MemReceipt[] {
return useSyncExternalStore(
room.receiptSubs.getSubscriber(evtID),
() => room.receiptsByEventID.get(evtID) ?? emptyArray,
)
}
export function useRoomState(
room?: RoomStateStore, type?: EventType, stateKey: string | undefined = "",
): MemDBEvent | null {

View file

@ -21,6 +21,7 @@ import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subs
import { getDisplayname } from "@/util/validation.ts"
import {
ContentURI,
DBReceipt,
DBRoom,
EncryptedEventContent,
EventID,
@ -30,6 +31,7 @@ import {
ImagePack,
LazyLoadSummary,
MemDBEvent,
MemReceipt,
MemberEventContent,
PowerLevelEventContent,
RawDBEvent,
@ -83,6 +85,8 @@ export interface AutocompleteMemberEntry {
const collator = new Intl.Collator()
const UNSENT_TIMELINE_ROWID_BASE = 1000000000000000
export class RoomStateStore {
readonly roomID: RoomID
readonly meta: NonNullCachedEventDispatcher<DBRoom>
@ -98,6 +102,9 @@ export class RoomStateStore {
readonly typingSub = new Subscribable()
readonly stateSubs = new MultiSubscribable()
readonly eventSubs = new MultiSubscribable()
readonly receiptsByEventID: Map<EventID, MemReceipt[]> = new Map()
readonly receiptsByUserID: Map<UserID, MemReceipt> = new Map()
readonly receiptSubs = new MultiSubscribable()
readonly requestedEvents: Set<EventID> = new Set()
readonly requestedMembers: Set<UserID> = new Set()
readonly accountData: Map<string, UnknownEventContent> = new Map()
@ -126,7 +133,7 @@ export class RoomStateStore {
this.preferences = getPreferenceProxy(parent, this)
}
notifyTimelineSubscribers() {
#updateTimelineCache() {
this.timelineCache = this.timeline.map(rt => {
const evt = this.eventsByRowID.get(rt.event_rowid)
if (!evt) {
@ -137,6 +144,10 @@ export class RoomStateStore {
}).concat(this.pendingEvents
.map(rowID => this.eventsByRowID.get(rowID))
.filter(evt => !!evt))
}
notifyTimelineSubscribers() {
this.#updateTimelineCache()
this.timelineSub.notify()
}
@ -232,7 +243,7 @@ export class RoomStateStore {
return []
}
applyPagination(history: RawDBEvent[]) {
applyPagination(history: RawDBEvent[], allReceipts: Record<EventID, DBReceipt[]>) {
// Pagination comes in newest to oldest, timeline is in the opposite order
history.reverse()
const newTimeline = history.map(evt => {
@ -241,6 +252,50 @@ export class RoomStateStore {
})
this.timeline.splice(0, 0, ...newTimeline)
this.notifyTimelineSubscribers()
for (const [evtID, receipts] of Object.entries(allReceipts)) {
this.applyReceipts(receipts, evtID, true)
}
}
applyReceipts(receipts: DBReceipt[], evtID: EventID, override: boolean) {
const evt = this.eventsByID.get(evtID)
if (!evt?.timeline_rowid) {
return
}
const filtered = receipts.filter(receipt => this.applyReceipt(receipt, evt))
filtered.sort((a, b) => a.timestamp - b.timestamp)
if (override) {
this.receiptsByEventID.set(evtID, filtered)
} else {
const existing = this.receiptsByEventID.get(evtID) ?? []
this.receiptsByEventID.set(evtID, existing.concat(filtered))
}
this.receiptSubs.notify(evtID)
}
applyReceipt(receipt: DBReceipt, evt: MemDBEvent): receipt is MemReceipt {
const existingReceipt = this.receiptsByUserID.get(receipt.user_id)
if (existingReceipt) {
if (existingReceipt.timeline_rowid >= evt.timeline_rowid) {
return false
}
const oldArr = this.receiptsByEventID.get(existingReceipt.event_id)
if (oldArr) {
const idx = oldArr.indexOf(existingReceipt)
if (idx >= 0) {
oldArr.splice(idx, 1)
if (oldArr.length === 0) {
this.receiptsByEventID.delete(existingReceipt.event_id)
}
this.receiptSubs.notify(existingReceipt.event_id)
}
}
}
const memReceipt = receipt as MemReceipt
memReceipt.timeline_rowid = evt.timeline_rowid > UNSENT_TIMELINE_ROWID_BASE ? 1 : evt.timeline_rowid
memReceipt.event_rowid = evt.rowid
this.receiptsByUserID.set(receipt.user_id, memReceipt)
return true
}
applyEvent(evt: RawDBEvent, pending: boolean = false) {
@ -248,7 +303,7 @@ export class RoomStateStore {
memEvt.mem = true
memEvt.pending = pending
if (pending) {
memEvt.timeline_rowid = 1000000000000000 + memEvt.timestamp
memEvt.timeline_rowid = UNSENT_TIMELINE_ROWID_BASE + memEvt.timestamp
}
if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) {
memEvt.type = evt.decrypted_type
@ -287,6 +342,7 @@ export class RoomStateStore {
}
}
this.eventSubs.notify(memEvt.event_id)
return memEvt
}
applySendComplete(evt: RawDBEvent) {
@ -354,6 +410,9 @@ export class RoomStateStore {
this.openNotifications.clear()
}
this.notifyTimelineSubscribers()
for (const [evtID, receipts] of Object.entries(sync.receipts)) {
this.applyReceipts(receipts, evtID, false)
}
}
applyState(evt: RawDBEvent) {
@ -473,6 +532,8 @@ export class RoomStateStore {
const deletedEvents = this.eventsByRowID.size - eventsToKeep.size
this.eventsByRowID.clear()
this.eventsByID.clear()
this.receiptsByEventID.clear()
this.receiptsByUserID.clear()
for (const evt of eventsToKeepList) {
this.eventsByRowID.set(evt.rowid, evt)
this.eventsByID.set(evt.event_id, evt)

View file

@ -15,6 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import {
DBAccountData,
DBReceipt,
DBRoom,
DBRoomAccountData,
EventRowID,
@ -23,6 +24,7 @@ import {
} from "./hitypes.ts"
import {
DeviceID,
EventID,
EventType,
RoomID,
UserID,
@ -74,6 +76,7 @@ export interface SyncRoom {
reset: boolean
notifications: SyncNotification[]
account_data: Record<EventType, DBRoomAccountData>
receipts: Record<EventID, DBReceipt[]>
}
export interface SyncNotification {

View file

@ -22,6 +22,7 @@ import {
EventID,
EventType,
LazyLoadSummary,
ReceiptType,
RelationType,
RoomAlias,
RoomID,
@ -145,8 +146,22 @@ export interface DBRoomAccountData {
content: UnknownEventContent
}
export interface DBReceipt {
user_id: UserID
receipt_type: ReceiptType
thread_id?: EventID | "main"
event_id: EventID
timestamp: number
}
export interface MemReceipt extends DBReceipt {
event_rowid: EventRowID
timeline_rowid: TimelineRowID
}
export interface PaginationResponse {
events: RawDBEvent[]
receipts: Record<EventID, DBReceipt[]>
has_more: boolean
}

View file

@ -82,9 +82,11 @@
--timeline-sender-name-timestamp-gap: .25rem;
--timeline-sender-name-content-gap: 0;
--timeline-horizontal-padding: 1.5rem;
--timeline-status-size: 4rem;
@media screen and (max-width: 45rem) {
--timeline-horizontal-padding: .5rem;
--timeline-status-size: 2.25rem;
}
@media (prefers-color-scheme: dark) {

View file

@ -42,6 +42,7 @@ const TypingNotifications = () => {
use(ClientContext)?.requestMemberEvent(room, sender)
}
avatars.push(<img
key={sender}
className="small avatar"
loading="lazy"
src={getAvatarURL(sender, member)}

View file

@ -0,0 +1,29 @@
div.timeline-event > div.read-receipts {
grid-area: status;
display: flex;
justify-content: right;
align-items: end;
overflow: hidden;
margin-bottom: .125rem;
> div.overflow-count {
font-size: .75rem;
color: var(--secondary-text-color);
margin-right: .125rem;
user-select: none;
}
> div.avatars {
display: flex;
margin-left: .35rem;
> img {
margin-left: -.35rem;
border: 1px solid var(--background-color);
}
}
& + div.event-send-status {
display: none;
}
}

View file

@ -0,0 +1,61 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use } from "react"
import { getAvatarURL } from "@/api/media.ts"
import { RoomStateStore, useReadReceipts } from "@/api/statestore"
import { EventID, MemberEventContent } from "@/api/types"
import { humanJoin } from "@/util/join.ts"
import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
import "./ReadReceipts.css"
const ReadReceipts = ({ room, eventID }: { room: RoomStateStore, eventID: EventID }) => {
const receipts = useReadReceipts(room, eventID)
if (receipts.length === 0) {
return null
}
// Hacky hack for mobile clients. Would be nicer to get the number based on the CSS variable defining the size
const maxAvatarCount = window.innerWidth > 720 ? 4 : 2
const memberEvts = receipts.map(receipt => {
const memberEvt = room.getStateEvent("m.room.member", receipt.user_id)
const member = (memberEvt?.content ?? null) as MemberEventContent | null
if (!memberEvt) {
use(ClientContext)?.requestMemberEvent(room, receipt.user_id)
}
return [receipt.user_id, member] as const
})
const avatarMembers = receipts.length > maxAvatarCount ? memberEvts.slice(-maxAvatarCount+1) : memberEvts
const avatars = avatarMembers.map(([userID, member]) => {
return <img
key={userID}
className="small avatar"
loading="lazy"
src={getAvatarURL(userID, member)}
alt=""
/>
})
const names = memberEvts.map(([userID, member]) => getDisplayname(userID, member))
return <div className="read-receipts" title={`Read by ${humanJoin(names)}`}>
{avatars.length < receipts.length && <div className="overflow-count">
+{receipts.length - avatars.length}
</div>}
<div className="avatars">
{avatars}
</div>
</div>
}
export default ReadReceipts

View file

@ -5,10 +5,10 @@ div.timeline-event {
padding: 0 var(--timeline-horizontal-padding);
display: grid;
grid-template:
"cmc cmc cmc cmc" 0
"cmc cmc cmc empty" 0
"avatar gap sender sender" auto
"avatar gap content status" auto
/ var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr 2rem;
/ var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr var(--timeline-status-size);
contain: layout;
margin-top: var(--timeline-message-gap);
@ -119,9 +119,9 @@ div.timeline-event {
&.same-sender {
grid-template:
"cmc cmc cmc" 0
"cmc cmc empty" 0
"timestamp content status" auto
/ var(--timeline-avatar-total-size) 1fr 2rem;
/ var(--timeline-avatar-total-size) 1fr var(--timeline-status-size);
margin-top: var(--timeline-message-gap-same-sender);
> div.sender-avatar, > div.event-sender-and-time {
@ -135,9 +135,9 @@ div.timeline-event {
&.small-event {
grid-template:
"cmc cmc cmc cmc" 0
"cmc cmc cmc empty" 0
"timestamp avatar content status" auto
/ var(--timeline-avatar-total-size) 1.5rem 1fr 2rem;
/ var(--timeline-avatar-total-size) 1.5rem 1fr var(--timeline-status-size);
> div.sender-avatar {
width: 1.5rem;

View file

@ -23,6 +23,7 @@ import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts"
import { ModalContext } from "../modal/Modal.tsx"
import { useRoomContext } from "../roomview/roomcontext.ts"
import ReadReceipts from "./ReadReceipts.tsx"
import { ReplyIDBody } from "./ReplyBody.tsx"
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
@ -201,6 +202,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
</ContentErrorBoundary>
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
</div>
{!evt.event_id.startsWith("~") && <ReadReceipts room={roomCtx.store} eventID={evt.event_id} />}
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
</div>
return <>