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

* Rewrite doesRoomOrThreadHaveUnreadMessages to use the receipt rewrite from js-sdk

* Remove unit tests that rely on receipt timestamps

Previously, if we found a receipt for an unknown event, we would use the
receipt timestamp and declare all events before that time to be read.
Now, we ignore such "dangling" receipts until we find the event they
refer to.

This new behaviour is more correct, but does lead to more messages being
considered unread.

This commit deletes tests that checked for the old behaviour.

* Check for a missing thread in determineUnreadState

* Fix incorrect way to find room timeline

* More realistic test setup to support new receipt code

* Update snapshot to expect a room to be unread when there are no receipts

* Formatting fixes

* Update snapshot to show menu and notif button

* Disable some flaky tests

* Disable some flaky tests

* Fix test to make a threaded receipt for an event that is actually in the thread

---------

Co-authored-by: Florian Duros <florianduros@element.io>
Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
Andy Balaam 2023-11-29 13:36:52 +00:00 committed by GitHub
parent e207798a8f
commit 8b7f49e74e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 230 additions and 234 deletions

View file

@ -134,7 +134,7 @@ describe("Read receipts", () => {
goTo(room1); goTo(room1);
assertStillRead(room2); 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 // Given the room is marked as read
goTo(room1); goTo(room1);
receiveMessages(room2, ["Msg1"]); receiveMessages(room2, ["Msg1"]);
@ -145,7 +145,7 @@ describe("Read receipts", () => {
// When a message is edited // When a message is edited
receiveMessages(room2, [editOf("Msg1", "Msg1 Edit1")]); receiveMessages(room2, [editOf("Msg1", "Msg1 Edit1")]);
// Then the room remains unread // Then the room remains read
assertStillRead(room2); assertStillRead(room2);
}); });
it("Editing a reply after reading it makes the room unread", () => { it("Editing a reply after reading it makes the room unread", () => {
@ -264,8 +264,7 @@ describe("Read receipts", () => {
assertStillRead(room2); assertStillRead(room2);
assertReadThread("Msg1"); assertReadThread("Msg1");
}); });
// XXX: fails because the unread dot remains after marking as read it("Marking a room as read after an edit in a thread makes it read", () => {
it.skip("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 // Given an edit in a thread is making the room unread
goTo(room1); goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), editOf("Resp1", "Edit1")]); receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), editOf("Resp1", "Edit1")]);
@ -277,7 +276,7 @@ describe("Read receipts", () => {
// Then it is read // Then it is read
assertRead(room2); 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", () => { it.skip("Editing a thread message after marking as read leaves the room read", () => {
// Given a room is marked as read // Given a room is marked as read
goTo(room1); goTo(room1);
@ -292,9 +291,9 @@ describe("Read receipts", () => {
// Then the room becomes unread // Then the room becomes unread
assertStillRead(room2); 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", () => { 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); goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1")]); receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1")]);
markAsRead(room2); markAsRead(room2);
@ -304,7 +303,7 @@ describe("Read receipts", () => {
// When I restart // When I restart
saveAndReload(); saveAndReload();
// Then is it still unread // Then is it still read
assertRead(room2); assertRead(room2);
}); });
it("A room where all threaded edits are read is still read after restart", () => { it("A room where all threaded edits are read is still read after restart", () => {
@ -319,11 +318,11 @@ describe("Read receipts", () => {
saveAndReload(); saveAndReload();
assertRead(room2); 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", () => { it.skip("A room where all threaded edits are marked as read is still read after restart", () => {
goTo(room1); goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), editOf("Resp1", "Edit1")]); receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), editOf("Resp1", "Edit1")]);
assertUnread(room2, 3); assertUnread(room2, 2);
markAsRead(room2); markAsRead(room2);
assertRead(room2); assertRead(room2);
@ -336,7 +335,8 @@ describe("Read receipts", () => {
}); });
describe("thread roots", () => { 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 // Given I have read a thread
goTo(room1); goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1")]); receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1")]);
@ -392,8 +392,7 @@ describe("Read receipts", () => {
// Then the room stays read // Then the room stays read
assertStillRead(room2); assertStillRead(room2);
}); });
// XXX: fails because the room has an unread dot after I marked it as read it("Marking a room as read after an edit of a thread root keeps it read", () => {
it.skip("Marking a room as read after an edit of a thread root keeps it read", () => {
// Given a fully-read thread exists // Given a fully-read thread exists
goTo(room2); goTo(room2);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1")]); receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1")]);
@ -402,10 +401,11 @@ describe("Read receipts", () => {
goTo(room1); goTo(room1);
assertRead(room2); assertRead(room2);
// When the thread root is edited // When the thread root is edited (and I receive another message
receiveMessages(room2, [editOf("Msg1", "Msg1 Edit1")]); // 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); markAsRead(room2);
// Then the room becomes read and stays read // Then the room becomes read and stays read
@ -413,7 +413,7 @@ describe("Read receipts", () => {
goTo(room1); goTo(room1);
assertStillRead(room2); 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", () => { 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 // Given a thread based on a reply exists and is read because it is marked as read
goTo(room1); goTo(room1);
@ -423,7 +423,7 @@ describe("Read receipts", () => {
assertRead(room2); assertRead(room2);
// When I edit the thread root // When I edit the thread root
receiveMessages(room1, [editOf("Reply", "Edited Reply")]); receiveMessages(room2, [editOf("Reply", "Edited Reply")]);
// Then the room is read // Then the room is read
assertStillRead(room2); assertStillRead(room2);

View file

@ -188,7 +188,7 @@ describe("Read receipts", () => {
}); });
describe("Paging up", () => { 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", () => { 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 // Given lots of messages are in the room, but we have read them
goTo(room1); goTo(room1);

View file

@ -221,6 +221,8 @@ describe("Read receipts", () => {
assertRead(room2); assertRead(room2);
}); });
// XXX: fails because the room remains unread even though I sent a message // 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", () => { it.skip("Me sending a message from a different client marks room as read", () => {
// Given I have unread messages // Given I have unread messages
goTo(room1); goTo(room1);
@ -345,8 +347,7 @@ describe("Read receipts", () => {
// Then thread does appear unread // Then thread does appear unread
assertUnreadThread("Msg1"); assertUnreadThread("Msg1");
}); });
// XXX: fails because the room is still "bold" even though the notification counts all disappear it("Marking a room with unread threads as read makes it read", () => {
it.skip("Marking a room with unread threads as read makes it read", () => {
// Given I have an unread thread // Given I have an unread thread
goTo(room1); goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), threadedOff("Msg1", "Resp2")]); receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), threadedOff("Msg1", "Resp2")]);
@ -358,8 +359,7 @@ describe("Read receipts", () => {
// Then the room is read // Then the room is read
assertRead(room2); assertRead(room2);
}); });
// XXX: fails for the same reason as "Marking a room with unread threads as read makes it read" it("Sending a new thread message after marking as read makes it unread", () => {
it.skip("Sending a new thread message after marking as read makes it unread", () => {
// Given a thread exists // Given a thread exists
goTo(room1); goTo(room1);
receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), threadedOff("Msg1", "Resp2")]); receiveMessages(room2, ["Msg1", threadedOff("Msg1", "Resp1"), threadedOff("Msg1", "Resp2")]);
@ -374,8 +374,7 @@ describe("Read receipts", () => {
// Then the room becomes unread // Then the room becomes unread
assertUnread(room2, 1); assertUnread(room2, 1);
}); });
// XXX: fails for the same reason as "Marking a room with unread threads as read makes it read" it("Sending a new different-thread message after marking as read makes it unread", () => {
it.skip("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 // Given 2 threads exist, and Thread2 has the latest message in it
goTo(room1); goTo(room1);
receiveMessages(room2, ["Thread1", "Thread2", threadedOff("Thread1", "t1a")]); receiveMessages(room2, ["Thread1", "Thread2", threadedOff("Thread1", "t1a")]);

View file

@ -261,9 +261,16 @@ export function determineUnreadState(
} }
// We don't have any notified messages, but we might have unread messages. Let's find out. // We don't have any notified messages, but we might have unread messages. Let's find out.
let hasUnread: boolean; let hasUnread = false;
if (threadId) hasUnread = doesRoomOrThreadHaveUnreadMessages(room.getThread(threadId)!); if (threadId) {
else hasUnread = doesRoomHaveUnreadMessages(room); 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 { return {
symbol: null, symbol: null,

View file

@ -57,69 +57,67 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean {
return false; return false;
} }
for (const timeline of [room, ...room.getThreads()]) { for (const withTimeline of [room, ...room.getThreads()]) {
// If the current timeline has unread messages, we're done. if (doesTimelineHaveUnreadMessages(room, withTimeline.timeline)) {
if (doesRoomOrThreadHaveUnreadMessages(timeline)) { // We found an unread, so the room is unread
return true; return true;
} }
} }
// If we got here then no timelines were found with unread messages. // If we got here then no timelines were found with unread messages.
return false; return false;
} }
export function doesRoomOrThreadHaveUnreadMessages(roomOrThread: Room | Thread): boolean { function doesTimelineHaveUnreadMessages(room: Room, timeline: Array<MatrixEvent>): boolean {
// NOTE: this shares logic with hasUserReadEvent in const myUserId = room.client.getSafeUserId();
// matrix-js-sdk/src/models/read-receipt.ts. They are not combined (yet) const latestImportantEventId = findLatestImportantEvent(room.client, timeline)?.getId();
// because hasUserReadEvent is focussed on a single event, and this is if (latestImportantEventId) {
// focussed on the whole room/thread. return !room.hasUserReadEvent(myUserId, latestImportantEventId);
} else {
// If there are no messages yet in the timeline then it isn't fully initialised // We couldn't find an important event to check - check the unimportant ones.
// and cannot be unread. const earliestUnimportantEventId = timeline.at(0)?.getId();
if (!roomOrThread || roomOrThread.timeline.length === 0) { if (!earliestUnimportantEventId) {
// There are no events in this timeline - it is uninitialised, so we
// consider it read
return false; return false;
} } else if (room.hasUserReadEvent(myUserId, earliestUnimportantEventId)) {
// Some of the unimportant events are read, and there are no
const myUserId = roomOrThread.client.getSafeUserId(); // important ones after them, so we've read everything.
// 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; return false;
} } else {
// We have events. and none of them are read. We must guess that
const readUpToId = roomOrThread.getEventReadUpTo(myUserId); // the timeline is unread, because there could be older unread
const hasReceipt = makeHasReceipt(roomOrThread, readUpToId, myUserId); // important events that we don't have loaded.
// 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.
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.
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", { logger.warn("Falling back to unread room because of no read receipt or counting message found", {
roomOrThreadId: roomOrThread.roomId, roomId: room.roomId,
readUpToId, earliestUnimportantEventId: earliestUnimportantEventId,
}); });
return true; return true;
} }
}
}
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);
}
/**
* Look backwards through the timeline and find the last event that is
* "important" in the sense of isImportantEvent.
*
* @returns the latest important event, or null if none were found
*/
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;
}
/** /**
* Given this event does not have a receipt, is it important enough to make * Given this event does not have a receipt, is it important enough to make
@ -128,39 +126,3 @@ export function doesRoomOrThreadHaveUnreadMessages(roomOrThread: Room | Thread):
function isImportantEvent(client: MatrixClient, event: MatrixEvent): boolean { function isImportantEvent(client: MatrixClient, event: MatrixEvent): boolean {
return !shouldHideEvent(event) && eventTriggersUnreadCount(client, event); return !shouldHideEvent(event) && eventTriggersUnreadCount(client, event);
} }
/**
* @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.
*
* 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.
*/
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;
}
// 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;
}

