diff --git a/package.json b/package.json index 1207fd49114..a654514a15b 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 00000000000..0aba6d53344 --- /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 00000000000..d15018876cb --- /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 00000000000..c7a1f9fea15 Binary files /dev/null and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-no-indicator-linux.png differ 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 00000000000..135861794fe Binary files /dev/null and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-mix-unread-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-notification-unread-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-notification-unread-linux.png new file mode 100644 index 00000000000..85a987ddeca Binary files /dev/null and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-panel-notification-unread-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 17ab3c0bb22..a29e7e98570 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 00000000000..2c1370fd69e --- /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 ee74813d20b..0db1c5c7790 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 4b6a0944281..5bdc90a1f7c 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 00000000000..f6374ef32a9 --- /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 00000000000..13478c8c5bf --- /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 00000000000..79ab01906eb --- /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 00000000000..c9c40b6cb6b --- /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 944920d87c0..0c0aa995afe 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 489d529ffcb..326c41c53fd 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 6ec3870ddcf..877469251da 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 dbea233f350..1dd2dd7788b 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 bd65a783e86..5caeeb7f346 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 00000000000..4ae890638f4 --- /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 00000000000..80a19900187 --- /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 62200c7ef9b..30316dd5e68 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 b3bcce02ee0..3936a36a53c 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"