mirror of
https://github.com/element-hq/element-web
synced 2024-11-22 01:05:42 +03:00
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:
parent
f8e210f1a0
commit
4ae94ae247
9 changed files with 190 additions and 7 deletions
|
@ -283,8 +283,12 @@ export class Helpers {
|
||||||
/**
|
/**
|
||||||
* Assert that the threads activity centre button has no indicator
|
* Assert that the threads activity centre button has no indicator
|
||||||
*/
|
*/
|
||||||
assertNoTacIndicator() {
|
async assertNoTacIndicator() {
|
||||||
return expect(this.getTacButton()).toMatchScreenshot("tac-no-indicator.png");
|
// 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() {
|
expandSpacePanel() {
|
||||||
return this.page.getByRole("button", { name: "Expand" }).click();
|
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 };
|
export { expect };
|
||||||
|
|
|
@ -147,4 +147,17 @@ test.describe("Threads Activity Centre", () => {
|
||||||
await util.hoverTacButton();
|
await util.hoverTacButton();
|
||||||
await expect(util.getSpacePanel()).toMatchScreenshot("tac-hovered-expanded.png");
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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 {
|
||||||
.mx_BaseCard_header_title {
|
.mx_BaseCard_header_title {
|
||||||
|
.mx_BaseCard_header_title_heading {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_AccessibleButton {
|
.mx_AccessibleButton {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: $secondary-content;
|
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 {
|
.mx_ThreadPanel_dropdown {
|
||||||
padding: 3px $spacing-4 3px $spacing-8;
|
padding: 3px $spacing-4 3px $spacing-8;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
6
res/img/element-icons/check-all.svg
Normal file
6
res/img/element-icons/check-all.svg
Normal 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 |
|
@ -17,14 +17,17 @@ limitations under the License.
|
||||||
import { Optional } from "matrix-events-sdk";
|
import { Optional } from "matrix-events-sdk";
|
||||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||||
import { EventTimelineSet, Room, Thread } from "matrix-js-sdk/src/matrix";
|
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 BaseCard from "../views/right_panel/BaseCard";
|
||||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
import MatrixClientContext, { useMatrixClientContext } from "../../contexts/MatrixClientContext";
|
||||||
import { _t } from "../../languageHandler";
|
import { _t } from "../../languageHandler";
|
||||||
import { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
|
import { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton";
|
||||||
import ContextMenu, { ChevronFace, MenuItemRadio, useContextMenu } from "./ContextMenu";
|
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 TimelinePanel from "./TimelinePanel";
|
||||||
import { Layout } from "../../settings/enums/Layout";
|
import { Layout } from "../../settings/enums/Layout";
|
||||||
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||||
|
@ -33,6 +36,7 @@ import PosthogTrackers from "../../PosthogTrackers";
|
||||||
import { ButtonEvent } from "../views/elements/AccessibleButton";
|
import { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||||
import Spinner from "../views/elements/Spinner";
|
import Spinner from "../views/elements/Spinner";
|
||||||
import Heading from "../views/typography/Heading";
|
import Heading from "../views/typography/Heading";
|
||||||
|
import { clearRoomNotification } from "../../utils/notifications";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
@ -71,6 +75,8 @@ export const ThreadPanelHeader: React.FC<{
|
||||||
setFilterOption: (filterOption: ThreadFilterType) => void;
|
setFilterOption: (filterOption: ThreadFilterType) => void;
|
||||||
empty: boolean;
|
empty: boolean;
|
||||||
}> = ({ filterOption, setFilterOption, empty }) => {
|
}> = ({ filterOption, setFilterOption, empty }) => {
|
||||||
|
const mxClient = useMatrixClientContext();
|
||||||
|
const roomContext = useRoomContext();
|
||||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
|
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
|
||||||
const options: readonly ThreadPanelHeaderOption[] = [
|
const options: readonly ThreadPanelHeaderOption[] = [
|
||||||
{
|
{
|
||||||
|
@ -109,6 +115,22 @@ export const ThreadPanelHeader: React.FC<{
|
||||||
{contextMenuOptions}
|
{contextMenuOptions}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
) : null;
|
) : 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 (
|
return (
|
||||||
<div className="mx_BaseCard_header_title">
|
<div className="mx_BaseCard_header_title">
|
||||||
<Heading size="4" className="mx_BaseCard_header_title_heading">
|
<Heading size="4" className="mx_BaseCard_header_title_heading">
|
||||||
|
@ -116,6 +138,16 @@ export const ThreadPanelHeader: React.FC<{
|
||||||
</Heading>
|
</Heading>
|
||||||
{!empty && (
|
{!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
|
<ContextMenuButton
|
||||||
className="mx_ThreadPanel_dropdown"
|
className="mx_ThreadPanel_dropdown"
|
||||||
ref={button}
|
ref={button}
|
||||||
|
|
|
@ -3151,6 +3151,7 @@
|
||||||
"empty_heading": "Keep discussions organised with threads",
|
"empty_heading": "Keep discussions organised with threads",
|
||||||
"empty_tip": "<b>Tip:</b> Use “%(replyInThread)s” when hovering over a message.",
|
"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",
|
"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": "My threads",
|
||||||
"my_threads_description": "Shows all threads you've participated in",
|
"my_threads_description": "Shows all threads you've participated in",
|
||||||
"open_thread": "Open thread",
|
"open_thread": "Open thread",
|
||||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
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 { mocked } from "jest-mock";
|
||||||
import {
|
import {
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
|
@ -34,8 +34,9 @@ import { _t } from "../../../src/languageHandler";
|
||||||
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
||||||
import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks";
|
||||||
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
|
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 { mkThread } from "../../test-utils/threads";
|
||||||
|
import { IRoomState } from "../../../src/components/structures/RoomView";
|
||||||
|
|
||||||
jest.mock("../../../src/utils/Feedback");
|
jest.mock("../../../src/utils/Feedback");
|
||||||
|
|
||||||
|
@ -48,6 +49,7 @@ describe("ThreadPanel", () => {
|
||||||
filterOption={ThreadFilterType.All}
|
filterOption={ThreadFilterType.All}
|
||||||
setFilterOption={() => undefined}
|
setFilterOption={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
|
{ wrapper: TooltipProvider },
|
||||||
);
|
);
|
||||||
expect(asFragment()).toMatchSnapshot();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
@ -64,6 +66,18 @@ describe("ThreadPanel", () => {
|
||||||
expect(asFragment()).toMatchSnapshot();
|
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", () => {
|
it("expect that ThreadPanelHeader properly opens a context menu when clicked on the button", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ThreadPanelHeader
|
<ThreadPanelHeader
|
||||||
|
@ -98,6 +112,50 @@ describe("ThreadPanel", () => {
|
||||||
);
|
);
|
||||||
expect(foundButton).toMatchSnapshot();
|
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", () => {
|
describe("Filtering", () => {
|
||||||
|
|
|
@ -10,6 +10,24 @@ exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properl
|
||||||
>
|
>
|
||||||
Threads
|
Threads
|
||||||
</h4>
|
</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
|
<div
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
|
@ -33,6 +51,24 @@ exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly
|
||||||
>
|
>
|
||||||
Threads
|
Threads
|
||||||
</h4>
|
</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
|
<div
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
|
@ -61,3 +97,17 @@ exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
`;
|
||||||
|
|
|
@ -598,6 +598,7 @@ export function mkStubRoom(
|
||||||
getJoinedMemberCount: jest.fn().mockReturnValue(1),
|
getJoinedMemberCount: jest.fn().mockReturnValue(1),
|
||||||
getJoinedMembers: jest.fn().mockReturnValue([]),
|
getJoinedMembers: jest.fn().mockReturnValue([]),
|
||||||
getLiveTimeline: jest.fn().mockReturnValue(stubTimeline),
|
getLiveTimeline: jest.fn().mockReturnValue(stubTimeline),
|
||||||
|
getLastLiveEvent: jest.fn().mockReturnValue(undefined),
|
||||||
getMember: jest.fn().mockReturnValue({
|
getMember: jest.fn().mockReturnValue({
|
||||||
userId: "@member:domain.bla",
|
userId: "@member:domain.bla",
|
||||||
name: "Member",
|
name: "Member",
|
||||||
|
|
Loading…
Reference in a new issue