Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement changes to MSC2285 (private read receipts) #2221

Merged
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions spec/unit/matrix-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { EventStatus, MatrixEvent } from "../../src/models/event";
import { Preset } from "../../src/@types/partials";
import { ReceiptType } from "../../src/@types/read_receipts";
import * as testUtils from "../test-utils/test-utils";
import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon";
Expand Down Expand Up @@ -992,6 +993,46 @@ describe("MatrixClient", function() {
});
});

describe("read-markers and read-receipts", () => {
it("setRoomReadMarkers", () => {
client.setRoomReadMarkersHttpRequest = jest.fn();
const room = {
hasPendingEvent: jest.fn().mockReturnValue(false),
addLocalEchoReceipt: jest.fn(),
};
const rrEvent = new MatrixEvent({ event_id: "read_event_id" });
const rpEvent = new MatrixEvent({ event_id: "read_private_event_id" });
client.getRoom = () => room;

client.setRoomReadMarkers(
"room_id",
"read_marker_event_id",
rrEvent,
rpEvent,
);

expect(client.setRoomReadMarkersHttpRequest).toHaveBeenCalledWith(
"room_id",
"read_marker_event_id",
"read_event_id",
"read_private_event_id",
);
expect(room.addLocalEchoReceipt).toHaveBeenCalledTimes(2);
expect(room.addLocalEchoReceipt).toHaveBeenNthCalledWith(
1,
client.credentials.userId,
rrEvent,
ReceiptType.Read,
);
expect(room.addLocalEchoReceipt).toHaveBeenNthCalledWith(
2,
client.credentials.userId,
rpEvent,
ReceiptType.ReadPrivate,
);
});
});

describe("beacons", () => {
const roomId = '!room:server.org';
const content = makeBeaconInfoContent(100, true);
Expand Down
29 changes: 28 additions & 1 deletion spec/unit/room.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import * as utils from "../test-utils/test-utils";
import {
DuplicateStrategy,
EventStatus,
EventTimelineSet,
EventType,
JoinRule,
MatrixEvent,
Expand All @@ -31,11 +32,12 @@ import {
RoomEvent,
} from "../../src";
import { EventTimeline } from "../../src/models/event-timeline";
import { Room } from "../../src/models/room";
import { IWrappedReceipt, Room } from "../../src/models/room";
import { RoomState } from "../../src/models/room-state";
import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event";
import { TestClient } from "../TestClient";
import { emitPromise } from "../test-utils/test-utils";
import { ReceiptType } from "../../src/@types/read_receipts";
import { Thread, ThreadEvent } from "../../src/models/thread";

describe("Room", function() {
Expand Down Expand Up @@ -2286,4 +2288,29 @@ describe("Room", function() {
expect(responseRelations[0][1].has(threadReaction)).toBeTruthy();
});
});

describe("getEventReadUpTo()", () => {
const client = new TestClient(userA).client;
const room = new Room(roomId, client, userA);

it("handles missing receipt type", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
return receiptType === ReceiptType.ReadPrivate ? { eventId: "eventId" } as IWrappedReceipt : null;
};

expect(room.getEventReadUpTo(userA)).toEqual("eventId");
});

it("prefers older receipt", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType) => {
return (receiptType === ReceiptType.Read
? { eventId: "eventId1" }
: { eventId: "eventId2" }
) as IWrappedReceipt;
};
room.getUnfilteredTimelineSet = () => ({ compareEventOrdering: (event1, event2) => 1 } as EventTimelineSet);

expect(room.getEventReadUpTo(userA)).toEqual("eventId1");
});
});
});
15 changes: 11 additions & 4 deletions spec/unit/sync-accumulator.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { ReceiptType } from "../../src/@types/read_receipts";
import { SyncAccumulator } from "../../src/sync-accumulator";

