Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Mark as Unread #12254

Merged
merged 39 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
cc7065a
Support the mark as unread flag
dbkr Feb 15, 2024
eb58431
Add mark as unread menu option
dbkr Feb 15, 2024
e87b938
Mark as read on viewing room
dbkr Feb 15, 2024
7da410d
Tests
dbkr Feb 15, 2024
6a98b43
Remove random import
dbkr Feb 15, 2024
5474696
Don't show mark as unread for historical rooms
dbkr Feb 15, 2024
2d00c14
Fix tests & add test for menu option
dbkr Feb 16, 2024
ca88013
Test RoomNotificationState updates on unread flag change
dbkr Feb 16, 2024
0afc9be
Test it doesn't update on other room account data
dbkr Feb 16, 2024
3be0a5d
New icon for mark as unread
dbkr Feb 16, 2024
406fbee
Add analytics events for mark as (un)read
dbkr Feb 20, 2024
dc17850
Bump to new analytics-events package
dbkr Feb 21, 2024
31e3c67
Merge branch 'develop' into dbkr/mark_as_unread
dbkr Feb 27, 2024
6515e49
Read from both stable & unstable prefixes
dbkr Feb 27, 2024
4bc6ea8
Cast to boolean before checking
dbkr Feb 27, 2024
ad3dfd5
Typo
dbkr Feb 29, 2024
6e161e8
Doc external interface (and the rest at the same time)
dbkr Feb 29, 2024
f057de8
Doc & rename unread market set function
dbkr Feb 29, 2024
2ced1d6
Doc const exports
dbkr Feb 29, 2024
86870d2
Remove listener on destroy
dbkr Feb 29, 2024
716a2e4
Add playwright test
dbkr Feb 29, 2024
523b142
Clearer language, hopefully
dbkr Mar 5, 2024
d8aca51
Move comment
dbkr Mar 5, 2024
b556a92
Add reference to the MSC
dbkr Mar 5, 2024
e0bd722
Expand on function doc
dbkr Mar 5, 2024
17eb159
Remove empty beforeEach
dbkr Mar 5, 2024
ae809dc
Rejig badge logic a little and add tests
dbkr Mar 5, 2024
ff7d281
Fix basdges to not display dots in room sublists again
dbkr Mar 6, 2024
50ba376
Remove duplicate license header (?)
dbkr Mar 6, 2024
f88a53f
Merge remote-tracking branch 'origin/develop' into dbkr/mark_as_unread
dbkr Mar 14, 2024
60f8fc7
Missing word (several times...)
dbkr Mar 14, 2024
25e1722
Merge branch 'develop' into dbkr/mark_as_unread
dbkr Mar 15, 2024
b418dc8
Incorporate PR suggestion on badge type switch
dbkr Mar 15, 2024
2454aba
Better description in doc comment
dbkr Mar 18, 2024
4ee353e
Update other doc comments in the same way
dbkr Mar 18, 2024
c4a96ad
Remove duplicate quote
dbkr Mar 18, 2024
1e50d22
Use quotes consistently
dbkr Mar 18, 2024
0eb14a3
Better test name
dbkr Mar 18, 2024
d18579c
c+p fail
dbkr Mar 18, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions playwright/e2e/room_options/marked_unread.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 4 additions & 0 deletions res/css/views/context_menus/_RoomGeneralContextMenu.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
4 changes: 4 additions & 0 deletions res/img/element-icons/roomlist/mark-as-unread.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion src/RoomNotifs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 };
}

Expand Down
90 changes: 77 additions & 13 deletions src/components/views/context_menus/RoomGeneralContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}

/**
Expand All @@ -67,6 +114,8 @@ export const RoomGeneralContextMenu: React.FC<RoomGeneralContextMenuProps> = ({
onPostSettingsClick,
onPostLeaveClick,
onPostForgetClick,
onPostMarkAsReadClick,
onPostMarkAsUnreadClick,
...props
}) => {
const cli = useContext(MatrixClientContext);
Expand Down Expand Up @@ -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 = (() => {
richvdh marked this conversation as resolved.
Show resolved Hide resolved
if (level > NotificationLevel.None) {
return (
<IconizedContextMenuOption
onClick={wrapHandler(() => {
clearRoomNotification(room, cli);
onFinished?.();
}, onPostMarkAsReadClick)}
label={_t("room|context_menu|mark_read")}
iconClassName="mx_RoomGeneralContextMenu_iconMarkAsRead"
/>
richvdh marked this conversation as resolved.
Show resolved Hide resolved
);
} 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 ? (
Expand Down
2 changes: 1 addition & 1 deletion src/components/views/rooms/NotificationBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is it ok/necessary to get rid of this condition?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this probably should have gone when splitting the PR out but I mistakenly left it in. We need to check only the level now because the Mark as Unread badge doesn't have a count but we still want it displayed in this case (it's considered a badge rather than a dot).

return null;
}

const commonProps: React.ComponentProps<typeof StatelessNotificationBadge> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,27 @@ 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so previously we would show a dot if count was 0 (I think?) but now we will show a badge in this case? I think?

Is that deliberate?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes: because there is no unread message that triggers a notification, the count will be zero for rooms marked as unread, but we still want to display a badge.

? "dot"
: !symbol || symbol.length < 3
? "badge_2char"
: "badge_3char";

const classes = classNames({
mx_NotificationBadge: true,
mx_NotificationBadge_visible: isEmptyBadge || knocked ? true : hasUnreadCount,
mx_NotificationBadge_level_notification: level == NotificationLevel.Notification,
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) {
Expand Down
6 changes: 6 additions & 0 deletions src/components/views/rooms/RoomTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/stores/RoomViewStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/stores/notifications/RoomNotificationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
dbkr marked this conversation as resolved.
Show resolved Hide resolved

this.room.on(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); // for server-sent counts
cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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();

Expand Down
Loading
Loading