You've already forked matrix-react-sdk
mirror of
https://github.com/matrix-org/matrix-react-sdk.git
synced 2026-01-03 21:42:32 +03:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user