diff --git a/package.json b/package.json
index 265cfa9a3f..afb50aec34 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 0000000000..799acf2250
--- /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 b5162bb1bb..4017a53f20 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 0000000000..a3ea89e3e9
--- /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 66be248f4f..1fb5e5ba4d 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 4cfd2ed604..4465c21902 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 d4f7ee5040..20ee53d95d 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 1d26083b6a..f9051f3aa1 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 e0baf41f19..b2eb7d0e1e 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 348b7a9ed5..0768dfb9de 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 4b7b165b44..007ae7b5b0 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 0503485584..449a6968fd 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 1dd2dd7788..46e61fc984 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 bb832612bf..498882d83d 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 79cd3fcae0..3650512adf 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 6ee93d82db..612eec286b 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 f26217d425..433b921686 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 1e124d1527..f41f13ff13 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 30316dd5e6..6e67ca9b02 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 fc2aac78c0..a32bc5fb8e 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"