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>pull/28217/head
parent
a8341c0e95
commit
a5ed97b903
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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 |
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
@ -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={() => {
|
||||
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 (
|
||||
<IconizedContextMenuOption
|
||||
onClick={wrapHandler(() => {
|
||||
clearRoomNotification(room, cli);
|
||||
onFinished?.();
|
||||
}, onPostMarkAsReadClick)}
|
||||
label={_t("room|context_menu|mark_read")}
|
||||
iconClassName="mx_RoomGeneralContextMenu_iconMarkAsRead"
|
||||
/>
|
||||
);
|
||||
} 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 ? (
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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} />,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue