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

Mark all threads as read button #12378

Merged
merged 15 commits into from
Mar 28, 2024
8 changes: 6 additions & 2 deletions playwright/e2e/spaces/threads-activity-centre/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

page.getByLabel("Mark all as read").click();
dbkr marked this conversation as resolved.
Show resolved Hide resolved

await util.assertNoTacIndicator();
});
});
13 changes: 12 additions & 1 deletion res/css/views/right_panel/_ThreadPanel.pcss
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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: 12px;
dbkr marked this conversation as resolved.
Show resolved Hide resolved
margin-right: 4px;
dbkr marked this conversation as resolved.
Show resolved Hide resolved
border-left: 1px solid var(--cpd-color-gray-400);
}

.mx_ThreadPanel_dropdown {
padding: 3px $spacing-4 3px $spacing-8;
border-radius: 4px;
Expand Down
6 changes: 6 additions & 0 deletions res/img/element-icons/check-all.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions src/components/structures/ThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ 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";
Expand All @@ -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;
Expand Down Expand Up @@ -71,6 +75,8 @@ export const ThreadPanelHeader: React.FC<{
setFilterOption: (filterOption: ThreadFilterType) => void;
empty: boolean;
}> = ({ filterOption, setFilterOption, empty }) => {
const mxClient = useContext(MatrixClientContext);
dbkr marked this conversation as resolved.
Show resolved Hide resolved
const roomContext = useContext(RoomContext);
dbkr marked this conversation as resolved.
Show resolved Hide resolved
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
const options: readonly ThreadPanelHeaderOption[] = [
{
Expand Down Expand Up @@ -109,13 +115,39 @@ export const ThreadPanelHeader: React.FC<{
{contextMenuOptions}
</ContextMenu>
) : 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 (
<div className="mx_BaseCard_header_title">
<Heading size="4" className="mx_BaseCard_header_title_heading">
{_t("common|threads")}
</Heading>
{!empty && (
<>
<Tooltip label={_t("threads|mark_all_read")}>
<IconButton
onClick={onMarkAllThreadsReadClick}
aria-label={_t("threads|mark_all_read")}
size="24px"
>
<MarkAllThreadsReadIcon />
</IconButton>
</Tooltip>
<div className="mx_ThreadPanel_vertical_separator" />
<ContextMenuButton
className="mx_ThreadPanel_dropdown"
ref={button}
Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -3151,6 +3151,7 @@
"empty_heading": "Keep discussions organised with threads",
"empty_tip": "<b>Tip:</b> 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",
Expand Down
64 changes: 63 additions & 1 deletion test/components/structures/ThreadPanel-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -36,6 +36,7 @@ import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { getRoomContext, mockPlatformPeg, stubClient } from "../../test-utils";
import { mkThread } from "../../test-utils/threads";
import { IRoomState } from "../../../src/components/structures/RoomView";

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

Expand All @@ -48,6 +49,7 @@ describe("ThreadPanel", () => {
filterOption={ThreadFilterType.All}
setFilterOption={() => undefined}
/>,
{ wrapper: TooltipProvider },
);
expect(asFragment()).toMatchSnapshot();
});
Expand All @@ -64,6 +66,18 @@ describe("ThreadPanel", () => {
expect(asFragment()).toMatchSnapshot();
});

it("matches snapshot when no threads", () => {
const { asFragment } = render(
<ThreadPanelHeader
empty={true}
filterOption={ThreadFilterType.All}
setFilterOption={() => undefined}
/>,
{ wrapper: TooltipProvider },
);
expect(asFragment()).toMatchSnapshot();
});

it("expect that ThreadPanelHeader properly opens a context menu when clicked on the button", () => {
const { container } = render(
<ThreadPanelHeader
Expand Down Expand Up @@ -98,6 +112,54 @@ describe("ThreadPanel", () => {
);
expect(foundButton).toMatchSnapshot();
});

it("sends an unthreaded read receipt when the Mark All Threads Read button is clicked", async () => {
const mockClient = {
sendReadReceipt: jest.fn(),
} as unknown as MatrixClient;
dbkr marked this conversation as resolved.
Show resolved Hide resolved
const mockEvent = {} as MatrixEvent;
const mockRoom = new Room("!roomId:example.org", mockClient, "", {});
dbkr marked this conversation as resolved.
Show resolved Hide resolved
mockRoom.getLastLiveEvent = jest.fn().mockReturnValue(mockEvent);
const roomContextObject = {
room: mockRoom,
} as unknown as IRoomState;
const { container } = render(
<RoomContext.Provider value={roomContextObject}>
<MatrixClientContext.Provider value={mockClient}>
<TooltipProvider>
<ThreadPanelHeader
empty={false}
filterOption={ThreadFilterType.All}
setFilterOption={() => undefined}
/>
</TooltipProvider>
</MatrixClientContext.Provider>
</RoomContext.Provider>,
);
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 = {
sendReadReceipt: jest.fn(),
} as unknown as MatrixClient;
const { container } = render(
<MatrixClientContext.Provider value={mockClient}>
<TooltipProvider>
<ThreadPanelHeader
empty={false}
filterOption={ThreadFilterType.All}
setFilterOption={() => undefined}
/>
</TooltipProvider>
</MatrixClientContext.Provider>,
);
fireEvent.click(getByRole(container, "button", { name: "Mark all as read" }));
await waitFor(() => expect(mockClient.sendReadReceipt).not.toHaveBeenCalled());
});
});

describe("Filtering", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properl
>
Threads
</h4>
<button
aria-label="Mark all as read"
class="_icon-button_16nk7_17"
data-state="closed"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
<div
class="mx_ThreadPanel_vertical_separator"
/>
<div
aria-expanded="false"
aria-haspopup="true"
Expand All @@ -33,6 +51,24 @@ exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly
>
Threads
</h4>
<button
aria-label="Mark all as read"
class="_icon-button_16nk7_17"
data-state="closed"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
<div
class="mx_ThreadPanel_vertical_separator"
/>
<div
aria-expanded="false"
aria-haspopup="true"
Expand Down Expand Up @@ -61,3 +97,17 @@ exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option
</span>
</div>
`;

exports[`ThreadPanel Header matches snapshot when no threads 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard_header_title"
>
<h4
class="mx_Heading_h4 mx_BaseCard_header_title_heading"
>
Threads
</h4>
</div>
</DocumentFragment>
`;
Loading