You've already forked matrix-js-sdk
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:
@ -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) => {
|
||||||
|
150
spec/unit/read-receipt.spec.ts
Normal file
150
spec/unit/read-receipt.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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",
|
||||||
});
|
});
|
||||||
|
@ -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>,
|
||||||
|
@ -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
306
src/models/read-receipt.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user