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:
parent
3052025dd0
commit
a4987060b7
24 changed files with 1455 additions and 14 deletions
355
playwright/e2e/spaces/threads-activity-centre/index.ts
Normal file
355
playwright/e2e/spaces/threads-activity-centre/index.ts
Normal 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 };
|
|
@ -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");
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue