diff --git a/playwright/e2e/spaces/threads-activity-centre/index.ts b/playwright/e2e/spaces/threads-activity-centre/index.ts index 4360ddb981..fadf079eca 100644 --- a/playwright/e2e/spaces/threads-activity-centre/index.ts +++ b/playwright/e2e/spaces/threads-activity-centre/index.ts @@ -283,8 +283,12 @@ export class Helpers { /** * Assert that the threads activity centre button has no indicator */ - assertNoTacIndicator() { - return expect(this.getTacButton()).toMatchScreenshot("tac-no-indicator.png"); + async assertNoTacIndicator() { + // Assert by checkng neither of the known indicators are visible first. This will wait + // if it takes a little time to disappear, but the screenshot comparison won't. + await expect(this.getTacButton().locator("[data-indicator='success']")).not.toBeVisible(); + await expect(this.getTacButton().locator("[data-indicator='critical']")).not.toBeVisible(); + await expect(this.getTacButton()).toMatchScreenshot("tac-no-indicator.png"); } /** @@ -375,6 +379,13 @@ export class Helpers { expandSpacePanel() { return this.page.getByRole("button", { name: "Expand" }).click(); } + + /** + * Clicks the button to mark all threads as read in the current room + */ + clickMarkAllThreadsRead() { + return this.page.getByLabel("Mark all as read").click(); + } } export { expect }; diff --git a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts index 93094073b3..13361a70a2 100644 --- a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts +++ b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts @@ -147,4 +147,17 @@ test.describe("Threads Activity Centre", () => { await util.hoverTacButton(); await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered-expanded.png"); }); + + test("should mark all threads as read", async ({ room1, room2, util, msg, page }) => { + await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + + await util.assertNotificationTac(); + + await util.openTac(); + await util.clickRoomInTac(room1.name); + + util.clickMarkAllThreadsRead(); + + await util.assertNoTacIndicator(); + }); }); diff --git a/res/css/views/right_panel/_ThreadPanel.pcss b/res/css/views/right_panel/_ThreadPanel.pcss index 9d14c993df..104430c190 100644 --- a/res/css/views/right_panel/_ThreadPanel.pcss +++ b/res/css/views/right_panel/_ThreadPanel.pcss @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021,2024 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. @@ -20,11 +20,22 @@ limitations under the License. .mx_BaseCard_header { .mx_BaseCard_header_title { + .mx_BaseCard_header_title_heading { + margin-right: auto; + } + .mx_AccessibleButton { font-size: 12px; color: $secondary-content; } + .mx_ThreadPanel_vertical_separator { + height: 16px; + margin-left: var(--cpd-space-3x); + margin-right: var(--cpd-space-1x); + border-left: 1px solid var(--cpd-color-gray-400); + } + .mx_ThreadPanel_dropdown { padding: 3px $spacing-4 3px $spacing-8; border-radius: 4px; diff --git a/res/img/element-icons/check-all.svg b/res/img/element-icons/check-all.svg new file mode 100644 index 0000000000..d81382504d --- /dev/null +++ b/res/img/element-icons/check-all.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index d43b4e25d1..51c89bcf52 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -17,14 +17,17 @@ limitations under the License. import { Optional } from "matrix-events-sdk"; import React, { useContext, useEffect, useRef, useState } from "react"; import { EventTimelineSet, Room, Thread } from "matrix-js-sdk/src/matrix"; +import { IconButton, Tooltip } from "@vector-im/compound-web"; +import { logger } from "matrix-js-sdk/src/logger"; +import { Icon as MarkAllThreadsReadIcon } from "../../../res/img/element-icons/check-all.svg"; import BaseCard from "../views/right_panel/BaseCard"; import ResizeNotifier from "../../utils/ResizeNotifier"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; +import MatrixClientContext, { useMatrixClientContext } from "../../contexts/MatrixClientContext"; import { _t } from "../../languageHandler"; import { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton"; import ContextMenu, { ChevronFace, MenuItemRadio, useContextMenu } from "./ContextMenu"; -import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; +import RoomContext, { TimelineRenderingType, useRoomContext } from "../../contexts/RoomContext"; import TimelinePanel from "./TimelinePanel"; import { Layout } from "../../settings/enums/Layout"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; @@ -33,6 +36,7 @@ import PosthogTrackers from "../../PosthogTrackers"; import { ButtonEvent } from "../views/elements/AccessibleButton"; import Spinner from "../views/elements/Spinner"; import Heading from "../views/typography/Heading"; +import { clearRoomNotification } from "../../utils/notifications"; interface IProps { roomId: string; @@ -71,6 +75,8 @@ export const ThreadPanelHeader: React.FC<{ setFilterOption: (filterOption: ThreadFilterType) => void; empty: boolean; }> = ({ filterOption, setFilterOption, empty }) => { + const mxClient = useMatrixClientContext(); + const roomContext = useRoomContext(); const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const options: readonly ThreadPanelHeaderOption[] = [ { @@ -109,6 +115,22 @@ export const ThreadPanelHeader: React.FC<{ {contextMenuOptions} ) : null; + + const onMarkAllThreadsReadClick = React.useCallback(() => { + if (!roomContext.room) { + logger.error("No room in context to mark all threads read"); + return; + } + // This actually clears all room notifications by sending an unthreaded read receipt. + // We'd have to loop over all unread threads (pagninating back to find any we don't + // know about yet) and send threaded receipts for all of them... or implement a + // specific API for it. In practice, the user will have to be viewing the room to + // see this button, so will have marked the room itself read anyway. + clearRoomNotification(roomContext.room, mxClient).catch((e) => { + logger.error("Failed to mark all threads read", e); + }); + }, [roomContext.room, mxClient]); + return (
@@ -116,6 +138,16 @@ export const ThreadPanelHeader: React.FC<{ {!empty && ( <> + + + + + +
Tip: Use ā€œ%(replyInThread)sā€ when hovering over a message.", "error_start_thread_existing_relation": "Can't create a thread from an event with an existing relation", + "mark_all_read": "Mark all as read", "my_threads": "My threads", "my_threads_description": "Shows all threads you've participated in", "open_thread": "Open thread", diff --git a/test/components/structures/ThreadPanel-test.tsx b/test/components/structures/ThreadPanel-test.tsx index 9c8bed0af6..df939044fb 100644 --- a/test/components/structures/ThreadPanel-test.tsx +++ b/test/components/structures/ThreadPanel-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, getByRole } from "@testing-library/react"; import { mocked } from "jest-mock"; import { MatrixClient, @@ -34,8 +34,9 @@ import { _t } from "../../../src/languageHandler"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks"; import ResizeNotifier from "../../../src/utils/ResizeNotifier"; -import { getRoomContext, mockPlatformPeg, stubClient } from "../../test-utils"; +import { createTestClient, getRoomContext, mkRoom, mockPlatformPeg, stubClient } from "../../test-utils"; import { mkThread } from "../../test-utils/threads"; +import { IRoomState } from "../../../src/components/structures/RoomView"; jest.mock("../../../src/utils/Feedback"); @@ -48,6 +49,7 @@ describe("ThreadPanel", () => { filterOption={ThreadFilterType.All} setFilterOption={() => undefined} />, + { wrapper: TooltipProvider }, ); expect(asFragment()).toMatchSnapshot(); }); @@ -64,6 +66,18 @@ describe("ThreadPanel", () => { expect(asFragment()).toMatchSnapshot(); }); + it("matches snapshot when no threads", () => { + const { asFragment } = render( + undefined} + />, + { wrapper: TooltipProvider }, + ); + expect(asFragment()).toMatchSnapshot(); + }); + it("expect that ThreadPanelHeader properly opens a context menu when clicked on the button", () => { const { container } = render( { ); expect(foundButton).toMatchSnapshot(); }); + + it("sends an unthreaded read receipt when the Mark All Threads Read button is clicked", async () => { + const mockClient = createTestClient(); + const mockEvent = {} as MatrixEvent; + const mockRoom = mkRoom(mockClient, "!roomId:example.org"); + mockRoom.getLastLiveEvent.mockReturnValue(mockEvent); + const roomContextObject = { + room: mockRoom, + } as unknown as IRoomState; + const { container } = render( + + + + undefined} + /> + + + , + ); + fireEvent.click(getByRole(container, "button", { name: "Mark all as read" })); + await waitFor(() => + expect(mockClient.sendReadReceipt).toHaveBeenCalledWith(mockEvent, expect.anything(), true), + ); + }); + + it("doesn't send a receipt if no room is in context", async () => { + const mockClient = createTestClient(); + const { container } = render( + + + undefined} + /> + + , + ); + fireEvent.click(getByRole(container, "button", { name: "Mark all as read" })); + await waitFor(() => expect(mockClient.sendReadReceipt).not.toHaveBeenCalled()); + }); }); describe("Filtering", () => { diff --git a/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap b/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap index b395e603a6..3a7e57cce2 100644 --- a/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap +++ b/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap @@ -10,6 +10,24 @@ exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properl > Threads + +