diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index 9c3a93e53..fdc8101eb 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -565,7 +565,7 @@ describe("MSC3089TreeSpace", () => { rooms = {}; rooms[tree.roomId] = parentRoom; (tree).room = parentRoom; // override readonly - client.getRoom = (r) => rooms[r]; + client.getRoom = (r) => rooms[r ?? ""]; clientSendStateFn = jest.fn() .mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => { diff --git a/spec/unit/read-receipt.spec.ts b/spec/unit/read-receipt.spec.ts new file mode 100644 index 000000000..2e906a3cc --- /dev/null +++ b/spec/unit/read-receipt.spec.ts @@ -0,0 +1,150 @@ +/* +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 MockHttpBackend from 'matrix-mock-request'; + +import { ReceiptType } from '../../src/@types/read_receipts'; +import { MatrixClient } from "../../src/client"; +import { IHttpOpts } from '../../src/http-api'; +import { EventType } from '../../src/matrix'; +import { MAIN_ROOM_TIMELINE } from '../../src/models/read-receipt'; +import { encodeUri } from '../../src/utils'; +import * as utils from "../test-utils/test-utils"; + +// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of +// other async methods which break the event loop, letting scheduled promise +// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do +// it manually (this is what sinon does under the hood). We do both in a loop +// until the thing we expect happens: hopefully this is the least flakey way +// and avoids assuming anything about the app's behaviour. +const realSetTimeout = setTimeout; +function flushPromises() { + return new Promise(r => { + realSetTimeout(r, 1); + }); +} + +let client: MatrixClient; +let httpBackend: MockHttpBackend; + +const THREAD_ID = "$thread_event_id"; +const ROOM_ID = "!123:matrix.org"; + +const threadEvent = utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: "@bob:matrix.org", + room: ROOM_ID, + content: { + "body": "Hello from a thread", + "m.relates_to": { + "event_id": THREAD_ID, + "m.in_reply_to": { + "event_id": THREAD_ID, + }, + "rel_type": "m.thread", + }, + }, +}); + +const roomEvent = utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: "@bob:matrix.org", + room: ROOM_ID, + content: { + "body": "Hello from a room", + }, +}); + +function mockServerSideSupport(client, hasServerSideSupport) { + const doesServerSupportUnstableFeature = client.doesServerSupportUnstableFeature; + client.doesServerSupportUnstableFeature = (unstableFeature) => { + if (unstableFeature === "org.matrix.msc3771") { + return Promise.resolve(hasServerSideSupport); + } else { + return doesServerSupportUnstableFeature(unstableFeature); + } + }; +} + +describe("Read receipt", () => { + beforeEach(() => { + httpBackend = new MockHttpBackend(); + client = new MatrixClient({ + baseUrl: "https://my.home.server", + accessToken: "my.access.token", + request: httpBackend.requestFn as unknown as IHttpOpts["request"], + }); + client.isGuest = () => false; + }); + + describe("sendReceipt", () => { + it("sends a thread read receipt", async () => { + httpBackend.when( + "POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", { + $roomId: ROOM_ID, + $receiptType: ReceiptType.Read, + $eventId: threadEvent.getId(), + }), + ).check((request) => { + expect(request.data.thread_id).toEqual(THREAD_ID); + }).respond(200, {}); + + mockServerSideSupport(client, true); + client.sendReceipt(threadEvent, ReceiptType.Read, {}); + + await httpBackend.flushAllExpected(); + await flushPromises(); + }); + + it("sends a room read receipt", async () => { + httpBackend.when( + "POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", { + $roomId: ROOM_ID, + $receiptType: ReceiptType.Read, + $eventId: roomEvent.getId(), + }), + ).check((request) => { + expect(request.data.thread_id).toEqual(MAIN_ROOM_TIMELINE); + }).respond(200, {}); + + mockServerSideSupport(client, true); + client.sendReceipt(roomEvent, ReceiptType.Read, {}); + + await httpBackend.flushAllExpected(); + await flushPromises(); + }); + + it("sends a room read receipt when there's no server support", async () => { + httpBackend.when( + "POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", { + $roomId: ROOM_ID, + $receiptType: ReceiptType.Read, + $eventId: threadEvent.getId(), + }), + ).check((request) => { + expect(request.data.thread_id).toBeUndefined(); + }).respond(200, {}); + + mockServerSideSupport(client, false); + client.sendReceipt(threadEvent, ReceiptType.Read, {}); + + await httpBackend.flushAllExpected(); + await flushPromises(); + }); + }); +}); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index f94eca4a9..e79fb7110 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -32,13 +32,14 @@ import { RoomEvent, } from "../../src"; import { EventTimeline } from "../../src/models/event-timeline"; -import { IWrappedReceipt, Room } from "../../src/models/room"; +import { Room } from "../../src/models/room"; import { RoomState } from "../../src/models/room-state"; import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { TestClient } from "../TestClient"; import { emitPromise } from "../test-utils/test-utils"; import { ReceiptType } from "../../src/@types/read_receipts"; import { Thread, ThreadEvent } from "../../src/models/thread"; +import { WrappedReceipt } from "../../src/models/read-receipt"; describe("Room", function() { const roomId = "!foo:bar"; @@ -1430,6 +1431,19 @@ describe("Room", function() { expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]); }); }); + + describe("hasUserReadUpTo", function() { + it("should acknowledge if an event has been read", function() { + const ts = 13787898424; + room.addReceipt(mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts), + ])); + expect(room.hasUserReadEvent(userB, eventToAck.getId())).toEqual(true); + }); + it("return false for an unknown event", function() { + expect(room.hasUserReadEvent(userB, "unknown_event")).toEqual(false); + }); + }); }); describe("tags", function() { @@ -2439,8 +2453,8 @@ describe("Room", function() { const room = new Room(roomId, client, userA); it("handles missing receipt type", () => { - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { - return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as IWrappedReceipt : null; + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { + return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as WrappedReceipt : null; }; expect(room.getEventReadUpTo(userA)).toEqual("eventId"); @@ -2448,12 +2462,12 @@ describe("Room", function() { describe("prefers newer receipt", () => { it("should compare correctly using timelines", () => { - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.ReadPrivate) { - return { eventId: "eventId1" } as IWrappedReceipt; + return { eventId: "eventId1" } as WrappedReceipt; } if (receiptType === ReceiptType.Read) { - return { eventId: "eventId2" } as IWrappedReceipt; + return { eventId: "eventId2" } as WrappedReceipt; } return null; }; @@ -2473,12 +2487,12 @@ describe("Room", function() { room.getUnfilteredTimelineSet = () => ({ compareEventOrdering: (_1, _2) => null, } as EventTimelineSet); - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.ReadPrivate) { - return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as IWrappedReceipt; + return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as WrappedReceipt; } if (receiptType === ReceiptType.Read) { - return { eventId: "eventId2", data: { ts: i === 2 ? 2 : 1 } } as IWrappedReceipt; + return { eventId: "eventId2", data: { ts: i === 2 ? 2 : 1 } } as WrappedReceipt; } return null; }; @@ -2491,9 +2505,9 @@ describe("Room", function() { room.getUnfilteredTimelineSet = () => ({ compareEventOrdering: (_1, _2) => null, } as EventTimelineSet); - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.Read) { - return { eventId: "eventId2", data: { ts: 1 } } as IWrappedReceipt; + return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt; } return null; }; @@ -2510,12 +2524,12 @@ describe("Room", function() { }); it("should give precedence to m.read.private", () => { - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.ReadPrivate) { - return { eventId: "eventId1" } as IWrappedReceipt; + return { eventId: "eventId1" } as WrappedReceipt; } if (receiptType === ReceiptType.Read) { - return { eventId: "eventId2" } as IWrappedReceipt; + return { eventId: "eventId2" } as WrappedReceipt; } return null; }; @@ -2524,9 +2538,9 @@ describe("Room", function() { }); it("should give precedence to m.read", () => { - room.getReadReceiptForUserId = (userId, ignore, receiptType) => { + room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => { if (receiptType === ReceiptType.Read) { - return { eventId: "eventId3" } as IWrappedReceipt; + return { eventId: "eventId3" } as WrappedReceipt; } return null; }; diff --git a/src/client.ts b/src/client.ts index cf7c45082..5764016e2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -198,6 +198,7 @@ import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; import { UnstableValue } from "./NamespacedValue"; import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue"; import { ToDeviceBatch } from "./models/ToDeviceMessage"; +import { MAIN_ROOM_TIMELINE } from "./models/read-receipt"; import { IgnoredInvites } from "./models/invites-ignorer"; export type Store = IStore; @@ -3332,7 +3333,10 @@ export class MatrixClient extends TypedEventEmitter { + public async sendReceipt( + event: MatrixEvent, + receiptType: ReceiptType, + body: any, + callback?: Callback, + ): Promise<{}> { if (typeof (body) === 'function') { callback = body as any as Callback; // legacy body = {}; @@ -4605,10 +4614,19 @@ export class MatrixClient extends TypedEventEmitter { + public async sendReadReceipt( + event: MatrixEvent | null, + receiptType = ReceiptType.Read, + callback?: Callback, + ): Promise<{} | undefined> { + if (!event) return; const eventId = event.getId(); const room = this.getRoom(event.getRoomId()); if (room && room.hasPendingEvent(eventId)) { @@ -7346,7 +7369,7 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId }); const prefix = PREFIX_V3; - return this.http.authedRequest(undefined, Method.Get, path, null, null, { prefix }); + return this.http.authedRequest(undefined, Method.Get, path, undefined, undefined, { prefix }); } /** @@ -7888,7 +7911,7 @@ export class MatrixClient extends TypedEventEmitter { const path = "/account/3pid/add"; const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE; - return this.http.authedRequest(undefined, Method.Post, path, null, data, { prefix }); + return this.http.authedRequest(undefined, Method.Post, path, undefined, data, { prefix }); } /** @@ -7929,7 +7952,7 @@ export class MatrixClient extends TypedEventEmitter { const path = "/account/3pid/delete"; - return this.http.authedRequest(undefined, Method.Post, path, null, { medium, address }); + return this.http.authedRequest(undefined, Method.Post, path, undefined, { medium, address }); } /** @@ -8019,7 +8042,7 @@ export class MatrixClient extends TypedEventEmitter( - callback, Method.Post, path, null, data, + callback, Method.Post, path, undefined, data, ); } @@ -8124,7 +8147,7 @@ export class MatrixClient extends TypedEventEmitter { const path = "/pushers/set"; - return this.http.authedRequest(callback, Method.Post, path, null, pusher); + return this.http.authedRequest(callback, Method.Post, path, undefined, pusher); } /** @@ -8911,7 +8934,7 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias }); - return this.http.authedRequest(undefined, Method.Get, path, { via }, null, { + return this.http.authedRequest(undefined, Method.Get, path, { via }, undefined, { qsStringifyOptions: { arrayFormat: 'repeat' }, prefix: "/_matrix/client/unstable/im.nheko.summary", }); diff --git a/src/http-api.ts b/src/http-api.ts index 3e601c795..1a7376b00 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -365,7 +365,7 @@ export class MatrixHttpApi { // we're setting opts.json=false so that it doesn't JSON-encode the // request, which also means it doesn't JSON-decode the response. Either // way, we have to JSON-parse the response ourselves. - let bodyParser = null; + let bodyParser: ((body: string) => any) | undefined; if (!rawResponse) { bodyParser = function(rawBody: string) { let body = JSON.parse(rawBody); @@ -472,7 +472,7 @@ export class MatrixHttpApi { headers["Content-Length"] = "0"; } - promise = this.authedRequest( + promise = this.authedRequest>( opts.callback, Method.Post, "/upload", queryParams, body, { prefix: "/_matrix/media/r0", headers, @@ -590,10 +590,10 @@ export class MatrixHttpApi { * occurred. This includes network problems and Matrix-specific error JSON. */ public authedRequest = IRequestOpts>( - callback: Callback, + callback: Callback | undefined, method: Method, path: string, - queryParams?: Record, + queryParams?: Record, data?: CoreOptions["body"], opts?: O | number, // number is legacy ): IAbortablePromise> { @@ -667,7 +667,7 @@ export class MatrixHttpApi { * occurred. This includes network problems and Matrix-specific error JSON. */ public request = IRequestOpts>( - callback: Callback, + callback: Callback | undefined, method: Method, path: string, queryParams?: CoreOptions["qs"], @@ -711,7 +711,7 @@ export class MatrixHttpApi { * occurred. This includes network problems and Matrix-specific error JSON. */ public requestOtherUrl = IRequestOpts>( - callback: Callback, + callback: Callback | undefined, method: Method, uri: string, queryParams?: CoreOptions["qs"], @@ -778,7 +778,7 @@ export class MatrixHttpApi { * Generic O should be inferred */ private doRequest = IRequestOpts>( - callback: Callback, + callback: Callback | undefined, method: Method, uri: string, queryParams?: Record, diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index a951a399a..e378f0a30 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -287,7 +287,8 @@ export class EventTimelineSet extends TypedEventEmitter (both nullable) + }; +}; + +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. + private receipts: Receipts = {}; // { receipt_type: { user_id: Receipt } } + private receiptCacheByEventId: ReceiptCache = {}; // { event_id: CachedReceipt[] } + + public abstract getUnfilteredTimelineSet(): EventTimelineSet; + public abstract 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[receiptType]?.[userId] ?? []; + if (ignoreSynthesized) { + return realReceipt; + } + + return syntheticReceipt ?? realReceipt; + } + + /** + * Get the ID of the event that a given user has read up to, or null if we + * have received no read receipts from them. + * @param {String} userId The user ID to get read receipt event ID for + * @param {Boolean} ignoreSynthesized If true, return only receipts that have been + * sent by the server, not implicit ones generated + * by the JS SDK. + * @return {String} ID of the latest event that the given user has read, or null. + */ + public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | 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 timelineSet = this.getUnfilteredTimelineSet(); + 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 = timelineSet.compareEventOrdering(publicReadReceipt?.eventId, privateReadReceipt?.eventId); + } + + // If we didn't get a comparison try to compare the ts of the receipts + if (!comparison && publicReadReceipt?.data?.ts && privateReadReceipt?.data?.ts) { + comparison = publicReadReceipt?.data?.ts - privateReadReceipt?.data?.ts; + } + + // The public receipt is more likely to drift out of date so the private + // one has precedence + if (!comparison) return privateReadReceipt?.eventId ?? publicReadReceipt?.eventId ?? null; + + // If public read receipt is older, return the private one + return ((comparison < 0) ? privateReadReceipt?.eventId : publicReadReceipt?.eventId) ?? null; + } + + public addReceiptToStructure( + eventId: string, + receiptType: ReceiptType, + userId: string, + receipt: Receipt, + synthetic: boolean, + ): void { + if (!this.receipts[receiptType]) { + this.receipts[receiptType] = {}; + } + if (!this.receipts[receiptType][userId]) { + this.receipts[receiptType][userId] = [null, null]; + } + + const pair = this.receipts[receiptType][userId]; + + let existingReceipt = pair[ReceiptPairRealIndex]; + if (synthetic) { + existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; + } + + 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.getUnfilteredTimelineSet().compareEventOrdering( + existingReceipt.eventId, + eventId, + ); + if (ordering !== null && ordering >= 0) { + return; + } + } + + const wrappedReceipt: WrappedReceipt = { + eventId, + data: receipt, + }; + + 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[cachedReceipt.eventId]) { + const previousEventId = cachedReceipt.eventId; + // Remove the receipt we're about to clobber out of existence from the cache + this.receiptCacheByEventId[previousEventId] = ( + this.receiptCacheByEventId[previousEventId].filter(r => { + return r.type !== receiptType || r.userId !== userId; + }) + ); + + if (this.receiptCacheByEventId[previousEventId].length < 1) { + delete this.receiptCacheByEventId[previousEventId]; // clean up the cache keys + } + } + + // cache the new one + if (!this.receiptCacheByEventId[eventId]) { + this.receiptCacheByEventId[eventId] = []; + } + this.receiptCacheByEventId[eventId].push({ + userId: userId, + type: receiptType as ReceiptType, + data: receipt, + }); + } + + /** + * Get a list of receipts for the given event. + * @param {MatrixEvent} event the event to get receipts for + * @return {Object[]} A list of receipts with a userId, type and data keys or + * an empty list. + */ + public getReceiptsForEvent(event: MatrixEvent): CachedReceipt[] { + return this.receiptCacheByEventId[event.getId()] || []; + } + + public abstract addReceipt(event: MatrixEvent, synthetic: boolean): void; + + /** + * Add a temporary local-echo receipt to the room to reflect in the + * client the fact that we've sent one. + * @param {string} userId The user ID if the receipt sender + * @param {MatrixEvent} e The event that is to be acknowledged + * @param {ReceiptType} receiptType The type of receipt + */ + public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void { + this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); + } + + /** + * Get a list of user IDs who have read up to the given event. + * @param {MatrixEvent} event the event to get read receipts for. + * @return {String[]} A list of user IDs. + */ + public getUsersReadUpTo(event: MatrixEvent): string[] { + return this.getReceiptsForEvent(event).filter(function(receipt) { + return utils.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 {String} userId The user ID to check the read state of. + * @param {String} eventId The event ID to check if the user read. + * @returns {Boolean} True if the user has read the event, false otherwise. + */ + public hasUserReadEvent(userId: string, eventId: string): boolean { + const readUpToId = this.getEventReadUpTo(userId, false); + if (readUpToId === eventId) return true; + + if (this.timeline.length + && this.timeline[this.timeline.length - 1].getSender() + && this.timeline[this.timeline.length - 1].getSender() === userId) { + // It doesn't matter where the event is in the timeline, the user has read + // it because they've sent the latest event. + return true; + } + + for (let i = this.timeline.length - 1; i >= 0; --i) { + const ev = this.timeline[i]; + + // If we encounter the target event first, the user hasn't read it + // however if we encounter the readUpToId first then the user has read + // it. These rules apply because we're iterating bottom-up. + if (ev.getId() === eventId) return false; + if (ev.getId() === readUpToId) return true; + } + + // We don't know if the user has read it, so assume not. + return false; + } +} diff --git a/src/models/room.ts b/src/models/room.ts index fbd47e6e5..75267fa2a 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -47,10 +47,16 @@ import { FILTER_RELATED_BY_SENDERS, ThreadFilterType, } from "./thread"; -import { TypedEventEmitter } from "./typed-event-emitter"; import { ReceiptType } from "../@types/read_receipts"; import { IStateEventWithRoomId } from "../@types/search"; import { RelationsContainer } from "./relations-container"; +import { + MAIN_ROOM_TIMELINE, + ReadReceipt, + Receipt, + ReceiptContent, + synthesizeReceipt, +} from "./read-receipt"; // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be @@ -61,23 +67,6 @@ import { RelationsContainer } from "./relations-container"; export const KNOWN_SAFE_ROOM_VERSION = '9'; const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; -function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent { - // console.log("synthesizing receipt for "+event.getId()); - return new MatrixEvent({ - content: { - [event.getId()]: { - [receiptType]: { - [userId]: { - ts: event.getTs(), - }, - }, - }, - }, - type: EventType.Receipt, - room_id: event.getRoomId(), - }); -} - interface IOpts { storageToken?: string; pendingEventOrdering?: PendingEventOrdering; @@ -91,40 +80,6 @@ export interface IRecommendedVersion { urgent: boolean; } -interface IReceipt { - ts: number; -} - -export interface IWrappedReceipt { - eventId: string; - data: IReceipt; -} - -interface ICachedReceipt { - type: ReceiptType; - userId: string; - data: IReceipt; -} - -type ReceiptCache = {[eventId: string]: ICachedReceipt[]}; - -interface IReceiptContent { - [eventId: string]: { - [key in ReceiptType]: { - [userId: string]: IReceipt; - }; - }; -} - -const ReceiptPairRealIndex = 0; -const ReceiptPairSyntheticIndex = 1; -// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer. -type Receipts = { - [receiptType: string]: { - [userId: string]: [IWrappedReceipt, IWrappedReceipt]; // Pair (both nullable) - }; -}; - // When inserting a visibility event affecting event `eventId`, we // need to scan through existing visibility events for `eventId`. // In theory, this could take an unlimited amount of time if: @@ -225,15 +180,9 @@ export type RoomEventHandlerMap = { BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange >; -export class Room extends TypedEventEmitter { +export class Room extends ReadReceipt { public readonly reEmitter: TypedReEmitter; private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } - // 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. - private receipts: Receipts = {}; // { receipt_type: { user_id: IReceipt } } - private receiptCacheByEventId: ReceiptCache = {}; // { event_id: ICachedReceipt[] } private notificationCounts: Partial> = {}; private readonly timelineSets: EventTimelineSet[]; public readonly threadsTimelineSets: EventTimelineSet[] = []; @@ -2400,10 +2349,10 @@ export class Room extends TypedEventEmitter threadId, } = this.eventShouldLiveIn(event, events, threadRoots); - if (shouldLiveInThread && !eventsByThread[threadId]) { - eventsByThread[threadId] = []; + if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) { + eventsByThread[threadId ?? ""] = []; } - eventsByThread[threadId]?.push(event); + eventsByThread[threadId ?? ""]?.push(event); if (shouldLiveInRoom) { this.addLiveEvent(event, options); @@ -2436,17 +2385,17 @@ export class Room extends TypedEventEmitter } if (shouldLiveInThread) { - event.setThreadId(threadId); + event.setThreadId(threadId ?? ""); memo[THREAD].push(event); } return memo; - }, [[], []]); + }, [[] as MatrixEvent[], [] as MatrixEvent[]]); } else { // When `experimentalThreadSupport` is disabled treat all events as timelineEvents return [ - events, - [], + events as MatrixEvent[], + [] as MatrixEvent[], ]; } } @@ -2458,12 +2407,43 @@ export class Room extends TypedEventEmitter const threadRoots = new Set(); for (const event of events) { if (event.isRelation(THREAD_RELATION_TYPE.name)) { - threadRoots.add(event.relationEventId); + threadRoots.add(event.relationEventId ?? ""); } } return threadRoots; } + /** + * Add a receipt event to the room. + * @param {MatrixEvent} event The m.receipt event. + * @param {Boolean} synthetic True if this event is implicit. + */ + public addReceipt(event: MatrixEvent, synthetic = false): void { + const content = event.getContent(); + 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 ?? ""); + receiptDestination?.addReceiptToStructure( + eventId, + receiptType as ReceiptType, + userId, + receipt, + synthetic, + ); + }); + }); + }); + + // 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); + } + /** * Adds/handles ephemeral events such as typing notifications and read receipts. * @param {MatrixEvent[]} events A list of events to process @@ -2554,243 +2534,6 @@ export class Room extends TypedEventEmitter } } - /** - * Get a list of user IDs who have read up to the given event. - * @param {MatrixEvent} event the event to get read receipts for. - * @return {String[]} A list of user IDs. - */ - public getUsersReadUpTo(event: MatrixEvent): string[] { - return this.getReceiptsForEvent(event).filter(function(receipt) { - return utils.isSupportedReceiptType(receipt.type); - }).map(function(receipt) { - return receipt.userId; - }); - } - - /** - * 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, - ): IWrappedReceipt | null { - const [realReceipt, syntheticReceipt] = this.receipts[receiptType]?.[userId] ?? []; - if (ignoreSynthesized) { - return realReceipt; - } - - return syntheticReceipt ?? realReceipt; - } - - /** - * Get the ID of the event that a given user has read up to, or null if we - * have received no read receipts from them. - * @param {String} userId The user ID to get read receipt event ID for - * @param {Boolean} ignoreSynthesized If true, return only receipts that have been - * sent by the server, not implicit ones generated - * by the JS SDK. - * @return {String} ID of the latest event that the given user has read, or null. - */ - public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | 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 timelineSet = this.getUnfilteredTimelineSet(); - 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 = timelineSet.compareEventOrdering(publicReadReceipt?.eventId, privateReadReceipt?.eventId); - } - - // If we didn't get a comparison try to compare the ts of the receipts - if (!comparison && publicReadReceipt?.data?.ts && privateReadReceipt?.data?.ts) { - comparison = publicReadReceipt?.data?.ts - privateReadReceipt?.data?.ts; - } - - // The public receipt is more likely to drift out of date so the private - // one has precedence - if (!comparison) return privateReadReceipt?.eventId ?? publicReadReceipt?.eventId ?? null; - - // If public read receipt is older, return the private one - return ((comparison < 0) ? privateReadReceipt?.eventId : publicReadReceipt?.eventId) ?? null; - } - - /** - * 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 {String} userId The user ID to check the read state of. - * @param {String} eventId The event ID to check if the user read. - * @returns {Boolean} True if the user has read the event, false otherwise. - */ - public hasUserReadEvent(userId: string, eventId: string): boolean { - const readUpToId = this.getEventReadUpTo(userId, false); - if (readUpToId === eventId) return true; - - if (this.timeline.length - && this.timeline[this.timeline.length - 1].getSender() - && this.timeline[this.timeline.length - 1].getSender() === userId) { - // It doesn't matter where the event is in the timeline, the user has read - // it because they've sent the latest event. - return true; - } - - for (let i = this.timeline.length - 1; i >= 0; --i) { - const ev = this.timeline[i]; - - // If we encounter the target event first, the user hasn't read it - // however if we encounter the readUpToId first then the user has read - // it. These rules apply because we're iterating bottom-up. - if (ev.getId() === eventId) return false; - if (ev.getId() === readUpToId) return true; - } - - // We don't know if the user has read it, so assume not. - return false; - } - - /** - * Get a list of receipts for the given event. - * @param {MatrixEvent} event the event to get receipts for - * @return {Object[]} A list of receipts with a userId, type and data keys or - * an empty list. - */ - public getReceiptsForEvent(event: MatrixEvent): ICachedReceipt[] { - return this.receiptCacheByEventId[event.getId()] || []; - } - - /** - * Add a receipt event to the room. - * @param {MatrixEvent} event The m.receipt event. - * @param {Boolean} synthetic True if this event is implicit. - */ - public addReceipt(event: MatrixEvent, synthetic = false): void { - this.addReceiptsToStructure(event, synthetic); - // 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); - } - - /** - * Add a receipt event to the room. - * @param {MatrixEvent} event The m.receipt event. - * @param {Boolean} synthetic True if this event is implicit. - */ - private addReceiptsToStructure(event: MatrixEvent, synthetic: boolean): void { - const content = event.getContent(); - Object.keys(content).forEach((eventId) => { - Object.keys(content[eventId]).forEach((receiptType) => { - Object.keys(content[eventId][receiptType]).forEach((userId) => { - const receipt = content[eventId][receiptType][userId]; - - if (!this.receipts[receiptType]) { - this.receipts[receiptType] = {}; - } - if (!this.receipts[receiptType][userId]) { - this.receipts[receiptType][userId] = [null, null]; - } - - const pair = this.receipts[receiptType][userId]; - - let existingReceipt = pair[ReceiptPairRealIndex]; - if (synthetic) { - existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex]; - } - - 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.getUnfilteredTimelineSet().compareEventOrdering( - existingReceipt.eventId, - eventId, - ); - if (ordering !== null && ordering >= 0) { - return; - } - } - - const wrappedReceipt: IWrappedReceipt = { - eventId, - data: receipt, - }; - - 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[cachedReceipt.eventId]) { - const previousEventId = cachedReceipt.eventId; - // Remove the receipt we're about to clobber out of existence from the cache - this.receiptCacheByEventId[previousEventId] = ( - this.receiptCacheByEventId[previousEventId].filter(r => { - return r.type !== receiptType || r.userId !== userId; - }) - ); - - if (this.receiptCacheByEventId[previousEventId].length < 1) { - delete this.receiptCacheByEventId[previousEventId]; // clean up the cache keys - } - } - - // cache the new one - if (!this.receiptCacheByEventId[eventId]) { - this.receiptCacheByEventId[eventId] = []; - } - this.receiptCacheByEventId[eventId].push({ - userId: userId, - type: receiptType as ReceiptType, - data: receipt, - }); - }); - }); - }); - } - - /** - * Add a temporary local-echo receipt to the room to reflect in the - * client the fact that we've sent one. - * @param {string} userId The user ID if the receipt sender - * @param {MatrixEvent} e The event that is to be acknowledged - * @param {ReceiptType} receiptType The type of receipt - */ - public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void { - this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); - } - /** * Update the room-tag event for the room. The previous one is overwritten. * @param {MatrixEvent} event the m.tag event diff --git a/src/models/thread.ts b/src/models/thread.ts index a8e9ae4ec..3c7add2f4 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -23,10 +23,10 @@ import { IThreadBundledRelationship, MatrixEvent } from "./event"; import { Direction, EventTimeline } from "./event-timeline"; import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set'; import { Room } from './room'; -import { TypedEventEmitter } from "./typed-event-emitter"; import { RoomState } from "./room-state"; import { ServerControlledNamespacedValue } from "../NamespacedValue"; import { logger } from "../logger"; +import { ReadReceipt } from "./read-receipt"; export enum ThreadEvent { New = "Thread.new", @@ -54,7 +54,7 @@ interface IThreadOpts { /** * @experimental */ -export class Thread extends TypedEventEmitter { +export class Thread extends ReadReceipt { public static hasServerSideSupport: boolean; /** @@ -429,6 +429,18 @@ export class Thread extends TypedEventEmitter { nextBatch, }; } + + public getUnfilteredTimelineSet(): EventTimelineSet { + return this.timelineSet; + } + + public get timeline(): MatrixEvent[] { + return this.events; + } + + public addReceipt(event: MatrixEvent, synthetic: boolean): void { + throw new Error("Unsupported function on the thread model"); + } } export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue( diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 08686c32d..037c9231b 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -66,7 +66,7 @@ interface IState { export interface ITimeline { events: Array; limited?: boolean; - prev_batch: string; + prev_batch: string | null; } export interface IJoinedRoom { @@ -401,7 +401,7 @@ export class SyncAccumulator { // typing forever until someone really does start typing (which // will prompt Synapse to send down an actual m.typing event to // clobber the one we persisted). - if (e.type !== "m.receipt" || !e.content) { + if (e.type !== EventType.Receipt || !e.content) { // This means we'll drop unknown ephemeral events but that // seems okay. return; @@ -528,7 +528,7 @@ export class SyncAccumulator { }); Object.keys(this.joinRooms).forEach((roomId) => { const roomData = this.joinRooms[roomId]; - const roomJson = { + const roomJson: IJoinedRoom = { ephemeral: { events: [] }, account_data: { events: [] }, state: { events: [] }, @@ -541,12 +541,12 @@ export class SyncAccumulator { }; // Add account data Object.keys(roomData._accountData).forEach((evType) => { - roomJson.account_data.events.push(roomData._accountData[evType]); + roomJson.account_data.events.push(roomData._accountData[evType] as IMinimalEvent); }); // Add receipt data const receiptEvent = { - type: "m.receipt", + type: EventType.Receipt, room_id: roomId, content: { // $event_id: { "m.read": { $user_id: $json } } @@ -566,7 +566,7 @@ export class SyncAccumulator { }); // add only if we have some receipt data if (Object.keys(receiptEvent.content).length > 0) { - roomJson.ephemeral.events.push(receiptEvent); + roomJson.ephemeral.events.push(receiptEvent as IMinimalEvent); } // Add timeline data @@ -609,8 +609,8 @@ export class SyncAccumulator { const rollBackState = Object.create(null); for (let i = roomJson.timeline.events.length - 1; i >=0; i--) { const timelineEvent = roomJson.timeline.events[i]; - if (timelineEvent.state_key === null || - timelineEvent.state_key === undefined) { + if ((timelineEvent as IStateEvent).state_key === null || + (timelineEvent as IStateEvent).state_key === undefined) { continue; // not a state event } // since we're going back in time, we need to use the previous diff --git a/src/sync.ts b/src/sync.ts index 396b5ba45..dd2956da3 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1235,7 +1235,9 @@ export class SyncApi { if (joinObj.isBrandNewRoom) { // set the back-pagination token. Do this *before* adding any // events so that clients can start back-paginating. - room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS); + if (joinObj.timeline.prev_batch !== null) { + room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS); + } } else if (joinObj.timeline.limited) { let limited = true;