diff --git a/cypress/e2e/read-receipts/read-receipts.spec.ts b/cypress/e2e/read-receipts/read-receipts.spec.ts
deleted file mode 100644
index e298f7fa98..0000000000
--- a/cypress/e2e/read-receipts/read-receipts.spec.ts
+++ /dev/null
@@ -1,355 +0,0 @@
-/*
-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 type { MatrixClient, MatrixEvent, ISendEventResponse, ReceiptType } from "matrix-js-sdk/src/matrix";
-import { HomeserverInstance } from "../../plugins/utils/homeserver";
-
-describe("Read receipts", () => {
- const userName = "Mae";
- const botName = "Other User";
- const selectedRoomName = "Selected Room";
- const otherRoomName = "Other Room";
-
- let homeserver: HomeserverInstance;
- let otherRoomId: string;
- let selectedRoomId: string;
- let bot: MatrixClient | undefined;
-
- const botSendMessage = (no = 1): Cypress.Chainable => {
- return cy.botSendMessage(bot, otherRoomId, `Message ${no}`);
- };
-
- const botSendThreadMessage = (threadId: string): Cypress.Chainable => {
- return cy.botSendThreadMessage(bot, otherRoomId, threadId, "Message");
- };
-
- const fakeEventFromSent = (eventResponse: ISendEventResponse, threadRootId: string | undefined): MatrixEvent => {
- return {
- getRoomId: () => otherRoomId,
- getId: () => eventResponse.event_id,
- threadRootId,
- getTs: () => 1,
- isRelation: (relType) => {
- return !relType || relType === "m.thread";
- },
- } as any as MatrixEvent;
- };
-
- /**
- * Send a threaded receipt marking the message referred to in
- * eventResponse as read. If threadRootEventResponse is supplied, the
- * receipt will have its event_id as the thread root ID for the receipt.
- */
- const sendThreadedReadReceipt = (
- eventResponse: ISendEventResponse,
- threadRootEventResponse: ISendEventResponse = undefined,
- ) => {
- cy.sendReadReceipt(fakeEventFromSent(eventResponse, threadRootEventResponse?.event_id));
- };
-
- /**
- * Send an unthreaded receipt marking the message referred to in
- * eventResponse as read.
- */
- const sendUnthreadedReadReceipt = (eventResponse: ISendEventResponse) => {
- cy.sendReadReceipt(fakeEventFromSent(eventResponse, undefined), "m.read" as any as ReceiptType, true);
- };
-
- beforeEach(() => {
- /*
- * Create 2 rooms:
- *
- * - Selected room - this one is clicked in the UI
- * - Other room - this one contains the bot, which will send events so
- * we can check its unread state.
- */
- cy.startHomeserver("default").then((data) => {
- homeserver = data;
- cy.initTestUser(homeserver, userName)
- .then(() => {
- cy.createRoom({ name: selectedRoomName }).then((createdRoomId) => {
- selectedRoomId = createdRoomId;
- });
- })
- .then(() => {
- cy.createRoom({ name: otherRoomName }).then((createdRoomId) => {
- otherRoomId = createdRoomId;
- });
- })
- .then(() => {
- cy.getBot(homeserver, { displayName: botName }).then((botClient) => {
- bot = botClient;
- });
- })
- .then(() => {
- // Invite the bot to Other room
- cy.inviteUser(otherRoomId, bot.getUserId());
- cy.visit("/#/room/" + otherRoomId);
- cy.findByText(botName + " joined the room").should("exist");
-
- // Then go into Selected room
- cy.visit("/#/room/" + selectedRoomId);
- });
- });
- });
-
- afterEach(() => {
- cy.stopHomeserver(homeserver);
- });
-
- it(
- "With sync accumulator, considers main thread and unthreaded receipts #24629",
- {
- // When #24629 exists, the test fails the first time but passes later, so we disable retries
- // to be sure we are going to fail if the bug comes back.
- // Why does it pass the second time? I wish I knew. (andyb)
- retries: 0,
- },
- () => {
- // Details are in https://github.com/vector-im/element-web/issues/24629
- // This proves we've fixed one of the "stuck unreads" issues.
-
- // Given we sent 3 events on the main thread
- botSendMessage();
- botSendMessage().then((main2) => {
- botSendMessage().then((main3) => {
- // (So the room starts off unread)
- cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist");
-
- // When we send a threaded receipt for the last event in main
- // And an unthreaded receipt for an earlier event
- sendThreadedReadReceipt(main3);
- sendUnthreadedReadReceipt(main2);
-
- // (So the room has no unreads)
- cy.findByLabelText(`${otherRoomName}`).should("exist");
-
- // And we persuade the app to persist its state to indexeddb by reloading and waiting
- cy.reload();
- cy.findByLabelText(`${selectedRoomName}`).should("exist");
-
- // And we reload again, fetching the persisted state FROM indexeddb
- cy.reload();
-
- // Then the room is read, because the persisted state correctly remembers both
- // receipts. (In #24629, the unthreaded receipt overwrote the main thread one,
- // meaning that the room still said it had unread messages.)
- cy.findByLabelText(`${otherRoomName}`).should("exist");
- cy.findByLabelText(`${otherRoomName} Unread messages.`).should("not.exist");
- });
- });
- },
- );
-
- it("Recognises unread messages on main thread after receiving a receipt for earlier ones", () => {
- // Given we sent 3 events on the main thread
- botSendMessage();
- botSendMessage().then((main2) => {
- botSendMessage().then(() => {
- // (The room starts off unread)
- cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist");
-
- // When we send a threaded receipt for the second-last event in main
- sendThreadedReadReceipt(main2);
-
- // Then the room has only one unread
- cy.findByLabelText(`${otherRoomName} 1 unread message.`).should("exist");
- });
- });
- });
-
- it("Considers room read if there is only a main thread and we have a main receipt", () => {
- // Given we sent 3 events on the main thread
- botSendMessage();
- botSendMessage().then(() => {
- botSendMessage().then((main3) => {
- // (The room starts off unread)
- cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist");
-
- // When we send a threaded receipt for the last event in main
- sendThreadedReadReceipt(main3);
-
- // Then the room has no unreads
- cy.findByLabelText(`${otherRoomName}`).should("exist");
- });
- });
- });
-
- it("Recognises unread messages on other thread after receiving a receipt for earlier ones", () => {
- // Given we sent 3 events on the main thread
- botSendMessage().then((main1) => {
- botSendThreadMessage(main1.event_id).then((thread1a) => {
- botSendThreadMessage(main1.event_id).then((thread1b) => {
- // 1 unread on the main thread, 2 in the new thread
- cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist");
-
- // When we send receipts for main, and the second-last in the thread
- sendThreadedReadReceipt(main1);
- sendThreadedReadReceipt(thread1a, main1);
-
- // Then the room has only one unread - the one in the thread
- cy.findByLabelText(`${otherRoomName} 1 unread message.`).should("exist");
- });
- });
- });
- });
-
- it("Considers room read if there are receipts for main and other thread", () => {
- // Given we sent 3 events on the main thread
- botSendMessage().then((main1) => {
- botSendThreadMessage(main1.event_id).then((thread1a) => {
- botSendThreadMessage(main1.event_id).then((thread1b) => {
- // 1 unread on the main thread, 2 in the new thread
- cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist");
-
- // When we send receipts for main, and the last in the thread
- sendThreadedReadReceipt(main1);
- sendThreadedReadReceipt(thread1b, main1);
-
- // Then the room has no unreads
- cy.findByLabelText(`${otherRoomName}`).should("exist");
- });
- });
- });
- });
-
- it("Recognises unread messages on a thread after receiving a unthreaded receipt for earlier ones", () => {
- // Given we sent 3 events on the main thread
- botSendMessage().then((main1) => {
- botSendThreadMessage(main1.event_id).then((thread1a) => {
- botSendThreadMessage(main1.event_id).then(() => {
- // 1 unread on the main thread, 2 in the new thread
- cy.findByLabelText(`${otherRoomName} 3 unread messages.`).should("exist");
-
- // When we send an unthreaded receipt for the second-last in the thread
- sendUnthreadedReadReceipt(thread1a);
-
- // Then the room has only one unread - the one in the
- // thread. The one in main is read because the unthreaded
- // receipt is for a later event.
- cy.findByLabelText(`${otherRoomName} 1 unread message.`).should("exist");
- });
- });
- });
- });
-
- it("Recognises unread messages on main after receiving a unthreaded receipt for a thread message", () => {
- // Given we sent 3 events on the main thread
- botSendMessage().then((main1) => {
- botSendThreadMessage(main1.event_id).then(() => {
- botSendThreadMessage(main1.event_id).then((thread1b) => {
- botSendMessage().then(() => {
- // 2 unreads on the main thread, 2 in the new thread
- cy.findByLabelText(`${otherRoomName} 4 unread messages.`).should("exist");
-
- // When we send an unthreaded receipt for the last in the thread
- sendUnthreadedReadReceipt(thread1b);
-
- // Then the room has only one unread - the one in the
- // main thread, because it is later than the unthreaded
- // receipt.
- cy.findByLabelText(`${otherRoomName} 1 unread message.`).should("exist");
- });
- });
- });
- });
- });
-
- /**
- * The idea of this test is to intercept the receipt / read read_markers requests and
- * assert that the correct ones are sent.
- * Prose playbook:
- * - Another user sends enough messages that the timeline becomes scrollable
- * - The current user looks at the room and jumps directly to the first unread message
- * - At this point, a receipt for the last message in the room and
- * a fully read marker for the last visible message are expected to be sent
- * - Then the user jumps to the end of the timeline
- * - A fully read marker for the last message in the room is expected to be sent
- */
- it("Should send the correct receipts", () => {
- const uriEncodedOtherRoomId = encodeURIComponent(otherRoomId);
-
- cy.intercept({
- method: "POST",
- url: new RegExp(
- `http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/receipt/m\\.read/.+`,
- ),
- }).as("receiptRequest");
-
- const numberOfMessages = 20;
- const sendMessagePromises = [];
-
- for (let i = 1; i <= numberOfMessages; i++) {
- sendMessagePromises.push(botSendMessage(i));
- }
-
- cy.all(sendMessagePromises).then((sendMessageResponses) => {
- const lastMessageId = sendMessageResponses.at(-1).event_id;
- const uriEncodedLastMessageId = encodeURIComponent(lastMessageId);
-
- // wait until all messages have been received
- cy.findByLabelText(`${otherRoomName} ${sendMessagePromises.length} unread messages.`).should("exist");
-
- // switch to the room with the messages
- cy.visit("/#/room/" + otherRoomId);
-
- cy.wait("@receiptRequest").should((req) => {
- // assert the read receipt for the last message in the room
- expect(req.request.url).to.contain(uriEncodedLastMessageId);
- expect(req.request.body).to.deep.equal({
- thread_id: "main",
- });
- });
-
- // the following code tests the fully read marker somewhere in the middle of the room
-
- cy.intercept({
- method: "POST",
- url: new RegExp(`http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/read_markers`),
- }).as("readMarkersRequest");
-
- cy.findByRole("button", { name: "Jump to first unread message." }).click();
-
- cy.wait("@readMarkersRequest").should((req) => {
- // since this is not pixel perfect,
- // the fully read marker should be +/- 1 around the last visible message
- expect(Array.from(Object.keys(req.request.body))).to.deep.equal(["m.fully_read"]);
- expect(req.request.body["m.fully_read"]).to.be.oneOf([
- sendMessageResponses[11].event_id,
- sendMessageResponses[12].event_id,
- sendMessageResponses[13].event_id,
- ]);
- });
-
- // the following code tests the fully read marker at the bottom of the room
-
- cy.intercept({
- method: "POST",
- url: new RegExp(`http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/read_markers`),
- }).as("readMarkersRequest");
-
- cy.findByRole("button", { name: "Scroll to most recent messages" }).click();
-
- cy.wait("@readMarkersRequest").should((req) => {
- expect(req.request.body).to.deep.equal({
- ["m.fully_read"]: sendMessageResponses.at(-1).event_id,
- });
- });
- });
- });
-});
diff --git a/cypress/support/client.ts b/cypress/support/client.ts
index 44bc1487af..4fc1a24e05 100644
--- a/cypress/support/client.ts
+++ b/cypress/support/client.ts
@@ -19,14 +19,12 @@ limitations under the License.
import type {
MatrixClient,
Room,
- MatrixEvent,
IContent,
FileType,
Upload,
UploadOpts,
ICreateRoomOpts,
ISendEventResponse,
- ReceiptType,
} from "matrix-js-sdk/src/matrix";
import Chainable = Cypress.Chainable;
import { UserCredentials } from "./login";
@@ -76,13 +74,6 @@ declare global {
eventType: string,
content: IContent,
): Chainable;
- /**
- * @param {MatrixEvent} event
- * @param {ReceiptType} receiptType
- * @param {boolean} unthreaded
- * @return {module:http-api.MatrixError} Rejects: with an error response.
- */
- sendReadReceipt(event: MatrixEvent, receiptType?: ReceiptType, unthreaded?: boolean): Chainable<{}>;
/**
* @param {string} name
* @param {module:client.callback} callback Optional.
@@ -209,15 +200,6 @@ Cypress.Commands.add(
},
);
-Cypress.Commands.add(
- "sendReadReceipt",
- (event: MatrixEvent, receiptType?: ReceiptType, unthreaded?: boolean): Chainable<{}> => {
- return cy.getClient().then(async (cli: MatrixClient) => {
- return cli.sendReadReceipt(event, receiptType, unthreaded);
- });
- },
-);
-
Cypress.Commands.add("setDisplayName", (name: string): Chainable<{}> => {
return cy.getClient().then(async (cli: MatrixClient) => {
return cli.setDisplayName(name);
diff --git a/playwright/e2e/read-receipts/read-receipts.spec.ts b/playwright/e2e/read-receipts/read-receipts.spec.ts
new file mode 100644
index 0000000000..f2bad62dd0
--- /dev/null
+++ b/playwright/e2e/read-receipts/read-receipts.spec.ts
@@ -0,0 +1,331 @@
+/*
+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 type { JSHandle } from "@playwright/test";
+import type { MatrixEvent, ISendEventResponse, ReceiptType } from "matrix-js-sdk/src/matrix";
+import { test, expect } from "../../element-web-test";
+import { ElementAppPage } from "../../pages/ElementAppPage";
+import { Bot } from "../../pages/bot";
+
+test.describe("Read receipts", () => {
+ test.use({
+ displayName: "Mae",
+ botCreateOpts: { displayName: "Other User" },
+ });
+
+ const selectedRoomName = "Selected Room";
+ const otherRoomName = "Other Room";
+
+ let otherRoomId: string;
+ let selectedRoomId: string;
+
+ const sendMessage = async (bot: Bot, no = 1): Promise => {
+ return bot.sendMessage(otherRoomId, { body: `Message ${no}`, msgtype: "m.text" });
+ };
+
+ const botSendThreadMessage = (bot: Bot, threadId: string): Promise => {
+ return bot.sendEvent(otherRoomId, threadId, "m.room.message", { body: "Message", msgtype: "m.text" });
+ };
+
+ const fakeEventFromSent = (
+ app: ElementAppPage,
+ eventResponse: ISendEventResponse,
+ threadRootId: string | undefined,
+ ): Promise> => {
+ return app.client.evaluateHandle(
+ (client, { otherRoomId, eventResponse, threadRootId }) => {
+ return {
+ getRoomId: () => otherRoomId,
+ getId: () => eventResponse.event_id,
+ threadRootId,
+ getTs: () => 1,
+ isRelation: (relType) => {
+ return !relType || relType === "m.thread";
+ },
+ } as any as MatrixEvent;
+ },
+ { otherRoomId, eventResponse, threadRootId },
+ );
+ };
+
+ /**
+ * Send a threaded receipt marking the message referred to in
+ * eventResponse as read. If threadRootEventResponse is supplied, the
+ * receipt will have its event_id as the thread root ID for the receipt.
+ */
+ const sendThreadedReadReceipt = async (
+ app: ElementAppPage,
+ eventResponse: ISendEventResponse,
+ threadRootEventResponse: ISendEventResponse = undefined,
+ ) => {
+ await app.client.sendReadReceipt(
+ await fakeEventFromSent(app, eventResponse, threadRootEventResponse?.event_id),
+ );
+ };
+
+ /**
+ * Send an unthreaded receipt marking the message referred to in
+ * eventResponse as read.
+ */
+ const sendUnthreadedReadReceipt = async (app: ElementAppPage, eventResponse: ISendEventResponse) => {
+ await app.client.sendReadReceipt(
+ await fakeEventFromSent(app, eventResponse, undefined),
+ "m.read" as any as ReceiptType,
+ true,
+ );
+ };
+
+ test.beforeEach(async ({ page, app, user, bot }) => {
+ /*
+ * Create 2 rooms:
+ *
+ * - Selected room - this one is clicked in the UI
+ * - Other room - this one contains the bot, which will send events so
+ * we can check its unread state.
+ */
+ selectedRoomId = await app.client.createRoom({ name: selectedRoomName });
+ // Invite the bot to Other room
+ otherRoomId = await app.client.createRoom({ name: otherRoomName, invite: [bot.credentials.userId] });
+
+ await page.goto(`/#/room/${otherRoomId}`);
+ await expect(page.getByText(`${bot.credentials.displayName} joined the room`)).toBeVisible();
+
+ // Then go into Selected room
+ await page.goto(`/#/room/${selectedRoomId}`);
+ });
+
+ test("With sync accumulator, considers main thread and unthreaded receipts #24629", async ({ page, app, bot }) => {
+ // Details are in https://github.com/vector-im/element-web/issues/24629
+ // This proves we've fixed one of the "stuck unreads" issues.
+
+ // Given we sent 3 events on the main thread
+ await sendMessage(bot);
+ const main2 = await sendMessage(bot);
+ const main3 = await sendMessage(bot);
+
+ // (So the room starts off unread)
+ await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible();
+
+ // When we send a threaded receipt for the last event in main
+ // And an unthreaded receipt for an earlier event
+ await sendThreadedReadReceipt(app, main3);
+ await sendUnthreadedReadReceipt(app, main2);
+
+ // (So the room has no unreads)
+ await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible();
+
+ // And we persuade the app to persist its state to indexeddb by reloading and waiting
+ await page.reload();
+ await expect(page.getByLabel(`${selectedRoomName}`)).toBeVisible();
+
+ // And we reload again, fetching the persisted state FROM indexeddb
+ await page.reload();
+
+ // Then the room is read, because the persisted state correctly remembers both
+ // receipts. (In #24629, the unthreaded receipt overwrote the main thread one,
+ // meaning that the room still said it had unread messages.)
+ await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible();
+ await expect(page.getByLabel(`${otherRoomName} Unread messages.`)).not.toBeVisible();
+ });
+
+ test("Recognises unread messages on main thread after receiving a receipt for earlier ones", async ({
+ page,
+ app,
+ bot,
+ }) => {
+ // Given we sent 3 events on the main thread
+ await sendMessage(bot);
+ const main2 = await sendMessage(bot);
+ await sendMessage(bot);
+
+ // (The room starts off unread)
+ await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible();
+
+ // When we send a threaded receipt for the second-last event in main
+ await sendThreadedReadReceipt(app, main2);
+
+ // Then the room has only one unread
+ await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible();
+ });
+
+ test("Considers room read if there is only a main thread and we have a main receipt", async ({
+ page,
+ app,
+ bot,
+ }) => {
+ // Given we sent 3 events on the main thread
+ await sendMessage(bot);
+ await sendMessage(bot);
+ const main3 = await sendMessage(bot);
+ // (The room starts off unread)
+ await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible();
+
+ // When we send a threaded receipt for the last event in main
+ await sendThreadedReadReceipt(app, main3);
+
+ // Then the room has no unreads
+ await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible();
+ });
+
+ test("Recognises unread messages on other thread after receiving a receipt for earlier ones", async ({
+ page,
+ app,
+ bot,
+ }) => {
+ // Given we sent 3 events on the main thread
+ const main1 = await sendMessage(bot);
+ const thread1a = await botSendThreadMessage(bot, main1.event_id);
+ await botSendThreadMessage(bot, main1.event_id);
+ // 1 unread on the main thread, 2 in the new thread
+ await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible();
+
+ // When we send receipts for main, and the second-last in the thread
+ await sendThreadedReadReceipt(app, main1);
+ await sendThreadedReadReceipt(app, thread1a, main1);
+
+ // Then the room has only one unread - the one in the thread
+ await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible();
+ });
+
+ test("Considers room read if there are receipts for main and other thread", async ({ page, app, bot }) => {
+ // Given we sent 3 events on the main thread
+ const main1 = await sendMessage(bot);
+ await botSendThreadMessage(bot, main1.event_id);
+ const thread1b = await botSendThreadMessage(bot, main1.event_id);
+ // 1 unread on the main thread, 2 in the new thread
+ await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible();
+
+ // When we send receipts for main, and the last in the thread
+ await sendThreadedReadReceipt(app, main1);
+ await sendThreadedReadReceipt(app, thread1b, main1);
+
+ // Then the room has no unreads
+ await expect(page.getByLabel(`${otherRoomName}`)).toBeVisible();
+ });
+
+ test("Recognises unread messages on a thread after receiving a unthreaded receipt for earlier ones", async ({
+ page,
+ app,
+ bot,
+ }) => {
+ // Given we sent 3 events on the main thread
+ const main1 = await sendMessage(bot);
+ const thread1a = await botSendThreadMessage(bot, main1.event_id);
+ await botSendThreadMessage(bot, main1.event_id);
+ // 1 unread on the main thread, 2 in the new thread
+ await expect(page.getByLabel(`${otherRoomName} 3 unread messages.`)).toBeVisible();
+
+ // When we send an unthreaded receipt for the second-last in the thread
+ await sendUnthreadedReadReceipt(app, thread1a);
+
+ // Then the room has only one unread - the one in the
+ // thread. The one in main is read because the unthreaded
+ // receipt is for a later event.
+ await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible();
+ });
+
+ test("Recognises unread messages on main after receiving a unthreaded receipt for a thread message", async ({
+ page,
+ app,
+ bot,
+ }) => {
+ // Given we sent 3 events on the main thread
+ const main1 = await sendMessage(bot);
+ await botSendThreadMessage(bot, main1.event_id);
+ const thread1b = await botSendThreadMessage(bot, main1.event_id);
+ await sendMessage(bot);
+ // 2 unreads on the main thread, 2 in the new thread
+ await expect(page.getByLabel(`${otherRoomName} 4 unread messages.`)).toBeVisible();
+
+ // When we send an unthreaded receipt for the last in the thread
+ await sendUnthreadedReadReceipt(app, thread1b);
+
+ // Then the room has only one unread - the one in the
+ // main thread, because it is later than the unthreaded
+ // receipt.
+ await expect(page.getByLabel(`${otherRoomName} 1 unread message.`)).toBeVisible();
+ });
+
+ /**
+ * The idea of this test is to intercept the receipt / read read_markers requests and
+ * assert that the correct ones are sent.
+ * Prose playbook:
+ * - Another user sends enough messages that the timeline becomes scrollable
+ * - The current user looks at the room and jumps directly to the first unread message
+ * - At this point, a receipt for the last message in the room and
+ * a fully read marker for the last visible message are expected to be sent
+ * - Then the user jumps to the end of the timeline
+ * - A fully read marker for the last message in the room is expected to be sent
+ */
+ test("Should send the correct receipts", async ({ page, bot }) => {
+ const uriEncodedOtherRoomId = encodeURIComponent(otherRoomId);
+
+ const receiptRequestPromise = page.waitForRequest(
+ new RegExp(`http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/receipt/m\\.read/.+`),
+ );
+
+ const numberOfMessages = 20;
+ const sendMessageResponses: ISendEventResponse[] = [];
+
+ for (let i = 1; i <= numberOfMessages; i++) {
+ sendMessageResponses.push(await sendMessage(bot, i));
+ }
+
+ const lastMessageId = sendMessageResponses.at(-1).event_id;
+ const uriEncodedLastMessageId = encodeURIComponent(lastMessageId);
+
+ // wait until all messages have been received
+ await expect(page.getByLabel(`${otherRoomName} ${sendMessageResponses.length} unread messages.`)).toBeVisible();
+
+ // switch to the room with the messages
+ await page.goto(`/#/room/${otherRoomId}`);
+
+ const receiptRequest = await receiptRequestPromise;
+ // assert the read receipt for the last message in the room
+ expect(receiptRequest.url()).toContain(uriEncodedLastMessageId);
+ expect(receiptRequest.postDataJSON()).toEqual({
+ thread_id: "main",
+ });
+
+ // the following code tests the fully read marker somewhere in the middle of the room
+ const readMarkersRequestPromise = page.waitForRequest(
+ new RegExp(`http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/read_markers`),
+ );
+
+ await page.getByRole("button", { name: "Jump to first unread message." }).click();
+
+ const readMarkersRequest = await readMarkersRequestPromise;
+ // since this is not pixel perfect,
+ // the fully read marker should be +/- 1 around the last visible message
+ expect([
+ sendMessageResponses[11].event_id,
+ sendMessageResponses[12].event_id,
+ sendMessageResponses[13].event_id,
+ ]).toContain(readMarkersRequest.postDataJSON()["m.fully_read"]);
+
+ // the following code tests the fully read marker at the bottom of the room
+ const readMarkersRequestPromise2 = page.waitForRequest(
+ new RegExp(`http://localhost:\\d+/_matrix/client/v3/rooms/${uriEncodedOtherRoomId}/read_markers`),
+ );
+
+ await page.getByRole("button", { name: "Scroll to most recent messages" }).click();
+
+ const readMarkersRequest2 = await readMarkersRequestPromise2;
+ expect(readMarkersRequest2.postDataJSON()).toEqual({
+ ["m.fully_read"]: sendMessageResponses.at(-1).event_id,
+ });
+ });
+});
diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts
index ca8d8a6014..da669c0f2a 100644
--- a/playwright/pages/client.ts
+++ b/playwright/pages/client.ts
@@ -17,7 +17,15 @@ limitations under the License.
import { JSHandle, Page } from "@playwright/test";
import { PageFunctionOn } from "playwright-core/types/structs";
-import type { IContent, ICreateRoomOpts, ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix";
+import type {
+ IContent,
+ ICreateRoomOpts,
+ ISendEventResponse,
+ MatrixClient,
+ Room,
+ MatrixEvent,
+ ReceiptType,
+} from "matrix-js-sdk/src/matrix";
export class Client {
protected client: JSHandle;
@@ -196,4 +204,23 @@ export class Client {
userId,
});
}
+
+ /**
+ * @param {MatrixEvent} event
+ * @param {ReceiptType} receiptType
+ * @param {boolean} unthreaded
+ */
+ public async sendReadReceipt(
+ event: JSHandle,
+ receiptType?: ReceiptType,
+ unthreaded?: boolean,
+ ): Promise<{}> {
+ const client = await this.prepareClient();
+ return client.evaluate(
+ (client, { event, receiptType, unthreaded }) => {
+ return client.sendReadReceipt(event, receiptType, unthreaded);
+ },
+ { event, receiptType, unthreaded },
+ );
+ }
}