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