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 <florianduros@element.io>

* Use dedicated useRoomContext function

Co-authored-by: Florian Duros <florianduros@element.io>

* Compound spacing variables

Co-authored-by: Florian Duros <florianduros@element.io>

* Compound spacing variables

Co-authored-by: Florian Duros <florianduros@element.io>

* Imports

* Use createTestClient()

* Add function to utils

* Use mkRoom

---------

Co-authored-by: Florian Duros <florianduros@element.io>
This commit is contained in:
David Baker 2024-03-28 17:38:21 +00:00 committed by GitHub
parent f8e210f1a0
commit 4ae94ae247
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 190 additions and 7 deletions

View file

@ -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 };

View file

@ -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();
});
});

View file

@ -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;

View file

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.75 2H14.25" stroke="#656D77" stroke-width="1.5" stroke-linecap="round"/>
<path d="M1.75 6H14.25" stroke="#656D77" stroke-width="1.5" stroke-linecap="round"/>
<path d="M1.75 10H8.25" stroke="#656D77" stroke-width="1.5" stroke-linecap="round"/>
<path d="M6.17188 13L8.2932 15.1213L13.95 9.46447" stroke="#656D77" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 494 B

View file

@ -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<HTMLElement>();
const options: readonly ThreadPanelHeaderOption[] = [
{
@ -109,6 +115,22 @@ 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">
@ -116,6 +138,16 @@ export const ThreadPanelHeader: React.FC<{
</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}

View file

@ -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",

View file

@ -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(
<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
@ -98,6 +112,50 @@ describe("ThreadPanel", () => {
);
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(
<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 = createTestClient();
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", () => {

View file

@ -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"
@ -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"
@ -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>
`;

View file

@ -598,6 +598,7 @@ export function mkStubRoom(
getJoinedMemberCount: jest.fn().mockReturnValue(1),
getJoinedMembers: jest.fn().mockReturnValue([]),
getLiveTimeline: jest.fn().mockReturnValue(stubTimeline),
getLastLiveEvent: jest.fn().mockReturnValue(undefined),
getMember: jest.fn().mockReturnValue({
userId: "@member:domain.bla",
name: "Member",