diff --git a/spec/integ/matrix-client-syncing.spec.ts b/spec/integ/matrix-client-syncing.spec.ts index 9a0bafce0b..d772e54fed 100644 --- a/spec/integ/matrix-client-syncing.spec.ts +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -1878,12 +1878,8 @@ describe("MatrixClient syncing", () => { const room = client?.getRoom(roomOne); expect(room).toBeInstanceOf(Room); - expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(true); - const thread = room!.createThread(THREAD_ID, undefined, [], true); - expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(false); - const receipt = thread.getReadReceiptForUserId("@alice:localhost"); expect(receipt).toStrictEqual({ @@ -1924,12 +1920,8 @@ describe("MatrixClient syncing", () => { const room = client?.getRoom(roomOne); expect(room).toBeInstanceOf(Room); - expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(true); - const thread = room!.createThread(THREAD_ID, undefined, [], true); - expect(room?.cachedThreadReadReceipts.has(THREAD_ID)).toBe(false); - const receipt = thread.getReadReceiptForUserId("@alice:localhost"); expect(receipt).toStrictEqual({ diff --git a/spec/integ/matrix-client-unread-notifications.spec.ts b/spec/integ/matrix-client-unread-notifications.spec.ts index ff32f3f7dd..2be829b88b 100644 --- a/spec/integ/matrix-client-unread-notifications.spec.ts +++ b/spec/integ/matrix-client-unread-notifications.spec.ts @@ -130,13 +130,27 @@ describe("MatrixClient syncing", () => { await room.addLiveEvents([thread.rootEvent], { addToState: false }); // Initialize read receipt datastructure before testing the reaction - room.addReceiptToStructure(thread.rootEvent.getId()!, ReceiptType.Read, selfUserId, { ts: 1 }, false); - thread.thread.addReceiptToStructure( - threadReply.getId()!, - ReceiptType.Read, - selfUserId, - { thread_id: thread.thread.id, ts: 1 }, - false, + room.addReceipt( + new MatrixEvent({ + type: "m.receipt", + content: { + [thread.rootEvent.getId()!]: { + [ReceiptType.Read]: { [selfUserId]: { ts: 1 } }, + }, + }, + }), + ); + room.addReceipt( + new MatrixEvent({ + type: "m.receipt", + content: { + [threadReply.getId()!]: { + [ReceiptType.Read]: { + [selfUserId]: { thread_id: thread.thread.id, ts: 1 }, + }, + }, + }, + }), ); expect(room.getReadReceiptForUserId(selfUserId, false)?.eventId).toEqual(thread.rootEvent.getId()); expect(thread.thread.getReadReceiptForUserId(selfUserId, false)?.eventId).toEqual(threadReply.getId()); diff --git a/spec/unit/models/room-receipts.spec.ts b/spec/unit/models/room-receipts.spec.ts index 3a8bc47ffb..eca20325d8 100644 --- a/spec/unit/models/room-receipts.spec.ts +++ b/spec/unit/models/room-receipts.spec.ts @@ -19,6 +19,7 @@ import { type MatrixClient, MatrixEvent, type ReceiptContent, + ReceiptType, THREAD_RELATION_TYPE, Thread, } from "../../../src"; @@ -371,6 +372,209 @@ describe("RoomReceipts", () => { expect(room.hasUserReadEvent(readerId, thread2bId)).toBe(true); }); + describe("RoomReceipts public surface", () => { + // These tests target the new `RoomReceipts` methods directly. PR 2 will + // wire them up to `Room`; until then we go through `getRoomReceipts`. + + describe("getReceiptsForEvent / getUsersReadUpTo", () => { + it("returns an empty list when no receipts are known", () => { + const room = createRoom(); + const [, eventId] = createEvent(); + // Event not pushed into the timeline, so no synthetic receipt is created. + expect(getRoomReceipts(room).getReceiptsForEvent(eventId)).toEqual([]); + expect(getRoomReceipts(room).getUsersReadUpTo(eventId)).toEqual([]); + }); + + it("returns the user's receipt cached by event", () => { + const room = createRoom(); + const [event, eventId] = createEvent(); + room.addReceipt(createReceipt(readerId, event)); + expect(getRoomReceipts(room).getReceiptsForEvent(eventId)).toEqual([ + { type: ReceiptType.Read, userId: readerId, data: { ts: 123 } }, + ]); + expect(getRoomReceipts(room).getUsersReadUpTo(eventId)).toEqual([readerId]); + }); + + it("preserves insertion order across multiple receipt types for the same user/event", () => { + const room = createRoom(); + const [event, eventId] = createEvent(); + room.addReceipt(makeMultiTypeReceipt(readerId, event, ["m.delivered", "m.read", "m.seen"])); + + expect( + getRoomReceipts(room) + .getReceiptsForEvent(eventId) + .map((r) => r.type), + ).toEqual(["m.delivered", "m.read", "m.seen"]); + }); + + it("evicts the user's prior cache entry when their receipt moves to a later event", () => { + const room = createRoom(); + const [event1, event1Id] = createEvent(); + const [event2, event2Id] = createEvent(); + room.addLiveEvents([event1, event2], { addToState: false }); + + room.addReceipt(createReceipt(readerId, event1)); + expect( + getRoomReceipts(room) + .getReceiptsForEvent(event1Id) + .filter((r) => r.userId === readerId), + ).toHaveLength(1); + + room.addReceipt(createReceipt(readerId, event2)); + expect( + getRoomReceipts(room) + .getReceiptsForEvent(event1Id) + .filter((r) => r.userId === readerId), + ).toEqual([]); + expect( + getRoomReceipts(room) + .getReceiptsForEvent(event2Id) + .filter((r) => r.userId === readerId), + ).toHaveLength(1); + }); + + it("filters m.fully_read out of getUsersReadUpTo but keeps it in getReceiptsForEvent", () => { + const room = createRoom(); + const [event, eventId] = createEvent(); + room.addReceipt(makeMultiTypeReceipt(readerId, event, ["m.fully_read", "m.read"])); + + expect( + getRoomReceipts(room) + .getReceiptsForEvent(eventId) + .map((r) => r.type) + .sort(), + ).toEqual(["m.fully_read", "m.read"].sort()); + expect(getRoomReceipts(room).getUsersReadUpTo(eventId)).toEqual([readerId]); + }); + + it("populates the cache for dangling receipts whose event is not loaded yet", () => { + const room = createRoom(); + const [event, eventId] = createEvent(); + // Event not added to the timeline — receipt is dangling. + room.addReceipt(createReceipt(readerId, event)); + + expect(getRoomReceipts(room).getReceiptsForEvent(eventId)).toEqual([ + { type: ReceiptType.Read, userId: readerId, data: { ts: 123 } }, + ]); + }); + }); + + describe("synthetic vs real receipts", () => { + it("the synthetic wins when it points at a later event", () => { + const room = createRoom(); + const [event1] = createEvent(); + const [event2, event2Id] = createEvent(); + room.addLiveEvents([event1, event2], { addToState: false }); + + room.addReceipt(createReceipt(readerId, event1)); + room.addReceipt(createReceipt(readerId, event2), true); + + expect(getRoomReceipts(room).getEventReadUpTo(readerId)).toEqual(event2Id); + expect( + getRoomReceipts(room) + .getReceiptsForEvent(event2Id) + .filter((r) => r.userId === readerId), + ).toHaveLength(1); + }); + + it("ignoreSynthesized exposes the most recent real receipt", () => { + const room = createRoom(); + const [event1] = createEvent(); + const [event2, event2Id] = createEvent(); + const [event3, event3Id] = createEvent(); + room.addLiveEvents([event1, event2, event3], { addToState: false }); + + room.addReceipt(createReceipt(readerId, event1)); + room.addReceipt(createReceipt(readerId, event3), true); + room.addReceipt(createReceipt(readerId, event2)); + + expect(getRoomReceipts(room).getEventReadUpTo(readerId)).toEqual(event3Id); + expect(getRoomReceipts(room).getEventReadUpTo(readerId, true)).toEqual(event2Id); + }); + }); + + describe("Read vs ReadPrivate precedence", () => { + it("getEventReadUpTo prefers the later receipt across types", () => { + const room = createRoom(); + const [event1] = createEvent(); + const [event2, event2Id] = createEvent(); + room.addLiveEvents([event1, event2], { addToState: false }); + + room.addReceipt(createReceipt(readerId, event1, ReceiptType.Read)); + room.addReceipt(createReceipt(readerId, event2, ReceiptType.ReadPrivate)); + + expect(getRoomReceipts(room).getEventReadUpTo(readerId)).toEqual(event2Id); + }); + + it("getReadReceiptForUserId can be scoped to a specific receipt type", () => { + const room = createRoom(); + const [event1, event1Id] = createEvent(); + const [event2, event2Id] = createEvent(); + room.addLiveEvents([event1, event2], { addToState: false }); + + room.addReceipt(createReceipt(readerId, event1, ReceiptType.Read)); + room.addReceipt(createReceipt(readerId, event2, ReceiptType.ReadPrivate)); + + expect( + getRoomReceipts(room).getReadReceiptForUserId(readerId, false, ReceiptType.Read)?.eventId, + ).toEqual(event1Id); + expect( + getRoomReceipts(room).getReadReceiptForUserId(readerId, false, ReceiptType.ReadPrivate)?.eventId, + ).toEqual(event2Id); + }); + }); + + describe("getEventReadUpTo validity", () => { + it("returns null when the receipt points at an event we don't have", () => { + const room = createRoom(); + const [missing] = createEvent(); + room.addReceipt(createReceipt(readerId, missing)); + expect(getRoomReceipts(room).getEventReadUpTo(readerId)).toBeNull(); + }); + }); + + describe("getLastUnthreadedReceiptFor", () => { + it("returns the raw unthreaded Receipt", () => { + const room = createRoom(); + const [event] = createEvent(); + room.addLiveEvents([event], { addToState: false }); + room.addReceipt(createReceipt(readerId, event)); + expect(getRoomReceipts(room).getLastUnthreadedReceiptFor(readerId)).toEqual({ ts: 123 }); + }); + + it("returns undefined when only threaded receipts exist", () => { + const room = createRoom(); + const [root] = createEvent(); + const [event] = createThreadedEvent(root); + setupThread(room, root); + room.addLiveEvents([root, event], { addToState: false }); + room.addReceipt(createThreadedReceipt(readerId, event, root.getId()!)); + expect(getRoomReceipts(room).getLastUnthreadedReceiptFor(readerId)).toBeUndefined(); + }); + }); + + describe("getOldestThreadedReceiptTs", () => { + it("tracks the minimum ts of threaded receipts per user", () => { + const room = createRoom(); + const [root] = createEvent(); + const [event1] = createThreadedEvent(root); + const [event2] = createThreadedEvent(root); + setupThread(room, root); + room.addLiveEvents([root, event1, event2], { addToState: false }); + + room.addReceipt(threadedReceiptWithTs(readerId, event1, root.getId()!, 500)); + room.addReceipt(threadedReceiptWithTs(readerId, event2, root.getId()!, 200)); + + expect(getRoomReceipts(room).getOldestThreadedReceiptTs(readerId)).toEqual(200); + }); + + it("returns Infinity if the user has no threaded receipts", () => { + const room = createRoom(); + expect(getRoomReceipts(room).getOldestThreadedReceiptTs(readerId)).toEqual(Infinity); + }); + }); + }); + describe("dangling receipts", () => { it("reports unread if the unthreaded receipt is in a dangling state", () => { const room = createRoom(); @@ -454,6 +658,25 @@ function createRoom(): Room { return new Room("!rid", createFakeClient(), "@u:s.nz", { timelineSupport: true }); } +// PR 1 of the receipt-storage migration adds the new public surface to +// `RoomReceipts` but does not yet wire it up to `Room`. Until PR 2 lands, +// tests for the new methods reach into the private field. +function getRoomReceipts(room: Room): { + getReceiptsForEvent: (eventId: string) => Array<{ type: string; userId: string; data: { ts: number } }>; + getUsersReadUpTo: (eventId: string) => string[]; + getReadReceiptForUserId: ( + userId: string, + ignoreSynthesized?: boolean, + receiptType?: ReceiptType, + ) => { eventId: string; data: { ts: number } } | null; + getEventReadUpTo: (userId: string, ignoreSynthesized?: boolean) => string | null; + getLastUnthreadedReceiptFor: (userId: string) => { ts: number } | undefined; + getOldestThreadedReceiptTs: (userId: string) => number; +} { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (room as any).roomReceipts; +} + let idCounter = 0; function nextId(): string { return "$" + (idCounter++).toString(10); @@ -495,10 +718,14 @@ function createThreadedEvent(root: MatrixEvent): [MatrixEvent, string] { return [event, event.getId()!]; } -function createReceipt(userId: string, referencedEvent: MatrixEvent): MatrixEvent { +function createReceipt( + userId: string, + referencedEvent: MatrixEvent, + receiptType: string = ReceiptType.Read, +): MatrixEvent { const content: ReceiptContent = { [referencedEvent.getId()!]: { - "m.read": { + [receiptType]: { [userId]: { ts: 123, }, @@ -512,6 +739,31 @@ function createReceipt(userId: string, referencedEvent: MatrixEvent): MatrixEven }); } +function makeMultiTypeReceipt(userId: string, referencedEvent: MatrixEvent, receiptTypes: string[]): MatrixEvent { + const perType: Record> = {}; + for (const t of receiptTypes) { + perType[t] = { [userId]: { ts: 123 } }; + } + const content: ReceiptContent = { [referencedEvent.getId()!]: perType }; + return new MatrixEvent({ type: "m.receipt", content }); +} + +function threadedReceiptWithTs( + userId: string, + referencedEvent: MatrixEvent, + threadId: string, + ts: number, +): MatrixEvent { + const content: ReceiptContent = { + [referencedEvent.getId()!]: { + "m.read": { + [userId]: { ts, thread_id: threadId }, + }, + }, + }; + return new MatrixEvent({ type: "m.receipt", content }); +} + function createThreadedReceipt(userId: string, referencedEvent: MatrixEvent, threadId: string): MatrixEvent { const content: ReceiptContent = { [referencedEvent.getId()!]: { diff --git a/spec/unit/read-receipt.spec.ts b/spec/unit/read-receipt.spec.ts index 9ae67c345b..0acd00aace 100644 --- a/spec/unit/read-receipt.spec.ts +++ b/spec/unit/read-receipt.spec.ts @@ -16,10 +16,10 @@ limitations under the License. import MockHttpBackend from "matrix-mock-request"; -import { MAIN_ROOM_TIMELINE, ReceiptType, type WrappedReceipt } from "../../src/@types/read_receipts"; +import { MAIN_ROOM_TIMELINE, type ReceiptContent, ReceiptType } from "../../src/@types/read_receipts"; import { MatrixClient } from "../../src/client"; import { EventType, type MatrixEvent, RelationType, Room, threadIdForReceipt } from "../../src/matrix"; -import { synthesizeReceipt } from "../../src/models/read-receipt"; +import { synthesizeReceipt } from "../../src/models/room-receipts"; import { encodeUri } from "../../src/utils"; import * as utils from "../test-utils/test-utils"; import { flushPromises } from "../test-utils/flushPromises.ts"; @@ -224,42 +224,32 @@ describe("Read receipt", () => { }); }); - describe("addReceiptToStructure", () => { - it("should not allow an older unthreaded receipt to clobber a `main` threaded one", () => { + describe("addReceipt", () => { + function receiptEvent(event: MatrixEvent, userId: string, receiptType: ReceiptType, ts: number): MatrixEvent { + const content: ReceiptContent = { + [event.getId()!]: { + [receiptType]: { + [userId]: { ts }, + }, + }, + }; + return utils.mkEvent({ event: true, type: EventType.Receipt, content, room: ROOM_ID }); + } + + it("should not allow an older receipt to clobber a newer one of the same type", async () => { const userId = client.getSafeUserId(); const room = new Room(ROOM_ID, client, userId); - room.findEventById = vi.fn().mockReturnValue({} as MatrixEvent); - const unthreadedReceipt: WrappedReceipt = { - eventId: "$olderEvent", - data: { - ts: 1234567880, - }, - }; - const mainTimelineReceipt: WrappedReceipt = { - eventId: "$newerEvent", - data: { - ts: 1234567890, - }, - }; + const olderEvent = utils.mkMessage({ room: ROOM_ID, user: userId, msg: "older", event: true }); + const newerEvent = utils.mkMessage({ room: ROOM_ID, user: userId, msg: "newer", event: true }); + await room.addLiveEvents([olderEvent, newerEvent], { addToState: false }); - room.addReceiptToStructure( - mainTimelineReceipt.eventId, - ReceiptType.ReadPrivate, - userId, - mainTimelineReceipt.data, - false, - ); - expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId); + room.addReceipt(receiptEvent(newerEvent, userId, ReceiptType.ReadPrivate, 1234567890)); + expect(room.getEventReadUpTo(userId)).toBe(newerEvent.getId()); - room.addReceiptToStructure( - unthreadedReceipt.eventId, - ReceiptType.ReadPrivate, - userId, - unthreadedReceipt.data, - false, - ); - expect(room.getEventReadUpTo(userId)).toBe(mainTimelineReceipt.eventId); + // An older receipt for the same (user, type) must not overwrite the newer one. + room.addReceipt(receiptEvent(olderEvent, userId, ReceiptType.ReadPrivate, 1234567880)); + expect(room.getEventReadUpTo(userId)).toBe(newerEvent.getId()); }); }); diff --git a/src/@types/read_receipts.ts b/src/@types/read_receipts.ts index 759240387f..9706155d41 100644 --- a/src/@types/read_receipts.ts +++ b/src/@types/read_receipts.ts @@ -47,15 +47,3 @@ export interface ReceiptContent { }; }; } - -// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer. -// map: receipt type → user Id → receipt -export type Receipts = Map>; - -export type CachedReceiptStructure = { - eventId: string; - receiptType: string | ReceiptType; - userId: string; - receipt: Receipt; - synthetic: boolean; -}; diff --git a/src/models/read-receipt.ts b/src/models/read-receipt.ts deleted file mode 100644 index 3ef452fc4e..0000000000 --- a/src/models/read-receipt.ts +++ /dev/null @@ -1,422 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { - type CachedReceipt, - MAIN_ROOM_TIMELINE, - type Receipt, - type ReceiptCache, - ReceiptType, - type WrappedReceipt, -} from "../@types/read_receipts.ts"; -import { type ListenerMap, TypedEventEmitter } from "./typed-event-emitter.ts"; -import { isSupportedReceiptType } from "../utils.ts"; -import { MatrixEvent } from "./event.ts"; -import { EventType } from "../@types/event.ts"; -import { type EventTimelineSet } from "./event-timeline-set.ts"; -import { MapWithDefault } from "../utils.ts"; -import { NotificationCountType } from "./room.ts"; -import { logger } from "../logger.ts"; -import { inMainTimelineForReceipt, threadIdForReceipt } from "../client.ts"; - -/** - * Create a synthetic receipt for the given event - * @param userId - The user ID if the receipt sender - * @param event - The event that is to be acknowledged - * @param receiptType - The type of receipt - * @param unthreaded - the receipt is unthreaded - * @returns a new event with the synthetic receipt in it - */ -export function synthesizeReceipt( - userId: string, - event: MatrixEvent, - receiptType: ReceiptType, - unthreaded = false, -): MatrixEvent { - return new MatrixEvent({ - content: { - [event.getId()!]: { - [receiptType]: { - [userId]: { - ts: event.getTs(), - ...(!unthreaded && { thread_id: threadIdForReceipt(event) }), - }, - }, - }, - }, - type: EventType.Receipt, - room_id: event.getRoomId(), - }); -} - -const ReceiptPairRealIndex = 0; -const ReceiptPairSyntheticIndex = 1; - -export abstract class ReadReceipt< - Events extends string, - Arguments extends ListenerMap, - SuperclassArguments extends ListenerMap = Arguments, -> extends TypedEventEmitter { - // receipts should clobber based on receipt_type and user_id pairs hence - // the form of this structure. This is sub-optimal for the exposed APIs - // which pass in an event ID and get back some receipts, so we also store - // a pre-cached list for this purpose. - // Map: receipt type → user Id → receipt - private receipts = new MapWithDefault< - string, - Map - >(() => new Map()); - private receiptCacheByEventId: ReceiptCache = new Map(); - - public abstract getUnfilteredTimelineSet(): EventTimelineSet; - public abstract get timeline(): MatrixEvent[]; - - /** - * Gets the latest receipt for a given user in the room - * @param userId - The id of the user for which we want the receipt - * @param ignoreSynthesized - Whether to ignore synthesized receipts or not - * @param receiptType - Optional. The type of the receipt we want to get - * @returns the latest receipts of the chosen type for the chosen user - */ - public getReadReceiptForUserId( - userId: string, - ignoreSynthesized = false, - receiptType = ReceiptType.Read, - ): WrappedReceipt | null { - const [realReceipt, syntheticReceipt] = this.receipts.get(receiptType)?.get(userId) ?? [null, null]; - if (ignoreSynthesized) { - return realReceipt; - } - - return syntheticReceipt ?? realReceipt; - } - - private compareReceipts(a: WrappedReceipt, b: WrappedReceipt): number { - // Try compare them in our unfiltered timeline set order, falling back to receipt timestamp which should be - // relatively sane as receipts are set only by the originating homeserver so as long as its clock doesn't - // jump around then it should be valid. - return this.getUnfilteredTimelineSet().compareEventOrdering(a.eventId, b.eventId) ?? a.data.ts - b.data.ts; - } - - /** - * Get the ID of the event that a given user has read up to, or null if: - * - we have received no read receipts for them, or - * - the receipt we have points at an event we don't have, or - * - the thread ID in the receipt does not match the thread root of the - * referenced event. - * - * (The event might not exist if it is not loaded, and the thread ID might - * not match if the event has moved thread because it was redacted.) - * - * @param userId - The user ID to get read receipt event ID for - * @param ignoreSynthesized - If true, return only receipts that have been - * sent by the server, not implicit ones generated - * by the JS SDK. - * @returns ID of the latest existing event that the given user has read, or null. - */ - public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null { - // Find what the latest receipt says is the latest event we have read - const latestReceipt = this.getLatestReceipt(userId, ignoreSynthesized); - - if (!latestReceipt) { - return null; - } - - return this.receiptPointsAtConsistentEvent(latestReceipt) ? latestReceipt.eventId : null; - } - - /** - * Returns true if the event pointed at by this receipt exists, and its - * threadRootId is consistent with the thread information in the receipt. - */ - private receiptPointsAtConsistentEvent(receipt: WrappedReceipt): boolean { - const event = this.findEventById(receipt.eventId); - if (!event) { - // If the receipt points at a non-existent event, we have multiple - // possibilities: - // - // 1. We don't have the event because it's not loaded yet - probably - // it's old and we're best off ignoring the receipt - we can just - // send a new one when we read a new event. - // - // 2. We have a bug e.g. we misclassified this event into the wrong - // thread. - // - // 3. The referenced event moved out of this thread (e.g. because it - // was deleted.) - // - // 4. The receipt had the incorrect thread ID (due to a bug in a - // client, or malicious behaviour). - - // This receipt is not "valid" because it doesn't point at an event - // we have. We want to pretend it doesn't exist. - return false; - } - - if (!receipt.data?.thread_id) { - // If this is an unthreaded receipt, it could point at any event, so - // there is no need to validate further - this receipt is valid. - return true; - } - // Otherwise it is a threaded receipt... - - if (receipt.data.thread_id === MAIN_ROOM_TIMELINE) { - // The receipt is for the main timeline: we check that the event is - // in the main timeline. - - // Check if the event is in the main timeline - const eventIsInMainTimeline = inMainTimelineForReceipt(event); - - if (eventIsInMainTimeline) { - // The receipt is for the main timeline, and so is the event, so - // the receipt is valid. - return true; - } - } else { - // The receipt is for a different thread (not the main timeline) - - if (event.threadRootId === receipt.data.thread_id) { - // If the receipt and event agree on the thread ID, the receipt - // is valid. - return true; - } - } - - // The receipt thread ID disagrees with the event thread ID. There are 2 - // possibilities: - // - // 1. The event moved to a different thread after the receipt was - // created. This can happen if the event was redacted because that - // moves it to the main timeline. - // - // 2. There is a bug somewhere - either we put the event into the wrong - // thread, or someone sent an incorrect receipt. - // - // In many cases, we won't get here because the call to findEventById - // would have already returned null. We include this check to cover - // cases when `this` is a room, meaning findEventById will find events - // in any thread, and to be defensive against unforeseen code paths. - logger.warn( - `Ignoring receipt because its thread_id (${receipt.data.thread_id}) disagrees ` + - `with the thread root (${event.threadRootId}) of the referenced event ` + - `(event ID = ${receipt.eventId})`, - ); - - // This receipt is not "valid" because it disagrees with us about what - // thread the event is in. We want to pretend it doesn't exist. - return false; - } - - private getLatestReceipt(userId: string, ignoreSynthesized: boolean): WrappedReceipt | null { - // XXX: This is very very ugly and I hope I won't have to ever add a new - // receipt type here again. IMHO this should be done by the server in - // some more intelligent manner or the client should just use timestamps - - const publicReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.Read); - const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.ReadPrivate); - - // If we have both, compare them - let comparison: number | null | undefined; - if (publicReadReceipt?.eventId && privateReadReceipt?.eventId) { - comparison = this.compareReceipts(publicReadReceipt, privateReadReceipt); - } - - // The public receipt is more likely to drift out of date so the private - // one has precedence - if (!comparison) return privateReadReceipt ?? publicReadReceipt ?? null; - - // If public read receipt is older, return the private one - return (comparison < 0 ? privateReadReceipt : publicReadReceipt) ?? null; - } - - public addReceiptToStructure( - eventId: string, - receiptType: ReceiptType, - userId: string, - receipt: Receipt, - synthetic: boolean, - ): void { - const receiptTypesMap = this.receipts.getOrCreate(receiptType); - let pair = receiptTypesMap.get(userId); - - if (!pair) { - pair = [null, null]; - receiptTypesMap.set(userId, pair); - } - - let existingReceipt = pair[ReceiptPairRealIndex]; - if (synthetic) { - existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; - } - - const wrappedReceipt: WrappedReceipt = { - eventId, - data: receipt, - }; - - if (existingReceipt) { - // We only want to add this receipt if we think it is later than the one we already have. - // This is managed server-side, but because we synthesize RRs locally we have to do it here too. - const ordering = this.compareReceipts(existingReceipt, wrappedReceipt); - if (ordering >= 0) { - return; - } - } - - const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt; - const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex]; - - let ordering: number | null = null; - if (realReceipt && syntheticReceipt) { - ordering = this.getUnfilteredTimelineSet().compareEventOrdering( - realReceipt.eventId, - syntheticReceipt.eventId, - ); - } - - const preferSynthetic = ordering === null || ordering < 0; - - // we don't bother caching just real receipts by event ID as there's nothing that would read it. - // Take the current cached receipt before we overwrite the pair elements. - const cachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; - - if (synthetic && preferSynthetic) { - pair[ReceiptPairSyntheticIndex] = wrappedReceipt; - } else if (!synthetic) { - pair[ReceiptPairRealIndex] = wrappedReceipt; - - if (!preferSynthetic) { - pair[ReceiptPairSyntheticIndex] = null; - } - } - - const newCachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; - if (cachedReceipt === newCachedReceipt) return; - - // clean up any previous cache entry - if (cachedReceipt && this.receiptCacheByEventId.get(cachedReceipt.eventId)) { - const previousEventId = cachedReceipt.eventId; - // Remove the receipt we're about to clobber out of existence from the cache - this.receiptCacheByEventId.set( - previousEventId, - this.receiptCacheByEventId.get(previousEventId)!.filter((r) => { - return r.type !== receiptType || r.userId !== userId; - }), - ); - - if (this.receiptCacheByEventId.get(previousEventId)!.length < 1) { - this.receiptCacheByEventId.delete(previousEventId); // clean up the cache keys - } - } - - // cache the new one - if (!this.receiptCacheByEventId.get(eventId)) { - this.receiptCacheByEventId.set(eventId, []); - } - this.receiptCacheByEventId.get(eventId)!.push({ - userId: userId, - type: receiptType as ReceiptType, - data: receipt, - }); - } - - /** - * Get a list of receipts for the given event. - * @param event - the event to get receipts for - * @returns A list of receipts with a userId, type and data keys or - * an empty list. - */ - public getReceiptsForEvent(event: MatrixEvent): CachedReceipt[] { - return this.receiptCacheByEventId.get(event.getId()!) || []; - } - - public abstract addReceipt(event: MatrixEvent, synthetic: boolean): void; - - public abstract setUnread(type: NotificationCountType, count: number): void; - - /** - * Look in this room/thread's timeline to find an event. If `this` is a - * room, we look in all threads, but if `this` is a thread, we look only - * inside this thread. - */ - public abstract findEventById(eventId: string): MatrixEvent | undefined; - - /** - * This issue should also be addressed on synapse's side and is tracked as part - * of https://github.com/matrix-org/synapse/issues/14837 - * - * Retrieves the read receipt for the logged in user and checks if it matches - * the last event in the room and whether that event originated from the logged - * in user. - * Under those conditions we can consider the context as read. This is useful - * because we never send read receipts against our own events - * @param userId - the logged in user - */ - public fixupNotifications(userId: string): void { - const receipt = this.getReadReceiptForUserId(userId, false); - - const lastEvent = this.timeline[this.timeline.length - 1]; - if (lastEvent && receipt?.eventId === lastEvent.getId() && userId === lastEvent.getSender()) { - this.setUnread(NotificationCountType.Total, 0); - this.setUnread(NotificationCountType.Highlight, 0); - } - } - - /** - * Add a temporary local-echo receipt to the room to reflect in the - * client the fact that we've sent one. - * @param userId - The user ID if the receipt sender - * @param e - The event that is to be acknowledged - * @param receiptType - The type of receipt - * @param unthreaded - the receipt is unthreaded - */ - public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType, unthreaded = false): void { - this.addReceipt(synthesizeReceipt(userId, e, receiptType, unthreaded), true); - } - - /** - * Get a list of user IDs who have read up to the given event. - * @param event - the event to get read receipts for. - * @returns A list of user IDs. - */ - public getUsersReadUpTo(event: MatrixEvent): string[] { - return this.getReceiptsForEvent(event) - .filter(function (receipt) { - return isSupportedReceiptType(receipt.type); - }) - .map(function (receipt) { - return receipt.userId; - }); - } - - /** - * Determines if the given user has read a particular event ID with the known - * history of the room. This is not a definitive check as it relies only on - * what is available to the room at the time of execution. - * @param userId - The user ID to check the read state of. - * @param eventId - The event ID to check if the user read. - * @returns True if the user has read the event, false otherwise. - */ - public abstract hasUserReadEvent(userId: string, eventId: string): boolean; - - /** - * Returns the most recent unthreaded receipt for a given user - * @param userId - the MxID of the User - * @returns an unthreaded Receipt. Can be undefined if receipts have been disabled - * or a user chooses to use private read receipts (or we have simply not received - * a receipt from this user yet). - * - * @deprecated use `hasUserReadEvent` or `getEventReadUpTo` instead - */ - public abstract getLastUnthreadedReceiptFor(userId: string): Receipt | undefined; -} diff --git a/src/models/room-receipts.ts b/src/models/room-receipts.ts index 1e962c3133..574eb93f26 100644 --- a/src/models/room-receipts.ts +++ b/src/models/room-receipts.ts @@ -14,11 +14,117 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MAIN_ROOM_TIMELINE, type Receipt, type ReceiptContent } from "../@types/read_receipts.ts"; -import { threadIdForReceipt } from "../client.ts"; +import { + type CachedReceipt, + MAIN_ROOM_TIMELINE, + type Receipt, + type ReceiptCache, + type ReceiptContent, + ReceiptType, + type WrappedReceipt, +} from "../@types/read_receipts.ts"; +import { inMainTimelineForReceipt, threadIdForReceipt } from "../client.ts"; import { type Room, RoomEvent } from "./room.ts"; -import { type MatrixEvent } from "./event.ts"; +import { MatrixEvent } from "./event.ts"; +import { EventType } from "../@types/event.ts"; import { logger } from "../logger.ts"; +import { isSupportedReceiptType } from "../utils.ts"; + +/** + * Create a synthetic receipt for the given event. + * @param userId - The user ID of the receipt sender + * @param event - The event that is to be acknowledged + * @param receiptType - The type of receipt + * @param unthreaded - the receipt is unthreaded + * @returns a new event with the synthetic receipt in it + */ +export function synthesizeReceipt( + userId: string, + event: MatrixEvent, + receiptType: ReceiptType, + unthreaded = false, +): MatrixEvent { + return new MatrixEvent({ + content: { + [event.getId()!]: { + [receiptType]: { + [userId]: { + ts: event.getTs(), + ...(!unthreaded && { thread_id: threadIdForReceipt(event) }), + }, + }, + }, + }, + type: EventType.Receipt, + room_id: event.getRoomId(), + }); +} + +/** + * Compute the event a user has read up to, by combining their public + * `m.read` and private `m.read.private` receipts. Private wins on a tie, + * matching the legacy `ReadReceipt.getLatestReceipt` precedence. + * + * The receipt is validated via `findEventById` (whose scope is the caller's + * choice — Room searches all timelines, Thread only its own). + */ +export function computeEventReadUpTo( + room: Room, + findEventById: (eventId: string) => MatrixEvent | undefined, + getReceiptForType: (receiptType: ReceiptType) => WrappedReceipt | null, +): string | null { + const publicRead = getReceiptForType(ReceiptType.Read); + const privateRead = getReceiptForType(ReceiptType.ReadPrivate); + + let latest: WrappedReceipt | null; + if (publicRead && privateRead) { + const ordering = + room.getUnfilteredTimelineSet().compareEventOrdering(publicRead.eventId, privateRead.eventId) ?? + publicRead.data.ts - privateRead.data.ts; + // private wins on tie or unknown ordering + latest = ordering > 0 ? publicRead : privateRead; + } else { + latest = privateRead ?? publicRead ?? null; + } + + if (!latest) return null; + return receiptPointsAtConsistentEvent(latest, findEventById) ? latest.eventId : null; +} + +/** + * Returns true if the event pointed at by this receipt exists, and its + * threadRootId is consistent with the thread information in the receipt. + */ +function receiptPointsAtConsistentEvent( + receipt: WrappedReceipt, + findEventById: (eventId: string) => MatrixEvent | undefined, +): boolean { + const event = findEventById(receipt.eventId); + if (!event) { + // The receipt points at an event we don't have — treat as if absent. + return false; + } + + if (!receipt.data?.thread_id) { + // Unthreaded receipt: no further validation needed. + return true; + } + + if (receipt.data.thread_id === MAIN_ROOM_TIMELINE) { + if (inMainTimelineForReceipt(event)) { + return true; + } + } else if (event.threadRootId === receipt.data.thread_id) { + return true; + } + + logger.warn( + `Ignoring receipt because its thread_id (${receipt.data.thread_id}) disagrees ` + + `with the thread root (${event.threadRootId}) of the referenced event ` + + `(event ID = ${receipt.eventId})`, + ); + return false; +} /** * The latest receipts we have for a room. @@ -28,12 +134,37 @@ export class RoomReceipts { private threadedReceipts: ThreadedReceipts; private unthreadedReceipts: ReceiptsByUser; private danglingReceipts: DanglingReceipts; + /** + * Reverse index mapping eventId → cached receipts pointing at that event. + * One entry per (userId, receiptType) per event, mirroring the + * `receiptCacheByEventId` invariants from `ReadReceipt`. + */ + private receiptsByEventId: ReceiptCache; + /** + * Forward index: latest cached eventId per `${userId}|${receiptType}`. + * Used to evict stale reverse-index entries when a user's effective receipt + * moves to a different event — including across the dangling/loaded boundary. + */ + private currentEventByUserType: Map; + /** Oldest non-main threaded receipt timestamp per user (only used for the current user in practice). */ + private oldestThreadedReceiptTsByUser: Map; + /** + * Latest unthreaded receipt per user, by ts. Tracked independently of the + * `unthreadedReceipts` storage so callers see receipts whose event isn't + * loaded yet — matching the legacy ts-based map this replaces. + */ + private latestUnthreadedByUser: Map; public constructor(room: Room) { this.room = room; - this.threadedReceipts = new ThreadedReceipts(room); - this.unthreadedReceipts = new ReceiptsByUser(room); - this.danglingReceipts = new DanglingReceipts(); + this.receiptsByEventId = new Map(); + this.currentEventByUserType = new Map(); + const cache = new ReverseReceiptCache(this.receiptsByEventId, this.currentEventByUserType); + this.threadedReceipts = new ThreadedReceipts(room, cache); + this.unthreadedReceipts = new ReceiptsByUser(room, cache); + this.danglingReceipts = new DanglingReceipts(cache); + this.oldestThreadedReceiptTsByUser = new Map(); + this.latestUnthreadedByUser = new Map(); // We listen for timeline events so we can process dangling receipts room.on(RoomEvent.Timeline, this.onTimelineEvent); } @@ -73,22 +204,34 @@ export class RoomReceipts { for (const [eventId, eventReceipt] of Object.entries(receiptContent)) { for (const [receiptType, receiptsByUser] of Object.entries(eventReceipt)) { for (const [userId, receipt] of Object.entries(receiptsByUser)) { - const referencedEvent = this.room.findEventById(eventId); - if (!referencedEvent) { + // Side-effect tracking runs unconditionally, so dangling + // receipts are still visible via getOldestThreadedReceiptTs + // and getLastUnthreadedReceiptFor. + if (receipt.thread_id && receipt.thread_id !== MAIN_ROOM_TIMELINE) { + this.trackOldestThreadedReceipt(userId, receipt); + } + if (!receipt.thread_id) { + this.trackLatestUnthreaded(userId, receipt); + } + + if (receipt.thread_id) { + // Threaded receipts go straight into the thread bucket: we + // already know which thread they belong to from the + // receipt itself, so they don't need to wait for the + // referenced event to arrive. This makes them visible to + // `getReadReceiptForUserIdInThread` immediately, matching + // the legacy `cachedThreadReadReceipts` behaviour. + this.threadedReceipts.set(receipt.thread_id, eventId, receiptType, userId, receipt, synthetic); + } else if (!this.room.findEventById(eventId)) { + // Unthreaded receipts that point at an event we don't + // have yet are deferred: we can't tell which thread the + // event belongs to (for `hasUserReadEvent`'s + // `threadIdForReceipt` step) until it arrives. this.danglingReceipts.add( new DanglingReceipt(eventId, receiptType, userId, receipt, synthetic), ); - } else if (receipt.thread_id) { - this.threadedReceipts.set( - receipt.thread_id, - eventId, - receiptType, - userId, - receipt.ts, - synthetic, - ); } else { - this.unthreadedReceipts.set(eventId, receiptType, userId, receipt.ts, synthetic); + this.unthreadedReceipts.set(eventId, receiptType, userId, receipt, synthetic); } } } @@ -96,9 +239,9 @@ export class RoomReceipts { } /** - * Look for dangling receipts for the given event ID, - * and add them to the thread of unthread receipts if found. - * @param event - the event to look for + * Look for dangling receipts for the given event ID, and add them to the + * threaded or unthreaded receipts store now that we know which event they + * point at. */ private onTimelineEvent = (event: MatrixEvent): void => { const eventId = event.getId(); @@ -107,28 +250,33 @@ export class RoomReceipts { const danglingReceipts = this.danglingReceipts.remove(eventId); danglingReceipts?.forEach((danglingReceipt) => { - // The receipt is a thread receipt - if (danglingReceipt.receipt.thread_id) { - this.threadedReceipts.set( - danglingReceipt.receipt.thread_id, - danglingReceipt.eventId, - danglingReceipt.receiptType, - danglingReceipt.userId, - danglingReceipt.receipt.ts, - danglingReceipt.synthetic, - ); - } else { - this.unthreadedReceipts.set( - eventId, - danglingReceipt.receiptType, - danglingReceipt.userId, - danglingReceipt.receipt.ts, - danglingReceipt.synthetic, - ); - } + // Only unthreaded receipts are ever deferred — see `add` above. + // Side-effect tracking already ran when the receipt was first added; + // we only need to push it into unthreadedReceipts now. + this.unthreadedReceipts.set( + eventId, + danglingReceipt.receiptType, + danglingReceipt.userId, + danglingReceipt.receipt, + danglingReceipt.synthetic, + ); }); }; + private trackOldestThreadedReceipt(userId: string, receipt: Receipt): void { + const prior = this.oldestThreadedReceiptTsByUser.get(userId); + if (prior === undefined || receipt.ts < prior) { + this.oldestThreadedReceiptTsByUser.set(userId, receipt.ts); + } + } + + private trackLatestUnthreaded(userId: string, receipt: Receipt): void { + const prior = this.latestUnthreadedByUser.get(userId); + if (!prior || receipt.ts > prior.ts) { + this.latestUnthreadedByUser.set(userId, receipt); + } + } + public hasUserReadEvent(userId: string, eventId: string): boolean { const unthreaded = this.unthreadedReceipts.get(userId); if (unthreaded) { @@ -184,13 +332,190 @@ export class RoomReceipts { return !!(timeline && timeline.length > 0 && timeline[timeline.length - 1].getSender() === userId); } + + /** + * Get the list of cached receipts for the given event. + * Returns receipts in the order they were inserted, with at most one entry + * per (userId, receiptType) pair. + */ + public getReceiptsForEvent(eventId: string): CachedReceipt[] { + return this.receiptsByEventId.get(eventId) ?? []; + } + + /** + * Get the cached receipts for an event, restricted to those scoped to the + * given thread. Used by {@link Thread} to avoid returning receipts from + * other thread contexts. + */ + public getReceiptsForEventInThread(eventId: string, threadId: string): CachedReceipt[] { + return this.getReceiptsForEvent(eventId).filter((r) => receiptIsInThread(r, threadId)); + } + + /** + * Get the IDs of users that have read up to the given event. + * Filters to the receipt types matrix-js-sdk considers as "read up to". + */ + public getUsersReadUpTo(eventId: string): string[] { + return this.getReceiptsForEvent(eventId) + .filter((receipt) => isSupportedReceiptType(receipt.type)) + .map((receipt) => receipt.userId); + } + + /** + * Like {@link getUsersReadUpTo} but restricted to receipts scoped to the + * given thread. + */ + public getUsersReadUpToInThread(eventId: string, threadId: string): string[] { + return this.getReceiptsForEventInThread(eventId, threadId) + .filter((receipt) => isSupportedReceiptType(receipt.type)) + .map((receipt) => receipt.userId); + } + + /** + * Get the latest receipt for a specific user and (optionally) receipt type, + * scoped to a thread. + */ + public getReadReceiptForUserIdInThread( + threadId: string, + userId: string, + ignoreSynthesized = false, + receiptType: ReceiptType = ReceiptType.Read, + ): WrappedReceipt | null { + return this.threadedReceipts.getByReceiptType(threadId, userId, receiptType, ignoreSynthesized); + } + + /** + * Get the latest receipt for a specific user and (optionally) receipt type. + */ + public getReadReceiptForUserId( + userId: string, + ignoreSynthesized = false, + receiptType: ReceiptType = ReceiptType.Read, + ): WrappedReceipt | null { + // Look in both the main-thread bucket and unthreaded bucket — together + // they cover what the legacy `Room` storage held. + const threaded = this.threadedReceipts.getByReceiptType( + MAIN_ROOM_TIMELINE, + userId, + receiptType, + ignoreSynthesized, + ); + const unthreaded = this.unthreadedReceipts.getByReceiptType(userId, receiptType, ignoreSynthesized); + return pickLater(this.room, threaded, unthreaded); + } + + /** + * Get the event a user has read up to in the main timeline + unthreaded scope. + * Picks the later of the user's `Read` / `ReadPrivate` receipts, preferring + * `ReadPrivate` on a tie. Returns `null` if the chosen receipt points at + * an event we don't have, or whose thread doesn't match. + */ + public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null { + const publicRead = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.Read); + const privateRead = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.ReadPrivate); + + // Pick the later, preferring private on a tie or when comparison is unknown + // (matches ReadReceipt.getLatestReceipt semantics). + let latest: WrappedReceipt | null; + if (publicRead && privateRead) { + const ordering = this.room.compareEventOrdering(publicRead.eventId, privateRead.eventId); + if (ordering === null || ordering === 0) { + latest = privateRead; + } else { + latest = ordering < 0 ? privateRead : publicRead; + } + // compareReceipts also falls back to ts when ordering is null — but in + // ReadReceipt.getLatestReceipt that fallback yields "0" via `a.data.ts - b.data.ts` + // and then privateRead wins via `!comparison` branch. So our behaviour matches. + } else { + latest = privateRead ?? publicRead ?? null; + } + + if (!latest) return null; + return this.receiptPointsAtConsistentEvent(latest) ? latest.eventId : null; + } + + /** + * Returns true if the event pointed at by this receipt exists, and its + * threadRootId is consistent with the thread information in the receipt. + */ + private receiptPointsAtConsistentEvent(receipt: WrappedReceipt): boolean { + const event = this.room.findEventById(receipt.eventId); + if (!event) { + // The receipt points at an event we don't have — treat as if absent. + return false; + } + + if (!receipt.data?.thread_id) { + // Unthreaded receipt: no further validation needed. + return true; + } + + if (receipt.data.thread_id === MAIN_ROOM_TIMELINE) { + if (inMainTimelineForReceipt(event)) { + return true; + } + } else if (event.threadRootId === receipt.data.thread_id) { + return true; + } + + logger.warn( + `Ignoring receipt because its thread_id (${receipt.data.thread_id}) disagrees ` + + `with the thread root (${event.threadRootId}) of the referenced event ` + + `(event ID = ${receipt.eventId})`, + ); + return false; + } + + /** + * Get the latest unthreaded receipt for a user, as a raw Receipt. + * Tracks by ts so that dangling receipts (whose events aren't loaded yet) + * are still visible to callers — matching the legacy behaviour. + */ + public getLastUnthreadedReceiptFor(userId: string): Receipt | undefined { + return this.latestUnthreadedByUser.get(userId); + } + + /** + * Find when a client has gained thread capabilities by inspecting the oldest + * threaded receipt for this user. + */ + public getOldestThreadedReceiptTs(userId: string): number { + return this.oldestThreadedReceiptTsByUser.get(userId) ?? Infinity; + } } // --- implementation details --- +/** + * True if the cached receipt is scoped to the given thread. + * An unthreaded receipt (no `thread_id`) is treated as belonging to the main + * timeline. + */ +function receiptIsInThread(receipt: CachedReceipt, threadId: string): boolean { + const receiptThreadId = receipt.data.thread_id ?? MAIN_ROOM_TIMELINE; + return receiptThreadId === threadId; +} + +/** + * Pick the later of two WrappedReceipts using timeline ordering with ts fallback. + * Null inputs are treated as "absent" — the other receipt wins. + */ +function pickLater(room: Room, a: WrappedReceipt | null, b: WrappedReceipt | null): WrappedReceipt | null { + if (!a) return b; + if (!b) return a; + const ordering = room.compareEventOrdering(a.eventId, b.eventId); + if (ordering !== null) { + if (ordering === 0) return a; + return ordering > 0 ? a : b; + } + // Unknown ordering — fall back to timestamp. + return a.data.ts >= b.data.ts ? a : b; +} + /** * The information "inside" a receipt once it has been stored inside - * RoomReceipts - what eventId it refers to, its type, and its ts. + * RoomReceipts - what eventId it refers to, its type, and the raw receipt data. * * Does not contain userId or threadId since these are stored as keys of the * maps in RoomReceipts. @@ -199,8 +524,26 @@ class ReceiptInfo { public constructor( public eventId: string, public receiptType: string, - public ts: number, + public receipt: Receipt, ) {} + + public get ts(): number { + return this.receipt.ts; + } + + /** + * Convert to a WrappedReceipt for the public API. + */ + public toWrapped(): WrappedReceipt { + return { eventId: this.eventId, data: this.receipt }; + } + + /** + * Convert to a CachedReceipt for the per-event reverse index. + */ + public toCached(userId: string): CachedReceipt { + return { userId, type: this.receiptType as ReceiptType, data: this.receipt }; + } } /** @@ -217,54 +560,137 @@ class DanglingReceipt { ) {} } -class UserReceipts { - private room: Room; +/** + * Maintains the eventId → CachedReceipt[] reverse index, with a forward + * `(userId, receiptType) → eventId` pointer for fast eviction. Shared by all + * receipt buckets (threaded, unthreaded, dangling) inside a RoomReceipts. + */ +class ReverseReceiptCache { + public constructor( + private receiptsByEventId: ReceiptCache, + private currentEventByUserType: Map, + ) {} /** - * The real receipt for this user. + * Record that this user's effective receipt for the given type now points at + * `newEventId`, with payload `data`. Evicts any prior entry the cache had + * for (userId, receiptType) at a different event. */ - private real: ReceiptInfo | undefined; + public update(userId: string, receiptType: string, newEventId: string, data: Receipt): void { + const key = `${userId}|${receiptType}`; + const oldEventId = this.currentEventByUserType.get(key); - /** - * The synthetic receipt for this user. If this is defined, it is later than real. - */ - private synthetic: ReceiptInfo | undefined; + if (oldEventId && oldEventId !== newEventId) { + this.evict(oldEventId, userId, receiptType); + } + + let bucket = this.receiptsByEventId.get(newEventId); + if (!bucket) { + bucket = []; + this.receiptsByEventId.set(newEventId, bucket); + } + + const existingIdx = bucket.findIndex((r) => r.userId === userId && r.type === receiptType); + const entry: CachedReceipt = { userId, type: receiptType as ReceiptType, data }; + if (existingIdx >= 0) { + bucket[existingIdx] = entry; + } else { + bucket.push(entry); + } + + this.currentEventByUserType.set(key, newEventId); + } + + private evict(eventId: string, userId: string, receiptType: string): void { + const bucket = this.receiptsByEventId.get(eventId); + if (!bucket) return; + const filtered = bucket.filter((r) => r.userId !== userId || r.type !== receiptType); + if (filtered.length === 0) { + this.receiptsByEventId.delete(eventId); + } else { + this.receiptsByEventId.set(eventId, filtered); + } + } +} + +/** + * The per-user storage of receipts, keyed by receipt type. + * + * For each receipt type we hold an optional real and synthetic receipt. The + * invariant is: synthetic is only set if it's strictly later than real. + */ +class UserReceipts { + private room: Room; + + /** Map of receiptType → {real, synthetic}. */ + private byType: Map; public constructor(room: Room) { this.room = room; - this.real = undefined; - this.synthetic = undefined; + this.byType = new Map(); } + /** + * Set the real or synthetic receipt for the given receipt type. + * Preserves the invariant that synthetic only exists if it's strictly later than real. + */ public set(synthetic: boolean, receiptInfo: ReceiptInfo): void { + const entry = this.byType.get(receiptInfo.receiptType) ?? {}; if (synthetic) { - this.synthetic = receiptInfo; + entry.synthetic = receiptInfo; } else { - this.real = receiptInfo; + entry.real = receiptInfo; } - // Preserve the invariant: synthetic is only defined if it's later than real - if (this.synthetic && this.real) { - if (isAfterOrSame(this.real.eventId, this.synthetic.eventId, this.room)) { - this.synthetic = undefined; + if (entry.synthetic && entry.real) { + if (isAfterOrSame(entry.real.eventId, entry.synthetic.eventId, this.room)) { + entry.synthetic = undefined; } } + + this.byType.set(receiptInfo.receiptType, entry); } /** - * Return the latest receipt we have - synthetic if we have one (and it's - * later), otherwise real. + * Return the effective receipt across all types — the latest one, + * preferring synthetic when it's later than real. */ public get(): ReceiptInfo | undefined { - // Relies on the invariant that synthetic is only defined if it's later than real. - return this.synthetic ?? this.real; + let latest: ReceiptInfo | undefined; + for (const entry of this.byType.values()) { + const candidate = entry.synthetic ?? entry.real; + if (!candidate) continue; + if (!latest) { + latest = candidate; + continue; + } + const ordering = this.room.compareEventOrdering(latest.eventId, candidate.eventId); + if (ordering !== null) { + if (ordering < 0) latest = candidate; + } else if (candidate.ts > latest.ts) { + latest = candidate; + } + } + return latest; + } + + /** + * Return the effective (real or synthetic, whichever is later) receipt for + * the given receipt type. + */ + public getByReceiptType(receiptType: string, ignoreSynthesized = false): ReceiptInfo | undefined { + const entry = this.byType.get(receiptType); + if (!entry) return undefined; + return ignoreSynthesized ? entry.real : (entry.synthetic ?? entry.real); } /** - * Return the latest receipt we have of the specified type (synthetic or not). + * Return either the real or the synthetic receipt for the given receipt type. */ - public getByType(synthetic: boolean): ReceiptInfo | undefined { - return synthetic ? this.synthetic : this.real; + public getRealOrSynthetic(receiptType: string, synthetic: boolean): ReceiptInfo | undefined { + const entry = this.byType.get(receiptType); + if (!entry) return undefined; + return synthetic ? entry.synthetic : entry.real; } } @@ -272,62 +698,60 @@ class UserReceipts { * The latest receipt info we have, either for a single thread, or all the * unthreaded receipts for a room. * - * userId: ReceiptInfo + * userId: UserReceipts */ class ReceiptsByUser { private room: Room; + private cache: ReverseReceiptCache; /** map of userId: UserReceipts */ private data: Map; - public constructor(room: Room) { + public constructor(room: Room, cache: ReverseReceiptCache) { this.room = room; + this.cache = cache; this.data = new Map(); } /** * Add the supplied receipt to our structure, if it is not earlier than the - * one we already hold for this user. + * one we already hold for this user / receipt type. */ - public set(eventId: string, receiptType: string, userId: string, ts: number, synthetic: boolean): void { + public set(eventId: string, receiptType: string, userId: string, receipt: Receipt, synthetic: boolean): void { const userReceipts = getOrCreate(this.data, userId, () => new UserReceipts(this.room)); - const existingReceipt = userReceipts.getByType(synthetic); - if (existingReceipt && isAfter(existingReceipt.eventId, eventId, this.room)) { - // The new receipt is before the existing one - don't store it. + const existingByKind = userReceipts.getRealOrSynthetic(receiptType, synthetic); + if (existingByKind && isAfter(existingByKind.eventId, eventId, this.room)) { + // The new receipt is strictly before the existing one of the same kind - don't store it. return; } - // Possibilities: - // - // 1. there was no existing receipt, or - // 2. the existing receipt was before this one, or - // 3. we were unable to compare the receipts. - // - // In the case of 3 it's difficult to decide what to do, so the - // most-recently-received receipt wins. - // - // Case 3 can only happen if the events for these receipts have - // disappeared, which is quite unlikely since the new one has just been - // checked, and the old one was checked before it was inserted here. - // - // We go ahead and store this receipt (replacing the other if it exists) - userReceipts.set(synthetic, new ReceiptInfo(eventId, receiptType, ts)); - } - - /** - * Find the latest receipt we have for this user. (Note - there is only one - * receipt per user, because we are already inside a specific thread or - * unthreaded list.) - * - * If there is a later synthetic receipt for this user, return that. - * Otherwise, return the real receipt. - * - * @returns the found receipt info, or undefined if we have no receipt for this user. + userReceipts.set(synthetic, new ReceiptInfo(eventId, receiptType, receipt)); + + const newEffective = userReceipts.getByReceiptType(receiptType); + if (newEffective) { + this.cache.update(userId, receiptType, newEffective.eventId, newEffective.receipt); + } + } + + /** + * Find the latest receipt we have for this user across all receipt types. */ public get(userId: string): ReceiptInfo | undefined { return this.data.get(userId)?.get(); } + + /** + * Find the latest receipt of a specific type for this user. + */ + public getByReceiptType( + userId: string, + receiptType: ReceiptType, + ignoreSynthesized: boolean, + ): WrappedReceipt | null { + const info = this.data.get(userId)?.getByReceiptType(receiptType, ignoreSynthesized); + return info ? info.toWrapped() : null; + } } /** @@ -335,12 +759,14 @@ class ReceiptsByUser { */ class ThreadedReceipts { private room: Room; + private cache: ReverseReceiptCache; /** map of threadId: ReceiptsByUser */ private data: Map; - public constructor(room: Room) { + public constructor(room: Room, cache: ReverseReceiptCache) { this.room = room; + this.cache = cache; this.data = new Map(); } @@ -353,11 +779,11 @@ class ThreadedReceipts { eventId: string, receiptType: string, userId: string, - ts: number, + receipt: Receipt, synthetic: boolean, ): void { - const receiptsByUser = getOrCreate(this.data, threadId, () => new ReceiptsByUser(this.room)); - receiptsByUser.set(eventId, receiptType, userId, ts, synthetic); + const receiptsByUser = getOrCreate(this.data, threadId, () => new ReceiptsByUser(this.room, this.cache)); + receiptsByUser.set(eventId, receiptType, userId, receipt, synthetic); } /** @@ -368,13 +794,30 @@ class ThreadedReceipts { public get(threadId: string, userId: string): ReceiptInfo | undefined { return this.data.get(threadId)?.get(userId); } + + /** + * Find the latest threaded receipt of a specific type for this user. + */ + public getByReceiptType( + threadId: string, + userId: string, + receiptType: ReceiptType, + ignoreSynthesized: boolean, + ): WrappedReceipt | null { + const byUser = this.data.get(threadId); + if (!byUser) return null; + return byUser.getByReceiptType(userId, receiptType, ignoreSynthesized); + } } /** * All the receipts that we have received but can't process because we can't * find the event they refer to. * - * We hold on to them so we can process them if their event arrives later. + * We hold on to them so we can process them if their event arrives later. To + * keep the public `receiptsByEventId` index consistent with the legacy + * behaviour, we also push dangling receipts into the reverse cache (with the + * per-(userId, type) eviction semantics). */ class DanglingReceipts { /** @@ -382,12 +825,26 @@ class DanglingReceipts { */ private data = new Map>(); + public constructor(private cache: ReverseReceiptCache) {} + /** - * Remember the supplied dangling receipt. + * Remember the supplied dangling receipt. Only stores it as the latest if + * it isn't strictly older (by ts) than an existing dangling receipt for the + * same (userId, receiptType). Updates the public reverse cache regardless, + * so consumers see the cached receipts. */ public add(danglingReceipt: DanglingReceipt): void { - const danglingReceipts = getOrCreate(this.data, danglingReceipt.eventId, () => []); - danglingReceipts.push(danglingReceipt); + const danglingList = getOrCreate(this.data, danglingReceipt.eventId, () => []); + danglingList.push(danglingReceipt); + + // We don't have the event yet, so we can't use the timeline for + // ordering — fall back to ts only. + this.cache.update( + danglingReceipt.userId, + danglingReceipt.receiptType, + danglingReceipt.eventId, + danglingReceipt.receipt, + ); } /** diff --git a/src/models/room.ts b/src/models/room.ts index 48ddfdae84..ea137a1124 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -62,17 +62,18 @@ import { ThreadFilterType, } from "./thread.ts"; import { - type CachedReceiptStructure, + type CachedReceipt, MAIN_ROOM_TIMELINE, type Receipt, type ReceiptContent, ReceiptType, + type WrappedReceipt, } from "../@types/read_receipts.ts"; import { type IStateEventWithRoomId } from "../@types/search.ts"; import { RelationsContainer } from "./relations-container.ts"; -import { ReadReceipt, synthesizeReceipt } from "./read-receipt.ts"; import { isPollEvent, Poll, PollEvent } from "./poll.ts"; -import { RoomReceipts } from "./room-receipts.ts"; +import { computeEventReadUpTo, RoomReceipts, synthesizeReceipt } from "./room-receipts.ts"; +import { TypedEventEmitter } from "./typed-event-emitter.ts"; import { compareEventOrdering } from "./compare-event-ordering.ts"; import { KnownMembership, type Membership } from "../@types/membership.ts"; import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts"; @@ -335,20 +336,12 @@ export type RoomEventHandlerMap = { > & Pick; -export class Room extends ReadReceipt { +export class Room extends TypedEventEmitter { public readonly reEmitter: TypedReEmitter; private txnToEvent: Map = new Map(); // Pending in-flight requests { string: MatrixEvent } private notificationCounts: NotificationCount = {}; private bumpStamp: number | undefined = undefined; private readonly threadNotifications = new Map(); - public readonly cachedThreadReadReceipts = new Map(); - // Useful to know at what point the current user has started using threads in this room - private oldestThreadedReceiptTs = Infinity; - /** - * A record of the latest unthread receipts per user - * This is useful in determining whether a user has read a thread or not - */ - private unthreadedReceipts = new Map(); private readonly timelineSets: EventTimelineSet[]; public readonly polls: Map = new Map(); @@ -2548,7 +2541,6 @@ export class Room extends ReadReceipt { room: this, client: this.client, pendingEventOrdering: this.opts.pendingEventOrdering, - receipts: this.cachedThreadReadReceipts.get(threadId) ?? [], }); // Add the re-emitter before we start adding events to the thread so we don't miss events @@ -2560,10 +2552,6 @@ export class Room extends ReadReceipt { RoomEvent.TimelineReset, ]); - // All read receipts should now come down from sync, we do not need to keep - // a reference to the cached receipts anymore. - this.cachedThreadReadReceipts.delete(threadId); - // If we managed to create a thread and figure out its `id` then we can use it // This has to happen before thread.addEvents, because that adds events to the eventtimeline, and the // eventtimeline sometimes looks up thread information via the room. @@ -3232,75 +3220,50 @@ export class Room extends ReadReceipt { this.roomReceipts.add(content, synthetic); - // TODO: delete the following code when it has been replaced by RoomReceipts - Object.keys(content).forEach((eventId: string) => { - Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => { - Object.keys(content[eventId][receiptType]).forEach((userId: string) => { - const receipt = content[eventId][receiptType][userId] as Receipt; - const receiptForMainTimeline = !receipt.thread_id || receipt.thread_id === MAIN_ROOM_TIMELINE; - const receiptDestination: Thread | this | undefined = receiptForMainTimeline - ? this - : this.threads.get(receipt.thread_id ?? ""); - - if (receiptDestination) { - receiptDestination.addReceiptToStructure( - eventId, - receiptType as ReceiptType, - userId, - receipt, - synthetic, - ); - - // If the read receipt sent for the logged in user matches - // the last event of the live timeline, then we know for a fact - // that the user has read that message, so we can mark the room - // as read and not wait for the remote echo from synapse. - // - // This needs to be done after the initial sync as we do not want this - // logic to run whilst the room is being initialised - // - // We only do this for non-synthetic receipts, because - // our intention is to do this when the user really did - // just read a message, not when we are e.g. receiving - // an event during the sync. More explanation at: - // https://github.com/matrix-org/matrix-js-sdk/issues/3684 - if (!synthetic && this.client.isInitialSyncComplete() && userId === this.client.getUserId()) { - const lastEvent = receiptDestination.timeline[receiptDestination.timeline.length - 1]; - if (lastEvent && eventId === lastEvent.getId() && userId === lastEvent.getSender()) { - receiptDestination.setUnread(NotificationCountType.Total, 0); - receiptDestination.setUnread(NotificationCountType.Highlight, 0); - } - } - } else { - // The thread does not exist locally, keep the read receipt - // in a cache locally, and re-apply the `addReceipt` logic - // when the thread is created - this.cachedThreadReadReceipts.set(receipt.thread_id!, [ - ...(this.cachedThreadReadReceipts.get(receipt.thread_id!) ?? []), - { eventId, receiptType, userId, receipt, synthetic }, - ]); - } - - const me = this.client.getUserId(); - // Track the time of the current user's oldest threaded receipt in the room. - if (userId === me && !receiptForMainTimeline && receipt.ts < this.oldestThreadedReceiptTs) { - this.oldestThreadedReceiptTs = receipt.ts; - } - - // Track each user's unthreaded read receipt. - if (!receipt.thread_id && receipt.ts > (this.unthreadedReceipts.get(userId)?.ts ?? 0)) { - this.unthreadedReceipts.set(userId, receipt); - } - }); - }); - }); - // End of code to delete when replaced by RoomReceipts + // If the read receipt sent for the logged in user matches the last event + // of the live timeline of its target context (room or thread), mark that + // context as read immediately instead of waiting for the remote echo from + // synapse. We only do this for non-synthetic receipts and after the + // initial sync — see https://github.com/matrix-org/matrix-js-sdk/issues/3684. + if (!synthetic && this.client.isInitialSyncComplete()) { + this.maybeResetUnreadOnReceipt(content); + } // send events after we've regenerated the structure & cache, otherwise things that // listened for the event would read stale data. this.emit(RoomEvent.Receipt, event, this); } + /** + * For receipts from the logged-in user that point at the last event in + * their target context, eagerly clear the unread counts so we don't have to + * wait for the remote echo. See {@link addReceipt}. + */ + private maybeResetUnreadOnReceipt(content: ReceiptContent): void { + const me = this.client.getUserId(); + if (!me) return; + + for (const eventId of Object.keys(content)) { + const byType = content[eventId]; + for (const receiptType of Object.keys(byType)) { + const receipt = byType[receiptType][me] as Receipt | undefined; + if (!receipt) continue; + + const receiptForMainTimeline: boolean = !receipt.thread_id || receipt.thread_id === MAIN_ROOM_TIMELINE; + const destination: Thread | this | undefined = receiptForMainTimeline + ? this + : this.threads.get(receipt.thread_id ?? ""); + if (!destination) continue; + + const lastEvent = destination.timeline[destination.timeline.length - 1]; + if (lastEvent && eventId === lastEvent.getId() && lastEvent.getSender() === me) { + destination.setUnread(NotificationCountType.Total, 0); + destination.setUnread(NotificationCountType.Highlight, 0); + } + } + } + } + /** * Adds/handles ephemeral events such as typing notifications and read receipts. * @param events - A list of events to process @@ -3934,12 +3897,13 @@ export class Room extends ReadReceipt { } /** - * Find when a client has gained thread capabilities by inspecting the oldest - * threaded receipt - * @returns the timestamp of the oldest threaded receipt + * Find when a user gained thread capabilities by inspecting the oldest + * non-main threaded receipt we have from them. + * @param userId - The user ID whose oldest threaded receipt timestamp to return. + * @returns the timestamp of the oldest threaded receipt, or Infinity if none. */ - public getOldestThreadedReceiptTs(): number { - return this.oldestThreadedReceiptTs; + public getOldestThreadedReceiptTs(userId: string): number { + return this.roomReceipts.getOldestThreadedReceiptTs(userId); } /** @@ -3963,7 +3927,91 @@ export class Room extends ReadReceipt { * a receipt from this user yet). */ public getLastUnthreadedReceiptFor(userId: string): Receipt | undefined { - return this.unthreadedReceipts.get(userId); + return this.roomReceipts.getLastUnthreadedReceiptFor(userId); + } + + /** + * Get the latest receipt of the given type for a user. + * @param userId - The id of the user. + * @param ignoreSynthesized - When true, ignores receipts the JS SDK synthesized. + * @param receiptType - The type of receipt to look for. Defaults to `m.read`. + */ + public getReadReceiptForUserId( + userId: string, + ignoreSynthesized = false, + receiptType: ReceiptType = ReceiptType.Read, + ): WrappedReceipt | null { + return this.roomReceipts.getReadReceiptForUserId(userId, ignoreSynthesized, receiptType); + } + + /** + * Get the ID of the event the given user has read up to, or null if there + * is no receipt or the receipt points at an event we can't validate. + */ + public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null { + return computeEventReadUpTo( + this, + (id) => this.findEventById(id), + (receiptType) => this.getReadReceiptForUserId(userId, ignoreSynthesized, receiptType), + ); + } + + /** + * Add a temporary local-echo receipt to the room to reflect in the + * client the fact that we've sent one. + */ + public addLocalEchoReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType, unthreaded = false): void { + this.addReceipt(synthesizeReceipt(userId, event, receiptType, unthreaded), true); + } + + /** + * Get a list of receipts pointing at the given event. + */ + public getReceiptsForEvent(event: MatrixEvent): CachedReceipt[] { + const id = event.getId(); + if (!id) return []; + return this.roomReceipts.getReceiptsForEvent(id); + } + + /** + * Get the IDs of users who have read up to the given event (via supported + * receipt types). + */ + public getUsersReadUpTo(event: MatrixEvent): string[] { + const id = event.getId(); + if (!id) return []; + return this.roomReceipts.getUsersReadUpTo(id); + } + + /** + * Thread-scoped variant of {@link getReadReceiptForUserId}. Used by + * {@link Thread} which needs to query receipts within its own thread only. + */ + public getReadReceiptForUserIdInThread( + threadId: string, + userId: string, + ignoreSynthesized = false, + receiptType: ReceiptType = ReceiptType.Read, + ): WrappedReceipt | null { + return this.roomReceipts.getReadReceiptForUserIdInThread(threadId, userId, ignoreSynthesized, receiptType); + } + + /** + * Thread-scoped variant of {@link getReceiptsForEvent}. + */ + public getReceiptsForEventInThread(event: MatrixEvent, threadId: string): CachedReceipt[] { + const id = event.getId(); + if (!id) return []; + return this.roomReceipts.getReceiptsForEventInThread(id, threadId); + } + + /** + * Thread-scoped variant of {@link getUsersReadUpTo}. + */ + public getUsersReadUpToInThread(event: MatrixEvent, threadId: string): string[] { + const id = event.getId(); + if (!id) return []; + return this.roomReceipts.getUsersReadUpToInThread(id, threadId); } /** @@ -3978,7 +4026,15 @@ export class Room extends ReadReceipt { * contexts */ public fixupNotifications(userId: string): void { - super.fixupNotifications(userId); + // If the user's own receipt points at the last event in this room and + // they sent it, treat the room as read (we never send receipts against + // our own events). Inlined from the former ReadReceipt base class. + const receipt = this.getReadReceiptForUserId(userId, false); + const lastEvent = this.timeline[this.timeline.length - 1]; + if (lastEvent && receipt?.eventId === lastEvent.getId() && userId === lastEvent.getSender()) { + this.setUnread(NotificationCountType.Total, 0); + this.setUnread(NotificationCountType.Highlight, 0); + } const unreadThreads = this.getThreads().filter( (thread) => this.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0, diff --git a/src/models/thread.ts b/src/models/thread.ts index 871875dfc8..86acf4a2ca 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -20,12 +20,13 @@ import { RelationType } from "../@types/event.ts"; import { type IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "./event.ts"; import { Direction, EventTimeline } from "./event-timeline.ts"; import { EventTimelineSet, type EventTimelineSetHandlerMap } from "./event-timeline-set.ts"; -import { type NotificationCountType, type Room, RoomEvent } from "./room.ts"; +import { NotificationCountType, type Room, RoomEvent } from "./room.ts"; import { type RoomState } from "./room-state.ts"; import { ServerControlledNamespacedValue } from "../NamespacedValue.ts"; import { logger } from "../logger.ts"; -import { ReadReceipt } from "./read-receipt.ts"; -import { type CachedReceiptStructure, type Receipt, ReceiptType } from "../@types/read_receipts.ts"; +import { computeEventReadUpTo } from "./room-receipts.ts"; +import { TypedEventEmitter } from "./typed-event-emitter.ts"; +import { type CachedReceipt, type Receipt, ReceiptType, type WrappedReceipt } from "../@types/read_receipts.ts"; import { Feature, ServerSupport } from "../feature.ts"; export enum ThreadEvent { @@ -49,7 +50,6 @@ interface IThreadOpts { room: Room; client: MatrixClient; pendingEventOrdering?: PendingEventOrdering; - receipts?: CachedReceiptStructure[]; } export enum FeatureSupport { @@ -68,7 +68,7 @@ export function determineFeatureSupport(stable: boolean, unstable: boolean): Fea } } -export class Thread extends ReadReceipt { +export class Thread extends TypedEventEmitter { public static hasServerSideSupport = FeatureSupport.None; public static hasServerSideListSupport = FeatureSupport.None; public static hasServerSideFwdPaginationSupport = FeatureSupport.None; @@ -177,8 +177,6 @@ export class Thread extends ReadReceipt(THREAD_RELATION_TYPE.name); } @@ -771,10 +757,6 @@ export class Thread extends ReadReceipt this.findEventById(id), + (receiptType) => this.getReadReceiptForUserId(userId, ignoreSynthesized, receiptType), + ); // Check whether the unthreaded read receipt for that user is more recent // than the read receipt inside that thread. @@ -866,7 +855,7 @@ export class Thread extends ReadReceipt