From a4987060b788112f119a3fe071c8208a22037e70 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 7 Feb 2024 14:49:40 +0100 Subject: [PATCH] Pop out of Threads Activity Centre (#12136) * Add `Thread Activity centre` labs flag * Rename translation string * WIP Thread Activity Centre * Update supportedLevels * css lint * i18n lint * Fix labs subsection test * Update Threads Activity Centre label * Rename Thread Activity Centre to Threads Activity Centre * Use compound `MenuItem` instead of custom button * Color thread icon when hovered * Make the pop-up scrollable and add a max height * Remove Math.random in key * Remove unused class * Change add comments on `mx_ThreadsActivityRows` and `mx_ThreadsActivityRow` * Make threads activity centre labs flag split out unread counts Just shows notif & unread counts for main thread if the TAC is enabled. * Fix tests * Simpler fix * Open thread panel when thread clicke in Threads Activity Centre Hopefully this is a sensible enough way. The panel will stay open of course (ie. if you go to a different room & come back), but that's the nature of the right panel. * Dynamic state of room * Add doc * Use the StatelessNotificationBadge component in ThreadsActivityCentre and re-use the existing NotificationLevel * Remove unused style * Add room sorting * Fix `ThreadsActivityRow` props doc * Pass in & cache the status of the TAC labs flag * Pass includeThreads as setting to doesRoomHaveUnreadMessages too * Fix tests * Add analytics to the TAC (#12179) * Update TAC label (#12186) * Add `IndicatorIcon` to the TAC button (#12182) Add `IndicatorIcon` to the TAC button * Threads don't have activity if the room is muted This makes it match the computation in determineUnreadState. Ideally this logic should all be in one place. * Re-use doesRoomHaveUnreadThreads for useRoomThreadNotifications This incorporates the logic of not showing unread dots if the room is muted * Add TAC description in labs (#12197) * Fox position & size of dot on the tac button IndicatorIcon doesn't like having the size of its icon adjusted and we probably shouldn't do it anyway: better to specify to the component what size we want it. * TAC: Utils tests (#12200) * Add tests for `doesRoomHaveUnreadThreads` * Add tests for `getThreadNotificationLevel` * Add test for the ThreadsActivityCentre component * Add snapshot test * Fix narrow hover background on TAC button Make the button 32x32 (and the inner icon 24x24) * Add caption for empty TAC * s/tac/threads_activity_centre/ * Fix i18n & add tests * Add playwright tests for the TAC (#12227) * Fox comments --------- Co-authored-by: David Baker --- package.json | 2 +- .../spaces/threads-activity-centre/index.ts | 355 ++++++++++++++++++ .../threadsActivityCentre.spec.ts | 112 ++++++ .../tac-no-indicator-linux.png | Bin 0 -> 318 bytes .../tac-panel-mix-unread-linux.png | Bin 0 -> 8160 bytes .../tac-panel-notification-unread-linux.png | Bin 0 -> 8172 bytes res/css/_components.pcss | 1 + .../structures/_ThreadsActivityCentre.pcss | 74 ++++ src/Unread.ts | 24 ++ .../views/avatars/DecoratedRoomAvatar.tsx | 5 +- src/components/views/spaces/SpacePanel.tsx | 6 + .../ThreadsActivityCentre.tsx | 136 +++++++ .../ThreadsActivityCentreButton.tsx | 67 ++++ .../spaces/threads-activity-centre/index.ts | 19 + .../useUnreadThreadRooms.ts | 104 +++++ src/hooks/room/useRoomThreadNotifications.ts | 11 +- src/i18n/strings/en_EN.json | 7 +- src/settings/Settings.tsx | 1 + src/utils/notifications.ts | 17 + test/Unread-test.ts | 109 ++++++ .../spaces/ThreadsActivityCentre-test.tsx | 176 +++++++++ .../ThreadsActivityCentre-test.tsx.snap | 211 +++++++++++ test/utils/notifications-test.ts | 24 ++ yarn.lock | 8 +- 24 files changed, 1455 insertions(+), 14 deletions(-) create mode 100644 playwright/e2e/spaces/threads-activity-centre/index.ts create mode 100644 playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts create mode 100644 playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-no-indicator-linux.png create mode 100644 playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-mix-unread-linux.png create mode 100644 playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-notification-unread-linux.png create mode 100644 res/css/structures/_ThreadsActivityCentre.pcss create mode 100644 src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx create mode 100644 src/components/views/spaces/threads-activity-centre/ThreadsActivityCentreButton.tsx create mode 100644 src/components/views/spaces/threads-activity-centre/index.ts create mode 100644 src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts create mode 100644 test/components/views/spaces/ThreadsActivityCentre-test.tsx create mode 100644 test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap diff --git a/package.json b/package.json index 1207fd4911..a654514a15 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/analytics-events": "^0.9.0", + "@matrix-org/analytics-events": "^0.10.0", "@matrix-org/emojibase-bindings": "^1.1.2", "@matrix-org/matrix-wysiwyg": "2.17.0", "@matrix-org/olm": "3.2.15", diff --git a/playwright/e2e/spaces/threads-activity-centre/index.ts b/playwright/e2e/spaces/threads-activity-centre/index.ts new file mode 100644 index 0000000000..0aba6d5334 --- /dev/null +++ b/playwright/e2e/spaces/threads-activity-centre/index.ts @@ -0,0 +1,355 @@ +/* +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 { JSHandle, Locator, Page } from "@playwright/test"; + +import type { MatrixEvent, IContent, Room } from "matrix-js-sdk/src/matrix"; +import { test as base, expect } from "../../../element-web-test"; +import { Bot } from "../../../pages/bot"; +import { Client } from "../../../pages/client"; +import { ElementAppPage } from "../../../pages/ElementAppPage"; + +/** + * Set up for a read receipt test: + * - Create a user with the supplied name + * - As that user, create two rooms with the supplied names + * - Create a bot with the supplied name + * - Invite the bot to both rooms and ensure that it has joined + */ +export const test = base.extend<{ + roomAlphaName?: string; + roomAlpha: { name: string; roomId: string }; + roomBetaName?: string; + roomBeta: { name: string; roomId: string }; + msg: MessageBuilder; + util: Helpers; +}>({ + displayName: "Mae", + botCreateOpts: { displayName: "Other User" }, + + roomAlphaName: "Room Alpha", + roomAlpha: async ({ roomAlphaName: name, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await use({ name, roomId }); + }, + roomBetaName: "Room Beta", + roomBeta: async ({ roomBetaName: name, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] }); + await use({ name, roomId }); + }, + msg: async ({ page, app, util }, use) => { + await use(new MessageBuilder(page, app, util)); + }, + util: async ({ roomAlpha, roomBeta, page, app, bot }, use) => { + await use(new Helpers(page, app, bot)); + }, +}); + +/** + * A utility that is able to find messages based on their content, by looking + * inside the `timeline` objects in the object model. + * + * Crucially, we hold on to references to events that have been edited or + * redacted, so we can still look them up by their old content. + * + * Provides utilities that build on the ability to find messages, e.g. replyTo, + * which finds a message and then constructs a reply to it. + */ +export class MessageBuilder { + constructor( + private page: Page, + private app: ElementAppPage, + private helpers: Helpers, + ) {} + + /** + * Map of message content -> event. + */ + messages = new Map>>(); + + /** + * Utility to find a MatrixEvent by its body content + * @param room - the room to search for the event in + * @param message - the body of the event to search for + * @param includeThreads - whether to search within threads too + */ + async getMessage(room: JSHandle, message: string, includeThreads = false): Promise> { + const cached = this.messages.get(message); + if (cached) { + return cached; + } + + const promise = room.evaluateHandle( + async (room, { message, includeThreads }) => { + let ev = room.timeline.find((e) => e.getContent().body === message); + if (!ev && includeThreads) { + for (const thread of room.getThreads()) { + ev = thread.timeline.find((e) => e.getContent().body === message); + if (ev) break; + } + } + + if (ev) return ev; + + return new Promise((resolve) => { + room.on("Room.timeline" as any, (ev: MatrixEvent) => { + if (ev.getContent().body === message) { + resolve(ev); + } + }); + }); + }, + { message, includeThreads }, + ); + + this.messages.set(message, promise); + return promise; + } + + /** + * MessageContentSpec to send a threaded response into a room + * @param rootMessage - the body of the thread root message to send a response to + * @param newMessage - the message body to send into the thread response or an object with the message content + */ + threadedOff(rootMessage: string, newMessage: string | IContent): MessageContentSpec { + return new (class extends MessageContentSpec { + public async getContent(room: JSHandle): Promise> { + const ev = await this.messageFinder.getMessage(room, rootMessage); + return ev.evaluate((ev, newMessage) => { + if (typeof newMessage === "string") { + return { + "msgtype": "m.text", + "body": newMessage, + "m.relates_to": { + event_id: ev.getId(), + is_falling_back: true, + rel_type: "m.thread", + }, + }; + } else { + return { + "msgtype": "m.text", + "m.relates_to": { + event_id: ev.getId(), + is_falling_back: true, + rel_type: "m.thread", + }, + ...newMessage, + }; + } + }, newMessage); + } + })(this); + } +} + +/** + * Something that can provide the content of a message. + * + * For example, we return and instance of this from {@link + * MessageBuilder.replyTo} which creates a reply based on a previous message. + */ +export abstract class MessageContentSpec { + messageFinder: MessageBuilder | null; + + constructor(messageFinder: MessageBuilder = null) { + this.messageFinder = messageFinder; + } + + public abstract getContent(room: JSHandle): Promise>; +} + +/** + * Something that we will turn into a message or event when we pass it in to + * e.g. receiveMessages. + */ +export type Message = string | MessageContentSpec; + +export class Helpers { + constructor( + private page: Page, + private app: ElementAppPage, + private bot: Bot, + ) {} + + /** + * Use the supplied client to send messages or perform actions as specified by + * the supplied {@link Message} items. + */ + async sendMessageAsClient(cli: Client, roomName: string | { name: string }, messages: Message[]) { + const room = await this.findRoomByName(typeof roomName === "string" ? roomName : roomName.name); + const roomId = await room.evaluate((room) => room.roomId); + + for (const message of messages) { + if (typeof message === "string") { + await cli.sendMessage(roomId, { body: message, msgtype: "m.text" }); + } else if (message instanceof MessageContentSpec) { + await cli.sendMessage(roomId, await message.getContent(room)); + } + // TODO: without this wait, some tests that send lots of messages flake + // from time to time. I (andyb) have done some investigation, but it + // needs more work to figure out. The messages do arrive over sync, but + // they never appear in the timeline, and they never fire a + // Room.timeline event. I think this only happens with events that refer + // to other events (e.g. replies), so it might be caused by the + // referring event arriving before the referred-to event. + await this.page.waitForTimeout(100); + } + } + + /** + * Open the room with the supplied name. + */ + async goTo(room: string | { name: string }) { + await this.app.viewRoomByName(typeof room === "string" ? room : room.name); + } + + /** + * Click the thread with the supplied content in the thread root to open it in + * the Threads panel. + */ + async openThread(rootMessage: string) { + const tile = this.page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: rootMessage }); + await tile.hover(); + await tile.getByRole("button", { name: "Reply in thread" }).click(); + await expect(this.page.locator(".mx_ThreadView_timelinePanelWrapper")).toBeVisible(); + } + + async findRoomByName(roomName: string): Promise> { + return this.app.client.evaluateHandle((cli, roomName) => { + return cli.getRooms().find((r) => r.name === roomName); + }, roomName); + } + + /** + * Sends messages into given room as a bot + * @param room - the name of the room to send messages into + * @param messages - the list of messages to send, these can be strings or implementations of MessageSpec like `editOf` + */ + async receiveMessages(room: string | { name: string }, messages: Message[]) { + await this.sendMessageAsClient(this.bot, room, messages); + } + + /** + * Get the threads activity centre button + * @private + */ + private getTacButton(): Locator { + return this.page.getByRole("navigation", { name: "Spaces" }).getByLabel("Threads"); + } + + /** + * Return the threads activity centre panel + */ + getTacPanel() { + return this.page.getByRole("menu", { name: "Threads" }); + } + + /** + * Open the Threads Activity Centre + */ + openTac() { + return this.getTacButton().click(); + } + + /** + * Click on a room in the Threads Activity Centre + * @param name - room name + */ + clickRoomInTac(name: string) { + return this.getTacPanel().getByRole("menuitem", { name }).click(); + } + + /** + * Assert that the threads activity centre button has no indicator + */ + assertNoTacIndicator() { + return expect(this.getTacButton()).toMatchScreenshot("tac-no-indicator.png"); + } + + /** + * Assert that the threads activity centre button has a notification indicator + */ + assertNotificationTac() { + return expect(this.getTacButton().locator("[data-indicator='success']")).toBeVisible(); + } + + /** + * Assert that the threads activity centre button has a highlight indicator + */ + assertHighlightIndicator() { + return expect(this.getTacButton().locator("[data-indicator='critical']")).toBeVisible(); + } + + /** + * Assert that the threads activity centre panel has the expected rooms + * @param content - the expected rooms and their notification levels + */ + async assertRoomsInTac(content: Array<{ room: string; notificationLevel: "highlight" | "notification" }>) { + const getBadgeClass = (notificationLevel: "highlight" | "notification") => + notificationLevel === "highlight" + ? "mx_NotificationBadge_level_highlight" + : "mx_NotificationBadge_level_notification"; + + // Ensure that we have the right number of rooms + await expect(this.getTacPanel().getByRole("menuitem")).toHaveCount(content.length); + + // Ensure that each room is present in the correct order and has the correct notification level + const roomsLocator = this.getTacPanel().getByRole("menuitem"); + for (const [index, { room, notificationLevel }] of content.entries()) { + const roomLocator = roomsLocator.nth(index); + // Ensure that the room name are correct + await expect(roomLocator).toHaveText(new RegExp(room)); + // There is no accessibility marker for the StatelessNotificationBadge + await expect(roomLocator.locator(`.${getBadgeClass(notificationLevel)}`)).toBeVisible(); + } + } + + /** + * Assert that the thread panel is opened + */ + assertThreadPanelIsOpened() { + return expect(this.page.locator(".mx_ThreadPanel")).toBeVisible(); + } + + /** + * Populate the rooms with messages and threads + * @param room1 + * @param room2 + * @param msg - MessageBuilder + */ + async populateThreads( + room1: { name: string; roomId: string }, + room2: { name: string; roomId: string }, + msg: MessageBuilder, + ) { + await this.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", { + "body": "User", + "format": "org.matrix.custom.html", + "formatted_body": "User", + "m.mentions": { + user_ids: ["@user:localhost"], + }, + }), + ]); + await this.receiveMessages(room2, ["Msg2", msg.threadedOff("Msg2", "Resp2")]); + await this.receiveMessages(room1, ["Msg3", msg.threadedOff("Msg3", "Resp3")]); + } +} + +export { expect }; diff --git a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts new file mode 100644 index 0000000000..d15018876c --- /dev/null +++ b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts @@ -0,0 +1,112 @@ +/* + * + * 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 { expect, test } from "."; + +test.describe("Threads Activity Centre", () => { + test.use({ + displayName: "Alice", + botCreateOpts: { displayName: "Other User" }, + labsFlags: ["threadsActivityCentre"], + }); + + test("should not show indicator when there is no thread", async ({ roomAlpha: room1, util }) => { + // No indicator should be shown + await util.assertNoTacIndicator(); + + await util.goTo(room1); + await util.receiveMessages(room1, ["Msg1"]); + + // A message in the main timeline should not affect the indicator + await util.assertNoTacIndicator(); + }); + + test("should show a notification indicator when there is a message in a thread", async ({ + roomAlpha: room1, + util, + msg, + }) => { + await util.goTo(room1); + await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + + // The indicator should be shown + await util.assertNotificationTac(); + }); + + test("should show a highlight indicator when there is a mention in a thread", async ({ + roomAlpha: room1, + util, + msg, + }) => { + await util.goTo(room1); + await util.receiveMessages(room1, [ + "Msg1", + msg.threadedOff("Msg1", { + "body": "User", + "format": "org.matrix.custom.html", + "formatted_body": "User", + "m.mentions": { + user_ids: ["@user:localhost"], + }, + }), + ]); + + // The indicator should be shown + await util.assertHighlightIndicator(); + }); + + test("should show the rooms with unread threads", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + await util.goTo(room2); + await util.populateThreads(room1, room2, msg); + // The indicator should be shown + await util.assertHighlightIndicator(); + + // Verify that we have the expected rooms in the TAC + await util.openTac(); + await util.assertRoomsInTac([ + { room: room2.name, notificationLevel: "highlight" }, + { room: room1.name, notificationLevel: "notification" }, + ]); + + // Verify that we don't have a visual regression + await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-mix-unread.png"); + }); + + test("should update with a thread is read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + await util.goTo(room2); + await util.populateThreads(room1, room2, msg); + + // Click on the first room in TAC + await util.openTac(); + await util.clickRoomInTac(room2.name); + + // Verify that the thread panel is opened after a click on the room in the TAC + await util.assertThreadPanelIsOpened(); + + // Open a thread and mark it as read + // The room 2 doesn't have a mention anymore in its unread, so the highest notification level is notification + await util.openThread("Msg1"); + await util.assertNotificationTac(); + await util.openTac(); + await util.assertRoomsInTac([ + { room: room1.name, notificationLevel: "notification" }, + { room: room2.name, notificationLevel: "notification" }, + ]); + await expect(util.getTacPanel()).toMatchScreenshot("tac-panel-notification-unread.png"); + }); +}); diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-no-indicator-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-no-indicator-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..c7a1f9fea15c2c7f3afbdb11106b873d2a613563 GIT binary patch literal 318 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}KRjI=Ln2y} zYkq%wTi?dW;=`Qza|N@TtbvBifhO-qpSI5C+5Js6t>W)9Sy!=+E)K&TmN$)FWUWk| zdR&re_jmb)H;UNU*xIh=-!pu3ubySOjUW4-s=w2cPInq+AGmy=!I~jm$%WlAvyjIq zNXTVn?UyH#hqqXIcLaHtaBSEl@HUJsEbP%$p|zXpZ`my3Rb}PrH*xTqvzU3+62_oM zGO|6(8YezvFbsUySlqCpL!f!}VqU+>MM4W_Pu6XoJXh89uImj(A0Ho{|Ns6be<&(W z*s$4XapzMWAn4g)r6#^UCOrWNE?mF3WaiTfSqFUsqfHN2FtcrCX6Oj8ljiku)&zzD NgQu&X%Q~loCIBgseA55` literal 0 HcmV?d00001 diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-mix-unread-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-mix-unread-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..135861794fe8dd6c4bc421ba852ebb88efc793b8 GIT binary patch literal 8160 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 literal 0 HcmV?d00001 diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 17ab3c0bb2..a29e7e9857 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -85,6 +85,7 @@ @import "./structures/_SpaceRoomView.pcss"; @import "./structures/_SplashPage.pcss"; @import "./structures/_TabbedView.pcss"; +@import "./structures/_ThreadsActivityCentre.pcss"; @import "./structures/_ToastContainer.pcss"; @import "./structures/_UploadBar.pcss"; @import "./structures/_UserMenu.pcss"; diff --git a/res/css/structures/_ThreadsActivityCentre.pcss b/res/css/structures/_ThreadsActivityCentre.pcss new file mode 100644 index 0000000000..2c1370fd69 --- /dev/null +++ b/res/css/structures/_ThreadsActivityCentre.pcss @@ -0,0 +1,74 @@ +/* + * + * 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. + * / + */ + +.mx_ThreadsActivityCentreButton { + color: $secondary-content; + height: 32px; + width: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + margin: auto; + + &.expanded { + /* align with settings icon */ + margin-left: 20px; + + & > .mx_ThreadsActivityCentreButton_IndicatorIcon { + /* align with settings label */ + margin-right: 12px; + } + } + + &:not(.expanded) { + &:hover, + &:hover .mx_ThreadsActivityCentreButton_Icon { + background-color: $quaternary-content; + color: $primary-content; + } + } + + & .mx_ThreadsActivityCentreButton_Icon { + color: $secondary-content; + } +} + +.mx_ThreadsActivity_rows { + overflow-y: scroll; + /* Let some space at the top and the bottom of the pop-up */ + max-height: calc(100vh - 200px); + + .mx_ThreadsActivityRow { + height: 48px; + + /* Make the label of the MenuItem stay on one line and truncate with ellipsis if needed */ + & > span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 210px; + } + } +} + +.mx_ThreadsActivityCentre_emptyCaption { + padding-left: 16px; + padding-right: 16px; + font-size: 13px; +} diff --git a/src/Unread.ts b/src/Unread.ts index ee74813d20..0db1c5c779 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -20,6 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import shouldHideEvent from "./shouldHideEvent"; import { haveRendererForEvent } from "./events/EventTileFactory"; import SettingsStore from "./settings/SettingsStore"; +import { RoomNotifState, getRoomNotifsState } from "./RoomNotifs"; /** * Returns true if this event arriving in a room should affect the room's @@ -105,6 +106,29 @@ function doesTimelineHaveUnreadMessages(room: Room, timeline: Array } } +/** + * Returns true if this room has unread threads. + * @param room The room to check + * @returns {boolean} True if the given room has unread threads + */ +export function doesRoomHaveUnreadThreads(room: Room): boolean { + if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute) { + // No unread for muted rooms, nor their threads + // NB. This logic duplicated in RoomNotifs.determineUnreadState + return false; + } + + for (const thread of room.getThreads()) { + if (doesTimelineHaveUnreadMessages(room, thread.timeline)) { + // We found an unread, so the room has an unread thread + return true; + } + } + + // If we got here then no threads were found with unread messages. + return false; +} + export function doesRoomOrThreadHaveUnreadMessages(roomOrThread: Room | Thread): boolean { const room = roomOrThread instanceof Thread ? roomOrThread.room : roomOrThread; const events = roomOrThread instanceof Thread ? roomOrThread.timeline : room.getLiveTimeline().getEvents(); diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index 4b6a094428..5bdc90a1f7 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -176,6 +176,9 @@ export default class DecoratedRoomAvatar extends React.PureComponent +
{ const invites = useEventEmitterState(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { @@ -350,6 +351,8 @@ const SpacePanel: React.FC = () => { } }); + const isThreadsActivityCentreEnabled = useSettingValue("threadsActivityCentre"); + return ( {({ onKeyDownHandler, onDragEndHandler }) => ( @@ -406,6 +409,9 @@ const SpacePanel: React.FC = () => { )} + {isThreadsActivityCentreEnabled && ( + + )} diff --git a/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx new file mode 100644 index 0000000000..f6374ef32a --- /dev/null +++ b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx @@ -0,0 +1,136 @@ +/* + * + * 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 React, { JSX, useState } from "react"; +import { Menu, MenuItem } from "@vector-im/compound-web"; +import { Room } from "matrix-js-sdk/src/matrix"; + +import { ThreadsActivityCentreButton } from "./ThreadsActivityCentreButton"; +import { _t } from "../../../../languageHandler"; +import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar"; +import { Action } from "../../../../dispatcher/actions"; +import defaultDispatcher from "../../../../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload"; +import RightPanelStore from "../../../../stores/right-panel/RightPanelStore"; +import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases"; +import { useUnreadThreadRooms } from "./useUnreadThreadRooms"; +import { StatelessNotificationBadge } from "../../rooms/NotificationBadge/StatelessNotificationBadge"; +import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel"; +import PosthogTrackers from "../../../../PosthogTrackers"; + +interface ThreadsActivityCentreProps { + /** + * Display the `Treads` label next to the icon. + */ + displayButtonLabel?: boolean; +} + +/** + * Display in a popup the list of rooms with unread threads. + * The popup is displayed when the user clicks on the `Threads` button. + */ +export function ThreadsActivityCentre({ displayButtonLabel }: ThreadsActivityCentreProps): JSX.Element { + const [open, setOpen] = useState(false); + const roomsAndNotifications = useUnreadThreadRooms(open); + + return ( + { + // Track only when the Threads Activity Centre is opened + if (newOpen) PosthogTrackers.trackInteraction("WebThreadsActivityCentreButton"); + + setOpen(newOpen); + }} + side="right" + title={_t("threads_activity_centre|header")} + trigger={ + + } + > + {/* Make the content of the pop-up scrollable */} +
+ {roomsAndNotifications.rooms.map(({ room, notificationLevel }) => ( + setOpen(false)} + /> + ))} + {roomsAndNotifications.rooms.length === 0 && ( +
+ {_t("threads_activity_centre|no_rooms_with_unreads_threads")} +
+ )} +
+
+ ); +} + +interface ThreadsActivityRow { + /** + * The room with unread threads. + */ + room: Room; + /** + * The notification level. + */ + notificationLevel: NotificationLevel; + /** + * Callback when the user clicks on the row. + */ + onClick: () => void; +} + +/** + * Display a room with unread threads. + */ +function ThreadsActivityRow({ room, onClick, notificationLevel }: ThreadsActivityRow): JSX.Element { + return ( + { + onClick(); + + // Set the right panel card for that room so the threads panel is open before we dispatch, + // so it will open once the room appears. + RightPanelStore.instance.setCard({ phase: RightPanelPhases.ThreadPanel }, true, room.roomId); + + // Track the click on the room + PosthogTrackers.trackInteraction("WebThreadsActivityCentreRoomItem", event); + + // Display the selected room in the timeline + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + show_room_tile: true, // make sure the room is visible in the list + room_id: room.roomId, + metricsTrigger: "WebThreadsActivityCentre", + }); + }} + label={room.name} + Icon={} + > + + + ); +} diff --git a/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentreButton.tsx b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentreButton.tsx new file mode 100644 index 0000000000..13478c8c5b --- /dev/null +++ b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentreButton.tsx @@ -0,0 +1,67 @@ +/* + * + * 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 React, { forwardRef, HTMLProps } from "react"; +import { Icon } from "@vector-im/compound-design-tokens/icons/threads-solid.svg"; +import classNames from "classnames"; +import { IndicatorIcon } from "@vector-im/compound-web"; + +import { _t } from "../../../../languageHandler"; +import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton"; +import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel"; +import { notificationLevelToIndicator } from "../../../../utils/notifications"; + +interface ThreadsActivityCentreButtonProps extends HTMLProps { + /** + * Display the `Treads` label next to the icon. + */ + displayLabel?: boolean; + /** + * The notification level of the threads. + */ + notificationLevel: NotificationLevel; +} + +/** + * A button to open the thread activity centre. + */ +export const ThreadsActivityCentreButton = forwardRef( + function ThreadsActivityCentreButton({ displayLabel, notificationLevel, ...props }, ref): React.JSX.Element { + return ( + + + + + {displayLabel && _t("common|threads")} + + ); + }, +); diff --git a/src/components/views/spaces/threads-activity-centre/index.ts b/src/components/views/spaces/threads-activity-centre/index.ts new file mode 100644 index 0000000000..79ab01906e --- /dev/null +++ b/src/components/views/spaces/threads-activity-centre/index.ts @@ -0,0 +1,19 @@ +/* + * + * 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. + * / + */ + +export { ThreadsActivityCentre } from "./ThreadsActivityCentre"; diff --git a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts new file mode 100644 index 0000000000..c9c40b6cb6 --- /dev/null +++ b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts @@ -0,0 +1,104 @@ +/* + * + * 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 { useEffect, useState } from "react"; +import { ClientEvent, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { doesRoomHaveUnreadThreads } from "../../../../Unread"; +import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel"; +import { getThreadNotificationLevel } from "../../../../utils/notifications"; +import { useSettingValue } from "../../../../hooks/useSettings"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { useEventEmitter } from "../../../../hooks/useEventEmitter"; +import { VisibilityProvider } from "../../../../stores/room-list/filters/VisibilityProvider"; + +type Result = { + greatestNotificationLevel: NotificationLevel; + rooms: Array<{ room: Room; notificationLevel: NotificationLevel }>; +}; + +/** + * Return the greatest notification level of all thread, the list of rooms with unread threads, and their notification level. + * The result is computed when the client syncs, or when forceComputation is true + * @param forceComputation + * @returns {Result} + */ +export function useUnreadThreadRooms(forceComputation: boolean): Result { + const msc3946ProcessDynamicPredecessor = useSettingValue("feature_dynamic_room_predecessors"); + const mxClient = useMatrixClientContext(); + + const [result, setResult] = useState({ greatestNotificationLevel: NotificationLevel.None, rooms: [] }); + + // Listen to sync events to update the result + useEventEmitter(mxClient, ClientEvent.Sync, () => { + setResult(computeUnreadThreadRooms(mxClient, msc3946ProcessDynamicPredecessor)); + }); + + // Force the list computation + useEffect(() => { + if (forceComputation) { + setResult(computeUnreadThreadRooms(mxClient, msc3946ProcessDynamicPredecessor)); + } + }, [mxClient, msc3946ProcessDynamicPredecessor, forceComputation]); + + return result; +} + +/** + * Compute the greatest notification level of all thread, the list of rooms with unread threads, and their notification level. + * @param mxClient - MatrixClient + * @param msc3946ProcessDynamicPredecessor + */ +function computeUnreadThreadRooms(mxClient: MatrixClient, msc3946ProcessDynamicPredecessor: boolean): Result { + // Only count visible rooms to not torment the user with notification counts in rooms they can't see. + // This will include highlights from the previous version of the room internally + const visibleRooms = mxClient.getVisibleRooms(msc3946ProcessDynamicPredecessor); + + let greatestNotificationLevel = NotificationLevel.None; + const rooms = []; + + for (const room of visibleRooms) { + // We only care about rooms with unread threads + if (VisibilityProvider.instance.isRoomVisible(room) && doesRoomHaveUnreadThreads(room)) { + // Get the greatest notification level of all rooms + const notificationLevel = getThreadNotificationLevel(room); + if (notificationLevel > greatestNotificationLevel) { + greatestNotificationLevel = notificationLevel; + } + + rooms.push({ room, notificationLevel }); + } + } + + const sortedRooms = rooms.sort((a, b) => sortRoom(a.notificationLevel, b.notificationLevel)); + return { greatestNotificationLevel, rooms: sortedRooms }; +} + +/** + * Sort notification level by the most important notification level to the least important + * Highlight > Notification > Activity + * @param notificationLevelA - notification level of room A + * @param notificationLevelB - notification level of room B + * @returns {number} + */ +function sortRoom(notificationLevelA: NotificationLevel, notificationLevelB: NotificationLevel): number { + // NotificationLevel is a numeric enum, so we can compare them directly + if (notificationLevelA > notificationLevelB) return -1; + else if (notificationLevelB > notificationLevelA) return 1; + else return 0; +} diff --git a/src/hooks/room/useRoomThreadNotifications.ts b/src/hooks/room/useRoomThreadNotifications.ts index 944920d87c..0c0aa995af 100644 --- a/src/hooks/room/useRoomThreadNotifications.ts +++ b/src/hooks/room/useRoomThreadNotifications.ts @@ -18,7 +18,7 @@ import { NotificationCountType, Room, RoomEvent, ThreadEvent } from "matrix-js-s import { useCallback, useEffect, useState } from "react"; import { NotificationLevel } from "../../stores/notifications/NotificationLevel"; -import { doesRoomOrThreadHaveUnreadMessages } from "../../Unread"; +import { doesRoomHaveUnreadThreads } from "../../Unread"; import { useEventEmitter } from "../useEventEmitter"; /** @@ -40,12 +40,9 @@ export const useRoomThreadNotifications = (room: Room): NotificationLevel => { } // We don't have any notified messages, but we might have unread messages. Let's // find out. - for (const thread of room!.getThreads()) { - // If the current thread has unread messages, we're done. - if (doesRoomOrThreadHaveUnreadMessages(thread)) { - setNotificationLevel(NotificationLevel.Activity); - return; - } + if (doesRoomHaveUnreadThreads(room)) { + setNotificationLevel(NotificationLevel.Activity); + return; } // default case diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 489d529ffc..326c41c53f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1460,7 +1460,8 @@ "sliding_sync_server_no_support": "Your server lacks native support", "sliding_sync_server_specify_proxy": "Your server lacks native support, you must specify a proxy", "sliding_sync_server_support": "Your server has native support", - "threads_activity_centre": "Threads Activity Centre (in development). Currently this just removes thread notification counts from the count total in the room list", + "threads_activity_centre": "Threads Activity Centre (in development)", + "threads_activity_centre_description": "Warning: Under active development; reloads Element.", "under_active_development": "Under active development.", "unrealiable_e2e": "Unreliable in encrypted rooms", "video_rooms": "Video rooms", @@ -3160,6 +3161,10 @@ "show_thread_filter": "Show:", "unable_to_decrypt": "Unable to decrypt message" }, + "threads_activity_centre": { + "header": "Threads activity", + "no_rooms_with_unreads_threads": "You don't have rooms with unread threads yet." + }, "time": { "about_day_ago": "about a day ago", "about_hour_ago": "about an hour ago", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 6ec3870ddc..877469251d 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -1128,6 +1128,7 @@ export const SETTINGS: { [setting: string]: ISetting } = { labsGroup: LabGroup.Threads, controller: new ReloadOnChangeController(), displayName: _td("labs|threads_activity_centre"), + description: _td("labs|threads_activity_centre_description"), default: false, isFeature: true, }, diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index dbea233f35..1dd2dd7788 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -134,3 +134,20 @@ export function notificationLevelToIndicator( return "critical"; } } + +/** + * Return the thread notification level for a room + * @param room + * @returns {NotificationLevel} + */ +export function getThreadNotificationLevel(room: Room): NotificationLevel { + const notificationCountType = room.threadsAggregateNotificationType; + switch (notificationCountType) { + case NotificationCountType.Highlight: + return NotificationLevel.Highlight; + case NotificationCountType.Total: + return NotificationLevel.Notification; + default: + return NotificationLevel.Activity; + } +} diff --git a/test/Unread-test.ts b/test/Unread-test.ts index bd65a783e8..5caeeb7f34 100644 --- a/test/Unread-test.ts +++ b/test/Unread-test.ts @@ -23,6 +23,7 @@ import { makeBeaconEvent, mkEvent, stubClient } from "./test-utils"; import { makeThreadEvents, mkThread, populateThread } from "./test-utils/threads"; import { doesRoomHaveUnreadMessages, + doesRoomHaveUnreadThreads, doesRoomOrThreadHaveUnreadMessages, eventTriggersUnreadCount, } from "../src/Unread"; @@ -533,4 +534,112 @@ describe("Unread", () => { }); }); }); + + describe("doesRoomHaveUnreadThreads()", () => { + let room: Room; + const roomId = "!abc:server.org"; + const myId = client.getSafeUserId(); + + beforeAll(() => { + client.supportsThreads = () => true; + }); + + beforeEach(async () => { + room = new Room(roomId, client, myId); + jest.spyOn(logger, "warn"); + + // Don't care about the code path of hidden events. + mocked(haveRendererForEvent).mockClear().mockReturnValue(true); + }); + + it("returns false when no threads", () => { + expect(doesRoomHaveUnreadThreads(room)).toBe(false); + + // Add event to the room + const event = mkEvent({ + event: true, + type: "m.room.message", + user: aliceId, + room: roomId, + content: {}, + }); + room.addLiveEvents([event]); + + // It still returns false + expect(doesRoomHaveUnreadThreads(room)).toBe(false); + }); + + it("return true when we don't have any receipt for the thread", async () => { + await populateThread({ + room, + client, + authorId: myId, + participantUserIds: [aliceId], + }); + + // There is no receipt for the thread, it should be unread + expect(doesRoomHaveUnreadThreads(room)).toBe(true); + }); + + it("return false when we have a receipt for the thread", async () => { + const { events, rootEvent } = await populateThread({ + room, + client, + authorId: myId, + participantUserIds: [aliceId], + }); + + // Mark the thread as read. + const receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [events[events.length - 1].getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1, thread_id: rootEvent.getId()! }, + }, + }, + }, + }); + room.addReceipt(receipt); + + // There is a receipt for the thread, it should be read + expect(doesRoomHaveUnreadThreads(room)).toBe(false); + }); + + it("return true when only of the threads has a receipt", async () => { + // Create a first thread + await populateThread({ + room, + client, + authorId: myId, + participantUserIds: [aliceId], + }); + + // Create a second thread + const { events, rootEvent } = await populateThread({ + room, + client, + authorId: myId, + participantUserIds: [aliceId], + }); + + // Mark the thread as read. + const receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [events[events.length - 1].getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1, thread_id: rootEvent.getId()! }, + }, + }, + }, + }); + room.addReceipt(receipt); + + // The first thread doesn't have a receipt, it should be unread + expect(doesRoomHaveUnreadThreads(room)).toBe(true); + }); + }); }); diff --git a/test/components/views/spaces/ThreadsActivityCentre-test.tsx b/test/components/views/spaces/ThreadsActivityCentre-test.tsx new file mode 100644 index 0000000000..4ae890638f --- /dev/null +++ b/test/components/views/spaces/ThreadsActivityCentre-test.tsx @@ -0,0 +1,176 @@ +/* + * + * 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 React from "react"; +import { getByText, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { NotificationCountType, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; + +import { ThreadsActivityCentre } from "../../../../src/components/views/spaces/threads-activity-centre"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import { stubClient } from "../../../test-utils"; +import { populateThread } from "../../../test-utils/threads"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; + +describe("ThreadsActivityCentre", () => { + const getTACButton = () => { + return screen.getByRole("button", { name: "Threads" }); + }; + + const getTACMenu = () => { + return screen.getByRole("menu"); + }; + + const renderTAC = () => { + render( + + + ); + , + ); + }; + + const cli = stubClient(); + cli.supportsThreads = () => true; + + const roomWithActivity = new Room("!room:server", cli, cli.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + roomWithActivity.name = "Just activity"; + + const roomWithNotif = new Room("!room:server", cli, cli.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + roomWithNotif.name = "A notification"; + + const roomWithHighlight = new Room("!room:server", cli, cli.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + roomWithHighlight.name = "This is a real highlight"; + + beforeAll(async () => { + jest.spyOn(MatrixClientPeg, "get").mockReturnValue(cli); + jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(cli); + + const dmRoomMap = new DMRoomMap(cli); + jest.spyOn(dmRoomMap, "getUserIdForRoomId"); + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + + await populateThread({ + room: roomWithActivity, + client: cli, + authorId: "@foo:bar", + participantUserIds: ["@fee:bar"], + }); + + const notifThreadInfo = await populateThread({ + room: roomWithNotif, + client: cli, + authorId: "@foo:bar", + participantUserIds: ["@fee:bar"], + }); + roomWithNotif.setThreadUnreadNotificationCount(notifThreadInfo.thread.id, NotificationCountType.Total, 1); + + const highlightThreadInfo = await populateThread({ + room: roomWithHighlight, + client: cli, + authorId: "@foo:bar", + participantUserIds: ["@fee:bar"], + }); + roomWithHighlight.setThreadUnreadNotificationCount( + highlightThreadInfo.thread.id, + NotificationCountType.Highlight, + 1, + ); + }); + + it("should render the threads activity centre button", async () => { + renderTAC(); + expect(getTACButton()).toBeInTheDocument(); + }); + + it("should render the threads activity centre menu when the button is clicked", async () => { + renderTAC(); + await userEvent.click(getTACButton()); + expect(getTACMenu()).toBeInTheDocument(); + }); + + it("should render a room with a activity in the TAC", async () => { + cli.getVisibleRooms = jest.fn().mockReturnValue([roomWithActivity]); + renderTAC(); + await userEvent.click(getTACButton()); + + const tacRows = screen.getAllByRole("menuitem"); + expect(tacRows.length).toEqual(1); + + getByText(tacRows[0], "Just activity"); + expect(tacRows[0].getElementsByClassName("mx_NotificationBadge").length).toEqual(1); + expect(tacRows[0].getElementsByClassName("mx_NotificationBadge_level_notification").length).toEqual(0); + }); + + it("should render a room with a regular notification in the TAC", async () => { + cli.getVisibleRooms = jest.fn().mockReturnValue([roomWithNotif]); + renderTAC(); + await userEvent.click(getTACButton()); + + const tacRows = screen.getAllByRole("menuitem"); + expect(tacRows.length).toEqual(1); + + getByText(tacRows[0], "A notification"); + expect(tacRows[0].getElementsByClassName("mx_NotificationBadge_level_notification").length).toEqual(1); + }); + + it("should render a room with a highlight notification in the TAC", async () => { + cli.getVisibleRooms = jest.fn().mockReturnValue([roomWithHighlight]); + renderTAC(); + await userEvent.click(getTACButton()); + + const tacRows = screen.getAllByRole("menuitem"); + expect(tacRows.length).toEqual(1); + + getByText(tacRows[0], "This is a real highlight"); + expect(tacRows[0].getElementsByClassName("mx_NotificationBadge_level_highlight").length).toEqual(1); + }); + + it("renders notifications matching the snapshot", async () => { + cli.getVisibleRooms = jest.fn().mockReturnValue([roomWithHighlight, roomWithNotif, roomWithActivity]); + renderTAC(); + await userEvent.click(getTACButton()); + + expect(screen.getByRole("menu")).toMatchSnapshot(); + }); + + it("should display a caption when no threads are unread", async () => { + cli.getVisibleRooms = jest.fn().mockReturnValue([]); + renderTAC(); + await userEvent.click(getTACButton()); + + expect(screen.getByRole("menu").getElementsByClassName("mx_ThreadsActivityCentre_emptyCaption").length).toEqual( + 1, + ); + }); + + it("should match snapshot when empty", async () => { + cli.getVisibleRooms = jest.fn().mockReturnValue([]); + renderTAC(); + await userEvent.click(getTACButton()); + + expect(screen.getByRole("menu")).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap b/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap new file mode 100644 index 0000000000..80a1990018 --- /dev/null +++ b/test/components/views/spaces/__snapshots__/ThreadsActivityCentre-test.tsx.snap @@ -0,0 +1,211 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] = ` + +`; + +exports[`ThreadsActivityCentre should match snapshot when empty 1`] = ` + +`; diff --git a/test/utils/notifications-test.ts b/test/utils/notifications-test.ts index 62200c7ef9..30316dd5e6 100644 --- a/test/utils/notifications-test.ts +++ b/test/utils/notifications-test.ts @@ -25,6 +25,7 @@ import { clearAllNotifications, clearRoomNotification, notificationLevelToIndicator, + getThreadNotificationLevel, } from "../../src/utils/notifications"; import SettingsStore from "../../src/settings/SettingsStore"; import { getMockClientWithEventEmitter } from "../test-utils/client"; @@ -235,4 +236,27 @@ describe("notifications", () => { expect(notificationLevelToIndicator(NotificationLevel.Highlight)).toEqual("critical"); }); }); + + describe("getThreadNotificationLevel", () => { + let room: Room; + + const ROOM_ID = "123"; + const USER_ID = "@bob:example.org"; + + beforeEach(() => { + room = new Room(ROOM_ID, MatrixClientPeg.safeGet(), USER_ID); + }); + + it.each([ + { notificationCountType: NotificationCountType.Highlight, expected: NotificationLevel.Highlight }, + { notificationCountType: NotificationCountType.Total, expected: NotificationLevel.Notification }, + { notificationCountType: null, expected: NotificationLevel.Activity }, + ])( + "returns NotificationLevel $expected when notificationCountType is $expected", + ({ notificationCountType, expected }) => { + jest.spyOn(room, "threadsAggregateNotificationType", "get").mockReturnValue(notificationCountType); + expect(getThreadNotificationLevel(room)).toEqual(expected); + }, + ); + }); }); diff --git a/yarn.lock b/yarn.lock index b3bcce02ee..3936a36a53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1828,10 +1828,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.9.0": - version "0.9.0" - resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.9.0.tgz#ac958b1f49ab84af6325da0264df2f459e87a985" - integrity sha512-pKhIspX2lHNe3sUdi42T8lL3RPFqI0kHkxfrF9R0jneJska6GNBzQwPENMY1SjM3YnGYdhz5GZ/QMm6gozuiJg== +"@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/emojibase-bindings@^1.1.2": version "1.1.3"