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

Commit 4ae94ae

Browse files
dbkrflorianduros
andauthored
Mark all threads as read button (#12378)
* Mark all threads as read button * Wrap in TooltipProvider and update snapshots * Remove TooltipProvider wrapper: just add it to the test * Add some more tests * Add test for no-room-context handler because sonarcloud * Add playwright test * Make assertNoTacIndicator wait * Use dedicated useMatrixClientContext function Co-authored-by: Florian Duros <[email protected]> * Use dedicated useRoomContext function Co-authored-by: Florian Duros <[email protected]> * Compound spacing variables Co-authored-by: Florian Duros <[email protected]> * Compound spacing variables Co-authored-by: Florian Duros <[email protected]> * Imports * Use createTestClient() * Add function to utils * Use mkRoom --------- Co-authored-by: Florian Duros <[email protected]>
1 parent f8e210f commit 4ae94ae

File tree

9 files changed

+190
-7
lines changed

9 files changed

+190
-7
lines changed

playwright/e2e/spaces/threads-activity-centre/index.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -283,8 +283,12 @@ export class Helpers {
283283
/**
284284
* Assert that the threads activity centre button has no indicator
285285
*/
286-
assertNoTacIndicator() {
287-
return expect(this.getTacButton()).toMatchScreenshot("tac-no-indicator.png");
286+
async assertNoTacIndicator() {
287+
// Assert by checkng neither of the known indicators are visible first. This will wait
288+
// if it takes a little time to disappear, but the screenshot comparison won't.
289+
await expect(this.getTacButton().locator("[data-indicator='success']")).not.toBeVisible();
290+
await expect(this.getTacButton().locator("[data-indicator='critical']")).not.toBeVisible();
291+
await expect(this.getTacButton()).toMatchScreenshot("tac-no-indicator.png");
288292
}
289293

290294
/**
@@ -375,6 +379,13 @@ export class Helpers {
375379
expandSpacePanel() {
376380
return this.page.getByRole("button", { name: "Expand" }).click();
377381
}
382+
383+
/**
384+
* Clicks the button to mark all threads as read in the current room
385+
*/
386+
clickMarkAllThreadsRead() {
387+
return this.page.getByLabel("Mark all as read").click();
388+
}
378389
}
379390

380391
export { expect };

playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts

+13
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,17 @@ test.describe("Threads Activity Centre", () => {
147147
await util.hoverTacButton();
148148
await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered-expanded.png");
149149
});
150+
151+
test("should mark all threads as read", async ({ room1, room2, util, msg, page }) => {
152+
await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
153+
154+
await util.assertNotificationTac();
155+
156+
await util.openTac();
157+
await util.clickRoomInTac(room1.name);
158+
159+
util.clickMarkAllThreadsRead();
160+
161+
await util.assertNoTacIndicator();
162+
});
150163
});

res/css/views/right_panel/_ThreadPanel.pcss

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2021 The Matrix.org Foundation C.I.C.
2+
Copyright 2021,2024 The Matrix.org Foundation C.I.C.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -20,11 +20,22 @@ limitations under the License.
2020

