diff --git a/desktop/go.mod b/desktop/go.mod index b40c4d3..cef1274 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -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 ( diff --git a/desktop/go.sum b/desktop/go.sum index 360d1a8..35bfb03 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -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= diff --git a/go.mod b/go.mod index df727e6..33d06b4 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c1e60de..959c6d2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/hicli/database/receipt.go b/pkg/hicli/database/receipt.go index 8b20816..0cf7047 100644 --- a/pkg/hicli/database/receipt.go +++ b/pkg/hicli/database/receipt.go @@ -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"` } diff --git a/pkg/hicli/events.go b/pkg/hicli/events.go index c46d661..bcc9fc4 100644 --- a/pkg/hicli/events.go +++ b/pkg/hicli/events.go @@ -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 { diff --git a/pkg/hicli/init.go b/pkg/hicli/init.go index 7b44f07..2654ee5 100644 --- a/pkg/hicli/init.go +++ b/pkg/hicli/init.go @@ -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 { diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 0492e36..081ba37 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -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"` +} diff --git a/pkg/hicli/paginate.go b/pkg/hicli/paginate.go index 6ce5d02..0169b8d 100644 --- a/pkg/hicli/paginate.go +++ b/pkg/hicli/paginate.go @@ -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) { diff --git a/pkg/hicli/sync.go b/pkg/hicli/sync.go index ab15a03..acd82e8 100644 --- a/pkg/hicli/sync.go +++ b/pkg/hicli/sync.go @@ -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 diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5b9e8fe..30d1c0e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 } diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index 61d80e6..2e6eec0 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . 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 { diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 86c65d2..6fc62fb 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -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 @@ -98,6 +102,9 @@ export class RoomStateStore { readonly typingSub = new Subscribable() readonly stateSubs = new MultiSubscribable() readonly eventSubs = new MultiSubscribable() + readonly receiptsByEventID: Map = new Map() + readonly receiptsByUserID: Map = new Map() + readonly receiptSubs = new MultiSubscribable() readonly requestedEvents: Set = new Set() readonly requestedMembers: Set = new Set() readonly accountData: Map = 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) { // 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) diff --git a/web/src/api/types/hievents.ts b/web/src/api/types/hievents.ts index 900478b..2ce1ee3 100644 --- a/web/src/api/types/hievents.ts +++ b/web/src/api/types/hievents.ts @@ -15,6 +15,7 @@ // along with this program. If not, see . 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 + receipts: Record } export interface SyncNotification { diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index c4bdd49..0758f5a 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -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 has_more: boolean } diff --git a/web/src/index.css b/web/src/index.css index 2d24b41..9d7f938 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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) { diff --git a/web/src/ui/composer/TypingNotifications.tsx b/web/src/ui/composer/TypingNotifications.tsx index 607a366..a8e9a08 100644 --- a/web/src/ui/composer/TypingNotifications.tsx +++ b/web/src/ui/composer/TypingNotifications.tsx @@ -42,6 +42,7 @@ const TypingNotifications = () => { use(ClientContext)?.requestMemberEvent(room, sender) } avatars.push( 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; + } +} diff --git a/web/src/ui/timeline/ReadReceipts.tsx b/web/src/ui/timeline/ReadReceipts.tsx new file mode 100644 index 0000000..7983c45 --- /dev/null +++ b/web/src/ui/timeline/ReadReceipts.tsx @@ -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 . +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 + }) + const names = memberEvts.map(([userID, member]) => getDisplayname(userID, member)) + return
+ {avatars.length < receipts.length &&
+ +{receipts.length - avatars.length} +
} +
+ {avatars} +
+
+} + +export default ReadReceipts diff --git a/web/src/ui/timeline/TimelineEvent.css b/web/src/ui/timeline/TimelineEvent.css index 64afb5f..caa2c9c 100644 --- a/web/src/ui/timeline/TimelineEvent.css +++ b/web/src/ui/timeline/TimelineEvent.css @@ -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; diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index dd2b686..3a96fd8 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -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) => { {evt.reactions ? : null} + {!evt.event_id.startsWith("~") && } {evt.sender === client.userID && evt.transaction_id ? : null} return <>