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

Commit e9224f6

Browse files
author
Germain
authored
Add mark as read option in room setting (#9798)
1 parent ce75d33 commit e9224f6

File tree

9 files changed

+378
-37
lines changed

9 files changed

+378
-37
lines changed

res/css/views/context_menus/_RoomGeneralContextMenu.pcss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
mask-image: url("$(res)/img/element-icons/roomlist/low-priority.svg");
77
}
88

9+
.mx_RoomGeneralContextMenu_iconMarkAsRead::before {
10+
mask-image: url("$(res)/img/element-icons/roomlist/mark-as-read.svg");
11+
}
12+
913
.mx_RoomGeneralContextMenu_iconNotificationsDefault::before {
1014
mask-image: url("$(res)/img/element-icons/notifications.svg");
1115
}
Lines changed: 3 additions & 0 deletions
Loading

src/components/views/context_menus/RoomGeneralContextMenu.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@ import RoomListActions from "../../../actions/RoomListActions";
2323
import MatrixClientContext from "../../../contexts/MatrixClientContext";
2424
import dis from "../../../dispatcher/dispatcher";
2525
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
26+
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
2627
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
2728
import { _t } from "../../../languageHandler";
29+
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
2830
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
2931
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
3032
import DMRoomMap from "../../../utils/DMRoomMap";
33+
import { clearRoomNotification } from "../../../utils/notifications";
3134
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
3235
import IconizedContextMenu, {
3336
IconizedContextMenuCheckbox,
@@ -36,7 +39,7 @@ import IconizedContextMenu, {
3639
} from "../context_menus/IconizedContextMenu";
3740
import { ButtonEvent } from "../elements/AccessibleButton";
3841

39-
interface IProps extends IContextMenuProps {
42+
export interface RoomGeneralContextMenuProps extends IContextMenuProps {
4043
room: Room;
4144
onPostFavoriteClick?: (event: ButtonEvent) => void;
4245
onPostLowPriorityClick?: (event: ButtonEvent) => void;
@@ -58,7 +61,7 @@ export const RoomGeneralContextMenu = ({
5861
onPostLeaveClick,
5962
onPostForgetClick,
6063
...props
61-
}: IProps) => {
64+
}: RoomGeneralContextMenuProps) => {
6265
const cli = useContext(MatrixClientContext);
6366
const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () =>
6467
RoomListStore.instance.getTagsForRoom(room),
@@ -115,8 +118,8 @@ export const RoomGeneralContextMenu = ({
115118
/>
116119
);
117120

118-
let inviteOption: JSX.Element;
119-
if (room.canInvite(cli.getUserId()) && !isDm) {
121+
let inviteOption: JSX.Element | null = null;
122+
if (room.canInvite(cli.getUserId()!) && !isDm) {
120123
inviteOption = (
121124
<IconizedContextMenuOption
122125
onClick={wrapHandler(
@@ -133,7 +136,7 @@ export const RoomGeneralContextMenu = ({
133136
);
134137
}
135138

136-
let copyLinkOption: JSX.Element;
139+
let copyLinkOption: JSX.Element | null = null;
137140
if (!isDm) {
138141
copyLinkOption = (
139142
<IconizedContextMenuOption
@@ -201,17 +204,34 @@ export const RoomGeneralContextMenu = ({
201204
);
202205
}
203206

207+
const { color } = useUnreadNotifications(room);
208+
const markAsReadOption: JSX.Element | null =
209+
color > NotificationColor.None ? (
210+
<IconizedContextMenuCheckbox
211+
onClick={() => {
212+
clearRoomNotification(room, cli);
213+
onFinished?.();
214+
}}
215+
active={false}
216+
label={_t("Mark as read")}
217+
iconClassName="mx_RoomGeneralContextMenu_iconMarkAsRead"
218+
/>
219+
) : null;
220+
204221
return (
205222
<IconizedContextMenu {...props} onFinished={onFinished} className="mx_RoomGeneralContextMenu" compact>
206-
{!roomTags.includes(DefaultTagID.Archived) && (
207-
<IconizedContextMenuOptionList>
208-
{favoriteOption}
209-
{lowPriorityOption}
210-
{inviteOption}
211-
{copyLinkOption}
212-
{settingsOption}
213-
</IconizedContextMenuOptionList>
214-
)}
223+
<IconizedContextMenuOptionList>
224+
{markAsReadOption}
225+
{!roomTags.includes(DefaultTagID.Archived) && (
226+
<>
227+
{favoriteOption}
228+
{lowPriorityOption}
229+
{inviteOption}
230+
{copyLinkOption}
231+
{settingsOption}
232+
</>
233+
)}
234+
</IconizedContextMenuOptionList>
215235
<IconizedContextMenuOptionList red>{leaveOption}</IconizedContextMenuOptionList>
216236
</IconizedContextMenu>
217237
);

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3187,6 +3187,7 @@
31873187
"Copy room link": "Copy room link",
31883188
"Low Priority": "Low Priority",
31893189
"Forget Room": "Forget Room",
3190+
"Mark as read": "Mark as read",
31903191
"Use default": "Use default",
31913192
"Mentions & Keywords": "Mentions & Keywords",
31923193
"See room timeline (devtools)": "See room timeline (devtools)",

src/utils/notifications.ts

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
1818
import { LOCAL_NOTIFICATION_SETTINGS_PREFIX } from "matrix-js-sdk/src/@types/event";
1919
import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications";
2020
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts";
21-
import { Room } from "matrix-js-sdk/src/models/room";
21+
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
2222

2323
import SettingsStore from "../settings/SettingsStore";
2424

@@ -59,27 +59,57 @@ export function localNotificationsAreSilenced(cli: MatrixClient): boolean {
5959
return event?.getContent<LocalNotificationSettings>()?.is_silenced ?? false;
6060
}
6161

62-
export function clearAllNotifications(client: MatrixClient): Promise<Array<{}>> {
63-
const receiptPromises = client.getRooms().reduce((promises, room: Room) => {
62+
/**
63+
* Mark a room as read
64+
* @param room
65+
* @param client
66+
* @returns a promise that resolves when the room has been marked as read
67+
*/
68+
export async function clearRoomNotification(room: Room, client: MatrixClient): Promise<{} | undefined> {
69+
const roomEvents = room.getLiveTimeline().getEvents();
70+
const lastThreadEvents = room.lastThread?.events;
71+
72+
const lastRoomEvent = roomEvents?.[roomEvents?.length - 1];
73+
const lastThreadLastEvent = lastThreadEvents?.[lastThreadEvents?.length - 1];
74+
75+
const lastEvent =
76+
(lastRoomEvent?.getTs() ?? 0) > (lastThreadLastEvent?.getTs() ?? 0) ? lastRoomEvent : lastThreadLastEvent;
77+
78+
try {
79+
if (lastEvent) {
80+
const receiptType = SettingsStore.getValue("sendReadReceipts", room.roomId)
81+
? ReceiptType.Read
82+
: ReceiptType.ReadPrivate;
83+
return await client.sendReadReceipt(lastEvent, receiptType, true);
84+
} else {
85+
return {};
86+
}
87+
} finally {
88+
// We've had a lot of stuck unread notifications that in e2ee rooms
89+
// They occur on event decryption when clients try to replicate the logic
90+
//
91+
// This resets the notification on a room, even though no read receipt
92+
// has been sent, particularly useful when the clients has incorrectly
93+
// notified a user.
94+
room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
95+
room.setUnreadNotificationCount(NotificationCountType.Total, 0);
96+
for (const thread of room.getThreads()) {
97+
room.setThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight, 0);
98+
room.setThreadUnreadNotificationCount(thread.id, NotificationCountType.Total, 0);
99+
}
100+
}
101+
}
102+
103+
/**
104+
* Marks all rooms with an unread counter as read
105+
* @param client The matrix client
106+
* @returns a promise that resolves when all rooms have been marked as read
107+
*/
108+
export function clearAllNotifications(client: MatrixClient): Promise<Array<{} | undefined>> {
109+
const receiptPromises = client.getRooms().reduce((promises: Array<Promise<{} | undefined>>, room: Room) => {
64110
if (room.getUnreadNotificationCount() > 0) {
65-
const roomEvents = room.getLiveTimeline().getEvents();
66-
const lastThreadEvents = room.lastThread?.events;
67-
68-
const lastRoomEvent = roomEvents?.[roomEvents?.length - 1];
69-
const lastThreadLastEvent = lastThreadEvents?.[lastThreadEvents?.length - 1];
70-
71-
const lastEvent =
72-
(lastRoomEvent?.getTs() ?? 0) > (lastThreadLastEvent?.getTs() ?? 0)
73-
? lastRoomEvent
74-
: lastThreadLastEvent;
75-
76-
if (lastEvent) {
77-
const receiptType = SettingsStore.getValue("sendReadReceipts", room.roomId)
78-
? ReceiptType.Read
79-
: ReceiptType.ReadPrivate;
80-
const promise = client.sendReadReceipt(lastEvent, receiptType, true);
81-
promises.push(promise);
82-
}
111+
const promise = clearRoomNotification(room, client);
112+
promises.push(promise);
83113
}
84114

85115
return promises;
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { fireEvent, getByLabelText, render } from "@testing-library/react";
18+
import { mocked } from "jest-mock";
19+
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts";
20+
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
21+
import { Room } from "matrix-js-sdk/src/models/room";
22+
import React from "react";
23+
24+
import { ChevronFace } from "../../../../src/components/structures/ContextMenu";
25+
import {
26+
RoomGeneralContextMenu,
27+
RoomGeneralContextMenuProps,
28+
} from "../../../../src/components/views/context_menus/RoomGeneralContextMenu";
29+
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
30+
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
31+
import { DefaultTagID } from "../../../../src/stores/room-list/models";
32+
import RoomListStore from "../../../../src/stores/room-list/RoomListStore";
33+
import DMRoomMap from "../../../../src/utils/DMRoomMap";
34+
import { mkMessage, stubClient } from "../../../test-utils/test-utils";
35+
36+
describe("RoomGeneralContextMenu", () => {
37+
const ROOM_ID = "!123:matrix.org";
38+
39+
let room: Room;
40+
let mockClient: MatrixClient;
41+
42+
let onFinished: () => void;
43+
44+
function getComponent(props?: Partial<RoomGeneralContextMenuProps>) {
45+
return render(
46+
<MatrixClientContext.Provider value={mockClient}>
47+
<RoomGeneralContextMenu
48+
room={room}
49+
onFinished={onFinished}
50+
{...props}
51+
managed={true}
52+
mountAsChild={true}
53+
left={1}
54+
top={1}
55+
chevronFace={ChevronFace.Left}
56+
/>
57+
</MatrixClientContext.Provider>,
58+
);
59+
}
60+
61+
beforeEach(() => {
62+
jest.clearAllMocks();
63+
64+
stubClient();
65+
mockClient = mocked(MatrixClientPeg.get());
66+
67+
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
68+
pendingEventOrdering: PendingEventOrdering.Detached,
69+
});
70+
71+
const dmRoomMap = {
72+
getUserIdForRoomId: jest.fn(),
73+
} as unknown as DMRoomMap;
74+
DMRoomMap.setShared(dmRoomMap);
75+
76+
jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValueOnce([
77+
DefaultTagID.DM,
78+
DefaultTagID.Favourite,
79+
]);
80+
81+
onFinished = jest.fn();
82+
});
83+
84+
it("renders an empty context menu for archived rooms", async () => {
85+
jest.spyOn(RoomListStore.instance, "getTagsForRoom").mockReturnValueOnce([DefaultTagID.Archived]);
86+
87+
const { container } = getComponent({});
88+
expect(container).toMatchSnapshot();
89+
});
90+
91+
it("renders the default context menu", async () => {
92+
const { container } = getComponent({});
93+
expect(container).toMatchSnapshot();
94+
});
95+
96+
it("marks the room as read", async () => {
97+
const event = mkMessage({
98+
event: true,
99+
room: "!room:id",
100+
user: "@user:id",
101+
ts: 1000,
102+
});
103+
room.addLiveEvents([event], {});
104+
105+
const { container } = getComponent({});
106+
107+
const markAsReadBtn = getByLabelText(container, "Mark as read");
108+
fireEvent.click(markAsReadBtn);
109+
110+
expect(mockClient.sendReadReceipt).toHaveBeenCalledWith(event, ReceiptType.Read, true);
111+
expect(onFinished).toHaveBeenCalled();
112+
});
113+
});

0 commit comments

Comments
 (0)