Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Rewrite doesRoomOrThreadHaveUnreadMessages to use the receipt rewrite from js-sdk #11903

Merged
merged 18 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3f806a8
Rewrite doesRoomOrThreadHaveUnreadMessages to use the receipt rewrite…
andybalaam Nov 17, 2023
bca84fb
Merge branch 'develop' into andybalaam/rewrite-receipts2
florianduros Nov 21, 2023
91cbdc8
Remove unit tests that rely on receipt timestamps
andybalaam Nov 21, 2023
baf5d24
Check for a missing thread in determineUnreadState
andybalaam Nov 21, 2023
f6b8afd
Fix incorrect way to find room timeline
andybalaam Nov 21, 2023
7fd01e7
More realistic test setup to support new receipt code
andybalaam Nov 21, 2023
8831b3b
Update snapshot to expect a room to be unread when there are no receipts
andybalaam Nov 21, 2023
1731558
Formatting fixes
andybalaam Nov 21, 2023
4b25076
Update snapshot to show menu and notif button
andybalaam Nov 21, 2023
9ec71d9
Merge branch 'develop' into andybalaam/rewrite-receipts2
florianduros Nov 22, 2023
3e4e867
Disable some flaky tests
florianduros Nov 23, 2023
1356524
Disable some flaky tests
florianduros Nov 24, 2023
2fbc2e1
Merge branch 'develop' into andybalaam/rewrite-receipts2
florianduros Nov 27, 2023
5932de0
Merge branch 'develop' into andybalaam/rewrite-receipts2
andybalaam Nov 28, 2023
55f4948
Merge branch 'develop' into andybalaam/rewrite-receipts2
andybalaam Nov 28, 2023
a0966e6
Merge branch 'develop' into andybalaam/rewrite-receipts2
andybalaam Nov 29, 2023
e40241d
Fix test to make a threaded receipt for an event that is actually in …
andybalaam Nov 29, 2023
c9c5eab
Merge branch 'develop' into andybalaam/rewrite-receipts2
andybalaam Nov 29, 2023
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
38 changes: 19 additions & 19 deletions cypress/e2e/read-receipts/editing-messages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe("Read receipts", () => {
goTo(room1);
assertStillRead(room2);
});
it("Editing a message after marking as read makes the room unread", () => {
it("Editing a message after marking as read leaves the room read", () => {
// Given the room is marked as read
goTo(room1);
receiveMessages(room2, ["Msg1"]);
Expand All @@ -145,7 +145,7 @@ describe("Read receipts", () => {
// When a message is edited
receiveMessages(room2, [editOf("Msg1", "Msg1 Edit1")]);

// Then the room remains unread
// Then the room remains read
assertStillRead(room2);
});
it("Editing a reply after reading it makes the room unread", () => {
Expand Down Expand Up @@ -264,8 +264,7 @@ describe("Read receipts", () => {
assertStillRead(room2);
assertReadThread("Msg1");
});
// XXX: fails because the unread dot remains after marking as read
it.skip("Marking a room as read after an edit in a thread makes it read", () => {
it("Marking a room as read after an edit in a thread makes it read", () => {
// Given an edit in a thread is making the room unread
goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), editOf("Resp1", "Edit1")]);
Expand All @@ -277,7 +276,7 @@ describe("Read receipts", () => {
// Then it is read
assertRead(room2);
});
// XXX: fails because the unread dot remains after marking as read
// XXX: flaky
it.skip("Editing a thread message after marking as read leaves the room read", () => {
// Given a room is marked as read
goTo(room1);
Expand All @@ -292,9 +291,9 @@ describe("Read receipts", () => {
// Then the room becomes unread
assertStillRead(room2);
});
// XXX: fails because we see a dot instead of an unread number - probably the server and client disagree
// XXX: flaky
it.skip("A room with an edited threaded message is still read after restart", () => {
// Given an edit in a thread is making a room unread
// Given an edit in a thread is leaving a room read
goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1")]);
markAsRead(room2);
Expand All @@ -304,7 +303,7 @@ describe("Read receipts", () => {
// When I restart
saveAndReload();

// Then is it still unread
// Then is it still read
assertRead(room2);
});
it("A room where all threaded edits are read is still read after restart", () => {
Expand All @@ -319,11 +318,11 @@ describe("Read receipts", () => {
saveAndReload();
assertRead(room2);
});
// XXX: fails because we see a dot instead of an unread number - probably the server and client disagree
// XXX: fails because the room becomes unread after restart
it.skip("A room where all threaded edits are marked as read is still read after restart", () => {
goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), editOf("Resp1", "Edit1")]);
assertUnread(room2, 3);
assertUnread(room2, 2);
markAsRead(room2);
assertRead(room2);

Expand All @@ -336,7 +335,8 @@ describe("Read receipts", () => {
});

describe("thread roots", () => {
it("An edit of a thread root leaves the room read", () => {
// XXX: flaky
it.skip("An edit of a thread root leaves the room read", () => {
// Given I have read a thread
goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1")]);
Expand Down Expand Up @@ -392,8 +392,7 @@ describe("Read receipts", () => {
// Then the room stays read
assertStillRead(room2);
});
// XXX: fails because the room has an unread dot after I marked it as read
it.skip("Marking a room as read after an edit of a thread root keeps it read", () => {
it("Marking a room as read after an edit of a thread root keeps it read", () => {
// Given a fully-read thread exists
goTo(room2);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1")]);
Expand All @@ -402,18 +401,19 @@ describe("Read receipts", () => {
goTo(room1);
assertRead(room2);

// When the thread root is edited
receiveMessages(room2, [editOf("Msg1", "Msg1 Edit1")]);
// When the thread root is edited (and I receive another message
// to allow Mark as read)
receiveMessages(room2, [editOf("Msg1", "Msg1 Edit1"), "Msg2"]);

// And I mark the room as read
// And when I mark the room as read
markAsRead(room2);

// Then the room becomes read and stays read
assertStillRead(room2);
goTo(room1);
assertStillRead(room2);
});
// XXX: fails because the room has an unread dot after I marked it as read
// XXX: flaky
it.skip("Editing a thread root that is a reply after marking as read leaves the room read", () => {
// Given a thread based on a reply exists and is read because it is marked as read
goTo(room1);
Expand All @@ -423,14 +423,14 @@ describe("Read receipts", () => {
assertRead(room2);

// When I edit the thread root
receiveMessages(room1, [editOf("Reply", "Edited Reply")]);
receiveMessages(room2, [editOf("Reply", "Edited Reply")]);

// Then the room is read
assertStillRead(room2);

// And the thread is read
goTo(room2);
assertReadThread("EditedReply");
assertReadThread("Edited Reply");
});
it("Marking a room as read after an edit of a thread root that is a reply leaves it read", () => {
// Given a thread based on a reply exists and the reply has been edited
Expand Down
2 changes: 1 addition & 1 deletion cypress/e2e/read-receipts/high-level.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ describe("Read receipts", () => {
});

describe("Paging up", () => {
// Flaky test https://github.com/vector-im/element-web/issues/26437
// XXX: Fails because flaky test https://github.com/vector-im/element-web/issues/26437
it.skip("Paging up through old messages after a room is read leaves the room read", () => {
// Given lots of messages are in the room, but we have read them
goTo(room1);
Expand Down
11 changes: 5 additions & 6 deletions cypress/e2e/read-receipts/new-messages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ describe("Read receipts", () => {
assertRead(room2);
});
// XXX: fails because the room remains unread even though I sent a message
// Note: this test should not re-use the same MatrixClient - it
// should create a new one logged in as the same user.
it.skip("Me sending a message from a different client marks room as read", () => {
// Given I have unread messages
goTo(room1);
Expand Down Expand Up @@ -345,8 +347,7 @@ describe("Read receipts", () => {
// Then thread does appear unread
assertUnreadThread("Msg1");
});
// XXX: fails because the room is still "bold" even though the notification counts all disappear
it.skip("Marking a room with unread threads as read makes it read", () => {
it("Marking a room with unread threads as read makes it read", () => {
// Given I have an unread thread
goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), threadedOff("Msg1", "Resp2")]);
Expand All @@ -358,8 +359,7 @@ describe("Read receipts", () => {
// Then the room is read
assertRead(room2);
});
// XXX: fails for the same reason as "Marking a room with unread threads as read makes it read"
it.skip("Sending a new thread message after marking as read makes it unread", () => {
it("Sending a new thread message after marking as read makes it unread", () => {
// Given a thread exists
goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), threadedOff("Msg1", "Resp2")]);
Expand All @@ -374,8 +374,7 @@ describe("Read receipts", () => {
// Then the room becomes unread
assertUnread(room2, 1);
});
// XXX: fails for the same reason as "Marking a room with unread threads as read makes it read"
it.skip("Sending a new different-thread message after marking as read makes it unread", () => {
it("Sending a new different-thread message after marking as read makes it unread", () => {
// Given 2 threads exist, and Thread2 has the latest message in it
goTo(room1);
receiveMessages(room2, ["Thread1", "Thread2", threadedOff("Thread1", "t1a")]);
Expand Down
13 changes: 10 additions & 3 deletions src/RoomNotifs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,16 @@ export function determineUnreadState(
}

// We don't have any notified messages, but we might have unread messages. Let's find out.
let hasUnread: boolean;
if (threadId) hasUnread = doesRoomOrThreadHaveUnreadMessages(room.getThread(threadId)!);
else hasUnread = doesRoomHaveUnreadMessages(room);
let hasUnread = false;
if (threadId) {
const thread = room.getThread(threadId);
if (thread) {
hasUnread = doesRoomOrThreadHaveUnreadMessages(thread);
}
// If the thread does not exist, assume it contains no unreads
} else {
hasUnread = doesRoomHaveUnreadMessages(room);
}

return {
symbol: null,
Expand Down
134 changes: 48 additions & 86 deletions src/Unread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,110 +57,72 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean {
return false;
}

for (const timeline of [room, ...room.getThreads()]) {
// If the current timeline has unread messages, we're done.
if (doesRoomOrThreadHaveUnreadMessages(timeline)) {
for (const withTimeline of [room, ...room.getThreads()]) {
if (doesTimelineHaveUnreadMessages(room, withTimeline.timeline)) {
// We found an unread, so the room is unread
return true;
}
}

// If we got here then no timelines were found with unread messages.
return false;
}

export function doesRoomOrThreadHaveUnreadMessages(roomOrThread: Room | Thread): boolean {
// NOTE: this shares logic with hasUserReadEvent in
// matrix-js-sdk/src/models/read-receipt.ts. They are not combined (yet)
// because hasUserReadEvent is focussed on a single event, and this is
// focussed on the whole room/thread.

// If there are no messages yet in the timeline then it isn't fully initialised
// and cannot be unread.
if (!roomOrThread || roomOrThread.timeline.length === 0) {
return false;
}

const myUserId = roomOrThread.client.getSafeUserId();

// as we don't send RRs for our own messages, make sure we special case that
// if *we* sent the last message into the room, we consider it not unread!
// Should fix: https://github.com/vector-im/element-web/issues/3263
// https://github.com/vector-im/element-web/issues/2427
// ...and possibly some of the others at
// https://github.com/vector-im/element-web/issues/3363
if (roomOrThread.timeline[roomOrThread.timeline.length - 1]?.getSender() === myUserId) {
return false;
}

const readUpToId = roomOrThread.getEventReadUpTo(myUserId);
const hasReceipt = makeHasReceipt(roomOrThread, readUpToId, myUserId);

// Loop through messages, starting with the most recent...
for (let i = roomOrThread.timeline.length - 1; i >= 0; --i) {
const ev = roomOrThread.timeline[i];

if (hasReceipt(ev)) {
// If we've read up to this event, there's nothing more recent
// that counts and we can stop looking because the user's read
// this and everything before.
function doesTimelineHaveUnreadMessages(room: Room, timeline: Array<MatrixEvent>): boolean {
const myUserId = room.client.getSafeUserId();
const latestImportantEventId = findLatestImportantEvent(room.client, timeline)?.getId();
if (latestImportantEventId) {
return !room.hasUserReadEvent(myUserId, latestImportantEventId);
} else {
// We couldn't find an important event to check - check the unimportant ones.
const earliestUnimportantEventId = timeline.at(0)?.getId();
if (!earliestUnimportantEventId) {
// There are no events in this timeline - it is uninitialised, so we
// consider it read
return false;
} else if (isImportantEvent(roomOrThread.client, ev)) {
// We've found a message that counts before we hit
// the user's read receipt, so this room is definitely unread.
} else if (room.hasUserReadEvent(myUserId, earliestUnimportantEventId)) {
// Some of the unimportant events are read, and there are no
// important ones after them, so we've read everything.
return false;
} else {
// We have events. and none of them are read. We must guess that
// the timeline is unread, because there could be older unread
// important events that we don't have loaded.
logger.warn("Falling back to unread room because of no read receipt or counting message found", {
roomId: room.roomId,
earliestUnimportantEventId: earliestUnimportantEventId,
});
return true;
}
}

// If we got here, we didn't find a message was important but didn't find
// the user's read receipt either, so we guess and say that the room is
// unread on the theory that false positives are better than false
// negatives here.
logger.warn("Falling back to unread room because of no read receipt or counting message found", {
roomOrThreadId: roomOrThread.roomId,
readUpToId,
});
return true;
}

/**
* Given this event does not have a receipt, is it important enough to make
* this room unread?
*/
function isImportantEvent(client: MatrixClient, event: MatrixEvent): boolean {
return !shouldHideEvent(event) && eventTriggersUnreadCount(client, event);
export function doesRoomOrThreadHaveUnreadMessages(roomOrThread: Room | Thread): boolean {
const room = roomOrThread instanceof Thread ? roomOrThread.room : roomOrThread;
const events = roomOrThread instanceof Thread ? roomOrThread.timeline : room.getLiveTimeline().getEvents();
return doesTimelineHaveUnreadMessages(room, events);
}

/**
* @returns a function that tells us whether a given event matches our read
* receipt.
*
* We have the ID of an event based on a read receipt. If we can find the
* corresponding event, then it's easy - our returned function just decides
* whether the receipt refers to the event we are asking about.
* Look backwards through the timeline and find the last event that is
* "important" in the sense of isImportantEvent.
*
* If we can't find the event, we guess by saying of the receipt's timestamp is
* after this event's timestamp, then it's probably saying this event is read.
* @returns the latest important event, or null if none were found
*/
function makeHasReceipt(
roomOrThread: Room | Thread,
readUpToId: string | null,
myUserId: string,
): (event: MatrixEvent) => boolean {
// get the most recent read receipt sent by our account.
// N.B. this is NOT a read marker (RM, aka "read up to marker"),
// despite the name of the method :((
const readEvent = readUpToId ? roomOrThread.findEventById(readUpToId) : null;

if (readEvent) {
// If we found an event matching our receipt, then it's easy: this event
// has a receipt if its ID is the same as the one in the receipt.
return (ev) => ev.getId() == readUpToId;
function findLatestImportantEvent(client: MatrixClient, timeline: Array<MatrixEvent>): MatrixEvent | null {
for (let index = timeline.length - 1; index >= 0; index--) {
const event = timeline[index];
if (isImportantEvent(client, event)) {
return event;
}
}
return null;
}

// If we didn't, we have to guess by saying if this event is before the
// receipt's ts, then it we pretend it has a receipt.
const receiptTs = roomOrThread.getReadReceiptForUserId(myUserId)?.data.ts ?? 0;
const unthreadedReceiptTs = roomOrThread.getLastUnthreadedReceiptFor(myUserId)?.ts ?? 0;
// We pick the more recent of the two receipts as the latest
const receiptTimestamp = Math.max(receiptTs, unthreadedReceiptTs);
return (ev) => ev.getTs() < receiptTimestamp;
/**
* Given this event does not have a receipt, is it important enough to make
* this room unread?
*/
function isImportantEvent(client: MatrixClient, event: MatrixEvent): boolean {
return !shouldHideEvent(event) && eventTriggersUnreadCount(client, event);
}
Loading
Loading