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>
This commit is contained in:
David Baker 2024-03-19 13:28:20 +00:00 committed by GitHub
parent a8341c0e95
commit a5ed97b903
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 458 additions and 33 deletions

View file

@ -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",

View file

@ -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();
});
});

View file

@ -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");
}

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17 5H5C3.89543 5 3 5.89543 3 7V17.4C3 18.5046 3.89543 19.4 5 19.4H19C20.1046 19.4 21 18.5046 21 17.4V7.82929C20.6872 7.93985 20.3506 8 20 8C18.3431 8 17 6.65685 17 5ZM5.26294 10.3869C4.97723 10.2282 4.80002 9.92703 4.80002 9.60018C4.80002 8.91395 5.53722 8.48018 6.1371 8.81344L12 12.0706L17.8629 8.81344C18.4628 8.48018 19.2 8.91395 19.2 9.60018C19.2 9.92703 19.0228 10.2282 18.7371 10.3869L12.4371 13.8869C12.1653 14.0379 11.8348 14.0379 11.5629 13.8869L5.26294 10.3869Z" fill="#1B1D22"/>
<path d="M22 5C22 6.10457 21.1046 7 20 7C18.8954 7 18 6.10457 18 5C18 3.89543 18.8954 3 20 3C21.1046 3 22 3.89543 22 5Z" fill="#1B1D22"/>
</svg>

After

Width:  |  Height:  |  Size: 782 B

View file

@ -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 };
}

View file

@ -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<RoomGeneralContextMenuProps> = ({
onPostSettingsClick,
onPostLeaveClick,
onPostForgetClick,
onPostMarkAsReadClick,
onPostMarkAsUnreadClick,
...props
}) => {
const cli = useContext(MatrixClientContext);
@ -213,18 +262,33 @@ export const RoomGeneralContextMenu: React.FC<RoomGeneralContextMenuProps> = ({
}
const { level } = useUnreadNotifications(room);
const markAsReadOption: JSX.Element | null =
level > NotificationLevel.None ? (
<IconizedContextMenuCheckbox
onClick={() => {
const markAsReadOption: JSX.Element | null = (() => {
if (level > NotificationLevel.None) {
return (
<IconizedContextMenuOption
onClick={wrapHandler(() => {
clearRoomNotification(room, cli);
onFinished?.();
}}
active={false}
}, onPostMarkAsReadClick)}
label={_t("room|context_menu|mark_read")}
iconClassName="mx_RoomGeneralContextMenu_iconMarkAsRead"
/>
) : null;
);
} else if (!roomTags.includes(DefaultTagID.Archived)) {
return (
<IconizedContextMenuOption
onClick={wrapHandler(() => {
setMarkedUnreadState(room, cli, true);
onFinished?.();
}, onPostMarkAsUnreadClick)}
label={_t("room|context_menu|mark_unread")}
iconClassName="mx_RoomGeneralContextMenu_iconMarkAsUnread"
/>
);
} else {
return null;
}
})();
const developerModeEnabled = useSettingValue<boolean>("developerMode");
const developerToolsOption = developerModeEnabled ? (

View file

@ -102,7 +102,7 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
if (notification.isIdle && !notification.knocked) return null;
if (hideIfDot && notification.level < NotificationLevel.Notification) {
// This would just be a dot and we've been told not to show dots, so don't show it
if (!notification.hasUnreadCount) return null;
return null;
}
const commonProps: React.ComponentProps<typeof StatelessNotificationBadge> = {

View file

@ -70,6 +70,16 @@ export const StatelessNotificationBadge = forwardRef<HTMLDivElement, XOR<Props,
symbol = formatCount(count);
}
// We show a dot if either:
// * The props force us to, or
// * It's just an activity-level notification or (in theory) lower and the room isn't knocked
const badgeType =
forceDot || (level <= NotificationLevel.Activity && !knocked)
? "dot"
: !symbol || symbol.length < 3
? "badge_2char"
: "badge_3char";
const classes = classNames({
mx_NotificationBadge: true,
mx_NotificationBadge_visible: isEmptyBadge || knocked ? true : hasUnreadCount,
@ -77,10 +87,10 @@ export const StatelessNotificationBadge = forwardRef<HTMLDivElement, XOR<Props,
mx_NotificationBadge_level_highlight: level >= 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) {

View file

@ -362,6 +362,12 @@ export class RoomTile extends React.PureComponent<ClassProps, State> {
onPostLeaveClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", ev)
}
onPostMarkAsReadClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", ev)
}
onPostMarkAsUnreadClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", ev)
}
/>
)}
</React.Fragment>

View file

@ -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",

View file

@ -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

View file

@ -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();

View file

@ -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<Array<{} |
return Promise.all(receiptPromises);
}
/**
* Gives the marked_unread state of the given room
* @param room The room to check
* @returns - The marked_unread state of the room, or undefined if no explicit state is set.
*/
export function getMarkedUnreadState(room: Room): boolean | undefined {
const currentStateStable = room.getAccountData(MARKED_UNREAD_TYPE_STABLE)?.getContent<IMarkedUnreadEvent>()?.unread;
const currentStateUnstable = room
.getAccountData(MARKED_UNREAD_TYPE_UNSTABLE)
?.getContent<IMarkedUnreadEvent>()?.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<void> {
// 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

View file

@ -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();

View file

@ -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(<NotificationBadge roomId="!foo:bar" notification={notif} />);
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(<NotificationBadge roomId="!foo:bar" notification={notif} hideIfDot={true} />);
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(<NotificationBadge roomId="!foo:bar" notification={notif} hideIfDot={true} />);
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();

View file

@ -36,6 +36,13 @@ describe("StatelessNotificationBadge", () => {
expect(container.querySelector(".mx_NotificationBadge_knocked")).toBeInTheDocument();
});
it("has dot style for activity", () => {
const { container } = render(
<StatelessNotificationBadge symbol={null} count={3} level={NotificationLevel.Activity} />,
);
expect(container.querySelector(".mx_NotificationBadge_dot")).toBeInTheDocument();
});
it("has badge style for notification", () => {
const { container } = render(
<StatelessNotificationBadge symbol={null} count={3} level={NotificationLevel.Notification} />,

View file

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

View file

@ -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();

View file

@ -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();

View file

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