2121
.mx_BaseCard_header {
2222
.mx_BaseCard_header_title {
23+
.mx_BaseCard_header_title_heading {
24+
margin-right: auto;
25+
}
26+
2327
.mx_AccessibleButton {
2428
font-size: 12px;
2529
color: $secondary-content;
2630
}
2731

32+
.mx_ThreadPanel_vertical_separator {
33+
height: 16px;
34+
margin-left: var(--cpd-space-3x);
35+
margin-right: var(--cpd-space-1x);
36+
border-left: 1px solid var(--cpd-color-gray-400);
37+
}
38+
2839
.mx_ThreadPanel_dropdown {
2940
padding: 3px $spacing-4 3px $spacing-8;
3041
border-radius: 4px;

res/img/element-icons/check-all.svg

+6
Loading

src/components/structures/ThreadPanel.tsx

+34-2
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@ limitations under the License.
1717
import { Optional } from "matrix-events-sdk";
1818
import React, { useContext, useEffect, useRef, useState } from "react";
1919
import { EventTimelineSet, Room, Thread } from "matrix-js-sdk/src/matrix";
20+
import { IconButton, Tooltip } from "@vector-im/compound-web";
21+
import { logger } from "matrix-js-sdk/src/logger";
2022

23+
import { Icon as MarkAllThreadsReadIcon } from "../../../res/img/element-icons/check-all.svg";
2124
import BaseCard from "../views/right_panel/BaseCard";
2225
import ResizeNotifier from "../../utils/ResizeNotifier";
23-
import MatrixClientContext from "../../contexts/MatrixClientContext";
26+
import MatrixClientContext, { useMatrixClientContext } from "../../contexts/MatrixClientContext";
2427
import { _t } from "../../languageHandler";
2528
import { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
2629
import ContextMenu, { ChevronFace, MenuItemRadio, useContextMenu } from "./ContextMenu";
27-
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
30+
import RoomContext, { TimelineRenderingType, useRoomContext } from "../../contexts/RoomContext";
2831
import TimelinePanel from "./TimelinePanel";
2932
import { Layout } from "../../settings/enums/Layout";
3033
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
@@ -33,6 +36,7 @@ import PosthogTrackers from "../../PosthogTrackers";
3336
import { ButtonEvent } from "../views/elements/AccessibleButton";
3437
import Spinner from "../views/elements/Spinner";
3538
import Heading from "../views/typography/Heading";
39+
import { clearRoomNotification } from "../../utils/notifications";
3640

3741
interface IProps {
3842
roomId: string;
@@ -71,6 +75,8 @@ export const ThreadPanelHeader: React.FC<{
7175
setFilterOption: (filterOption: ThreadFilterType) => void;
7276
empty: boolean;
7377
}> = ({ filterOption, setFilterOption, empty }) => {
78+
const mxClient = useMatrixClientContext();
79+
const roomContext = useRoomContext();
7480
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
7581
const options: readonly ThreadPanelHeaderOption[] = [
7682
{
@@ -109,13 +115,39 @@ export const ThreadPanelHeader: React.FC<{
109115
{contextMenuOptions}
110116
</ContextMenu>
111117
) : null;
118+
119+
const onMarkAllThreadsReadClick = React.useCallback(() => {
120+
if (!roomContext.room) {
121+
logger.error("No room in context to mark all threads read");
122+
return;
123+
}
124+
// This actually clears all room notifications by sending an unthreaded read receipt.
125+
// We'd have to loop over all unread threads (pagninating back to find any we don't
126+
// know about yet) and send threaded receipts for all of them... or implement a
127+
// specific API for it. In practice, the user will have to be viewing the room to
128+
// see this button, so will have marked the room itself read anyway.
129+
clearRoomNotification(roomContext.room, mxClient).catch((e) => {
130+
logger.error("Failed to mark all threads read", e);
131+
});
132+
}, [roomContext.room, mxClient]);
133+
112134
return (
113135
<div className="mx_BaseCard_header_title">
114136
<Heading size="4" className="mx_BaseCard_header_title_heading">
115137
{_t("common|threads")}
116138
</Heading>
117139
{!empty && (
118140
<>
141+
<Tooltip label={_t("threads|mark_all_read")}>
142+
<IconButton
143+
onClick={onMarkAllThreadsReadClick}
144+
aria-label={_t("threads|mark_all_read")}
145+
size="24px"
146+
>
147+
<MarkAllThreadsReadIcon />
148+
</IconButton>
149+
</Tooltip>
150+
<div className="mx_ThreadPanel_vertical_separator" />
119151
<ContextMenuButton
120152
className="mx_ThreadPanel_dropdown"
121153
ref={button}

src/i18n/strings/en_EN.json

+1
Original file line numberDiff line numberDiff line change
@@ -3151,6 +3151,7 @@
31513151
"empty_heading": "Keep discussions organised with threads",
31523152
"empty_tip": "<b>Tip:</b> Use “%(replyInThread)s” when hovering over a message.",
31533153
"error_start_thread_existing_relation": "Can't create a thread from an event with an existing relation",
3154+
"mark_all_read": "Mark all as read",
31543155
"my_threads": "My threads",
31553156
"my_threads_description": "Shows all threads you've participated in",
31563157
"open_thread": "Open thread",

test/components/structures/ThreadPanel-test.tsx

+60-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ limitations under the License.
1515
*/
1616

1717
import React from "react";
18-
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
18+
import { render, screen, fireEvent, waitFor, getByRole } from "@testing-library/react";
1919
import { mocked } from "jest-mock";
2020
import {
2121
MatrixClient,
@@ -34,8 +34,9 @@ import { _t } from "../../../src/languageHandler";
3434
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
3535
import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks";
3636
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
37-
import { getRoomContext, mockPlatformPeg, stubClient } from "../../test-utils";
37+
import { createTestClient, getRoomContext, mkRoom, mockPlatformPeg, stubClient } from "../../test-utils";
3838
import { mkThread } from "../../test-utils/threads";
39+
import { IRoomState } from "../../../src/components/structures/RoomView";
3940

4041
jest.mock("../../../src/utils/Feedback");
4142

@@ -48,6 +49,7 @@ describe("ThreadPanel", () => {
4849
filterOption={ThreadFilterType.All}
4950
setFilterOption={() => undefined}
5051
/>,
52+
{ wrapper: TooltipProvider },
5153
);
5254
expect(asFragment()).toMatchSnapshot();
5355
});
@@ -64,6 +66,18 @@ describe("ThreadPanel", () => {
6466
expect(asFragment()).toMatchSnapshot();
6567
});
6668

69+
it("matches snapshot when no threads", () => {
70+
const { asFragment } = render(
71+
<ThreadPanelHeader
72+
empty={true}
73+
filterOption={ThreadFilterType.All}
74+
setFilterOption={() => undefined}
75+
/>,
76+
{ wrapper: TooltipProvider },
77+
);
78+
expect(asFragment()).toMatchSnapshot();
79+
});
80+
6781
it("expect that ThreadPanelHeader properly opens a context menu when clicked on the button", () => {
6882
const { container } = render(
6983
<ThreadPanelHeader
@@ -98,6 +112,50 @@ describe("ThreadPanel", () => {
98112
);
99113
expect(foundButton).toMatchSnapshot();
100114
});
115+
116+
it("sends an unthreaded read receipt when the Mark All Threads Read button is clicked", async () => {
117+
const mockClient = createTestClient();
118+
const mockEvent = {} as MatrixEvent;
119+
const mockRoom = mkRoom(mockClient, "!roomId:example.org");
120+
mockRoom.getLastLiveEvent.mockReturnValue(mockEvent);
121+
const roomContextObject = {
122+
room: mockRoom,
123+
} as unknown as IRoomState;
124+
const { container } = render(
125+
<RoomContext.Provider value={roomContextObject}>
126+
<MatrixClientContext.Provider value={mockClient}>
127+
<TooltipProvider>
128+
<ThreadPanelHeader
129+
empty={false}
130+
filterOption={ThreadFilterType.All}
131+
setFilterOption={() => undefined}
132+
/>
133+
</TooltipProvider>
134+
</MatrixClientContext.Provider>
135+
</RoomContext.Provider>,
136+
);
137+
fireEvent.click(getByRole(container, "button", { name: "Mark all as read" }));
138+
await waitFor(() =>
139+
expect(mockClient.sendReadReceipt).toHaveBeenCalledWith(mockEvent, expect.anything(), true),
140+
);
141+
});
142+
143+
it("doesn't send a receipt if no room is in context", async () => {
144+
const mockClient = createTestClient();
145+
const { container } = render(
146+
<MatrixClientContext.Provider value={mockClient}>
147+
<TooltipProvider>
148+
<ThreadPanelHeader
149+
empty={false}
150+
filterOption={ThreadFilterType.All}
151+
setFilterOption={() => undefined}
152+
/>
153+
</TooltipProvider>
154+
</MatrixClientContext.Provider>,
155+
);
156+
fireEvent.click(getByRole(container, "button", { name: "Mark all as read" }));
157+
await waitFor(() => expect(mockClient.sendReadReceipt).not.toHaveBeenCalled());
158+
});
101159
});
102160

103161
describe("Filtering", () => {

test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap

+50
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@ exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properl
1010
>
1111
Threads
1212
</h4>
13+
<button
14+
aria-label="Mark all as read"
15+
class="_icon-button_16nk7_17"
16+
data-state="closed"
17+
role="button"
18+
style="--cpd-icon-button-size: 24px;"
19+
tabindex="0"
20+
>
21+
<div
22+
class="_indicator-icon_133tf_26"
23+
style="--cpd-icon-button-size: 100%;"
24+
>
25+
<div />
26+
</div>
27+
</button>
28+
<div
29+
class="mx_ThreadPanel_vertical_separator"
30+
/>
1331
<div
1432
aria-expanded="false"
1533
aria-haspopup="true"
@@ -33,6 +51,24 @@ exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly
3351
>
3452
Threads
3553
</h4>
54+
<button
55+
aria-label="Mark all as read"
56+
class="_icon-button_16nk7_17"
57+
data-state="closed"
58+
role="button"
59+
style="--cpd-icon-button-size: 24px;"
60+
tabindex="0"
61+
>
62+
<div
63+
class="_indicator-icon_133tf_26"
64+
style="--cpd-icon-button-size: 100%;"
65+
>
66+
<div />
67+
</div>
68+
</button>
69+
<div
70+
class="mx_ThreadPanel_vertical_separator"
71+
/>
3672
<div
3773
aria-expanded="false"
3874
aria-haspopup="true"
@@ -61,3 +97,17 @@ exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option
6197
</span>
6298
</div>
6399
`;
100+
101+
exports[`ThreadPanel Header matches snapshot when no threads 1`] = `
102+
<DocumentFragment>
103+
<div
104+
class="mx_BaseCard_header_title"
105+
>
106+
<h4
107+
class="mx_Heading_h4 mx_BaseCard_header_title_heading"
108+
>
109+
Threads
110+
</h4>
111+
</div>
112+
</DocumentFragment>
113+
`;

test/test-utils/test-utils.ts

+1
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,7 @@ export function mkStubRoom(
598598
getJoinedMemberCount: jest.fn().mockReturnValue(1),
599599
getJoinedMembers: jest.fn().mockReturnValue([]),
600600
getLiveTimeline: jest.fn().mockReturnValue(stubTimeline),
601+
getLastLiveEvent: jest.fn().mockReturnValue(undefined),
601602
getMember: jest.fn().mockReturnValue({
602603
userId: "@member:domain.bla",
603604
name: "Member",

0 commit comments

Comments
 (0)