From a5ed97b903544a86d8b2fef40d6a0d9014d04021 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 19 Mar 2024 13:28:20 +0000 Subject: [PATCH] Mark as Unread (#12254) * Support the mark as unread flag * Add mark as unread menu option and make clering notifications also clear the unread flag * Mark as read on viewing room * Tests * Remove random import * Don't show mark as unread for historical rooms * Fix tests & add test for menu option * Test RoomNotificationState updates on unread flag change * Test it doesn't update on other room account data * New icon for mark as unread * Add analytics events for mark as (un)read * Bump to new analytics-events package * Read from both stable & unstable prefixes * Cast to boolean before checking to avoid setting state unnecessarily * Typo Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Doc external interface (and the rest at the same time) * Doc & rename unread market set function * Doc const exports * Remove listener on destroy * Add playwright test * Clearer language, hopefully * Move comment * Add reference to the MSC Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Expand on function doc * Remove empty beforeEach * Rejig badge logic a little and add tests * Fix basdges to not display dots in room sublists again and hopefully rename the forceDot option to something that better indicates what it does, and add tests. * Remove duplicate license header (?) * Missing word (several times...) * Incorporate PR suggestion on badge type switch * Better description in doc comment Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Update other doc comments in the same way * Remove duplicate quote * Use quotes consistently * Better test name * c+p fail --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- package.json | 2 +- .../e2e/room_options/marked_unread.spec.ts | 61 +++++++++ .../_RoomGeneralContextMenu.pcss | 4 + .../element-icons/roomlist/mark-as-unread.svg | 4 + src/RoomNotifs.ts | 4 +- .../context_menus/RoomGeneralContextMenu.tsx | 90 +++++++++++-- .../views/rooms/NotificationBadge.tsx | 2 +- .../StatelessNotificationBadge.tsx | 18 ++- src/components/views/rooms/RoomTile.tsx | 6 + src/i18n/strings/en_EN.json | 1 + src/stores/RoomViewStore.tsx | 3 + .../notifications/RoomNotificationState.ts | 9 ++ src/utils/notifications.ts | 49 +++++++ .../RoomGeneralContextMenu-test.tsx | 18 +++ .../NotificationBadge-test.tsx | 33 +++++ .../StatelessNotificationBadge-test.tsx | 7 + test/stores/RoomViewStore-test.ts | 12 ++ .../RoomNotificationState-test.ts | 33 ++++- test/utils/notifications-test.ts | 127 ++++++++++++++++-- yarn.lock | 8 +- 20 files changed, 458 insertions(+), 33 deletions(-) create mode 100644 playwright/e2e/room_options/marked_unread.spec.ts create mode 100644 res/img/element-icons/roomlist/mark-as-unread.svg diff --git a/package.json b/package.json index 265cfa9a3f6..afb50aec349 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/analytics-events": "^0.10.0", + "@matrix-org/analytics-events": "^0.12.0", "@matrix-org/emojibase-bindings": "^1.1.2", "@matrix-org/matrix-wysiwyg": "2.17.0", "@matrix-org/olm": "3.2.15", diff --git a/playwright/e2e/room_options/marked_unread.spec.ts b/playwright/e2e/room_options/marked_unread.spec.ts new file mode 100644 index 00000000000..799acf22500 --- /dev/null +++ b/playwright/e2e/room_options/marked_unread.spec.ts @@ -0,0 +1,61 @@ +/* +Copyright 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. +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 { test, expect } from "../../element-web-test"; + +const TEST_ROOM_NAME = "The mark unread test room"; + +test.describe("Mark as Unread", () => { + test.use({ + displayName: "Tom", + botCreateOpts: { + displayName: "BotBob", + autoAcceptInvites: true, + }, + }); + + test("should mark a room as unread", async ({ page, app, bot }) => { + const roomId = await app.client.createRoom({ + name: TEST_ROOM_NAME, + }); + const dummyRoomId = await app.client.createRoom({ + name: "Room of no consequence", + }); + await app.client.inviteUser(roomId, bot.credentials.userId); + await bot.joinRoom(roomId); + await bot.sendMessage(roomId, "I am a robot. Beep."); + + // Regular notification on new message + await expect(page.getByLabel(TEST_ROOM_NAME + " 1 unread message.")).toBeVisible(); + await expect(page).toHaveTitle("Element [1]"); + + await page.goto("/#/room/" + roomId); + + // should now be read, since we viewed the room (we have to assert the page title: + // the room badge isn't visible since we're viewing the room) + await expect(page).toHaveTitle("Element | " + TEST_ROOM_NAME); + + // navigate away from the room again + await page.goto("/#/room/" + dummyRoomId); + + const roomTile = page.getByLabel(TEST_ROOM_NAME); + await roomTile.focus(); + await roomTile.getByRole("button", { name: "Room options" }).click(); + await page.getByRole("menuitem", { name: "Mark as unread" }).click(); + + expect(page.getByLabel(TEST_ROOM_NAME + " Unread messages.")).toBeVisible(); + }); +}); diff --git a/res/css/views/context_menus/_RoomGeneralContextMenu.pcss b/res/css/views/context_menus/_RoomGeneralContextMenu.pcss index b5162bb1bbb..4017a53f202 100644 --- a/res/css/views/context_menus/_RoomGeneralContextMenu.pcss +++ b/res/css/views/context_menus/_RoomGeneralContextMenu.pcss @@ -10,6 +10,10 @@ mask-image: url("$(res)/img/element-icons/roomlist/mark-as-read.svg"); } +.mx_RoomGeneralContextMenu_iconMarkAsUnread::before { + mask-image: url("$(res)/img/element-icons/roomlist/mark-as-unread.svg"); +} + .mx_RoomGeneralContextMenu_iconNotificationsDefault::before { mask-image: url("$(res)/img/element-icons/notifications.svg"); } diff --git a/res/img/element-icons/roomlist/mark-as-unread.svg b/res/img/element-icons/roomlist/mark-as-unread.svg new file mode 100644 index 00000000000..a3ea89e3e93 --- /dev/null +++ b/res/img/element-icons/roomlist/mark-as-unread.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index 66be248f4f9..1fb5e5ba4d7 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -29,6 +29,7 @@ import { getUnsentMessages } from "./components/structures/RoomStatusBar"; import { doesRoomHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages } from "./Unread"; import { EffectiveMembership, getEffectiveMembership, isKnockDenied } from "./utils/membership"; import SettingsStore from "./settings/SettingsStore"; +import { getMarkedUnreadState } from "./utils/notifications"; export enum RoomNotifState { AllMessagesLoud = "all_messages_loud", @@ -279,7 +280,8 @@ export function determineUnreadState( return { symbol: null, count: trueCount, level: NotificationLevel.Highlight }; } - if (greyNotifs > 0) { + const markedUnreadState = getMarkedUnreadState(room); + if (greyNotifs > 0 || markedUnreadState) { return { symbol: null, count: trueCount, level: NotificationLevel.Notification }; } diff --git a/src/components/views/context_menus/RoomGeneralContextMenu.tsx b/src/components/views/context_menus/RoomGeneralContextMenu.tsx index 4cfd2ed6040..4465c219024 100644 --- a/src/components/views/context_menus/RoomGeneralContextMenu.tsx +++ b/src/components/views/context_menus/RoomGeneralContextMenu.tsx @@ -30,7 +30,7 @@ import { NotificationLevel } from "../../../stores/notifications/NotificationLev 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 { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications"; import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; import IconizedContextMenu, { IconizedContextMenuCheckbox, @@ -45,13 +45,60 @@ import { useSettingValue } from "../../../hooks/useSettings"; export interface RoomGeneralContextMenuProps extends IContextMenuProps { room: Room; + /** + * Called when the 'favourite' option is selected, after the menu has processed + * the mouse or keyboard event. + * @param event The event that caused the option to be selected. + */ onPostFavoriteClick?: (event: ButtonEvent) => void; + /** + * Called when the 'low priority' option is selected, after the menu has processed + * the mouse or keyboard event. + * @param event The event that caused the option to be selected. + */ onPostLowPriorityClick?: (event: ButtonEvent) => void; + /** + * Called when the 'invite' option is selected, after the menu has processed + * the mouse or keyboard event. + * @param event The event that caused the option to be selected. + */ onPostInviteClick?: (event: ButtonEvent) => void; + /** + * Called when the 'copy link' option is selected, after the menu has processed + * the mouse or keyboard event. + * @param event The event that caused the option to be selected. + */ onPostCopyLinkClick?: (event: ButtonEvent) => void; + /** + * Called when the 'settings' option is selected, after the menu has processed + * the mouse or keyboard event. + * @param event The event that caused the option to be selected. + */ onPostSettingsClick?: (event: ButtonEvent) => void; + /** + * Called when the 'forget room' option is selected, after the menu has processed + * the mouse or keyboard event. + * @param event The event that caused the option to be selected. + */ onPostForgetClick?: (event: ButtonEvent) => void; + /** + * Called when the 'leave' option is selected, after the menu has processed + * the mouse or keyboard event. + * @param event The event that caused the option to be selected. + */ onPostLeaveClick?: (event: ButtonEvent) => void; + /** + * Called when the 'mark as read' option is selected, after the menu has processed + * the mouse or keyboard event. + * @param event The event that caused the option to be selected. + */ + onPostMarkAsReadClick?: (event: ButtonEvent) => void; + /** + * Called when the 'mark as unread' option is selected, after the menu has processed + * the mouse or keyboard event. + * @param event The event that caused the option to be selected. + */ + onPostMarkAsUnreadClick?: (event: ButtonEvent) => void; } /** @@ -67,6 +114,8 @@ export const RoomGeneralContextMenu: React.FC = ({ onPostSettingsClick, onPostLeaveClick, onPostForgetClick, + onPostMarkAsReadClick, + onPostMarkAsUnreadClick, ...props }) => { const cli = useContext(MatrixClientContext); @@ -213,18 +262,33 @@ export const RoomGeneralContextMenu: React.FC = ({ } const { level } = useUnreadNotifications(room); - const markAsReadOption: JSX.Element | null = - level > NotificationLevel.None ? ( - { - clearRoomNotification(room, cli); - onFinished?.(); - }} - active={false} - label={_t("room|context_menu|mark_read")} - iconClassName="mx_RoomGeneralContextMenu_iconMarkAsRead" - /> - ) : null; + const markAsReadOption: JSX.Element | null = (() => { + if (level > NotificationLevel.None) { + return ( + { + clearRoomNotification(room, cli); + onFinished?.(); + }, onPostMarkAsReadClick)} + label={_t("room|context_menu|mark_read")} + iconClassName="mx_RoomGeneralContextMenu_iconMarkAsRead" + /> + ); + } else if (!roomTags.includes(DefaultTagID.Archived)) { + return ( + { + setMarkedUnreadState(room, cli, true); + onFinished?.(); + }, onPostMarkAsUnreadClick)} + label={_t("room|context_menu|mark_unread")} + iconClassName="mx_RoomGeneralContextMenu_iconMarkAsUnread" + /> + ); + } else { + return null; + } + })(); const developerModeEnabled = useSettingValue("developerMode"); const developerToolsOption = developerModeEnabled ? ( diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index d4f7ee50407..20ee53d95d6 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -102,7 +102,7 @@ export default class NotificationBadge extends React.PureComponent = { diff --git a/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx index 1d26083b6a0..f9051f3aa14 100644 --- a/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx @@ -70,6 +70,16 @@ export const StatelessNotificationBadge = forwardRef= NotificationLevel.Highlight, mx_NotificationBadge_knocked: knocked, - // At most one of mx_NotificationBadge_dot, mx_NotificationBadge_2char, mx_NotificationBadge_3char - mx_NotificationBadge_dot: (isEmptyBadge && !knocked) || forceDot, - mx_NotificationBadge_2char: !forceDot && symbol && symbol.length > 0 && symbol.length < 3, - mx_NotificationBadge_3char: !forceDot && symbol && symbol.length > 2, + // Exactly one of mx_NotificationBadge_dot, mx_NotificationBadge_2char, mx_NotificationBadge_3char + mx_NotificationBadge_dot: badgeType === "dot", + mx_NotificationBadge_2char: badgeType === "badge_2char", + mx_NotificationBadge_3char: badgeType === "badge_3char", }); if (props.onClick) { diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index e0baf41f191..b2eb7d0e1e6 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -362,6 +362,12 @@ export class RoomTile extends React.PureComponent { onPostLeaveClick={(ev: ButtonEvent) => PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", ev) } + onPostMarkAsReadClick={(ev: ButtonEvent) => + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", ev) + } + onPostMarkAsUnreadClick={(ev: ButtonEvent) => + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", ev) + } /> )} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 348b7a9ed5d..0768dfb9de0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1892,6 +1892,7 @@ "forget": "Forget Room", "low_priority": "Low Priority", "mark_read": "Mark as read", + "mark_unread": "Mark as unread", "mentions_only": "Mentions only", "notifications_default": "Match default setting", "notifications_mute": "Mute room", diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 4b7b165b441..007ae7b5b0f 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -62,6 +62,7 @@ import { ActionPayload } from "../dispatcher/payloads"; import { CancelAskToJoinPayload } from "../dispatcher/payloads/CancelAskToJoinPayload"; import { SubmitAskToJoinPayload } from "../dispatcher/payloads/SubmitAskToJoinPayload"; import { ModuleRunner } from "../modules/ModuleRunner"; +import { setMarkedUnreadState } from "../utils/notifications"; const NUM_JOIN_RETRY = 5; @@ -497,6 +498,8 @@ export class RoomViewStore extends EventEmitter { if (room) { pauseNonLiveBroadcastFromOtherRoom(room, this.stores.voiceBroadcastPlaybacksStore); this.doMaybeSetCurrentVoiceBroadcastPlayback(room); + + await setMarkedUnreadState(room, MatrixClientPeg.safeGet(), false); } } else if (payload.room_alias) { // Try the room alias to room ID navigation cache first to avoid diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index 0503485584d..449a6968fd9 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -23,6 +23,7 @@ import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import * as RoomNotifs from "../../RoomNotifs"; import { NotificationState } from "./NotificationState"; import SettingsStore from "../../settings/SettingsStore"; +import { MARKED_UNREAD_TYPE_STABLE, MARKED_UNREAD_TYPE_UNSTABLE } from "../../utils/notifications"; export class RoomNotificationState extends NotificationState implements IDestroyable { public constructor( @@ -36,6 +37,7 @@ export class RoomNotificationState extends NotificationState implements IDestroy this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); this.room.on(RoomEvent.Timeline, this.handleRoomEventUpdate); this.room.on(RoomEvent.Redaction, this.handleRoomEventUpdate); + this.room.on(RoomEvent.AccountData, this.handleRoomAccountDataUpdate); this.room.on(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); // for server-sent counts cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); @@ -51,6 +53,7 @@ export class RoomNotificationState extends NotificationState implements IDestroy this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); this.room.removeListener(RoomEvent.Timeline, this.handleRoomEventUpdate); this.room.removeListener(RoomEvent.Redaction, this.handleRoomEventUpdate); + this.room.removeListener(RoomEvent.AccountData, this.handleRoomAccountDataUpdate); cli.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); cli.removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate); } @@ -90,6 +93,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy } }; + private handleRoomAccountDataUpdate = (ev: MatrixEvent): void => { + if ([MARKED_UNREAD_TYPE_STABLE, MARKED_UNREAD_TYPE_UNSTABLE].includes(ev.getType())) { + this.updateNotificationState(); + } + }; + private updateNotificationState(): void { const snapshot = this.snapshot(); diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index 1dd2dd7788b..46e61fc9841 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -21,6 +21,7 @@ import { Room, LocalNotificationSettings, ReceiptType, + IMarkedUnreadEvent, } from "matrix-js-sdk/src/matrix"; import { IndicatorIcon } from "@vector-im/compound-web"; @@ -28,6 +29,19 @@ import SettingsStore from "../settings/SettingsStore"; import { NotificationLevel } from "../stores/notifications/NotificationLevel"; import { doesRoomHaveUnreadMessages } from "../Unread"; +// MSC2867 is not yet spec at time of writing. We read from both stable +// and unstable prefixes and accept the risk that the format may change, +// since the stable prefix is not actually defined yet. + +/** + * Unstable identifier for the marked_unread event, per MSC2867 + */ +export const MARKED_UNREAD_TYPE_UNSTABLE = "com.famedly.marked_unread"; +/** + * Stable identifier for the marked_unread event + */ +export const MARKED_UNREAD_TYPE_STABLE = "m.marked_unread"; + export const deviceNotificationSettingsKeys = [ "notificationsEnabled", "notificationBodyEnabled", @@ -74,6 +88,8 @@ export function localNotificationsAreSilenced(cli: MatrixClient): boolean { export async function clearRoomNotification(room: Room, client: MatrixClient): Promise<{} | undefined> { const lastEvent = room.getLastLiveEvent(); + await setMarkedUnreadState(room, client, false); + try { if (lastEvent) { const receiptType = SettingsStore.getValue("sendReadReceipts", room.roomId) @@ -117,6 +133,39 @@ export function clearAllNotifications(client: MatrixClient): Promise()?.unread; + const currentStateUnstable = room + .getAccountData(MARKED_UNREAD_TYPE_UNSTABLE) + ?.getContent()?.unread; + return currentStateStable ?? currentStateUnstable; +} + +/** + * Sets the marked_unread state of the given room. This sets some room account data that indicates to + * clients that the user considers this room to be 'unread', but without any actual notifications. + * + * @param room The room to set + * @param client MatrixClient object to use + * @param unread The new marked_unread state of the room + */ +export async function setMarkedUnreadState(room: Room, client: MatrixClient, unread: boolean): Promise { + // if there's no event, treat this as false as we don't need to send the flag to clear it if the event isn't there + const currentState = getMarkedUnreadState(room); + + if (Boolean(currentState) !== unread) { + // Assuming MSC2867 passes FCP with no changes, we should update to start writing + // the flag to the stable prefix (or both) and then ultimately use only the + // stable prefix. + await client.setRoomAccountData(room.roomId, MARKED_UNREAD_TYPE_UNSTABLE, { unread }); + } +} + /** * A helper to transform a notification color to the what the Compound Icon Button * expects diff --git a/test/components/views/context_menus/RoomGeneralContextMenu-test.tsx b/test/components/views/context_menus/RoomGeneralContextMenu-test.tsx index bb832612bfe..498882d83d5 100644 --- a/test/components/views/context_menus/RoomGeneralContextMenu-test.tsx +++ b/test/components/views/context_menus/RoomGeneralContextMenu-test.tsx @@ -140,10 +140,28 @@ describe("RoomGeneralContextMenu", () => { const markAsReadBtn = getByLabelText(container, "Mark as read"); fireEvent.click(markAsReadBtn); + await new Promise(setImmediate); + expect(mockClient.sendReadReceipt).toHaveBeenCalledWith(event, ReceiptType.Read, true); expect(onFinished).toHaveBeenCalled(); }); + it("marks the room as unread", async () => { + room.updateMyMembership("join"); + + const { container } = getComponent({}); + + const markAsUnreadBtn = getByLabelText(container, "Mark as unread"); + fireEvent.click(markAsUnreadBtn); + + await new Promise(setImmediate); + + expect(mockClient.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + unread: true, + }); + expect(onFinished).toHaveBeenCalled(); + }); + it("when developer mode is disabled, it should not render the developer tools option", () => { getComponent(); expect(screen.queryByText("Developer tools")).not.toBeInTheDocument(); diff --git a/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx index 79cd3fcae03..3650512adfa 100644 --- a/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx +++ b/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx @@ -20,8 +20,41 @@ import React from "react"; import { StatelessNotificationBadge } from "../../../../../src/components/views/rooms/NotificationBadge/StatelessNotificationBadge"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { NotificationLevel } from "../../../../../src/stores/notifications/NotificationLevel"; +import NotificationBadge from "../../../../../src/components/views/rooms/NotificationBadge"; +import { NotificationState } from "../../../../../src/stores/notifications/NotificationState"; + +class DummyNotificationState extends NotificationState { + constructor(level: NotificationLevel) { + super(); + this._level = level; + } +} describe("NotificationBadge", () => { + it("shows a dot if the level is activity", () => { + const notif = new DummyNotificationState(NotificationLevel.Activity); + + const { container } = render(); + expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument(); + expect(container.querySelector(".mx_NotificationBadge")).toBeInTheDocument(); + }); + + it("does not show a dot if the level is activity and hideIfDot is true", () => { + const notif = new DummyNotificationState(NotificationLevel.Activity); + + const { container } = render(); + expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument(); + expect(container.querySelector(".mx_NotificationBadge")).not.toBeInTheDocument(); + }); + + it("still shows an empty badge if hideIfDot us true", () => { + const notif = new DummyNotificationState(NotificationLevel.Notification); + + const { container } = render(); + expect(container.querySelector(".mx_NotificationBadge_dot")).not.toBeInTheDocument(); + expect(container.querySelector(".mx_NotificationBadge")).toBeInTheDocument(); + }); + describe("StatelessNotificationBadge", () => { it("lets you click it", () => { const cb = jest.fn(); diff --git a/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx index 6ee93d82db4..612eec286b5 100644 --- a/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx +++ b/test/components/views/rooms/NotificationBadge/StatelessNotificationBadge-test.tsx @@ -36,6 +36,13 @@ describe("StatelessNotificationBadge", () => { expect(container.querySelector(".mx_NotificationBadge_knocked")).toBeInTheDocument(); }); + it("has dot style for activity", () => { + const { container } = render( + , + ); + expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument(); + }); + it("has badge style for notification", () => { const { container } = render( , diff --git a/test/stores/RoomViewStore-test.ts b/test/stores/RoomViewStore-test.ts index f26217d4251..433b921686c 100644 --- a/test/stores/RoomViewStore-test.ts +++ b/test/stores/RoomViewStore-test.ts @@ -108,6 +108,7 @@ describe("RoomViewStore", function () { relations: jest.fn(), knockRoom: jest.fn(), leave: jest.fn(), + setRoomAccountData: jest.fn(), }); const room = new Room(roomId, mockClient, userId); const room2 = new Room(roomId2, mockClient, userId); @@ -339,6 +340,17 @@ describe("RoomViewStore", function () { expect(mocked(Modal).createDialog.mock.calls[0][1]).toMatchSnapshot(); }); + it("clears the unread flag when viewing a room", async () => { + room.getAccountData = jest.fn().mockReturnValue({ + getContent: jest.fn().mockReturnValue({ unread: true }), + }); + dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); + await untilDispatch(Action.ActiveRoomChanged, dis); + expect(mockClient.setRoomAccountData).toHaveBeenCalledWith(roomId, "com.famedly.marked_unread", { + unread: false, + }); + }); + describe("when listening to a voice broadcast", () => { let voiceBroadcastPlayback: VoiceBroadcastPlayback; diff --git a/test/stores/notifications/RoomNotificationState-test.ts b/test/stores/notifications/RoomNotificationState-test.ts index 1e124d15272..f41f13ff13c 100644 --- a/test/stores/notifications/RoomNotificationState-test.ts +++ b/test/stores/notifications/RoomNotificationState-test.ts @@ -22,6 +22,7 @@ import { NotificationCountType, EventType, MatrixEvent, + RoomEvent, } from "matrix-js-sdk/src/matrix"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; @@ -80,7 +81,7 @@ describe("RoomNotificationState", () => { room.setUnreadNotificationCount(NotificationCountType.Total, greys); } - it("Updates on event decryption", () => { + it("updates on event decryption", () => { const roomNotifState = new RoomNotificationState(room, true); const listener = jest.fn(); roomNotifState.addListener(NotificationStateEvents.Update, listener); @@ -92,6 +93,36 @@ describe("RoomNotificationState", () => { expect(listener).toHaveBeenCalled(); }); + it("emits an Update event on marked unread room account data", () => { + const roomNotifState = new RoomNotificationState(room, true); + const listener = jest.fn(); + roomNotifState.addListener(NotificationStateEvents.Update, listener); + const accountDataEvent = { + getType: () => "com.famedly.marked_unread", + getContent: () => { + return { unread: true }; + }, + } as unknown as MatrixEvent; + room.getAccountData = jest.fn().mockReturnValue(accountDataEvent); + room.emit(RoomEvent.AccountData, accountDataEvent, room); + expect(listener).toHaveBeenCalled(); + }); + + it("does not update on other account data", () => { + const roomNotifState = new RoomNotificationState(room, true); + const listener = jest.fn(); + roomNotifState.addListener(NotificationStateEvents.Update, listener); + const accountDataEvent = { + getType: () => "else.something", + getContent: () => { + return {}; + }, + } as unknown as MatrixEvent; + room.getAccountData = jest.fn().mockReturnValue(accountDataEvent); + room.emit(RoomEvent.AccountData, accountDataEvent, room); + expect(listener).not.toHaveBeenCalled(); + }); + it("removes listeners", () => { const roomNotifState = new RoomNotificationState(room, false); expect(() => roomNotifState.destroy()).not.toThrow(); diff --git a/test/utils/notifications-test.ts b/test/utils/notifications-test.ts index 30316dd5e68..6e67ca9b029 100644 --- a/test/utils/notifications-test.ts +++ b/test/utils/notifications-test.ts @@ -26,6 +26,8 @@ import { clearRoomNotification, notificationLevelToIndicator, getThreadNotificationLevel, + getMarkedUnreadState, + setMarkedUnreadState, } from "../../src/utils/notifications"; import SettingsStore from "../../src/settings/SettingsStore"; import { getMockClientWithEventEmitter } from "../test-utils/client"; @@ -135,8 +137,8 @@ describe("notifications", () => { }); }); - it("sends a request even if everything has been read", () => { - clearRoomNotification(room, client); + it("sends a request even if everything has been read", async () => { + await clearRoomNotification(room, client); expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.Read, true); }); @@ -155,8 +157,8 @@ describe("notifications", () => { sendReceiptsSetting = false; }); - it("should send a private read receipt", () => { - clearRoomNotification(room, client); + it("should send a private read receipt", async () => { + await clearRoomNotification(room, client); expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.ReadPrivate, true); }); }); @@ -186,7 +188,7 @@ describe("notifications", () => { expect(sendReadReceiptSpy).not.toHaveBeenCalled(); }); - it("sends unthreaded receipt requests", () => { + it("sends unthreaded receipt requests", async () => { const message = mkMessage({ event: true, room: ROOM_ID, @@ -196,12 +198,12 @@ describe("notifications", () => { room.addLiveEvents([message]); room.setUnreadNotificationCount(NotificationCountType.Total, 1); - clearAllNotifications(client); + await clearAllNotifications(client); expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.Read, true); }); - it("sends private read receipts", () => { + it("sends private read receipts", async () => { const message = mkMessage({ event: true, room: ROOM_ID, @@ -213,12 +215,121 @@ describe("notifications", () => { jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false); - clearAllNotifications(client); + await clearAllNotifications(client); expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.ReadPrivate, true); }); }); + describe("getMarkedUnreadState", () => { + let client: MatrixClient; + let room: Room; + + const ROOM_ID = "123"; + const USER_ID = "@bob:example.org"; + + beforeEach(() => { + stubClient(); + client = mocked(MatrixClientPeg.safeGet()); + room = new Room(ROOM_ID, client, USER_ID); + }); + + it("reads from stable prefix", async () => { + room.getAccountData = jest.fn().mockImplementation((eventType: string) => { + if (eventType === "m.marked_unread") { + return { getContent: jest.fn().mockReturnValue({ unread: true }) }; + } + return null; + }); + expect(getMarkedUnreadState(room)).toBe(true); + }); + + it("reads from unstable prefix", async () => { + room.getAccountData = jest.fn().mockImplementation((eventType: string) => { + if (eventType === "com.famedly.marked_unread") { + return { getContent: jest.fn().mockReturnValue({ unread: true }) }; + } + return null; + }); + expect(getMarkedUnreadState(room)).toBe(true); + }); + + it("returns undefined if neither prefix is present", async () => { + room.getAccountData = jest.fn().mockImplementation((eventType: string) => { + return null; + }); + expect(getMarkedUnreadState(room)).toBe(undefined); + }); + }); + + describe("setUnreadMarker", () => { + let client: MatrixClient; + let room: Room; + + const ROOM_ID = "123"; + const USER_ID = "@bob:example.org"; + + beforeEach(() => { + stubClient(); + client = mocked(MatrixClientPeg.safeGet()); + room = new Room(ROOM_ID, client, USER_ID); + }); + + // set true, no existing event + it("sets unread flag if event doesn't exist", async () => { + await setMarkedUnreadState(room, client, true); + expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + unread: true, + }); + }); + + // set false, no existing event + it("does nothing when clearing if flag is false", async () => { + await setMarkedUnreadState(room, client, false); + expect(client.setRoomAccountData).not.toHaveBeenCalled(); + }); + + // set true, existing event = false + it("sets unread flag to if existing event is false", async () => { + room.getAccountData = jest + .fn() + .mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: false }) }); + await setMarkedUnreadState(room, client, true); + expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + unread: true, + }); + }); + + // set false, existing event = false + it("does nothing if set false and existing event is false", async () => { + room.getAccountData = jest + .fn() + .mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: false }) }); + await setMarkedUnreadState(room, client, false); + expect(client.setRoomAccountData).not.toHaveBeenCalled(); + }); + + // set true, existing event = true + it("does nothing if setting true and existing event is true", async () => { + room.getAccountData = jest + .fn() + .mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: true }) }); + await setMarkedUnreadState(room, client, true); + expect(client.setRoomAccountData).not.toHaveBeenCalled(); + }); + + // set false, existing event = true + it("sets flag if setting false and existing event is true", async () => { + room.getAccountData = jest + .fn() + .mockReturnValue({ getContent: jest.fn().mockReturnValue({ unread: true }) }); + await setMarkedUnreadState(room, client, false); + expect(client.setRoomAccountData).toHaveBeenCalledWith(ROOM_ID, "com.famedly.marked_unread", { + unread: false, + }); + }); + }); + describe("notificationLevelToIndicator", () => { it("returns undefined if notification level is None", () => { expect(notificationLevelToIndicator(NotificationLevel.None)).toBeUndefined(); diff --git a/yarn.lock b/yarn.lock index fc2aac78c0e..a32bc5fb8ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1813,10 +1813,10 @@ resolved "https://registry.yarnpkg.com/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz#497c67a1cef50d1a2459ba60f315e448d2ad87fe" integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q== -"@matrix-org/analytics-events@^0.10.0": - version "0.10.0" - resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.10.0.tgz#d4d8b7859a516e888050d616ebbb0da539a15b1e" - integrity sha512-qzi7szEWxcl3nW2LDfq+SvFH/of/B/lwhfFUelhihGfr5TBPwgqM95Euc9GeYMZkU8Xm/2f5hYfA0ZleD6RKaA== +"@matrix-org/analytics-events@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.12.0.tgz#2e48c75eb39c38cbb52f0cd479eed4c835064e9f" + integrity sha512-J/rP11P2Q9PbH7iUzHIthnAQlJL1HEorUjtdd/yCrXDSk0Gw4dNe1FM2P75E6m2lUl2yJQhzGuahMmqe9xOWaw== "@matrix-org/emojibase-bindings@^1.1.2": version "1.1.3"