1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-31 15:24:23 +03:00

Read receipts for threads (#2635)

This commit is contained in:
Germain
2022-09-21 08:50:44 +01:00
committed by GitHub
parent 2e10b6065c
commit 2967ee6309
11 changed files with 606 additions and 355 deletions

View File

@ -565,7 +565,7 @@ describe("MSC3089TreeSpace", () => {
rooms = {}; rooms = {};
rooms[tree.roomId] = parentRoom; rooms[tree.roomId] = parentRoom;
(<any>tree).room = parentRoom; // override readonly (<any>tree).room = parentRoom; // override readonly
client.getRoom = (r) => rooms[r]; client.getRoom = (r) => rooms[r ?? ""];
clientSendStateFn = jest.fn() clientSendStateFn = jest.fn()
.mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => { .mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => {

View File

@ -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();
});
});
});

View File

@ -32,13 +32,14 @@ import {
RoomEvent, RoomEvent,
} from "../../src"; } from "../../src";
import { EventTimeline } from "../../src/models/event-timeline"; 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 { RoomState } from "../../src/models/room-state";
import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
import { emitPromise } from "../test-utils/test-utils"; import { emitPromise } from "../test-utils/test-utils";
import { ReceiptType } from "../../src/@types/read_receipts"; import { ReceiptType } from "../../src/@types/read_receipts";
import { Thread, ThreadEvent } from "../../src/models/thread"; import { Thread, ThreadEvent } from "../../src/models/thread";
import { WrappedReceipt } from "../../src/models/read-receipt";
describe("Room", function() { describe("Room", function() {
const roomId = "!foo:bar"; const roomId = "!foo:bar";
@ -1430,6 +1431,19 @@ describe("Room", function() {
expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]); 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() { describe("tags", function() {
@ -2439,8 +2453,8 @@ describe("Room", function() {
const room = new Room(roomId, client, userA); const room = new Room(roomId, client, userA);
it("handles missing receipt type", () => { it("handles missing receipt type", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => { room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as IWrappedReceipt : null; return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as WrappedReceipt : null;
}; };
expect(room.getEventReadUpTo(userA)).toEqual("eventId"); expect(room.getEventReadUpTo(userA)).toEqual("eventId");
@ -2448,12 +2462,12 @@ describe("Room", function() {
describe("prefers newer receipt", () => { describe("prefers newer receipt", () => {
it("should compare correctly using timelines", () => { it("should compare correctly using timelines", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => { room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) { if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1" } as IWrappedReceipt; return { eventId: "eventId1" } as WrappedReceipt;
} }
if (receiptType === ReceiptType.Read) { if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2" } as IWrappedReceipt; return { eventId: "eventId2" } as WrappedReceipt;
} }
return null; return null;
}; };
@ -2473,12 +2487,12 @@ describe("Room", function() {
room.getUnfilteredTimelineSet = () => ({ room.getUnfilteredTimelineSet = () => ({
compareEventOrdering: (_1, _2) => null, compareEventOrdering: (_1, _2) => null,
} as EventTimelineSet); } as EventTimelineSet);
room.getReadReceiptForUserId = (userId, ignore, receiptType) => { room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) { 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) { 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; return null;
}; };
@ -2491,9 +2505,9 @@ describe("Room", function() {
room.getUnfilteredTimelineSet = () => ({ room.getUnfilteredTimelineSet = () => ({
compareEventOrdering: (_1, _2) => null, compareEventOrdering: (_1, _2) => null,
} as EventTimelineSet); } as EventTimelineSet);
room.getReadReceiptForUserId = (userId, ignore, receiptType) => { room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.Read) { if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2", data: { ts: 1 } } as IWrappedReceipt; return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt;
} }
return null; return null;
}; };
@ -2510,12 +2524,12 @@ describe("Room", function() {
}); });
it("should give precedence to m.read.private", () => { it("should give precedence to m.read.private", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => { room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) { if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1" } as IWrappedReceipt; return { eventId: "eventId1" } as WrappedReceipt;
} }
if (receiptType === ReceiptType.Read) { if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2" } as IWrappedReceipt; return { eventId: "eventId2" } as WrappedReceipt;
} }
return null; return null;
}; };
@ -2524,9 +2538,9 @@ describe("Room", function() {
}); });
it("should give precedence to m.read", () => { it("should give precedence to m.read", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => { room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.Read) { if (receiptType === ReceiptType.Read) {
return { eventId: "eventId3" } as IWrappedReceipt; return { eventId: "eventId3" } as WrappedReceipt;
} }
return null; return null;
}; };

View File

@ -198,6 +198,7 @@ import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
import { UnstableValue } from "./NamespacedValue"; import { UnstableValue } from "./NamespacedValue";
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue"; import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
import { ToDeviceBatch } from "./models/ToDeviceMessage"; import { ToDeviceBatch } from "./models/ToDeviceMessage";
import { MAIN_ROOM_TIMELINE } from "./models/read-receipt";
import { IgnoredInvites } from "./models/invites-ignorer"; import { IgnoredInvites } from "./models/invites-ignorer";
export type Store = IStore; export type Store = IStore;
@ -3332,7 +3333,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} roomId The room ID * @param {string} roomId The room ID
* @return {Room|null} The Room or null if it doesn't exist or there is no data store. * @return {Room|null} The Room or null if it doesn't exist or there is no data store.
*/ */
public getRoom(roomId: string): Room | null { public getRoom(roomId: string | undefined): Room | null {
if (!roomId) {
return null;
}
return this.store.getRoom(roomId); return this.store.getRoom(roomId);
} }
@ -4590,7 +4594,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: to an empty object {} * @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public sendReceipt(event: MatrixEvent, receiptType: ReceiptType, body: any, callback?: Callback): Promise<{}> { public async sendReceipt(
event: MatrixEvent,
receiptType: ReceiptType,
body: any,
callback?: Callback,
): Promise<{}> {
if (typeof (body) === 'function') { if (typeof (body) === 'function') {
callback = body as any as Callback; // legacy callback = body as any as Callback; // legacy
body = {}; body = {};
@ -4605,10 +4614,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$receiptType: receiptType, $receiptType: receiptType,
$eventId: event.getId(), $eventId: event.getId(),
}); });
// TODO: Add a check for which spec version this will be released in
if (await this.doesServerSupportUnstableFeature("org.matrix.msc3771")) {
const isThread = !!event.threadRootId;
body.thread_id = isThread
? event.threadRootId
: MAIN_ROOM_TIMELINE;
}
const promise = this.http.authedRequest(callback, Method.Post, path, undefined, body || {}); const promise = this.http.authedRequest(callback, Method.Post, path, undefined, body || {});
const room = this.getRoom(event.getRoomId()); const room = this.getRoom(event.getRoomId());
if (room) { if (room && this.credentials.userId) {
room.addLocalEchoReceipt(this.credentials.userId, event, receiptType); room.addLocalEchoReceipt(this.credentials.userId, event, receiptType);
} }
return promise; return promise;
@ -4622,7 +4640,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: to an empty object {} * @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public async sendReadReceipt(event: MatrixEvent, receiptType = ReceiptType.Read, callback?: Callback): Promise<{}> { public async sendReadReceipt(
event: MatrixEvent | null,
receiptType = ReceiptType.Read,
callback?: Callback,
): Promise<{} | undefined> {
if (!event) return;
const eventId = event.getId(); const eventId = event.getId();
const room = this.getRoom(event.getRoomId()); const room = this.getRoom(event.getRoomId());
if (room && room.hasPendingEvent(eventId)) { if (room && room.hasPendingEvent(eventId)) {
@ -7346,7 +7369,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$eventType: eventType, $eventType: eventType,
}); });
return this.http.authedRequest( return this.http.authedRequest(
undefined, Method.Get, path, null, null, { undefined, Method.Get, path, undefined, undefined, {
prefix: PREFIX_UNSTABLE, prefix: PREFIX_UNSTABLE,
}, },
); );
@ -7651,7 +7674,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public getLocalAliases(roomId: string): Promise<{ aliases: string[] }> { public getLocalAliases(roomId: string): Promise<{ aliases: string[] }> {
const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId }); const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId });
const prefix = PREFIX_V3; 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<EmittedEvents, ClientEventHa
'bind': bind, 'bind': bind,
}; };
return this.http.authedRequest( return this.http.authedRequest(
callback, Method.Post, path, null, data, callback, Method.Post, path, undefined, data,
); );
} }
@ -7907,7 +7930,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> { public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> {
const path = "/account/3pid/add"; const path = "/account/3pid/add";
const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE; 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<EmittedEvents, ClientEventHa
const prefix = await this.isVersionSupported("r0.6.0") ? const prefix = await this.isVersionSupported("r0.6.0") ?
PREFIX_R0 : PREFIX_UNSTABLE; PREFIX_R0 : PREFIX_UNSTABLE;
return this.http.authedRequest( return this.http.authedRequest(
undefined, Method.Post, path, null, data, { prefix }, undefined, Method.Post, path, undefined, data, { prefix },
); );
} }
@ -7956,7 +7979,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
id_server: this.getIdentityServerUrl(true), id_server: this.getIdentityServerUrl(true),
}; };
const prefix = await this.isVersionSupported("r0.6.0") ? PREFIX_R0 : PREFIX_UNSTABLE; 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 });
} }
/** /**
@ -7973,7 +7996,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
): Promise<{ id_server_unbind_result: IdServerUnbindResult }> { ): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
const path = "/account/3pid/delete"; 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<EmittedEvents, ClientEventHa
}; };
return this.http.authedRequest<{}>( return this.http.authedRequest<{}>(
callback, Method.Post, path, null, data, callback, Method.Post, path, undefined, data,
); );
} }
@ -8124,7 +8147,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/ */
public setPusher(pusher: IPusherRequest, callback?: Callback): Promise<{}> { public setPusher(pusher: IPusherRequest, callback?: Callback): Promise<{}> {
const path = "/pushers/set"; 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<EmittedEvents, ClientEventHa
$eventId: eventId, $eventId: eventId,
}); });
return this.http.authedRequest(undefined, Method.Post, path, null, { score, reason }); return this.http.authedRequest(undefined, Method.Post, path, undefined, { score, reason });
} }
/** /**
@ -9073,7 +9096,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/ */
public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummary> { public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummary> {
const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias }); 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' }, qsStringifyOptions: { arrayFormat: 'repeat' },
prefix: "/_matrix/client/unstable/im.nheko.summary", prefix: "/_matrix/client/unstable/im.nheko.summary",
}); });

