1
0
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:
David Baker
2024-03-19 13:28:20 +00:00
committed by GitHub
parent a8341c0e95
commit a5ed97b903
20 changed files with 458 additions and 33 deletions

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={() => {
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 ? (

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