View file

@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { haveRendererForEvent } from "../src/events/EventTileFactory"; import { haveRendererForEvent } from "../src/events/EventTileFactory";
import { makeBeaconEvent, mkEvent, stubClient } from "./test-utils"; import { makeBeaconEvent, mkEvent, stubClient } from "./test-utils";
import { mkThread } from "./test-utils/threads"; import { makeThreadEvents, mkThread, populateThread } from "./test-utils/threads";
import { import {
doesRoomHaveUnreadMessages, doesRoomHaveUnreadMessages,
doesRoomOrThreadHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages,
@ -213,7 +213,7 @@ describe("Unread", () => {
expect(doesRoomHaveUnreadMessages(room)).toBe(true); expect(doesRoomHaveUnreadMessages(room)).toBe(true);
}); });
it("returns true for a room with an unread message in a thread", () => { it("returns true for a room with an unread message in a thread", async () => {
// Mark the main timeline as read. // Mark the main timeline as read.
const receipt = new MatrixEvent({ const receipt = new MatrixEvent({
type: "m.receipt", type: "m.receipt",
@ -245,12 +245,12 @@ describe("Unread", () => {
room.addReceipt(receipt2); room.addReceipt(receipt2);
// Create a thread as a different user. // Create a thread as a different user.
mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] }); await populateThread({ room, client, authorId: myId, participantUserIds: [aliceId] });
expect(doesRoomHaveUnreadMessages(room)).toBe(true); expect(doesRoomHaveUnreadMessages(room)).toBe(true);
}); });
it("returns false for a room when the latest thread event was sent by the current user", () => { it("returns false for a room when the latest thread event was sent by the current user", async () => {
// Mark the main timeline as read. // Mark the main timeline as read.
const receipt = new MatrixEvent({ const receipt = new MatrixEvent({
type: "m.receipt", type: "m.receipt",
@ -266,12 +266,12 @@ describe("Unread", () => {
room.addReceipt(receipt); room.addReceipt(receipt);
// Create a thread as the current user. // Create a thread as the current user.
mkThread({ room, client, authorId: myId, participantUserIds: [myId] }); await populateThread({ room, client, authorId: myId, participantUserIds: [myId] });
expect(doesRoomHaveUnreadMessages(room)).toBe(false); expect(doesRoomHaveUnreadMessages(room)).toBe(false);
}); });
it("returns false for a room with read thread messages", () => { it("returns false for a room with read thread messages", async () => {
// Mark the main timeline as read. // Mark the main timeline as read.
let receipt = new MatrixEvent({ let receipt = new MatrixEvent({
type: "m.receipt", type: "m.receipt",
@ -287,7 +287,12 @@ describe("Unread", () => {
room.addReceipt(receipt); room.addReceipt(receipt);
// Create threads. // Create threads.
const { rootEvent, events } = mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] }); const { rootEvent, events } = await populateThread({
room,
client,
authorId: myId,
participantUserIds: [aliceId],
});
// Mark the thread as read. // Mark the thread as read.
receipt = new MatrixEvent({ receipt = new MatrixEvent({
@ -306,7 +311,7 @@ describe("Unread", () => {
expect(doesRoomHaveUnreadMessages(room)).toBe(false); expect(doesRoomHaveUnreadMessages(room)).toBe(false);
}); });
it("returns true for a room when read receipt is not on the latest thread messages", () => { it("returns true for a room when read receipt is not on the latest thread messages", async () => {
// Mark the main timeline as read. // Mark the main timeline as read.
let receipt = new MatrixEvent({ let receipt = new MatrixEvent({
type: "m.receipt", type: "m.receipt",
@ -322,7 +327,12 @@ describe("Unread", () => {
room.addReceipt(receipt); room.addReceipt(receipt);
// Create threads. // Create threads.
const { rootEvent, events } = mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] }); const { rootEvent, events } = await populateThread({
room,
client,
authorId: myId,
participantUserIds: [aliceId],
});
// Mark the thread as read. // Mark the thread as read.
receipt = new MatrixEvent({ receipt = new MatrixEvent({
@ -341,8 +351,7 @@ describe("Unread", () => {
expect(doesRoomHaveUnreadMessages(room)).toBe(true); expect(doesRoomHaveUnreadMessages(room)).toBe(true);
}); });
// Fails with current implementation. Will be fixed or replaced after matrix-js-sdk#3901 it("returns true when the event for a thread receipt can't be found", async () => {
it.skip("returns false when the event for a thread receipt can't be found, but the receipt ts is late", () => {
// Given a room that is read // Given a room that is read
let receipt = new MatrixEvent({ let receipt = new MatrixEvent({
type: "m.receipt", type: "m.receipt",
@ -358,69 +367,12 @@ describe("Unread", () => {
room.addReceipt(receipt); room.addReceipt(receipt);
// And a thread // And a thread
const { rootEvent, events } = mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] }); const { rootEvent, events } = await populateThread({
// When we provide a receipt that points at an unknown event,
// but its timestamp is after all events in the thread
//
// (This could happen if we mis-filed a reaction into the main
// thread when it should actually have gone into this thread, or
// maybe the event is just not loaded for some reason.)
const receiptTs = Math.max(...events.map((e) => e.getTs())) + 100;
receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
["UNKNOWN_EVENT_ID"]: {
[ReceiptType.Read]: {
[myId]: { ts: receiptTs, thread_id: rootEvent.getId()! },
},
},
},
});
room.addReceipt(receipt);
expect(doesRoomHaveUnreadMessages(room)).toBe(false);
});
it("returns true when the event for a thread receipt can't be found, and the receipt ts is early", () => {
// Given a room that is read
let receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[event.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1 },
},
},
},
});
room.addReceipt(receipt);
// Create a read thread, so we don't consider all threads read
// because there are no threaded read receipts.
const { rootEvent: rootEvent1, events: events1 } = mkThread({
room, room,
client, client,
authorId: myId, authorId: myId,
participantUserIds: [aliceId], participantUserIds: [aliceId],
}); });
const receipt2 = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
[events1[events1.length - 1].getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1, thread_id: rootEvent1.getId() },
},
},
},
});
room.addReceipt(receipt2);
// And a thread
const { rootEvent, events } = mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] });
// When we provide a receipt that points at an unknown event, // When we provide a receipt that points at an unknown event,
// but its timestamp is before some of the events in the thread // but its timestamp is before some of the events in the thread
@ -464,8 +416,8 @@ describe("Unread", () => {
expect(logger.warn).toHaveBeenCalledWith( expect(logger.warn).toHaveBeenCalledWith(
"Falling back to unread room because of no read receipt or counting message found", "Falling back to unread room because of no read receipt or counting message found",
{ {
roomOrThreadId: room.roomId, roomId: room.roomId,
readUpToId: null, earliestUnimportantEventId: redactedEvent.getId(),
}, },
); );
}); });
@ -484,6 +436,13 @@ describe("Unread", () => {
beforeEach(() => { beforeEach(() => {
room = new Room(roomId, client, myId); room = new Room(roomId, client, myId);
jest.spyOn(logger, "warn"); jest.spyOn(logger, "warn");
// Don't care about the code path of hidden events.
mocked(haveRendererForEvent).mockClear().mockReturnValue(true);
});
describe("with a single event on the main timeline", () => {
beforeEach(() => {
event = mkEvent({ event = mkEvent({
event: true, event: true,
type: "m.room.message", type: "m.room.message",
@ -492,12 +451,9 @@ describe("Unread", () => {
content: {}, content: {},
}); });
room.addLiveEvents([event]); room.addLiveEvents([event]);
// Don't care about the code path of hidden events.
mocked(haveRendererForEvent).mockClear().mockReturnValue(true);
}); });
it("should consider unthreaded read receipts for main timeline", () => { it("an unthreaded receipt for the event makes the room read", () => {
// Send unthreaded receipt into room pointing at the latest event // Send unthreaded receipt into room pointing at the latest event
room.addReceipt( room.addReceipt(
new MatrixEvent({ new MatrixEvent({
@ -516,27 +472,60 @@ describe("Unread", () => {
expect(doesRoomOrThreadHaveUnreadMessages(room)).toBe(false); expect(doesRoomOrThreadHaveUnreadMessages(room)).toBe(false);
}); });
it("should consider unthreaded read receipts for thread timelines", () => { it("a threaded receipt for the event makes the room read", () => {
// Provide an unthreaded read receipt with ts greater than the latest thread event // Send threaded receipt into room pointing at the latest event
const receipt = new MatrixEvent({ room.addReceipt(
new MatrixEvent({
type: "m.receipt", type: "m.receipt",
room_id: "!foo:bar", room_id: "!foo:bar",
content: { content: {
[event.getId()!]: { [event.getId()!]: {
[ReceiptType.Read]: { [ReceiptType.Read]: {
[myId]: { ts: 10000000000 }, [myId]: { ts: 1, thread_id: "main" },
}, },
}, },
}, },
}); }),
room.addReceipt(receipt);
const { thread } = mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] });
expect(thread.replyToEvent!.getTs()).toBeLessThan(
receipt.getContent()[event.getId()!][ReceiptType.Read][myId].ts,
); );
expect(doesRoomOrThreadHaveUnreadMessages(thread)).toBe(false);
expect(doesRoomOrThreadHaveUnreadMessages(room)).toBe(false);
});
});
describe("with an event on the main timeline and a later one in a thread", () => {
let threadEvent: MatrixEvent;
beforeEach(() => {
const { events } = makeThreadEvents({
roomId: roomId,
authorId: aliceId,
participantUserIds: ["@x:s.co"],
length: 2,
ts: 100,
currentUserId: myId,
});
room.addLiveEvents(events);
threadEvent = events[1];
});
it("an unthreaded receipt for the later threaded event makes the room read", () => {
// Send unthreaded receipt into room pointing at the latest event
room.addReceipt(
new MatrixEvent({
type: "m.receipt",
room_id: roomId,
content: {
[threadEvent.getId()!]: {
[ReceiptType.Read]: {
[myId]: { ts: 1 },
},
},
},
}),
);
expect(doesRoomOrThreadHaveUnreadMessages(room)).toBe(false);
});
}); });
}); });
}); });

View file

@ -105,6 +105,7 @@ describe("LegacyRoomHeaderButtons-test.tsx", function () {
client, client,
authorId: client.getUserId()!, authorId: client.getUserId()!,
participantUserIds: ["@alice:example.org"], participantUserIds: ["@alice:example.org"],
length: 5,
}); });
// We need some receipt, otherwise we treat this thread as // We need some receipt, otherwise we treat this thread as
// "older than all threaded receipts" and consider it read. // "older than all threaded receipts" and consider it read.
@ -112,7 +113,7 @@ describe("LegacyRoomHeaderButtons-test.tsx", function () {
type: "m.receipt", type: "m.receipt",
room_id: room.roomId, room_id: room.roomId,
content: { content: {
[events[0].getId()!]: { [events[1].getId()!]: {
// Receipt for the first event in the thread // Receipt for the first event in the thread
[ReceiptType.Read]: { [ReceiptType.Read]: {
[client.getUserId()!]: { ts: 1, thread_id: rootEvent.getId() }, [client.getUserId()!]: { ts: 1, thread_id: rootEvent.getId() },
@ -139,7 +140,7 @@ describe("LegacyRoomHeaderButtons-test.tsx", function () {
}, },
}); });
room.addLiveEvents([event]); room.addLiveEvents([event]);
await expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull(); expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull();
// Mark it as unread again. // Mark it as unread again.
event = mkEvent({ event = mkEvent({

View file

@ -94,6 +94,7 @@ describe("EventTile", () => {
room = new Room(ROOM_ID, client, client.getSafeUserId(), { room = new Room(ROOM_ID, client, client.getSafeUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached, pendingEventOrdering: PendingEventOrdering.Detached,
timelineSupport: true,
}); });
jest.spyOn(client, "getRoom").mockReturnValue(room); jest.spyOn(client, "getRoom").mockReturnValue(room);

View file

@ -378,6 +378,7 @@ describe("RoomTile", () => {
{ {
lastReply: () => null, lastReply: () => null,
timeline: [], timeline: [],
findEventById: () => {},
} as Thread, } as Thread,
]); ]);
}); });

View file

@ -78,7 +78,7 @@ exports[`RoomTile when message previews are enabled and there is a message in th
<DocumentFragment> <DocumentFragment>
<div <div
aria-describedby="mx_RoomTile_messagePreview_!1:example.org" aria-describedby="mx_RoomTile_messagePreview_!1:example.org"
aria-label="!1:example.org" aria-label="!1:example.org Unread messages."
aria-selected="false" aria-selected="false"
class="mx_AccessibleButton mx_RoomTile" class="mx_AccessibleButton mx_RoomTile"
role="treeitem" role="treeitem"
@ -102,7 +102,7 @@ exports[`RoomTile when message previews are enabled and there is a message in th
class="mx_RoomTile_titleContainer" class="mx_RoomTile_titleContainer"
> >
<div <div
class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle" class="mx_RoomTile_title mx_RoomTile_titleWithSubtitle mx_RoomTile_titleHasUnreadEvents"
tabindex="-1" tabindex="-1"
title="!1:example.org" title="!1:example.org"
> >
@ -127,7 +127,15 @@ exports[`RoomTile when message previews are enabled and there is a message in th
<div <div
aria-hidden="true" aria-hidden="true"
class="mx_RoomTile_badgeContainer" class="mx_RoomTile_badgeContainer"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_dot"
>
<span
class="mx_NotificationBadge_count"
/> />
</div>
</div>
<div <div
aria-expanded="false" aria-expanded="false"
aria-haspopup="true" aria-haspopup="true"

View file

@ -111,6 +111,13 @@ type MakeThreadProps = {
ts?: number; ts?: number;
}; };
/**
* Create a thread but don't actually populate it with events - see
* populateThread for what you probably want to do.
*
* Leaving this here in case it is needed by some people, but I (andyb) would
* expect us to move to use populateThread exclusively.
*/
export const mkThread = ({ export const mkThread = ({
room, room,
client, client,
@ -135,8 +142,29 @@ export const mkThread = ({
const thread = room.createThread(rootEvent.getId()!, rootEvent, events, true); const thread = room.createThread(rootEvent.getId()!, rootEvent, events, true);
// So that we do not have to mock the thread loading
thread.initialEventsFetched = true;
return { thread, rootEvent, events }; return { thread, rootEvent, events };
}; };
/**
* Create a thread, and make sure the events 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 = async ({
room,
client,
authorId,
participantUserIds,
length = 2,
ts = 1,
}: MakeThreadProps): Promise<{ thread: Thread; rootEvent: MatrixEvent; events: MatrixEvent[] }> => {
const ret = mkThread({ room, client, authorId, participantUserIds, length, ts });
// So that we do not have to mock the thread loading, tell the thread
// that it is already loaded, and send the events again to the room
// so they are added to the thread timeline.
ret.thread.initialEventsFetched = true;
await room.addLiveEvents(ret.events);
return ret;
};