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 <dbkr@users.noreply.github.com>
This commit is contained in:
Florian Duros 2024-02-07 14:49:40 +01:00 committed by GitHub
parent 3052025dd0
commit a4987060b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1455 additions and 14 deletions

View file

@ -65,7 +65,7 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@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/emojibase-bindings": "^1.1.2",
"@matrix-org/matrix-wysiwyg": "2.17.0", "@matrix-org/matrix-wysiwyg": "2.17.0",
"@matrix-org/olm": "3.2.15", "@matrix-org/olm": "3.2.15",

View file

@ -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<String, Promise<JSHandle<MatrixEvent>>>();
/**
* 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<Room>, message: string, includeThreads = false): Promise<JSHandle<MatrixEvent>> {
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<MatrixEvent>((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<Room>): Promise<Record<string, unknown>> {
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<Room>): Promise<Record<string, unknown>>;
}
/**
* 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<JSHandle<Room>> {
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": "<a href='https://matrix.to/#/@user:localhost'>User</a>",
"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 };

View file

@ -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": "<a href='https://matrix.to/#/@user:localhost'>User</a>",
"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");
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

View file

@ -85,6 +85,7 @@
@import "./structures/_SpaceRoomView.pcss"; @import "./structures/_SpaceRoomView.pcss";
@import "./structures/_SplashPage.pcss"; @import "./structures/_SplashPage.pcss";
@import "./structures/_TabbedView.pcss"; @import "./structures/_TabbedView.pcss";
@import "./structures/_ThreadsActivityCentre.pcss";
@import "./structures/_ToastContainer.pcss"; @import "./structures/_ToastContainer.pcss";
@import "./structures/_UploadBar.pcss"; @import "./structures/_UploadBar.pcss";
@import "./structures/_UserMenu.pcss"; @import "./structures/_UserMenu.pcss";

View file

@ -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;
}

View file

@ -20,6 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import shouldHideEvent from "./shouldHideEvent"; import shouldHideEvent from "./shouldHideEvent";
import { haveRendererForEvent } from "./events/EventTileFactory"; import { haveRendererForEvent } from "./events/EventTileFactory";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { RoomNotifState, getRoomNotifsState } from "./RoomNotifs";
/** /**
* Returns true if this event arriving in a room should affect the room's * Returns true if this event arriving in a room should affect the room's
@ -105,6 +106,29 @@ function doesTimelineHaveUnreadMessages(room: Room, timeline: Array<MatrixEvent>
} }
} }
/**
* 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 { export function doesRoomOrThreadHaveUnreadMessages(roomOrThread: Room | Thread): boolean {
const room = roomOrThread instanceof Thread ? roomOrThread.room : roomOrThread; const room = roomOrThread instanceof Thread ? roomOrThread.room : roomOrThread;
const events = roomOrThread instanceof Thread ? roomOrThread.timeline : room.getLiveTimeline().getEvents(); const events = roomOrThread instanceof Thread ? roomOrThread.timeline : room.getLiveTimeline().getEvents();

View file

@ -176,6 +176,9 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
} }
public render(): React.ReactNode { public render(): React.ReactNode {
// Spread the remaining props to make it work with compound component
const { room, size, displayBadge, forceCount, oobData, viewAvatarOnClick, tooltipProps, ...props } = this.props;
let badge: React.ReactNode; let badge: React.ReactNode;
if (this.props.displayBadge && this.state.notificationState) { if (this.props.displayBadge && this.state.notificationState) {
badge = ( badge = (
@ -202,7 +205,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
}); });
return ( return (
<div className={classes}> <div className={classes} {...props}>
<RoomAvatar <RoomAvatar
room={this.props.room} room={this.props.room}
size={this.props.size} size={this.props.size}

View file

@ -72,6 +72,7 @@ import { NotificationState } from "../../../stores/notifications/NotificationSta
import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts"; import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature"; import { UIComponent } from "../../../settings/UIFeature";
import { ThreadsActivityCentre } from "./threads-activity-centre/";
const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => { const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => {
const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
@ -350,6 +351,8 @@ const SpacePanel: React.FC = () => {
} }
}); });
const isThreadsActivityCentreEnabled = useSettingValue<boolean>("threadsActivityCentre");
return ( return (
<RovingTabIndexProvider handleHomeEnd handleUpDown={!dragging}> <RovingTabIndexProvider handleHomeEnd handleUpDown={!dragging}>
{({ onKeyDownHandler, onDragEndHandler }) => ( {({ onKeyDownHandler, onDragEndHandler }) => (
@ -406,6 +409,9 @@ const SpacePanel: React.FC = () => {
)} )}
</Droppable> </Droppable>
{isThreadsActivityCentreEnabled && (
<ThreadsActivityCentre displayButtonLabel={!isPanelCollapsed} />
)}
<QuickSettingsButton isPanelCollapsed={isPanelCollapsed} /> <QuickSettingsButton isPanelCollapsed={isPanelCollapsed} />
</nav> </nav>
</DragDropContext> </DragDropContext>

View file

@ -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 (
<Menu
align="end"
open={open}
onOpenChange={(newOpen) => {
// 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={
<ThreadsActivityCentreButton
displayLabel={displayButtonLabel}
notificationLevel={roomsAndNotifications.greatestNotificationLevel}
/>
}
>
{/* Make the content of the pop-up scrollable */}
<div className="mx_ThreadsActivity_rows">
{roomsAndNotifications.rooms.map(({ room, notificationLevel }) => (
<ThreadsActivityRow
key={room.roomId}
room={room}
notificationLevel={notificationLevel}
onClick={() => setOpen(false)}
/>
))}
{roomsAndNotifications.rooms.length === 0 && (
<div className="mx_ThreadsActivityCentre_emptyCaption">
{_t("threads_activity_centre|no_rooms_with_unreads_threads")}
</div>
)}
</div>
</Menu>
);
}
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 (
<MenuItem
className="mx_ThreadsActivityRow"
onSelect={(event: Event) => {
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<ViewRoomPayload>({
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={<DecoratedRoomAvatar room={room} size="32px" />}
>
<StatelessNotificationBadge level={notificationLevel} count={0} symbol={null} type="dot" />
</MenuItem>
);
}

View file

@ -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<HTMLDivElement> {
/**
* 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<HTMLDivElement, ThreadsActivityCentreButtonProps>(
function ThreadsActivityCentreButton({ displayLabel, notificationLevel, ...props }, ref): React.JSX.Element {
return (
<AccessibleTooltipButton
className={classNames("mx_ThreadsActivityCentreButton", { expanded: displayLabel })}
title={_t("common|threads")}
// @ts-ignore
// ref nightmare...
ref={ref}
forceHide={displayLabel}
aria-expanded={displayLabel}
{...props}
>
<IndicatorIcon
className="mx_ThreadsActivityCentreButton_IndicatorIcon"
indicator={notificationLevelToIndicator(notificationLevel)}
size="24px"
>
<Icon className="mx_ThreadsActivityCentreButton_Icon" />
</IndicatorIcon>
{displayLabel && _t("common|threads")}
</AccessibleTooltipButton>
);
},
);

View file

@ -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";

View file

@ -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<boolean>("feature_dynamic_room_predecessors");
const mxClient = useMatrixClientContext();
const [result, setResult] = useState<Result>({ 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;
}

View file

@ -18,7 +18,7 @@ import { NotificationCountType, Room, RoomEvent, ThreadEvent } from "matrix-js-s
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { NotificationLevel } from "../../stores/notifications/NotificationLevel"; import { NotificationLevel } from "../../stores/notifications/NotificationLevel";
import { doesRoomOrThreadHaveUnreadMessages } from "../../Unread"; import { doesRoomHaveUnreadThreads } from "../../Unread";
import { useEventEmitter } from "../useEventEmitter"; 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 // We don't have any notified messages, but we might have unread messages. Let's
// find out. // find out.
for (const thread of room!.getThreads()) { if (doesRoomHaveUnreadThreads(room)) {
// If the current thread has unread messages, we're done. setNotificationLevel(NotificationLevel.Activity);
if (doesRoomOrThreadHaveUnreadMessages(thread)) { return;
setNotificationLevel(NotificationLevel.Activity);
return;
}
} }
// default case // default case

View file

@ -1460,7 +1460,8 @@
"sliding_sync_server_no_support": "Your server lacks native support", "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_specify_proxy": "Your server lacks native support, you must specify a proxy",
"sliding_sync_server_support": "Your server has native support", "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.", "under_active_development": "Under active development.",
"unrealiable_e2e": "Unreliable in encrypted rooms", "unrealiable_e2e": "Unreliable in encrypted rooms",
"video_rooms": "Video rooms", "video_rooms": "Video rooms",
@ -3160,6 +3161,10 @@
"show_thread_filter": "Show:", "show_thread_filter": "Show:",
"unable_to_decrypt": "Unable to decrypt message" "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": { "time": {
"about_day_ago": "about a day ago", "about_day_ago": "about a day ago",
"about_hour_ago": "about an hour ago", "about_hour_ago": "about an hour ago",

View file

@ -1128,6 +1128,7 @@ export const SETTINGS: { [setting: string]: ISetting } = {
labsGroup: LabGroup.Threads, labsGroup: LabGroup.Threads,
controller: new ReloadOnChangeController(), controller: new ReloadOnChangeController(),
displayName: _td("labs|threads_activity_centre"), displayName: _td("labs|threads_activity_centre"),
description: _td("labs|threads_activity_centre_description"),
default: false, default: false,
isFeature: true, isFeature: true,
}, },

View file

@ -134,3 +134,20 @@ export function notificationLevelToIndicator(
return "critical"; 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;
}
}

View file

@ -23,6 +23,7 @@ import { makeBeaconEvent, mkEvent, stubClient } from "./test-utils";
import { makeThreadEvents, mkThread, populateThread } from "./test-utils/threads"; import { makeThreadEvents, mkThread, populateThread } from "./test-utils/threads";
import { import {
doesRoomHaveUnreadMessages, doesRoomHaveUnreadMessages,
doesRoomHaveUnreadThreads,
doesRoomOrThreadHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages,
eventTriggersUnreadCount, eventTriggersUnreadCount,
} from "../src/Unread"; } 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);
});
});
}); });

View file

@ -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(
<MatrixClientContext.Provider value={cli}>
<ThreadsActivityCentre />
);
</MatrixClientContext.Provider>,
);
};
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();
});
});

View file

@ -0,0 +1,211 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ThreadsActivityCentre renders notifications matching the snapshot 1`] = `
<div
aria-labelledby="radix-13"
aria-orientation="vertical"
class="_menu_1x5h1_17"
data-align="end"
data-orientation="vertical"
data-radix-menu-content=""
data-side="right"
data-state="open"
dir="ltr"
id="radix-14"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;"
tabindex="-1"
>
<h3
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _title_1x5h1_83"
id=":r5:"
>
Threads activity
</h3>
<div
class="mx_ThreadsActivity_rows"
>
<button
class="mx_ThreadsActivityRow _item_1bcsk_17 _interactive_1bcsk_36"
data-kind="primary"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
class="_icon_1bcsk_44"
>
<span
class="_avatar_1o69u_17 mx_BaseAvatar _avatar-imageless_1o69u_60"
data-color="8"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
T
</span>
</div>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1bcsk_53"
>
This is a real highlight
</span>
<svg
aria-hidden="true"
class="_nav-hint_1bcsk_60"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.70005 17.3C8.51672 17.1167 8.42505 16.8834 8.42505 16.6C8.42505 16.3167 8.51672 16.0834 8.70005 15.9L12.6 12L8.70005 8.10005C8.51672 7.91672 8.42505 7.68338 8.42505 7.40005C8.42505 7.11672 8.51672 6.88338 8.70005 6.70005C8.88338 6.51672 9.11672 6.42505 9.40005 6.42505C9.68338 6.42505 9.91672 6.51672 10.1 6.70005L14.7 11.3C14.8 11.4 14.8709 11.5084 14.9125 11.625C14.9542 11.7417 14.975 11.8667 14.975 12C14.975 12.1334 14.9542 12.2584 14.9125 12.375C14.8709 12.4917 14.8 12.6 14.7 12.7L10.1 17.3C9.91672 17.4834 9.68338 17.575 9.40005 17.575C9.11672 17.575 8.88338 17.4834 8.70005 17.3Z"
fill="currentColor"
/>
</svg>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_highlight mx_NotificationBadge_dot"
>
<span
class="mx_NotificationBadge_count"
/>
</div>
</button>
<button
class="mx_ThreadsActivityRow _item_1bcsk_17 _interactive_1bcsk_36"
data-kind="primary"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
class="_icon_1bcsk_44"
>
<span
class="_avatar_1o69u_17 mx_BaseAvatar _avatar-imageless_1o69u_60"
data-color="8"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
A
</span>
</div>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1bcsk_53"
>
A notification
</span>
<svg
aria-hidden="true"
class="_nav-hint_1bcsk_60"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.70005 17.3C8.51672 17.1167 8.42505 16.8834 8.42505 16.6C8.42505 16.3167 8.51672 16.0834 8.70005 15.9L12.6 12L8.70005 8.10005C8.51672 7.91672 8.42505 7.68338 8.42505 7.40005C8.42505 7.11672 8.51672 6.88338 8.70005 6.70005C8.88338 6.51672 9.11672 6.42505 9.40005 6.42505C9.68338 6.42505 9.91672 6.51672 10.1 6.70005L14.7 11.3C14.8 11.4 14.8709 11.5084 14.9125 11.625C14.9542 11.7417 14.975 11.8667 14.975 12C14.975 12.1334 14.9542 12.2584 14.9125 12.375C14.8709 12.4917 14.8 12.6 14.7 12.7L10.1 17.3C9.91672 17.4834 9.68338 17.575 9.40005 17.575C9.11672 17.575 8.88338 17.4834 8.70005 17.3Z"
fill="currentColor"
/>
</svg>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_level_notification mx_NotificationBadge_dot"
>
<span
class="mx_NotificationBadge_count"
/>
</div>
</button>
<button
class="mx_ThreadsActivityRow _item_1bcsk_17 _interactive_1bcsk_36"
data-kind="primary"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
class="_icon_1bcsk_44"
>
<span
class="_avatar_1o69u_17 mx_BaseAvatar _avatar-imageless_1o69u_60"
data-color="8"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
J
</span>
</div>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1bcsk_53"
>
Just activity
</span>
<svg
aria-hidden="true"
class="_nav-hint_1bcsk_60"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.70005 17.3C8.51672 17.1167 8.42505 16.8834 8.42505 16.6C8.42505 16.3167 8.51672 16.0834 8.70005 15.9L12.6 12L8.70005 8.10005C8.51672 7.91672 8.42505 7.68338 8.42505 7.40005C8.42505 7.11672 8.51672 6.88338 8.70005 6.70005C8.88338 6.51672 9.11672 6.42505 9.40005 6.42505C9.68338 6.42505 9.91672 6.51672 10.1 6.70005L14.7 11.3C14.8 11.4 14.8709 11.5084 14.9125 11.625C14.9542 11.7417 14.975 11.8667 14.975 12C14.975 12.1334 14.9542 12.2584 14.9125 12.375C14.8709 12.4917 14.8 12.6 14.7 12.7L10.1 17.3C9.91672 17.4834 9.68338 17.575 9.40005 17.575C9.11672 17.575 8.88338 17.4834 8.70005 17.3Z"
fill="currentColor"
/>
</svg>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_dot"
>
<span
class="mx_NotificationBadge_count"
/>
</div>
</button>
</div>
</div>
`;
exports[`ThreadsActivityCentre should match snapshot when empty 1`] = `
<div
aria-labelledby="radix-20"
aria-orientation="vertical"
class="_menu_1x5h1_17"
data-align="end"
data-orientation="vertical"
data-radix-menu-content=""
data-side="right"
data-state="open"
dir="ltr"
id="radix-21"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;"
tabindex="-1"
>
<h3
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45 _title_1x5h1_83"
id=":r7:"
>
Threads activity
</h3>
<div
class="mx_ThreadsActivity_rows"
>
<div
class="mx_ThreadsActivityCentre_emptyCaption"
>
You don't have rooms with unread threads yet.
</div>
</div>
</div>
`;

View file

@ -25,6 +25,7 @@ import {
clearAllNotifications, clearAllNotifications,
clearRoomNotification, clearRoomNotification,
notificationLevelToIndicator, notificationLevelToIndicator,
getThreadNotificationLevel,
} from "../../src/utils/notifications"; } from "../../src/utils/notifications";
import SettingsStore from "../../src/settings/SettingsStore"; import SettingsStore from "../../src/settings/SettingsStore";
import { getMockClientWithEventEmitter } from "../test-utils/client"; import { getMockClientWithEventEmitter } from "../test-utils/client";
@ -235,4 +236,27 @@ describe("notifications", () => {
expect(notificationLevelToIndicator(NotificationLevel.Highlight)).toEqual("critical"); 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);
},
);
});
}); });

View file

@ -1828,10 +1828,10 @@
resolved "https://registry.yarnpkg.com/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz#497c67a1cef50d1a2459ba60f315e448d2ad87fe" resolved "https://registry.yarnpkg.com/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz#497c67a1cef50d1a2459ba60f315e448d2ad87fe"
integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q== integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==
"@matrix-org/analytics-events@^0.9.0": "@matrix-org/analytics-events@^0.10.0":
version "0.9.0" version "0.10.0"
resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.9.0.tgz#ac958b1f49ab84af6325da0264df2f459e87a985" resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.10.0.tgz#d4d8b7859a516e888050d616ebbb0da539a15b1e"
integrity sha512-pKhIspX2lHNe3sUdi42T8lL3RPFqI0kHkxfrF9R0jneJska6GNBzQwPENMY1SjM3YnGYdhz5GZ/QMm6gozuiJg== integrity sha512-qzi7szEWxcl3nW2LDfq+SvFH/of/B/lwhfFUelhihGfr5TBPwgqM95Euc9GeYMZkU8Xm/2f5hYfA0ZleD6RKaA==
"@matrix-org/emojibase-bindings@^1.1.2": "@matrix-org/emojibase-bindings@^1.1.2":
version "1.1.3" version "1.1.3"