// The event body & unsigned object get frozen to assert that they don't get altered
Expand Down Expand Up @@ -294,10 +295,13 @@ describe("SyncAccumulator", function() {
room_id: "!foo:bar",
content: {
"$event1:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 1 },
"@bob:localhost": { ts: 2 },
},
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
"some.other.receipt.type": {
"@should_be_ignored:localhost": { key: "val" },
},
Expand All @@ -309,7 +313,7 @@ describe("SyncAccumulator", function() {
room_id: "!foo:bar",
content: {
"$event2:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@bob:localhost": { ts: 2 }, // clobbers event1 receipt
"@charlie:localhost": { ts: 3 },
},
Expand Down Expand Up @@ -337,12 +341,15 @@ describe("SyncAccumulator", function() {
room_id: "!foo:bar",
content: {
"$event1:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 1 },
},
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
},
"$event2:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@bob:localhost": { ts: 2 },
"@charlie:localhost": { ts: 3 },
},
Expand Down
21 changes: 21 additions & 0 deletions src/@types/read_receipts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>

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.
*/

export enum ReceiptType {
Read = "m.read",
FullyRead = "m.fully_read",
ReadPrivate = "org.matrix.msc2285.read.private"
}
73 changes: 37 additions & 36 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ import { CryptoStore } from "./crypto/store/base";
import { MediaHandler } from "./webrtc/mediaHandler";
import { IRefreshTokenResponse } from "./@types/auth";
import { TypedEventEmitter } from "./models/typed-event-emitter";
import { ReceiptType } from "./@types/read_receipts";
import { Thread, THREAD_RELATION_TYPE } from "./models/thread";
import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";

Expand Down Expand Up @@ -1078,7 +1079,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// Figure out if we've read something or if it's just informational
const content = event.getContent();
const isSelf = Object.keys(content).filter(eid => {
return Object.keys(content[eid]['m.read']).includes(this.getUserId());
const read = content[eid][ReceiptType.Read];
if (read && Object.keys(read).includes(this.getUserId())) return true;

const readPrivate = content[eid][ReceiptType.ReadPrivate];
if (readPrivate && Object.keys(readPrivate).includes(this.getUserId())) return true;

return false;
}).length > 0;

if (!isSelf) return;
Expand Down Expand Up @@ -4493,13 +4500,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Send a receipt.
* @param {Event} event The event being acknowledged
* @param {string} receiptType The kind of receipt e.g. "m.read"
* @param {ReceiptType} receiptType The kind of receipt e.g. "m.read". Other than
* ReceiptType.Read are experimental!
* @param {object} body Additional content to send alongside the receipt.
* @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public sendReceipt(event: MatrixEvent, receiptType: string, body: any, callback?: Callback): Promise<{}> {
public sendReceipt(event: MatrixEvent, receiptType: ReceiptType, body: any, callback?: Callback): Promise<{}> {
if (typeof (body) === 'function') {
callback = body as any as Callback; // legacy
body = {};
Expand All @@ -4526,32 +4534,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Send a read receipt.
* @param {Event} event The event that has been read.
* @param {object} opts The options for the read receipt.
* @param {boolean} opts.hidden True to prevent the receipt from being sent to
* other users and homeservers. Default false (send to everyone). <b>This
* property is unstable and may change in the future.</b>
* @param {ReceiptType} receiptType other than ReceiptType.Read are experimental! Optional.
* @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: to an empty object
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public async sendReadReceipt(event: MatrixEvent, opts?: { hidden?: boolean }, callback?: Callback): Promise<{}> {
if (typeof (opts) === 'function') {
callback = opts as any as Callback; // legacy
opts = {};
}
if (!opts) opts = {};

public async sendReadReceipt(event: MatrixEvent, receiptType = ReceiptType.Read, callback?: Callback): Promise<{}> {
const eventId = event.getId();
const room = this.getRoom(event.getRoomId());
if (room && room.hasPendingEvent(eventId)) {
throw new Error(`Cannot set read receipt to a pending event (${eventId})`);
}

const addlContent = {
"org.matrix.msc2285.hidden": Boolean(opts.hidden),
};

return this.sendReceipt(event, "m.read", addlContent, callback);
return this.sendReceipt(event, receiptType, {}, callback);
}

/**
Expand All @@ -4564,35 +4559,42 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {MatrixEvent} rrEvent the event tracked by the read receipt. This is here for
* convenience because the RR and the RM are commonly updated at the same time as each
* other. The local echo of this receipt will be done if set. Optional.
* @param {object} opts Options for the read markers
* @param {object} opts.hidden True to hide the receipt from other users and homeservers.
* <b>This property is unstable and may change in the future.</b>
* @param {MatrixEvent} rpEvent the m.read.private read receipt event for when we don't
* want other users to see the read receipts. This is experimental. Optional.
* @return {Promise} Resolves: the empty object, {}.
*/
public async setRoomReadMarkers(
roomId: string,
rmEventId: string,
rrEvent: MatrixEvent,
opts: { hidden?: boolean },
rrEvent?: MatrixEvent,
rpEvent?: MatrixEvent,
): Promise<{}> {
const room = this.getRoom(roomId);
if (room && room.hasPendingEvent(rmEventId)) {
throw new Error(`Cannot set read marker to a pending event (${rmEventId})`);
}

// Add the optional RR update, do local echo like `sendReceipt`
let rrEventId;
let rrEventId: string;
if (rrEvent) {
rrEventId = rrEvent.getId();
if (room && room.hasPendingEvent(rrEventId)) {
if (room?.hasPendingEvent(rrEventId)) {
throw new Error(`Cannot set read receipt to a pending event (${rrEventId})`);
}
if (room) {
room.addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read");
room?.addLocalEchoReceipt(this.credentials.userId, rrEvent, ReceiptType.Read);
}

// Add the optional private RR update, do local echo like `sendReceipt`
let rpEventId: string;
if (rpEvent) {
rpEventId = rpEvent.getId();
if (room?.hasPendingEvent(rpEventId)) {
throw new Error(`Cannot set read receipt to a pending event (${rpEventId})`);
}
room?.addLocalEchoReceipt(this.credentials.userId, rpEvent, ReceiptType.ReadPrivate);
}

return this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, opts);
return this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId);
}

/**
Expand Down Expand Up @@ -7403,25 +7405,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} rrEventId ID of the event tracked by the read receipt. This is here
* for convenience because the RR and the RM are commonly updated at the same time as
* each other. Optional.
* @param {object} opts Options for the read markers.
* @param {object} opts.hidden True to hide the read receipt from other users. <b>This
* property is currently unstable and may change in the future.</b>
* @param {string} rpEventId rpEvent the m.read.private read receipt event for when we
* don't want other users to see the read receipts. This is experimental. Optional.
* @return {Promise} Resolves: the empty object, {}.
*/
public setRoomReadMarkersHttpRequest(
roomId: string,
rmEventId: string,
rrEventId: string,
opts: { hidden?: boolean },
rpEventId: string,
): Promise<{}> {
const path = utils.encodeUri("/rooms/$roomId/read_markers", {
$roomId: roomId,
});

const content = {
"m.fully_read": rmEventId,
"m.read": rrEventId,
"org.matrix.msc2285.hidden": Boolean(opts ? opts.hidden : false),
[ReceiptType.FullyRead]: rmEventId,
[ReceiptType.Read]: rrEventId,
[ReceiptType.ReadPrivate]: rpEventId,
};

return this.http.authedRequest(undefined, Method.Post, path, undefined, content);
Expand Down
Loading