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

Rewrite receipt-handling code (#3901)

* Rewrite receipt-handling code

* Add tests around dangling receipts

* Fix mark as read for some rooms

* Add missing word

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
Co-authored-by: Florian Duros <florianduros@element.io>
This commit is contained in:
Andy Balaam
2023-11-28 14:43:48 +00:00
committed by GitHub
parent a7496627fc
commit c49a527e5e
12 changed files with 1385 additions and 80 deletions

View File

@ -161,3 +161,23 @@ export const mkThread = ({
return { thread, rootEvent, events }; return { thread, rootEvent, events };
}; };
/**
* Create a thread, and make sure the events are added to the thread and the
* room's timeline as if they came in via sync.
*
* Note that mkThread doesn't actually add the events properly to the room.
*/
export const populateThread = ({
room,
client,
authorId,
participantUserIds,
length = 2,
ts = 1,
}: MakeThreadProps): MakeThreadResult => {
const ret = mkThread({ room, client, authorId, participantUserIds, length, ts });
ret.thread.initialEventsFetched = true;
room.addLiveEvents(ret.events);
return ret;
};

View File

@ -0,0 +1,541 @@
/*
Copyright 2023 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 { FeatureSupport, MatrixClient, MatrixEvent, ReceiptContent, THREAD_RELATION_TYPE, Thread } from "../../../src";
import { Room } from "../../../src/models/room";
/**
* Note, these tests check the functionality of the RoomReceipts class, but most
* of them access that functionality via the surrounding Room class, because a
* room is required for RoomReceipts to function, and this matches the pattern
* of how this code is used in the wild.
*/
describe("RoomReceipts", () => {
beforeAll(() => {
jest.replaceProperty(Thread, "hasServerSideSupport", FeatureSupport.Stable);
});
afterAll(() => {
jest.restoreAllMocks();
});
it("reports events unread if there are no receipts", () => {
// Given there are no receipts in the room
const room = createRoom();
const [event] = createEvent();
room.addLiveEvents([event]);
// When I ask about any event, then it is unread
expect(room.hasUserReadEvent(readerId, event.getId()!)).toBe(false);
});
it("reports events we sent as read even if there are no (real) receipts", () => {
// Given there are no receipts in the room
const room = createRoom();
const [event] = createEventSentBy(readerId);
room.addLiveEvents([event]);
// When I ask about an event I sent, it is read (because a synthetic
// receipt was created and stored in RoomReceipts)
expect(room.hasUserReadEvent(readerId, event.getId()!)).toBe(true);
});
it("reports read if we receive an unthreaded receipt for this event", () => {
// Given my event exists and is unread
const room = createRoom();
const [event, eventId] = createEvent();
room.addLiveEvents([event]);
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// When we receive a receipt for this event+user
room.addReceipt(createReceipt(readerId, event));
// Then that event is read
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
it("reports read if we receive an unthreaded receipt for a later event", () => {
// Given we have 2 events
const room = createRoom();
const [event1, event1Id] = createEvent();
const [event2] = createEvent();
room.addLiveEvents([event1, event2]);
// When we receive a receipt for the later event
room.addReceipt(createReceipt(readerId, event2));
// Then the earlier one is read
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
});
it("reports read for a non-live event if we receive an unthreaded receipt for a live one", () => {
// Given we have 2 events: one live and one old
const room = createRoom();
const [oldEvent, oldEventId] = createEvent();
const [liveEvent] = createEvent();
room.addLiveEvents([liveEvent]);
createOldTimeline(room, [oldEvent]);
// When we receive a receipt for the live event
room.addReceipt(createReceipt(readerId, liveEvent));
// Then the earlier one is read
expect(room.hasUserReadEvent(readerId, oldEventId)).toBe(true);
});
it("compares by timestamp if two events are in separate old timelines", () => {
// Given we have 2 events, both in old timelines, with event2 after
// event1 in terms of timestamps
const room = createRoom();
const [event1, event1Id] = createEvent();
const [event2, event2Id] = createEvent();
event1.event.origin_server_ts = 1;
event2.event.origin_server_ts = 2;
createOldTimeline(room, [event1]);
createOldTimeline(room, [event2]);
// When we receive a receipt for the older event
room.addReceipt(createReceipt(readerId, event1));
// Then the earlier one is read and the later one is not
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
});
it("reports unread if we receive an unthreaded receipt for an earlier event", () => {
// Given we have 2 events
const room = createRoom();
const [event1] = createEvent();
const [event2, event2Id] = createEvent();
room.addLiveEvents([event1, event2]);
// When we receive a receipt for the earlier event
room.addReceipt(createReceipt(readerId, event1));
// Then the later one is unread
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
});
it("reports unread if we receive an unthreaded receipt for a different user", () => {
// Given my event exists and is unread
const room = createRoom();
const [event, eventId] = createEvent();
room.addLiveEvents([event]);
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// When we receive a receipt for another user
room.addReceipt(createReceipt(otherUserId, event));
// Then the event is still unread since the receipt was not for us
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// But it's read for the other person
expect(room.hasUserReadEvent(otherUserId, eventId)).toBe(true);
});
it("reports events we sent as read even if an earlier receipt arrives", () => {
// Given we sent an event after some other event
const room = createRoom();
const [previousEvent] = createEvent();
const [myEvent] = createEventSentBy(readerId);
room.addLiveEvents([previousEvent, myEvent]);
// And I just received a receipt for the previous event
room.addReceipt(createReceipt(readerId, previousEvent));
// When I ask about the event I sent, it is read (because of synthetic receipts)
expect(room.hasUserReadEvent(readerId, myEvent.getId()!)).toBe(true);
});
it("considers events after ones we sent to be unread", () => {
// Given we sent an event, then another event came in
const room = createRoom();
const [myEvent] = createEventSentBy(readerId);
const [laterEvent] = createEvent();
room.addLiveEvents([myEvent, laterEvent]);
// When I ask about the later event, it is unread (because it's after the synthetic receipt)
expect(room.hasUserReadEvent(readerId, laterEvent.getId()!)).toBe(false);
});
it("correctly reports readness even when receipts arrive out of order", () => {
// Given we have 3 events
const room = createRoom();
const [event1] = createEvent();
const [event2, event2Id] = createEvent();
const [event3, event3Id] = createEvent();
room.addLiveEvents([event1, event2, event3]);
// When we receive receipts for the older events out of order
room.addReceipt(createReceipt(readerId, event2));
room.addReceipt(createReceipt(readerId, event1));
// Then we correctly ignore the older receipt
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(true);
expect(room.hasUserReadEvent(readerId, event3Id)).toBe(false);
});
it("reports read if we receive a threaded receipt for this event (main)", () => {
// Given my event exists and is unread
const room = createRoom();
const [event, eventId] = createEvent();
room.addLiveEvents([event]);
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// When we receive a receipt for this event+user
room.addReceipt(createThreadedReceipt(readerId, event, "main"));
// Then that event is read
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
it("reports read if we receive a threaded receipt for this event (non-main)", () => {
// Given my event exists and is unread
const room = createRoom();
const [root, rootId] = createEvent();
const [event, eventId] = createThreadedEvent(root);
setupThread(room, root);
room.addLiveEvents([root, event]);
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// When we receive a receipt for this event on this thread
room.addReceipt(createThreadedReceipt(readerId, event, rootId));
// Then that event is read
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
it("reports read if we receive an threaded receipt for a later event", () => {
// Given we have 2 events in a thread
const room = createRoom();
const [root, rootId] = createEvent();
const [event1, event1Id] = createThreadedEvent(root);
const [event2] = createThreadedEvent(root);
setupThread(room, root);
room.addLiveEvents([root, event1, event2]);
// When we receive a receipt for the later event
room.addReceipt(createThreadedReceipt(readerId, event2, rootId));
// Then the earlier one is read
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
});
it("reports unread if we receive an threaded receipt for an earlier event", () => {
// Given we have 2 events in a thread
const room = createRoom();
const [root, rootId] = createEvent();
const [event1] = createThreadedEvent(root);
const [event2, event2Id] = createThreadedEvent(root);
setupThread(room, root);
room.addLiveEvents([root, event1, event2]);
// When we receive a receipt for the earlier event
room.addReceipt(createThreadedReceipt(readerId, event1, rootId));
// Then the later one is unread
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
});
it("reports unread if we receive an threaded receipt for a different user", () => {
// Given my event exists and is unread
const room = createRoom();
const [root, rootId] = createEvent();
const [event, eventId] = createThreadedEvent(root);
setupThread(room, root);
room.addLiveEvents([root, event]);
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// When we receive a receipt for another user
room.addReceipt(createThreadedReceipt(otherUserId, event, rootId));
// Then the event is still unread since the receipt was not for us
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// But it's read for the other person
expect(room.hasUserReadEvent(otherUserId, eventId)).toBe(true);
});
it("reports unread if we receive a receipt for a later event in a different thread", () => {
// Given 2 events exist in different threads
const room = createRoom();
const [root1] = createEvent();
const [root2] = createEvent();
const [thread1, thread1Id] = createThreadedEvent(root1);
const [thread2] = createThreadedEvent(root2);
setupThread(room, root1);
setupThread(room, root2);
room.addLiveEvents([root1, root2, thread1, thread2]);
// When we receive a receipt for the later event
room.addReceipt(createThreadedReceipt(readerId, thread2, root2.getId()!));
// Then the old one is still unread since the receipt was not for this thread
expect(room.hasUserReadEvent(readerId, thread1Id)).toBe(false);
});
it("correctly reports readness even when threaded receipts arrive out of order", () => {
// Given we have 3 events
const room = createRoom();
const [root, rootId] = createEvent();
const [event1] = createThreadedEvent(root);
const [event2, event2Id] = createThreadedEvent(root);
const [event3, event3Id] = createThreadedEvent(root);
setupThread(room, root);
room.addLiveEvents([root, event1, event2, event3]);
// When we receive receipts for the older events out of order
room.addReceipt(createThreadedReceipt(readerId, event2, rootId));
room.addReceipt(createThreadedReceipt(readerId, event1, rootId));
// Then we correctly ignore the older receipt
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(true);
expect(room.hasUserReadEvent(readerId, event3Id)).toBe(false);
});
it("correctly reports readness when mixing threaded and unthreaded receipts", () => {
// Given we have a setup from this presentation:
// https://docs.google.com/presentation/d/1H1gxRmRFAm8d71hCILWmpOYezsvdlb7cB6ANl-20Gns/edit?usp=sharing
//
// Main1----\
// | ---Thread1a <- threaded receipt
// | |
// | Thread1b
// threaded receipt -> Main2--\
// | ----------------Thread2a <- unthreaded receipt
// Main3 |
// Thread2b <- threaded receipt
//
const room = createRoom();
const [main1, main1Id] = createEvent();
const [main2, main2Id] = createEvent();
const [main3, main3Id] = createEvent();
const [thread1a, thread1aId] = createThreadedEvent(main1);
const [thread1b, thread1bId] = createThreadedEvent(main1);
const [thread2a, thread2aId] = createThreadedEvent(main2);
const [thread2b, thread2bId] = createThreadedEvent(main2);
setupThread(room, main1);
setupThread(room, main2);
room.addLiveEvents([main1, thread1a, thread1b, main2, thread2a, main3, thread2b]);
// And the timestamps on the events are consistent with the order above
main1.event.origin_server_ts = 1;
thread1a.event.origin_server_ts = 2;
thread1b.event.origin_server_ts = 3;
main2.event.origin_server_ts = 4;
thread2a.event.origin_server_ts = 5;
main3.event.origin_server_ts = 6;
thread2b.event.origin_server_ts = 7;
// (Note: in principle, we have the information needed to order these
// events without using their timestamps, since they all came in via
// addLiveEvents. In reality, some of them would have come in via the
// /relations API, making it impossible to get the correct ordering
// without MSC4033, which is why we fall back to timestamps. I.e. we
// definitely could fix the code to make the above
// timestamp-manipulation unnecessary, but it would only make this test
// neater, not actually help in the real world.)
// When the receipts arrive
room.addReceipt(createThreadedReceipt(readerId, main2, "main"));
room.addReceipt(createThreadedReceipt(readerId, thread1a, main1Id));
room.addReceipt(createReceipt(readerId, thread2a));
room.addReceipt(createThreadedReceipt(readerId, thread2b, main2Id));
// Then we correctly identify that only main3 is unread
expect(room.hasUserReadEvent(readerId, main1Id)).toBe(true);
expect(room.hasUserReadEvent(readerId, main2Id)).toBe(true);
expect(room.hasUserReadEvent(readerId, main3Id)).toBe(false);
expect(room.hasUserReadEvent(readerId, thread1aId)).toBe(true);
expect(room.hasUserReadEvent(readerId, thread1bId)).toBe(true);
expect(room.hasUserReadEvent(readerId, thread2aId)).toBe(true);
expect(room.hasUserReadEvent(readerId, thread2bId)).toBe(true);
});
describe("dangling receipts", () => {
it("reports unread if the unthreaded receipt is in a dangling state", () => {
const room = createRoom();
const [event, eventId] = createEvent();
// When we receive a receipt for this event+user
room.addReceipt(createReceipt(readerId, event));
// The event is not added in the room
// So the receipt is in a dangling state
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// Add the event to the room
// The receipt is removed from the dangling state
room.addLiveEvents([event]);
// Then the event is read
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
it("reports unread if the threaded receipt is in a dangling state", () => {
const room = createRoom();
const [root, rootId] = createEvent();
const [event, eventId] = createThreadedEvent(root);
setupThread(room, root);
// When we receive a receipt for this event+user
room.addReceipt(createThreadedReceipt(readerId, event, rootId));
// The event is not added in the room
// So the receipt is in a dangling state
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// Add the events to the room
// The receipt is removed from the dangling state
room.addLiveEvents([root, event]);
// Then the event is read
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
it("should handle multiple dangling receipts for the same event", () => {
const room = createRoom();
const [event, eventId] = createEvent();
// When we receive a receipt for this event+user
room.addReceipt(createReceipt(readerId, event));
// We receive another receipt in the same event for another user
room.addReceipt(createReceipt(otherUserId, event));
// The event is not added in the room
// So the receipt is in a dangling state
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// Add the event to the room
// The two receipts should be processed
room.addLiveEvents([event]);
// Then the event is read
// We expect that the receipt of `otherUserId` didn't replace/erase the receipt of `readerId`
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
});
});
function createFakeClient(): MatrixClient {
return {
getUserId: jest.fn(),
getEventMapper: jest.fn().mockReturnValue(jest.fn()),
isInitialSyncComplete: jest.fn().mockReturnValue(true),
supportsThreads: jest.fn().mockReturnValue(true),
fetchRoomEvent: jest.fn().mockResolvedValue({}),
paginateEventTimeline: jest.fn(),
canSupport: { get: jest.fn() },
} as unknown as MatrixClient;
}
const senderId = "sender:s.ss";
const readerId = "reader:r.rr";
const otherUserId = "other:o.oo";
function createRoom(): Room {
return new Room("!rid", createFakeClient(), "@u:s.nz", { timelineSupport: true });
}
let idCounter = 0;
function nextId(): string {
return "$" + (idCounter++).toString(10);
}
/**
* Create an event and return it and its ID.
*/
function createEvent(): [MatrixEvent, string] {
return createEventSentBy(senderId);
}
/**
* Create an event with the supplied sender and return it and its ID.
*/
function createEventSentBy(customSenderId: string): [MatrixEvent, string] {
const event = new MatrixEvent({ sender: customSenderId, event_id: nextId() });
return [event, event.getId()!];
}
/**
* Create an event in the thread of the supplied root and return it and its ID.
*/
function createThreadedEvent(root: MatrixEvent): [MatrixEvent, string] {
const rootEventId = root.getId()!;
const event = new MatrixEvent({
sender: senderId,
event_id: nextId(),
content: {
"m.relates_to": {
event_id: rootEventId,
rel_type: THREAD_RELATION_TYPE.name,
["m.in_reply_to"]: {
event_id: rootEventId,
},
},
},
});
return [event, event.getId()!];
}
function createReceipt(userId: string, referencedEvent: MatrixEvent): MatrixEvent {
const content: ReceiptContent = {
[referencedEvent.getId()!]: {
"m.read": {
[userId]: {
ts: 123,
},
},
},
};
return new MatrixEvent({
type: "m.receipt",
content,
});
}
function createThreadedReceipt(userId: string, referencedEvent: MatrixEvent, threadId: string): MatrixEvent {
const content: ReceiptContent = {
[referencedEvent.getId()!]: {
"m.read": {
[userId]: {
ts: 123,
thread_id: threadId,
},
},
},
};
return new MatrixEvent({
type: "m.receipt",
content,
});
}
/**
* Create a timeline in the timeline set that is not the live timeline.
*/
function createOldTimeline(room: Room, events: MatrixEvent[]) {
const oldTimeline = room.getUnfilteredTimelineSet().addTimeline();
room.getUnfilteredTimelineSet().addEventsToTimeline(events, true, oldTimeline);
}
/**
* Perform the hacks required for this room to create a thread based on the root
* event supplied.
*/
function setupThread(room: Room, root: MatrixEvent) {
const thread = room.createThread(root.getId()!, root, [root], false);
thread.initialEventsFetched = true;
}

View File

@ -19,7 +19,7 @@ import { mocked } from "jest-mock";
import { MatrixClient, PendingEventOrdering } from "../../../src/client"; import { MatrixClient, PendingEventOrdering } from "../../../src/client";
import { Room, RoomEvent } from "../../../src/models/room"; import { Room, RoomEvent } from "../../../src/models/room";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread"; import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
import { makeThreadEvent, mkThread } from "../../test-utils/thread"; import { makeThreadEvent, mkThread, populateThread } from "../../test-utils/thread";
import { TestClient } from "../../TestClient"; import { TestClient } from "../../TestClient";
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils"; import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src"; import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
@ -149,20 +149,38 @@ describe("Thread", () => {
}); });
it("considers other events with no RR as unread", () => { it("considers other events with no RR as unread", () => {
const { thread, events } = mkThread({ // Given a long thread exists
const { thread, events } = populateThread({
room, room,
client, client,
authorId: myUserId, authorId: "@other:foo.com",
participantUserIds: [myUserId], participantUserIds: ["@other:foo.com"],
length: 25, length: 25,
ts: 190, ts: 190,
}); });
// Before alice's last unthreaded receipt const event1 = events.at(1)!;
expect(thread.hasUserReadEvent("@alice:example.org", events.at(1)!.getId() ?? "")).toBeTruthy(); const event2 = events.at(2)!;
const event24 = events.at(24)!;
// After alice's last unthreaded receipt // And we have read the second message in it with an unthreaded receipt
expect(thread.hasUserReadEvent("@alice:example.org", events.at(-1)!.getId() ?? "")).toBeFalsy(); const receipt = new MatrixEvent({
type: "m.receipt",
room_id: room.roomId,
content: {
// unthreaded receipt for the second message in the thread
[event2.getId()!]: {
[ReceiptType.Read]: {
[myUserId]: { ts: 200 },
},
},
},
});
room.addReceipt(receipt);
// Then we have read the first message in the thread, and not the last
expect(thread.hasUserReadEvent(myUserId, event1.getId()!)).toBe(true);
expect(thread.hasUserReadEvent(myUserId, event24.getId()!)).toBe(false);
}); });
it("considers event as read if there's a more recent unthreaded receipt", () => { it("considers event as read if there's a more recent unthreaded receipt", () => {
@ -481,13 +499,13 @@ describe("Thread", () => {
// And a thread with an added event (with later timestamp) // And a thread with an added event (with later timestamp)
const userId = "user1"; const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId); const { thread, message2 } = await createThreadAnd2Events(client, 1, 100, 200, userId);
// Then a receipt was added to the thread // Then a receipt was added to the thread
const receipt = thread.getReadReceiptForUserId(userId); const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt).toBeTruthy(); expect(receipt).toBeTruthy();
expect(receipt?.eventId).toEqual(message.getId()); expect(receipt?.eventId).toEqual(message2.getId());
expect(receipt?.data.ts).toEqual(100); expect(receipt?.data.ts).toEqual(200);
expect(receipt?.data.thread_id).toEqual(thread.id); expect(receipt?.data.thread_id).toEqual(thread.id);
// (And the receipt was synthetic) // (And the receipt was synthetic)
@ -505,14 +523,14 @@ describe("Thread", () => {
// And a thread with an added event with a lower timestamp than its other events // And a thread with an added event with a lower timestamp than its other events
const userId = "user1"; const userId = "user1";
const { thread } = await createThreadAndEvent(client, 200, 100, userId); const { thread, message1 } = await createThreadAnd2Events(client, 300, 200, 100, userId);
// Then no receipt was added to the thread (the receipt is still // Then the receipt is for the first message, because its
// for the thread root). This happens because since we have no // timestamp is later. This happens because since we have no
// recursive relations support, we know that sometimes events // recursive relations support, we know that sometimes events
// appear out of order, so we have to check their timestamps as // appear out of order, so we have to check their timestamps as
// a guess of the correct order. // a guess of the correct order.
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId()); expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(message1.getId());
}); });
}); });
@ -530,11 +548,11 @@ describe("Thread", () => {
// And a thread with an added event (with later timestamp) // And a thread with an added event (with later timestamp)
const userId = "user1"; const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId); const { thread, message2 } = await createThreadAnd2Events(client, 1, 100, 200, userId);
// Then a receipt was added to the thread // Then a receipt was added to the thread
const receipt = thread.getReadReceiptForUserId(userId); const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt?.eventId).toEqual(message.getId()); expect(receipt?.eventId).toEqual(message2.getId());
}); });
it("Creates a local echo receipt even for events BEFORE an existing receipt", async () => { it("Creates a local echo receipt even for events BEFORE an existing receipt", async () => {
@ -550,22 +568,24 @@ describe("Thread", () => {
// And a thread with an added event with a lower timestamp than its other events // And a thread with an added event with a lower timestamp than its other events
const userId = "user1"; const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 200, 100, userId); const { thread, message2 } = await createThreadAnd2Events(client, 300, 200, 100, userId);
// Then a receipt was added to the thread, because relations // Then a receipt was added for the last message, even though it
// recursion is available, so we trust the server to have // has lower ts, because relations recursion is available, so we
// provided us with events in the right order. // trust the server to have provided us with events in the right
// order.
const receipt = thread.getReadReceiptForUserId(userId); const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt?.eventId).toEqual(message.getId()); expect(receipt?.eventId).toEqual(message2.getId());
}); });
}); });
async function createThreadAndEvent( async function createThreadAnd2Events(
client: MatrixClient, client: MatrixClient,
rootTs: number, rootTs: number,
eventTs: number, message1Ts: number,
message2Ts: number,
userId: string, userId: string,
): Promise<{ thread: Thread; message: MatrixEvent }> { ): Promise<{ thread: Thread; message1: MatrixEvent; message2: MatrixEvent }> {
const room = new Room("room1", client, userId); const room = new Room("room1", client, userId);
// Given a thread // Given a thread
@ -576,24 +596,41 @@ describe("Thread", () => {
participantUserIds: [], participantUserIds: [],
ts: rootTs, ts: rootTs,
}); });
// Sanity: the current receipt is for the thread root // Sanity: there is no read receipt on the thread yet because the
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId()); // thread events don't get properly added to the room by mkThread.
expect(thread.getReadReceiptForUserId(userId)).toBeNull();
const awaitTimelineEvent = new Promise<void>((res) => thread.on(RoomEvent.Timeline, () => res())); const awaitTimelineEvent = new Promise<void>((res) => thread.on(RoomEvent.Timeline, () => res()));
// When we add a message that is before the latest receipt // Add a message with ts message1Ts
const message = makeThreadEvent({ const message1 = makeThreadEvent({
event: true, event: true,
rootEventId: thread.id, rootEventId: thread.id,
replyToEventId: thread.id, replyToEventId: thread.id,
user: userId, user: userId,
room: room.roomId, room: room.roomId,
ts: eventTs, ts: message1Ts,
}); });
await thread.addEvent(message, false, true); await thread.addEvent(message1, false, true);
await awaitTimelineEvent; await awaitTimelineEvent;
return { thread, message }; // Sanity: the thread now has a properly-added event, so this event
// has a synthetic receipt.
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(message1.getId());
// Add a message with ts message2Ts
const message2 = makeThreadEvent({
event: true,
rootEventId: thread.id,
replyToEventId: thread.id,
user: userId,
room: room.roomId,
ts: message2Ts,
});
await thread.addEvent(message2, false, true);
await awaitTimelineEvent;
return { thread, message1, message2 };
} }
function createClientWithEventMapper(canSupport: Map<Feature, ServerSupport> = new Map()): MatrixClient { function createClientWithEventMapper(canSupport: Map<Feature, ServerSupport> = new Map()): MatrixClient {

View File

@ -43,8 +43,10 @@ const THREAD_ID = "$thread_event_id";
const ROOM_ID = "!123:matrix.org"; const ROOM_ID = "!123:matrix.org";
describe("Read receipt", () => { describe("Read receipt", () => {
let threadRoot: MatrixEvent;
let threadEvent: MatrixEvent; let threadEvent: MatrixEvent;
let roomEvent: MatrixEvent; let roomEvent: MatrixEvent;
let editOfThreadRoot: MatrixEvent;
beforeEach(() => { beforeEach(() => {
httpBackend = new MockHttpBackend(); httpBackend = new MockHttpBackend();
@ -57,6 +59,15 @@ describe("Read receipt", () => {
client.isGuest = () => false; client.isGuest = () => false;
client.supportsThreads = () => true; client.supportsThreads = () => true;
threadRoot = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: ROOM_ID,
content: { body: "This is the thread root" },
});
threadRoot.event.event_id = THREAD_ID;
threadEvent = utils.mkEvent({ threadEvent = utils.mkEvent({
event: true, event: true,
type: EventType.RoomMessage, type: EventType.RoomMessage,
@ -82,6 +93,9 @@ describe("Read receipt", () => {
body: "Hello from a room", body: "Hello from a room",
}, },
}); });
editOfThreadRoot = utils.mkEdit(threadRoot, client, "@bob:matrix.org", ROOM_ID);
editOfThreadRoot.setThreadId(THREAD_ID);
}); });
describe("sendReceipt", () => { describe("sendReceipt", () => {
@ -208,6 +222,7 @@ describe("Read receipt", () => {
it.each([ it.each([
{ getEvent: () => roomEvent, destinationId: MAIN_ROOM_TIMELINE }, { getEvent: () => roomEvent, destinationId: MAIN_ROOM_TIMELINE },
{ getEvent: () => threadEvent, destinationId: THREAD_ID }, { getEvent: () => threadEvent, destinationId: THREAD_ID },
{ getEvent: () => editOfThreadRoot, destinationId: MAIN_ROOM_TIMELINE },
])("adds the receipt to $destinationId", ({ getEvent, destinationId }) => { ])("adds the receipt to $destinationId", ({ getEvent, destinationId }) => {
const event = getEvent(); const event = getEvent();
const userId = "@bob:example.org"; const userId = "@bob:example.org";

View File

@ -1743,13 +1743,70 @@ describe("Room", function () {
}); });
describe("hasUserReadUpTo", function () { describe("hasUserReadUpTo", function () {
it("should acknowledge if an event has been read", function () { it("returns true if there is a receipt for this event (main timeline)", function () {
const ts = 13787898424; const ts = 13787898424;
room.addLiveEvents([eventToAck]);
room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)])); room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]));
room.findEventById = jest.fn().mockReturnValue({} as MatrixEvent); room.findEventById = jest.fn().mockReturnValue({ getThread: jest.fn() } as unknown as MatrixEvent);
expect(room.hasUserReadEvent(userB, eventToAck.getId()!)).toEqual(true); expect(room.hasUserReadEvent(userB, eventToAck.getId()!)).toEqual(true);
}); });
it("return false for an unknown event", function () {
it("returns true if there is a receipt for a later event (main timeline)", async function () {
// Given some events exist in the room
const events: MatrixEvent[] = [
utils.mkMessage({
room: roomId,
user: userA,
msg: "1111",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "2222",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "3333",
event: true,
}),
];
await room.addLiveEvents(events);
// When I add a receipt for the latest one
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)]));
// Then the older ones are read too
expect(room.hasUserReadEvent(userB, events[0].getId()!)).toEqual(true);
expect(room.hasUserReadEvent(userB, events[1].getId()!)).toEqual(true);
});
describe("threads enabled", () => {
beforeEach(() => {
jest.spyOn(room.client, "supportsThreads").mockReturnValue(true);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("returns true if there is an unthreaded receipt for a later event in a thread", async () => {
// Given a thread exists in the room
const { thread, events } = mkThread({ room, length: 3 });
thread.initialEventsFetched = true;
await room.addLiveEvents(events);
// When I add an unthreaded receipt for the latest thread message
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)]));
// Then the main timeline message is read
expect(room.hasUserReadEvent(userB, events[0].getId()!)).toEqual(true);
});
});
it("returns false for an unknown event", function () {
expect(room.hasUserReadEvent(userB, "unknown_event")).toEqual(false); expect(room.hasUserReadEvent(userB, "unknown_event")).toEqual(false);
}); });
}); });

View File

@ -5168,7 +5168,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const room = this.getRoom(event.getRoomId()); const room = this.getRoom(event.getRoomId());
if (room && this.credentials.userId) { if (room && this.credentials.userId) {
room.addLocalEchoReceipt(this.credentials.userId, event, receiptType); room.addLocalEchoReceipt(this.credentials.userId, event, receiptType, unthreaded);
} }
return promise; return promise;
} }
@ -9916,7 +9916,7 @@ export function threadIdForReceipt(event: MatrixEvent): string {
* @returns true if this event is considered to be in the main timeline as far * @returns true if this event is considered to be in the main timeline as far
* as receipts are concerned. * as receipts are concerned.
*/ */
function inMainTimelineForReceipt(event: MatrixEvent): boolean { export function inMainTimelineForReceipt(event: MatrixEvent): boolean {
if (!event.threadRootId) { if (!event.threadRootId) {
// Not in a thread: then it is in the main timeline // Not in a thread: then it is in the main timeline
return true; return true;

View File

@ -0,0 +1,139 @@
/*
Copyright 2023 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 { MatrixEvent } from "./event";
import { Room } from "./room";
import { inMainTimelineForReceipt, threadIdForReceipt } from "../client";
/**
* Determine the order of two events in a room.
*
* In principle this should use the same order as the server, but in practice
* this is difficult for events that were not received over the Sync API. See
* MSC4033 for details.
*
* This implementation leans on the order of events within their timelines, and
* falls back to comparing event timestamps when they are in different
* timelines.
*
* See https://github.com/matrix-org/matrix-js-sdk/issues/3325 for where we are
* tracking the work to fix this.
*
* @param room - the room we are looking in
* @param leftEventId - the id of the first event
* @param rightEventId - the id of the second event
* @returns -1 if left \< right, 1 if left \> right, 0 if left == right, null if
* we can't tell (because we can't find the events).
*/
export function compareEventOrdering(room: Room, leftEventId: string, rightEventId: string): number | null {
const leftEvent = room.findEventById(leftEventId);
const rightEvent = room.findEventById(rightEventId);
if (!leftEvent || !rightEvent) {
// Without the events themselves, we can't find their thread or
// timeline, or guess based on timestamp, so we just don't know.
return null;
}
// Check whether the events are in the main timeline
const isLeftEventInMainTimeline = inMainTimelineForReceipt(leftEvent);
const isRightEventInMainTimeline = inMainTimelineForReceipt(rightEvent);
if (isLeftEventInMainTimeline && isRightEventInMainTimeline) {
return compareEventsInMainTimeline(room, leftEventId, rightEventId, leftEvent, rightEvent);
} else {
// At least one event is not in the timeline, so we can't use the room's
// unfiltered timeline set.
return compareEventsInThreads(leftEventId, rightEventId, leftEvent, rightEvent);
}
}
function compareEventsInMainTimeline(
room: Room,
leftEventId: string,
rightEventId: string,
leftEvent: MatrixEvent,
rightEvent: MatrixEvent,
): number | null {
// Get the timeline set that contains all the events.
const timelineSet = room.getUnfilteredTimelineSet();
// If they are in the same timeline, compareEventOrdering does what we need
const compareSameTimeline = timelineSet.compareEventOrdering(leftEventId, rightEventId);
if (compareSameTimeline !== null) {
return compareSameTimeline;
}
// Find which timeline each event is in. Refuse to provide an ordering if we
// can't find either of the events.
const leftTimeline = timelineSet.getTimelineForEvent(leftEventId);
if (leftTimeline === timelineSet.getLiveTimeline()) {
// The left event is part of the live timeline, so it must be after the
// right event (since they are not in the same timeline or we would have
// returned after compareEventOrdering.
return 1;
}
const rightTimeline = timelineSet.getTimelineForEvent(rightEventId);
if (rightTimeline === timelineSet.getLiveTimeline()) {
// The right event is part of the live timeline, so it must be after the
// left event.
return -1;
}
// They are in older timeline sets (because they were fetched by paging up).
return guessOrderBasedOnTimestamp(leftEvent, rightEvent);
}
function compareEventsInThreads(
leftEventId: string,
rightEventId: string,
leftEvent: MatrixEvent,
rightEvent: MatrixEvent,
): number | null {
const leftEventThreadId = threadIdForReceipt(leftEvent);
const rightEventThreadId = threadIdForReceipt(rightEvent);
const leftThread = leftEvent.getThread();
if (leftThread && leftEventThreadId === rightEventThreadId) {
// They are in the same thread, so we can ask the thread's timeline to
// figure it out for us
return leftThread.timelineSet.compareEventOrdering(leftEventId, rightEventId);
} else {
return guessOrderBasedOnTimestamp(leftEvent, rightEvent);
}
}
/**
* Guess the order of events based on server timestamp. This is not good, but
* difficult to avoid without MSC4033.
*
* See https://github.com/matrix-org/matrix-js-sdk/issues/3325
*/
function guessOrderBasedOnTimestamp(leftEvent: MatrixEvent, rightEvent: MatrixEvent): number {
const leftTs = leftEvent.getTs();
const rightTs = rightEvent.getTs();
if (leftTs < rightTs) {
return -1;
} else if (leftTs > rightTs) {
return 1;
} else {
return 0;
}
}

View File

@ -899,11 +899,10 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* @param eventId1 - The id of the first event * @param eventId1 - The id of the first event
* @param eventId2 - The id of the second event * @param eventId2 - The id of the second event
* @returns a number less than zero if eventId1 precedes eventId2, and * @returns -1 if eventId1 precedes eventId2, and +1 eventId1 succeeds
* greater than zero if eventId1 succeeds eventId2. zero if they are the * eventId2. 0 if they are the same event; null if we can't tell (either
* same event; null if we can't tell (either because we don't know about one * because we don't know about one of the events, or because they are in
* of the events, or because they are in separate timelines which don't join * separate timelines which don't join up).
* up).
*/ */
public compareEventOrdering(eventId1: string, eventId2: string): number | null { public compareEventOrdering(eventId1: string, eventId2: string): number | null {
if (eventId1 == eventId2) { if (eventId1 == eventId2) {
@ -935,7 +934,16 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
idx2 = idx; idx2 = idx;
} }
} }
return idx1! - idx2!; const difference = idx1! - idx2!;
// Return the sign of difference.
if (difference < 0) {
return -1;
} else if (difference > 0) {
return 1;
} else {
return 0;
}
} }
// the events are in different timelines. Iterate through the // the events are in different timelines. Iterate through the

View File

@ -27,15 +27,29 @@ import { EventTimelineSet } from "./event-timeline-set";
import { MapWithDefault } from "../utils"; import { MapWithDefault } from "../utils";
import { NotificationCountType } from "./room"; import { NotificationCountType } from "./room";
import { logger } from "../logger"; import { logger } from "../logger";
import { inMainTimelineForReceipt, threadIdForReceipt } from "../client";
export function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent { /**
* Create a synthetic receipt for the given event
* @param userId - The user ID if the receipt sender
* @param event - The event that is to be acknowledged
* @param receiptType - The type of receipt
* @param unthreaded - the receipt is unthreaded
* @returns a new event with the synthetic receipt in it
*/
export function synthesizeReceipt(
userId: string,
event: MatrixEvent,
receiptType: ReceiptType,
unthreaded = false,
): MatrixEvent {
return new MatrixEvent({ return new MatrixEvent({
content: { content: {
[event.getId()!]: { [event.getId()!]: {
[receiptType]: { [receiptType]: {
[userId]: { [userId]: {
ts: event.getTs(), ts: event.getTs(),
thread_id: event.threadRootId ?? MAIN_ROOM_TIMELINE, ...(!unthreaded && { thread_id: threadIdForReceipt(event) }),
}, },
}, },
}, },
@ -160,11 +174,8 @@ export abstract class ReadReceipt<
// The receipt is for the main timeline: we check that the event is // The receipt is for the main timeline: we check that the event is
// in the main timeline. // in the main timeline.
// There are two ways to know an event is in the main timeline: // Check if the event is in the main timeline
// either it has no threadRootId, or it is a thread root. const eventIsInMainTimeline = inMainTimelineForReceipt(event);
// (Note: it's a little odd because the thread root is in the main
// timeline, but it still has a threadRootId.)
const eventIsInMainTimeline = !event.threadRootId || event.isThreadRoot;
if (eventIsInMainTimeline) { if (eventIsInMainTimeline) {
// The receipt is for the main timeline, and so is the event, so // The receipt is for the main timeline, and so is the event, so
@ -367,9 +378,10 @@ export abstract class ReadReceipt<
* @param userId - The user ID if the receipt sender * @param userId - The user ID if the receipt sender
* @param e - The event that is to be acknowledged * @param e - The event that is to be acknowledged
* @param receiptType - The type of receipt * @param receiptType - The type of receipt
* @param unthreaded - the receipt is unthreaded
*/ */
public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void { public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType, unthreaded = false): void {
this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); this.addReceipt(synthesizeReceipt(userId, e, receiptType, unthreaded), true);
} }
/** /**
@ -395,33 +407,7 @@ export abstract class ReadReceipt<
* @param eventId - The event ID to check if the user read. * @param eventId - The event ID to check if the user read.
* @returns True if the user has read the event, false otherwise. * @returns True if the user has read the event, false otherwise.
*/ */
public hasUserReadEvent(userId: string, eventId: string): boolean { public abstract 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;
}
/** /**
* Returns the most recent unthreaded receipt for a given user * Returns the most recent unthreaded receipt for a given user
@ -429,6 +415,8 @@ export abstract class ReadReceipt<
* @returns an unthreaded Receipt. Can be undefined if receipts have been disabled * @returns an unthreaded Receipt. Can be undefined if receipts have been disabled
* or a user chooses to use private read receipts (or we have simply not received * or a user chooses to use private read receipts (or we have simply not received
* a receipt from this user yet). * a receipt from this user yet).
*
* @deprecated use `hasUserReadEvent` or `getEventReadUpTo` instead
*/ */
public abstract getLastUnthreadedReceiptFor(userId: string): Receipt | undefined; public abstract getLastUnthreadedReceiptFor(userId: string): Receipt | undefined;
} }

429
src/models/room-receipts.ts Normal file
View File

@ -0,0 +1,429 @@
/*
Copyright 2023 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 { MAIN_ROOM_TIMELINE, Receipt, ReceiptContent } from "../@types/read_receipts";
import { threadIdForReceipt } from "../client";
import { Room, RoomEvent } from "./room";
import { MatrixEvent } from "./event";
/**
* The latest receipts we have for a room.
*/
export class RoomReceipts {
private room: Room;
private threadedReceipts: ThreadedReceipts;
private unthreadedReceipts: ReceiptsByUser;
private danglingReceipts: DanglingReceipts;
public constructor(room: Room) {
this.room = room;
this.threadedReceipts = new ThreadedReceipts(room);
this.unthreadedReceipts = new ReceiptsByUser(room);
this.danglingReceipts = new DanglingReceipts();
// We listen for timeline events so we can process dangling receipts
room.on(RoomEvent.Timeline, this.onTimelineEvent);
}
/**
* Remember the receipt information supplied. For each receipt:
*
* If we don't have the event for this receipt, store it as "dangling" so we
* can process it later.
*
* Otherwise store it per-user in either the threaded store for its
* thread_id, or the unthreaded store if there is no thread_id.
*
* Ignores any receipt that is before an existing receipt for the same user
* (in the same thread, if applicable). "Before" is defined by the
* unfilteredTimelineSet of the room.
*/
public add(receiptContent: ReceiptContent, synthetic: boolean): void {
/*
Transform this structure:
{
"$EVENTID": {
"m.read|m.read.private": {
"@user:example.org": {
"ts": 1661,
"thread_id": "main|$THREAD_ROOT_ID" // or missing/undefined for an unthreaded receipt
}
}
},
...
}
into maps of:
threaded :: threadid :: userId :: ReceiptInfo
unthreaded :: userId :: ReceiptInfo
dangling :: eventId :: DanglingReceipt
*/
for (const [eventId, eventReceipt] of Object.entries(receiptContent)) {
for (const [receiptType, receiptsByUser] of Object.entries(eventReceipt)) {
for (const [userId, receipt] of Object.entries(receiptsByUser)) {
const referencedEvent = this.room.findEventById(eventId);
if (!referencedEvent) {
this.danglingReceipts.add(
new DanglingReceipt(eventId, receiptType, userId, receipt, synthetic),
);
} else if (receipt.thread_id) {
this.threadedReceipts.set(
receipt.thread_id,
eventId,
receiptType,
userId,
receipt.ts,
synthetic,
);
} else {
this.unthreadedReceipts.set(eventId, receiptType, userId, receipt.ts, synthetic);
}
}
}
}
}
/**
* Look for dangling receipts for the given event ID,
* and add them to the thread of unthread receipts if found.
* @param eventId - the event ID to look for
*/
private onTimelineEvent = (event: MatrixEvent): void => {
const eventId = event.getId();
if (!eventId) return;
const danglingReceipts = this.danglingReceipts.remove(eventId);
danglingReceipts?.forEach((danglingReceipt) => {
// The receipt is a thread receipt
if (danglingReceipt.receipt.thread_id) {
this.threadedReceipts.set(
danglingReceipt.receipt.thread_id,
danglingReceipt.eventId,
danglingReceipt.receiptType,
danglingReceipt.userId,
danglingReceipt.receipt.ts,
danglingReceipt.synthetic,
);
} else {
this.unthreadedReceipts.set(
eventId,
danglingReceipt.receiptType,
danglingReceipt.userId,
danglingReceipt.receipt.ts,
danglingReceipt.synthetic,
);
}
});
};
public hasUserReadEvent(userId: string, eventId: string): boolean {
const unthreaded = this.unthreadedReceipts.get(userId);
if (unthreaded) {
if (isAfterOrSame(unthreaded.eventId, eventId, this.room)) {
// The unthreaded receipt is after this event, so we have read it.
return true;
}
}
const event = this.room.findEventById(eventId);
if (!event) {
// We don't know whether the user has read it - default to caution and say no.
return false;
}
const threadId = threadIdForReceipt(event);
const threaded = this.threadedReceipts.get(threadId, userId);
if (threaded) {
if (isAfterOrSame(threaded.eventId, eventId, this.room)) {
// The threaded receipt is after this event, so we have read it.
return true;
}
}
// TODO: what if they sent the second-last event in the thread?
if (this.userSentLatestEventInThread(threadId, userId)) {
// The user sent the latest message in this event's thread, so we
// consider everything in the thread to be read.
//
// Note: maybe we don't need this because synthetic receipts should
// do this job for us?
return true;
}
// Neither of the receipts were after the event, so it's unread.
return false;
}
/**
* @returns true if the thread with this ID can be found, and the supplied
* user sent the latest message in it.
*/
private userSentLatestEventInThread(threadId: string, userId: String): boolean {
const timeline =
threadId === MAIN_ROOM_TIMELINE
? this.room.getLiveTimeline().getEvents()
: this.room.getThread(threadId)?.timeline;
return !!(timeline && timeline.length > 0 && timeline[timeline.length - 1].getSender() === userId);
}
}
// --- implementation details ---
/**
* The information "inside" a receipt once it has been stored inside
* RoomReceipts - what eventId it refers to, its type, and its ts.
*
* Does not contain userId or threadId since these are stored as keys of the
* maps in RoomReceipts.
*/
class ReceiptInfo {
public constructor(public eventId: string, public receiptType: string, public ts: number) {}
}
/**
* Everything we know about a receipt that is "dangling" because we can't find
* the event to which it refers.
*/
class DanglingReceipt {
public constructor(
public eventId: string,
public receiptType: string,
public userId: string,
public receipt: Receipt,
public synthetic: boolean,
) {}
}
class UserReceipts {
private room: Room;
/**
* The real receipt for this user.
*/
private real: ReceiptInfo | undefined;
/**
* The synthetic receipt for this user. If this is defined, it is later than real.
*/
private synthetic: ReceiptInfo | undefined;
public constructor(room: Room) {
this.room = room;
this.real = undefined;
this.synthetic = undefined;
}
public set(synthetic: boolean, receiptInfo: ReceiptInfo): void {
if (synthetic) {
this.synthetic = receiptInfo;
} else {
this.real = receiptInfo;
}
// Preserve the invariant: synthetic is only defined if it's later than real
if (this.synthetic && this.real) {
if (isAfterOrSame(this.real.eventId, this.synthetic.eventId, this.room)) {
this.synthetic = undefined;
}
}
}
/**
* Return the latest receipt we have - synthetic if we have one (and it's
* later), otherwise real.
*/
public get(): ReceiptInfo | undefined {
// Relies on the invariant that synthetic is only defined if it's later than real.
return this.synthetic ?? this.real;
}
/**
* Return the latest receipt we have of the specified type (synthetic or not).
*/
public getByType(synthetic: boolean): ReceiptInfo | undefined {
return synthetic ? this.synthetic : this.real;
}
}
/**
* The latest receipt info we have, either for a single thread, or all the
* unthreaded receipts for a room.
*
* userId: ReceiptInfo
*/
class ReceiptsByUser {
private room: Room;
/** map of userId: UserReceipts */
private data: Map<String, UserReceipts>;
public constructor(room: Room) {
this.room = room;
this.data = new Map<string, UserReceipts>();
}
/**
* Add the supplied receipt to our structure, if it is not earlier than the
* one we already hold for this user.
*/
public set(eventId: string, receiptType: string, userId: string, ts: number, synthetic: boolean): void {
const userReceipts = getOrCreate(this.data, userId, () => new UserReceipts(this.room));
const existingReceipt = userReceipts.getByType(synthetic);
if (existingReceipt && isAfter(existingReceipt.eventId, eventId, this.room)) {
// The new receipt is before the existing one - don't store it.
return;
}
// Possibilities:
//
// 1. there was no existing receipt, or
// 2. the existing receipt was before this one, or
// 3. we were unable to compare the receipts.
//
// In the case of 3 it's difficult to decide what to do, so the
// most-recently-received receipt wins.
//
// Case 3 can only happen if the events for these receipts have
// disappeared, which is quite unlikely since the new one has just been
// checked, and the old one was checked before it was inserted here.
//
// We go ahead and store this receipt (replacing the other if it exists)
userReceipts.set(synthetic, new ReceiptInfo(eventId, receiptType, ts));
}
/**
* Find the latest receipt we have for this user. (Note - there is only one
* receipt per user, because we are already inside a specific thread or
* unthreaded list.)
*
* If there is a later synthetic receipt for this user, return that.
* Otherwise, return the real receipt.
*
* @returns the found receipt info, or undefined if we have no receipt for this user.
*/
public get(userId: string): ReceiptInfo | undefined {
return this.data.get(userId)?.get();
}
}
/**
* The latest threaded receipts we have for a room.
*/
class ThreadedReceipts {
private room: Room;
/** map of threadId: ReceiptsByUser */
private data: Map<string, ReceiptsByUser>;
public constructor(room: Room) {
this.room = room;
this.data = new Map<string, ReceiptsByUser>();
}
/**
* Add the supplied receipt to our structure, if it is not earlier than one
* we already hold for this user in this thread.
*/
public set(
threadId: string,
eventId: string,
receiptType: string,
userId: string,
ts: number,
synthetic: boolean,
): void {
const receiptsByUser = getOrCreate(this.data, threadId, () => new ReceiptsByUser(this.room));
receiptsByUser.set(eventId, receiptType, userId, ts, synthetic);
}
/**
* Find the latest threaded receipt for the supplied user in the supplied thread.
*
* @returns the found receipt info or undefined if we don't have one.
*/
public get(threadId: string, userId: string): ReceiptInfo | undefined {
return this.data.get(threadId)?.get(userId);
}
}
/**
* All the receipts that we have received but can't process because we can't
* find the event they refer to.
*
* We hold on to them so we can process them if their event arrives later.
*/
class DanglingReceipts {
/**
* eventId: DanglingReceipt[]
*/
private data = new Map<string, Array<DanglingReceipt>>();
/**
* Remember the supplied dangling receipt.
*/
public add(danglingReceipt: DanglingReceipt): void {
const danglingReceipts = getOrCreate(this.data, danglingReceipt.eventId, () => []);
danglingReceipts.push(danglingReceipt);
}
/**
* Remove and return the dangling receipts for the given event ID.
* @param eventId - the event ID to look for
* @returns the found dangling receipts, or undefined if we don't have one.
*/
public remove(eventId: string): Array<DanglingReceipt> | undefined {
const danglingReceipts = this.data.get(eventId);
this.data.delete(eventId);
return danglingReceipts;
}
}
function getOrCreate<K, V>(m: Map<K, V>, key: K, createFn: () => V): V {
const found = m.get(key);
if (found) {
return found;
} else {
const created = createFn();
m.set(key, created);
return created;
}
}
/**
* Is left after right (or the same)?
*
* Only returns true if both events can be found, and left is after or the same
* as right.
*
* @returns left \>= right
*/
function isAfterOrSame(leftEventId: string, rightEventId: string, room: Room): boolean {
const comparison = room.compareEventOrdering(leftEventId, rightEventId);
return comparison !== null && comparison >= 0;
}
/**
* Is left strictly after right?
*
* Only returns true if both events can be found, and left is strictly after right.
*
* @returns left \> right
*/
function isAfter(leftEventId: string, rightEventId: string, room: Room): boolean {
const comparison = room.compareEventOrdering(leftEventId, rightEventId);
return comparison !== null && comparison > 0;
}

View File

@ -66,6 +66,8 @@ import { IStateEventWithRoomId } from "../@types/search";
import { RelationsContainer } from "./relations-container"; import { RelationsContainer } from "./relations-container";
import { ReadReceipt, synthesizeReceipt } from "./read-receipt"; import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
import { isPollEvent, Poll, PollEvent } from "./poll"; import { isPollEvent, Poll, PollEvent } from "./poll";
import { RoomReceipts } from "./room-receipts";
import { compareEventOrdering } from "./compare-event-ordering";
// 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
@ -432,6 +434,12 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/ */
private visibilityEvents = new Map<string, MatrixEvent[]>(); private visibilityEvents = new Map<string, MatrixEvent[]>();
/**
* The latest receipts (synthetic and real) for each user in each thread
* (and unthreaded).
*/
private roomReceipts = new RoomReceipts(this);
/** /**
* Construct a new Room. * Construct a new Room.
* *
@ -2935,6 +2943,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/ */
public addReceipt(event: MatrixEvent, synthetic = false): void { public addReceipt(event: MatrixEvent, synthetic = false): void {
const content = event.getContent<ReceiptContent>(); const content = event.getContent<ReceiptContent>();
this.roomReceipts.add(content, synthetic);
// TODO: delete the following code when it has been replaced by RoomReceipts
Object.keys(content).forEach((eventId: string) => { Object.keys(content).forEach((eventId: string) => {
Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => { Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => {
Object.keys(content[eventId][receiptType]).forEach((userId: string) => { Object.keys(content[eventId][receiptType]).forEach((userId: string) => {
@ -2996,6 +3008,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}); });
}); });
}); });
// End of code to delete when replaced by RoomReceipts
// send events after we've regenerated the structure & cache, otherwise things that // send events after we've regenerated the structure & cache, otherwise things that
// listened for the event would read stale data. // listened for the event would read stale data.
@ -3582,6 +3595,19 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return this.oldestThreadedReceiptTs; return this.oldestThreadedReceiptTs;
} }
/**
* Determines if the given user has read a particular event ID with the known
* history of the room. This is not a definitive check as it relies only on
* what is available to the room at the time of execution.
*
* @param userId - The user ID to check the read state of.
* @param eventId - The event ID to check if the user read.
* @returns true if the user has read the event, false otherwise.
*/
public hasUserReadEvent(userId: string, eventId: string): boolean {
return this.roomReceipts.hasUserReadEvent(userId, eventId);
}
/** /**
* Returns the most recent unthreaded receipt for a given user * Returns the most recent unthreaded receipt for a given user
* @param userId - the MxID of the User * @param userId - the MxID of the User
@ -3615,6 +3641,30 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
thread.fixupNotifications(userId); thread.fixupNotifications(userId);
} }
} }
/**
* Determine the order of two events in this room.
*
* In principle this should use the same order as the server, but in practice
* this is difficult for events that were not received over the Sync API. See
* MSC4033 for details.
*
* This implementation leans on the order of events within their timelines, and
* falls back to comparing event timestamps when they are in different
* timelines.
*
* See https://github.com/matrix-org/matrix-js-sdk/issues/3325 for where we are
* tracking the work to fix this.
*
* @param leftEventId - the id of the first event
* @param rightEventId - the id of the second event
* @returns -1 if left \< right, 1 if left \> right, 0 if left == right, null if
* we can't tell (because we can't find the events).
*/
public compareEventOrdering(leftEventId: string, rightEventId: string): number | null {
return compareEventOrdering(this, leftEventId, rightEventId);
}
} }
// a map from current event status to a list of allowed next statuses // a map from current event status to a list of allowed next statuses

View File

@ -748,6 +748,27 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
* @returns ID of the latest event that the given user has read, or null. * @returns ID of the latest event that the given user has read, or null.
*/ */
public getEventReadUpTo(userId: string, ignoreSynthesized?: boolean): string | null { public getEventReadUpTo(userId: string, ignoreSynthesized?: boolean): string | null {
// TODO: we think the implementation here is not right. Here is a sketch
// of the right answer:
//
// for event in timeline.events.reversed():
// if room.hasUserReadEvent(event):
// return event
// return null
//
// If this is too slow, we might be able to improve it by trying walking
// forward from the threaded receipt in this thread. We could alternate
// between backwards-from-front and forwards-from-threaded-receipt to
// improve our chances of hitting the right answer sooner.
//
// Either way, it's still fundamentally slow because we have to walk
// events.
//
// We also might just want to limit the time we spend on this by giving
// up after, say, 100 events.
//
// --- andyb
const isCurrentUser = userId === this.client.getUserId(); const isCurrentUser = userId === this.client.getUserId();
const lastReply = this.timeline[this.timeline.length - 1]; const lastReply = this.timeline[this.timeline.length - 1];
if (isCurrentUser && lastReply) { if (isCurrentUser && lastReply) {
@ -816,7 +837,7 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
} }
} }
return super.hasUserReadEvent(userId, eventId); return this.room.hasUserReadEvent(userId, eventId);
} }
public setUnread(type: NotificationCountType, count: number): void { public setUnread(type: NotificationCountType, count: number): void {