View File

@ -365,7 +365,7 @@ export class MatrixHttpApi {
// we're setting opts.json=false so that it doesn't JSON-encode the // 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 // request, which also means it doesn't JSON-decode the response. Either
// way, we have to JSON-parse the response ourselves. // way, we have to JSON-parse the response ourselves.
let bodyParser = null; let bodyParser: ((body: string) => any) | undefined;
if (!rawResponse) { if (!rawResponse) {
bodyParser = function(rawBody: string) { bodyParser = function(rawBody: string) {
let body = JSON.parse(rawBody); let body = JSON.parse(rawBody);
@ -472,7 +472,7 @@ export class MatrixHttpApi {
headers["Content-Length"] = "0"; headers["Content-Length"] = "0";
} }
promise = this.authedRequest( promise = this.authedRequest<UploadContentResponseType<O>>(
opts.callback, Method.Post, "/upload", queryParams, body, { opts.callback, Method.Post, "/upload", queryParams, body, {
prefix: "/_matrix/media/r0", prefix: "/_matrix/media/r0",
headers, headers,
@ -590,10 +590,10 @@ export class MatrixHttpApi {
* occurred. This includes network problems and Matrix-specific error JSON. * occurred. This includes network problems and Matrix-specific error JSON.
*/ */
public authedRequest<T, O extends IRequestOpts<T> = IRequestOpts<T>>( public authedRequest<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>, callback: Callback<T> | undefined,
method: Method, method: Method,
path: string, path: string,
queryParams?: Record<string, string | string[]>, queryParams?: Record<string, string | string[] | undefined>,
data?: CoreOptions["body"], data?: CoreOptions["body"],
opts?: O | number, // number is legacy opts?: O | number, // number is legacy
): IAbortablePromise<ResponseType<T, O>> { ): IAbortablePromise<ResponseType<T, O>> {
@ -667,7 +667,7 @@ export class MatrixHttpApi {
* occurred. This includes network problems and Matrix-specific error JSON. * occurred. This includes network problems and Matrix-specific error JSON.
*/ */
public request<T, O extends IRequestOpts<T> = IRequestOpts<T>>( public request<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>, callback: Callback<T> | undefined,
method: Method, method: Method,
path: string, path: string,
queryParams?: CoreOptions["qs"], queryParams?: CoreOptions["qs"],
@ -711,7 +711,7 @@ export class MatrixHttpApi {
* occurred. This includes network problems and Matrix-specific error JSON. * occurred. This includes network problems and Matrix-specific error JSON.
*/ */
public requestOtherUrl<T, O extends IRequestOpts<T> = IRequestOpts<T>>( public requestOtherUrl<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>, callback: Callback<T> | undefined,
method: Method, method: Method,
uri: string, uri: string,
queryParams?: CoreOptions["qs"], queryParams?: CoreOptions["qs"],
@ -778,7 +778,7 @@ export class MatrixHttpApi {
* Generic O should be inferred * Generic O should be inferred
*/ */
private doRequest<T, O extends IRequestOpts<T> = IRequestOpts<T>>( private doRequest<T, O extends IRequestOpts<T> = IRequestOpts<T>>(
callback: Callback<T>, callback: Callback<T> | undefined,
method: Method, method: Method,
uri: string, uri: string,
queryParams?: Record<string, string>, queryParams?: Record<string, string>,

View File

@ -287,7 +287,8 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* @return {?module:models/event-timeline~EventTimeline} timeline containing * @return {?module:models/event-timeline~EventTimeline} timeline containing
* the given event, or null if unknown * the given event, or null if unknown
*/ */
public getTimelineForEvent(eventId: string): EventTimeline | null { public getTimelineForEvent(eventId: string | null): EventTimeline | null {
if (eventId === null) { return null; }
const res = this._eventIdToTimeline.get(eventId); const res = this._eventIdToTimeline.get(eventId);
return (res === undefined) ? null : res; return (res === undefined) ? null : res;
} }

306
src/models/read-receipt.ts Normal file
View File

@ -0,0 +1,306 @@
/*
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 { ReceiptType } from "../@types/read_receipts";
import { EventTimelineSet, EventType, MatrixEvent } from "../matrix";
import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter";
import * as utils from "../utils";
export const MAIN_ROOM_TIMELINE = "main";
export function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent {
return new MatrixEvent({
content: {
[event.getId()]: {
[receiptType]: {
[userId]: {
ts: event.getTs(),
threadId: event.threadRootId ?? MAIN_ROOM_TIMELINE,
},
},
},
},
type: EventType.Receipt,
room_id: event.getRoomId(),
});
}
export interface Receipt {
ts: number;
thread_id?: string;
}
export interface WrappedReceipt {
eventId: string;
data: Receipt;
}
interface CachedReceipt {
type: ReceiptType;
userId: string;
data: Receipt;
}
type ReceiptCache = {[eventId: string]: CachedReceipt[]};
export interface ReceiptContent {
[eventId: string]: {
[key in ReceiptType]: {
[userId: string]: Receipt;
};
};
}
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]: [WrappedReceipt | null, WrappedReceipt | null]; // Pair<real receipt, synthetic receipt> (both nullable)
};
};
export abstract class ReadReceipt<
Events extends string,
Arguments extends ListenerMap<Events>,
SuperclassArguments extends ListenerMap<any> = Arguments,
> extends TypedEventEmitter<Events, Arguments, SuperclassArguments> {
// 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 <b>read up to</b> 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;
}
}

View File

@ -47,10 +47,16 @@ import {
FILTER_RELATED_BY_SENDERS, FILTER_RELATED_BY_SENDERS,
ThreadFilterType, ThreadFilterType,
} from "./thread"; } from "./thread";
import { TypedEventEmitter } from "./typed-event-emitter";
import { ReceiptType } from "../@types/read_receipts"; import { ReceiptType } from "../@types/read_receipts";
import { IStateEventWithRoomId } from "../@types/search"; import { IStateEventWithRoomId } from "../@types/search";
import { RelationsContainer } from "./relations-container"; 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 // 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 // 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'; export const KNOWN_SAFE_ROOM_VERSION = '9';
const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6', '7', '8', '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 { interface IOpts {
storageToken?: string; storageToken?: string;
pendingEventOrdering?: PendingEventOrdering; pendingEventOrdering?: PendingEventOrdering;
@ -91,40 +80,6 @@ export interface IRecommendedVersion {
urgent: boolean; 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<real receipt, synthetic receipt> (both nullable)
};
};
// When inserting a visibility event affecting event `eventId`, we // When inserting a visibility event affecting event `eventId`, we
// need to scan through existing visibility events for `eventId`. // need to scan through existing visibility events for `eventId`.
// In theory, this could take an unlimited amount of time if: // In theory, this could take an unlimited amount of time if:
@ -225,15 +180,9 @@ export type RoomEventHandlerMap = {
BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange
>; >;
export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap> { export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
public readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>; public readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>;
private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent } private txnToEvent: Record<string, MatrixEvent> = {}; // 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<Record<NotificationCountType, number>> = {}; private notificationCounts: Partial<Record<NotificationCountType, number>> = {};
private readonly timelineSets: EventTimelineSet[]; private readonly timelineSets: EventTimelineSet[];
public readonly threadsTimelineSets: EventTimelineSet[] = []; public readonly threadsTimelineSets: EventTimelineSet[] = [];
@ -2400,10 +2349,10 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
threadId, threadId,
} = this.eventShouldLiveIn(event, events, threadRoots); } = this.eventShouldLiveIn(event, events, threadRoots);
if (shouldLiveInThread && !eventsByThread[threadId]) { if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) {
eventsByThread[threadId] = []; eventsByThread[threadId ?? ""] = [];
} }
eventsByThread[threadId]?.push(event); eventsByThread[threadId ?? ""]?.push(event);
if (shouldLiveInRoom) { if (shouldLiveInRoom) {
this.addLiveEvent(event, options); this.addLiveEvent(event, options);
@ -2436,17 +2385,17 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
} }
if (shouldLiveInThread) { if (shouldLiveInThread) {
event.setThreadId(threadId); event.setThreadId(threadId ?? "");
memo[THREAD].push(event); memo[THREAD].push(event);
} }
return memo; return memo;
}, [[], []]); }, [[] as MatrixEvent[], [] as MatrixEvent[]]);
} else { } else {
// When `experimentalThreadSupport` is disabled treat all events as timelineEvents // When `experimentalThreadSupport` is disabled treat all events as timelineEvents
return [ return [
events, events as MatrixEvent[],
[], [] as MatrixEvent[],
]; ];
} }
} }
@ -2458,12 +2407,43 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
const threadRoots = new Set<string>(); const threadRoots = new Set<string>();
for (const event of events) { for (const event of events) {
if (event.isRelation(THREAD_RELATION_TYPE.name)) { if (event.isRelation(THREAD_RELATION_TYPE.name)) {
threadRoots.add(event.relationEventId); threadRoots.add(event.relationEventId ?? "");
} }
} }
return threadRoots; 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<ReceiptContent>();
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. * Adds/handles ephemeral events such as typing notifications and read receipts.
* @param {MatrixEvent[]} events A list of events to process * @param {MatrixEvent[]} events A list of events to process
@ -2554,243 +2534,6 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
} }
} }
/**
* Get a list of user IDs who have <b>read up to</b> 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<IReceiptContent>();
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. * Update the room-tag event for the room. The previous one is overwritten.
* @param {MatrixEvent} event the m.tag event * @param {MatrixEvent} event the m.tag event

View File

@ -23,10 +23,10 @@ import { IThreadBundledRelationship, MatrixEvent } from "./event";
import { Direction, EventTimeline } from "./event-timeline"; import { Direction, EventTimeline } from "./event-timeline";
import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set'; import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set';
import { Room } from './room'; import { Room } from './room';
import { TypedEventEmitter } from "./typed-event-emitter";
import { RoomState } from "./room-state"; import { RoomState } from "./room-state";
import { ServerControlledNamespacedValue } from "../NamespacedValue"; import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger"; import { logger } from "../logger";
import { ReadReceipt } from "./read-receipt";
export enum ThreadEvent { export enum ThreadEvent {
New = "Thread.new", New = "Thread.new",
@ -54,7 +54,7 @@ interface IThreadOpts {
/** /**
* @experimental * @experimental
*/ */
export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> { export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
public static hasServerSideSupport: boolean; public static hasServerSideSupport: boolean;
/** /**
@ -429,6 +429,18 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
nextBatch, 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( export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue(

View File

@ -66,7 +66,7 @@ interface IState {
export interface ITimeline { export interface ITimeline {
events: Array<IRoomEvent | IStateEvent>; events: Array<IRoomEvent | IStateEvent>;
limited?: boolean; limited?: boolean;
prev_batch: string; prev_batch: string | null;
} }
export interface IJoinedRoom { export interface IJoinedRoom {
@ -401,7 +401,7 @@ export class SyncAccumulator {
// typing forever until someone really does start typing (which // typing forever until someone really does start typing (which
// will prompt Synapse to send down an actual m.typing event to // will prompt Synapse to send down an actual m.typing event to
// clobber the one we persisted). // 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 // This means we'll drop unknown ephemeral events but that
// seems okay. // seems okay.
return; return;
@ -528,7 +528,7 @@ export class SyncAccumulator {
}); });
Object.keys(this.joinRooms).forEach((roomId) => { Object.keys(this.joinRooms).forEach((roomId) => {
const roomData = this.joinRooms[roomId]; const roomData = this.joinRooms[roomId];
const roomJson = { const roomJson: IJoinedRoom = {
ephemeral: { events: [] }, ephemeral: { events: [] },
account_data: { events: [] }, account_data: { events: [] },
state: { events: [] }, state: { events: [] },
@ -541,12 +541,12 @@ export class SyncAccumulator {
}; };
// Add account data // Add account data
Object.keys(roomData._accountData).forEach((evType) => { 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 // Add receipt data
const receiptEvent = { const receiptEvent = {
type: "m.receipt", type: EventType.Receipt,
room_id: roomId, room_id: roomId,
content: { content: {
// $event_id: { "m.read": { $user_id: $json } } // $event_id: { "m.read": { $user_id: $json } }
@ -566,7 +566,7 @@ export class SyncAccumulator {
}); });
// add only if we have some receipt data // add only if we have some receipt data
if (Object.keys(receiptEvent.content).length > 0) { if (Object.keys(receiptEvent.content).length > 0) {
roomJson.ephemeral.events.push(receiptEvent); roomJson.ephemeral.events.push(receiptEvent as IMinimalEvent);
} }
// Add timeline data // Add timeline data
@ -609,8 +609,8 @@ export class SyncAccumulator {
const rollBackState = Object.create(null); const rollBackState = Object.create(null);
for (let i = roomJson.timeline.events.length - 1; i >=0; i--) { for (let i = roomJson.timeline.events.length - 1; i >=0; i--) {
const timelineEvent = roomJson.timeline.events[i]; const timelineEvent = roomJson.timeline.events[i];
if (timelineEvent.state_key === null || if ((timelineEvent as IStateEvent).state_key === null ||
timelineEvent.state_key === undefined) { (timelineEvent as IStateEvent).state_key === undefined) {
continue; // not a state event continue; // not a state event
} }
// since we're going back in time, we need to use the previous // since we're going back in time, we need to use the previous

View File

@ -1235,7 +1235,9 @@ export class SyncApi {
if (joinObj.isBrandNewRoom) { if (joinObj.isBrandNewRoom) {
// set the back-pagination token. Do this *before* adding any // set the back-pagination token. Do this *before* adding any
// events so that clients can start back-paginating. // 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) { } else if (joinObj.timeline.limited) {
let limited = true; let limited = true;