From e9224f6fce1a1ca5096fc8253281b57b27853afe Mon Sep 17 00:00:00 2001 From: Germain Date: Thu, 22 Dec 2022 13:18:38 +0000 Subject: [PATCH] Add mark as read option in room setting (#9798) --- .../_RoomGeneralContextMenu.pcss | 4 + .../element-icons/roomlist/mark-as-read.svg | 3 + .../context_menus/RoomGeneralContextMenu.tsx | 48 +++++-- src/i18n/strings/en_EN.json | 1 + src/utils/notifications.ts | 72 +++++++--- .../RoomGeneralContextMenu-test.tsx | 113 +++++++++++++++ .../RoomGeneralContextMenu-test.tsx.snap | 135 ++++++++++++++++++ .../views/settings/Notifications-test.tsx | 2 +- test/utils/notifications-test.ts | 37 ++++- 9 files changed, 378 insertions(+), 37 deletions(-) create mode 100644 res/img/element-icons/roomlist/mark-as-read.svg create mode 100644 test/components/views/context_menus/RoomGeneralContextMenu-test.tsx create mode 100644 test/components/views/context_menus/__snapshots__/RoomGeneralContextMenu-test.tsx.snap diff --git a/res/css/views/context_menus/_RoomGeneralContextMenu.pcss b/res/css/views/context_menus/_RoomGeneralContextMenu.pcss index 54c6193c2b..b5162bb1bb 100644 --- a/res/css/views/context_menus/_RoomGeneralContextMenu.pcss +++ b/res/css/views/context_menus/_RoomGeneralContextMenu.pcss @@ -6,6 +6,10 @@ mask-image: url("$(res)/img/element-icons/roomlist/low-priority.svg"); } +.mx_RoomGeneralContextMenu_iconMarkAsRead::before { + mask-image: url("$(res)/img/element-icons/roomlist/mark-as-read.svg"); +} + .mx_RoomGeneralContextMenu_iconNotificationsDefault::before { mask-image: url("$(res)/img/element-icons/notifications.svg"); } diff --git a/res/img/element-icons/roomlist/mark-as-read.svg b/res/img/element-icons/roomlist/mark-as-read.svg new file mode 100644 index 0000000000..322fa6c791 --- /dev/null +++ b/res/img/element-icons/roomlist/mark-as-read.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/views/context_menus/RoomGeneralContextMenu.tsx b/src/components/views/context_menus/RoomGeneralContextMenu.tsx index fc9bb333c8..b9d5ea412c 100644 --- a/src/components/views/context_menus/RoomGeneralContextMenu.tsx +++ b/src/components/views/context_menus/RoomGeneralContextMenu.tsx @@ -23,11 +23,14 @@ import RoomListActions from "../../../actions/RoomListActions"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import dis from "../../../dispatcher/dispatcher"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; +import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { _t } from "../../../languageHandler"; +import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import DMRoomMap from "../../../utils/DMRoomMap"; +import { clearRoomNotification } from "../../../utils/notifications"; import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; import IconizedContextMenu, { IconizedContextMenuCheckbox, @@ -36,7 +39,7 @@ import IconizedContextMenu, { } from "../context_menus/IconizedContextMenu"; import { ButtonEvent } from "../elements/AccessibleButton"; -interface IProps extends IContextMenuProps { +export interface RoomGeneralContextMenuProps extends IContextMenuProps { room: Room; onPostFavoriteClick?: (event: ButtonEvent) => void; onPostLowPriorityClick?: (event: ButtonEvent) => void; @@ -58,7 +61,7 @@ export const RoomGeneralContextMenu = ({ onPostLeaveClick, onPostForgetClick, ...props -}: IProps) => { +}: RoomGeneralContextMenuProps) => { const cli = useContext(MatrixClientContext); const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => RoomListStore.instance.getTagsForRoom(room), @@ -115,8 +118,8 @@ export const RoomGeneralContextMenu = ({ /> ); - let inviteOption: JSX.Element; - if (room.canInvite(cli.getUserId()) && !isDm) { + let inviteOption: JSX.Element | null = null; + if (room.canInvite(cli.getUserId()!) && !isDm) { inviteOption = ( NotificationColor.None ? ( + { + clearRoomNotification(room, cli); + onFinished?.(); + }} + active={false} + label={_t("Mark as read")} + iconClassName="mx_RoomGeneralContextMenu_iconMarkAsRead" + /> + ) : null; + return ( - {!roomTags.includes(DefaultTagID.Archived) && ( - - {favoriteOption} - {lowPriorityOption} - {inviteOption} - {copyLinkOption} - {settingsOption} - - )} + + {markAsReadOption} + {!roomTags.includes(DefaultTagID.Archived) && ( + <> + {favoriteOption} + {lowPriorityOption} + {inviteOption} + {copyLinkOption} + {settingsOption} + + )} + {leaveOption} ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 13ee074d58..63dd331bbf 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3187,6 +3187,7 @@ "Copy room link": "Copy room link", "Low Priority": "Low Priority", "Forget Room": "Forget Room", + "Mark as read": "Mark as read", "Use default": "Use default", "Mentions & Keywords": "Mentions & Keywords", "See room timeline (devtools)": "See room timeline (devtools)", diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index 8929240e6f..e6a919c984 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -18,7 +18,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import { LOCAL_NOTIFICATION_SETTINGS_PREFIX } from "matrix-js-sdk/src/@types/event"; import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; -import { Room } from "matrix-js-sdk/src/models/room"; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; import SettingsStore from "../settings/SettingsStore"; @@ -59,27 +59,57 @@ export function localNotificationsAreSilenced(cli: MatrixClient): boolean { return event?.getContent()?.is_silenced ?? false; } -export function clearAllNotifications(client: MatrixClient): Promise> { - const receiptPromises = client.getRooms().reduce((promises, room: Room) => { +/** + * Mark a room as read + * @param room + * @param client + * @returns a promise that resolves when the room has been marked as read + */ +export async function clearRoomNotification(room: Room, client: MatrixClient): Promise<{} | undefined> { + const roomEvents = room.getLiveTimeline().getEvents(); + const lastThreadEvents = room.lastThread?.events; + + const lastRoomEvent = roomEvents?.[roomEvents?.length - 1]; + const lastThreadLastEvent = lastThreadEvents?.[lastThreadEvents?.length - 1]; + + const lastEvent = + (lastRoomEvent?.getTs() ?? 0) > (lastThreadLastEvent?.getTs() ?? 0) ? lastRoomEvent : lastThreadLastEvent; + + try { + if (lastEvent) { + const receiptType = SettingsStore.getValue("sendReadReceipts", room.roomId) + ? ReceiptType.Read + : ReceiptType.ReadPrivate; + return await client.sendReadReceipt(lastEvent, receiptType, true); + } else { + return {}; + } + } finally { + // We've had a lot of stuck unread notifications that in e2ee rooms + // They occur on event decryption when clients try to replicate the logic + // + // This resets the notification on a room, even though no read receipt + // has been sent, particularly useful when the clients has incorrectly + // notified a user. + room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); + room.setUnreadNotificationCount(NotificationCountType.Total, 0); + for (const thread of room.getThreads()) { + room.setThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight, 0); + room.setThreadUnreadNotificationCount(thread.id, NotificationCountType.Total, 0); + } + } +} + +/** + * Marks all rooms with an unread counter as read + * @param client The matrix client + * @returns a promise that resolves when all rooms have been marked as read + */ +export function clearAllNotifications(client: MatrixClient): Promise> { + const receiptPromises = client.getRooms().reduce((promises: Array>, room: Room) => { if (room.getUnreadNotificationCount() > 0) { - const roomEvents = room.getLiveTimeline().getEvents(); - const lastThreadEvents = room.lastThread?.events; - - const lastRoomEvent = roomEvents?.[roomEvents?.length - 1]; - const lastThreadLastEvent = lastThreadEvents?.[lastThreadEvents?.length - 1]; - - const lastEvent = - (lastRoomEvent?.getTs() ?? 0) > (lastThreadLastEvent?.getTs() ?? 0) - ? lastRoomEvent - : lastThreadLastEvent; - - if (lastEvent) { - const receiptType = SettingsStore.getValue("sendReadReceipts", room.roomId) - ? ReceiptType.Read - : ReceiptType.ReadPrivate; - const promise = client.sendReadReceipt(lastEvent, receiptType, true); - promises.push(promise); - } + const promise = clearRoomNotification(room, client); + promises.push(promise); } return promises; diff --git a/test/components/views/context_menus/RoomGeneralContextMenu-test.tsx b/test/components/views/context_menus/RoomGeneralContextMenu-test.tsx new file mode 100644 index 0000000000..c3962e10ff --- /dev/null +++ b/test/components/views/context_menus/RoomGeneralContextMenu-test.tsx @@ -0,0 +1,113 @@ +/* +Copyright 2022 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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { fireEvent, getByLabelText, render } from "@testing-library/react"; +import { mocked } from "jest-mock"; +import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; +import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { Room } from "matrix-js-sdk/src/models/room"; +import React from "react"; + +import { ChevronFace } from "../../../../src/components/structures/ContextMenu"; +import { + RoomGeneralContextMenu, + RoomGeneralContextMenuProps, +} from "../../../../src/components/views/context_menus/RoomGeneralContextMenu"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { DefaultTagID } from "../../../../src/stores/room-list/models"; +import RoomListStore from "../../../../src/stores/room-list/RoomListStore"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import { mkMessage, stubClient } from "../../../test-utils/test-utils"; + +describe("RoomGeneralContextMenu", () => { + const ROOM_ID = "!123:matrix.org"; + + let room: Room; + let mockClient: MatrixClient; + + let onFinished: () => void; + + function getComponent(props?: Partial) { + return render( + + + , + ); + } + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + mockClient = mocked(MatrixClientPeg.get()); + + room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + const dmRoomMap = { + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap; + DMRoomMap.setShared(dmRoomMap); + + jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValueOnce([ + DefaultTagID.DM, + DefaultTagID.Favourite, + ]); + + onFinished = jest.fn(); + }); + + it("renders an empty context menu for archived rooms", async () => { + jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValueOnce([DefaultTagID.Archived]); + + const { container } = getComponent({}); + expect(container).toMatchSnapshot(); + }); + + it("renders the default context menu", async () => { + const { container } = getComponent({}); + expect(container).toMatchSnapshot(); + }); + + it("marks the room as read", async () => { + const event = mkMessage({ + event: true, + room: "!room:id", + user: "@user:id", + ts: 1000, + }); + room.addLiveEvents([event], {}); + + const { container } = getComponent({}); + + const markAsReadBtn = getByLabelText(container, "Mark as read"); + fireEvent.click(markAsReadBtn); + + expect(mockClient.sendReadReceipt).toHaveBeenCalledWith(event, ReceiptType.Read, true); + expect(onFinished).toHaveBeenCalled(); + }); +}); diff --git a/test/components/views/context_menus/__snapshots__/RoomGeneralContextMenu-test.tsx.snap b/test/components/views/context_menus/__snapshots__/RoomGeneralContextMenu-test.tsx.snap new file mode 100644 index 0000000000..8594b2f205 --- /dev/null +++ b/test/components/views/context_menus/__snapshots__/RoomGeneralContextMenu-test.tsx.snap @@ -0,0 +1,135 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RoomGeneralContextMenu renders an empty context menu for archived rooms 1`] = ` +
+
+
+ +
+`; + +exports[`RoomGeneralContextMenu renders the default context menu 1`] = ` +
+
+
+ +
+`; diff --git a/test/components/views/settings/Notifications-test.tsx b/test/components/views/settings/Notifications-test.tsx index 6ee844d4e0..f3bb4abc30 100644 --- a/test/components/views/settings/Notifications-test.tsx +++ b/test/components/views/settings/Notifications-test.tsx @@ -467,7 +467,7 @@ describe("", () => { expect(mockClient.sendReadReceipt).toHaveBeenCalled(); await waitFor(() => { - expect(clearNotificationEl.className).not.toContain("mx_AccessibleButton_disabled"); + expect(clearNotificationEl).not.toBeInTheDocument(); }); }); }); diff --git a/test/utils/notifications-test.ts b/test/utils/notifications-test.ts index 5aa8e2427f..ab275181bb 100644 --- a/test/utils/notifications-test.ts +++ b/test/utils/notifications-test.ts @@ -26,6 +26,7 @@ import { createLocalNotificationSettingsIfNeeded, deviceNotificationSettingsKeys, clearAllNotifications, + clearRoomNotification, } from "../../src/utils/notifications"; import SettingsStore from "../../src/settings/SettingsStore"; import { getMockClientWithEventEmitter } from "../test-utils/client"; @@ -107,10 +108,44 @@ describe("notifications", () => { }); }); + describe("clearRoomNotification", () => { + let client: MatrixClient; + let room: Room; + let sendReadReceiptSpy: jest.SpyInstance; + const ROOM_ID = "123"; + const USER_ID = "@bob:example.org"; + + beforeEach(() => { + stubClient(); + client = mocked(MatrixClientPeg.get()); + room = new Room(ROOM_ID, client, USER_ID); + sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockResolvedValue({}); + jest.spyOn(client, "getRooms").mockReturnValue([room]); + jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => { + return name === "sendReadReceipts"; + }); + }); + + it("sends a request even if everything has been read", () => { + clearRoomNotification(room, client); + expect(sendReadReceiptSpy).not.toBeCalled(); + }); + + it("marks the room as read even if the receipt failed", async () => { + room.setUnreadNotificationCount(NotificationCountType.Total, 5); + sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockReset().mockRejectedValue({}); + try { + await clearRoomNotification(room, client); + } finally { + expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0); + } + }); + }); + describe("clearAllNotifications", () => { let client: MatrixClient; let room: Room; - let sendReadReceiptSpy; + let sendReadReceiptSpy: jest.SpyInstance; const ROOM_ID = "123"; const USER_ID = "@bob:example.org";