* Refactor NotificationBadge to shared MVVM * Added snapshot images * Replace legacy NotificationBadge with adapter * Fix Eslint * Use regular NotificationBadge prop names * Remove legacy NotificationBadge selectors * Update TAC badge Playwright selectors * Rename NotificationBadgeAdapter to NotificationBadge Addresses review feedback: the component is the app-facing badge, not an adapter, so name it NotificationBadge. * Make NotificationBadgeView isClickable a required boolean Addresses review feedback: there is no semantic difference between `isClickable: false` and `isClickable: undefined` (both render the non-interactive variant), so the field should be a plain boolean. The view model always provides it. * Drop redundant comparison guards in NotificationBadge view model setters Addresses review feedback: Snapshot.merge already compares each field with Object.is and only emits when something actually changed, so the manual equality guards in each setter were redundant. Recompute the snapshot unconditionally and let merge dedupe. * Resolve notification badge labels inside NotificationBadgeView Addresses review feedback: static UI labels belong in the view, not the snapshot. The view now resolves the knock and unsent-message strings via useI18n, and the snapshot exposes a showUnsentTooltip boolean instead of a prebuilt tooltipLabel (and drops knockLabel entirely). Adds the room|knock_sent and notifications|message_didnt_send keys to the shared-components i18n catalogue so they resolve in tests and storybook. * Remove unused translation because of shared components movement * Fix typescript issue * Fix lint issue * Remove unused notification badge count setting * Pass notification badge class names explicitly
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
/*
|
|
Copyright 2024 New Vector Ltd.
|
|
Copyright 2024 The Matrix.org Foundation C.I.C.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE files in the repository root for full details.
|
|
*/
|
|
|
|
import { type JSHandle, type Locator, type Page } from "@playwright/test";
|
|
|
|
import type { MatrixEvent, IContent, Room, Preset } from "matrix-js-sdk/src/matrix";
|
|
import { test as base, expect } from "../../../element-web-test";
|
|
import { type Bot } from "../../../pages/bot";
|
|
import { type Client } from "../../../pages/client";
|
|
import { type ElementAppPage } from "../../../pages/ElementAppPage";
|
|
import { type Credentials } from "../../../plugins/homeserver";
|
|
|
|
type RoomRef = { name: string; roomId: string };
|
|
|
|
/**
|
|
* 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<{
|
|
room1Name: string;
|
|
room1: { name: string; roomId: string };
|
|
room2Name: string;
|
|
room2: { name: string; roomId: string };
|
|
msg: MessageBuilder;
|
|
util: Helpers;
|
|
}>({
|
|
displayName: "Mae",
|
|
botCreateOpts: { displayName: "Other User" },
|
|
|
|
room1Name: "Room 1",
|
|
room1: async ({ room1Name: name, app, user, bot }, use) => {
|
|
const roomId = await app.client.createRoom({
|
|
name,
|
|
invite: [bot.credentials!.userId],
|
|
preset: "public_chat" as Preset,
|
|
});
|
|
await bot.awaitRoomMembership(roomId);
|
|
await use({ name, roomId });
|
|
},
|
|
room2Name: "Room 2",
|
|
room2: async ({ room2Name: name, app, user, bot }, use) => {
|
|
const roomId = await app.client.createRoom({ name, invite: [bot.credentials!.userId] });
|
|
await bot.awaitRoomMembership(roomId);
|
|
await use({ name, roomId });
|
|
},
|
|
msg: async ({ page, app, util }, use) => {
|
|
await use(new MessageBuilder());
|
|
},
|
|
util: async ({ room1, room2, 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 {
|
|
/**
|
|
* 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 {
|
|
constructor(public readonly messageFinder: MessageBuilder) {}
|
|
|
|
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, roomRef: RoomRef, messages: Message[]) {
|
|
const roomId = roomRef.roomId;
|
|
const room = await this.findRoomById(roomId);
|
|
expect(room).toBeTruthy();
|
|
|
|
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: RoomRef) {
|
|
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 findRoomById(roomId: string): Promise<JSHandle<Room>> {
|
|
return this.app.client.evaluateHandle((cli, roomId) => {
|
|
return cli.getRooms().find((r) => r.roomId === roomId)!;
|
|
}, roomId);
|
|
}
|
|
|
|
/**
|
|
* 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: RoomRef, 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();
|
|
}
|
|
|
|
/**
|
|
* Hover over the Threads Activity Centre button
|
|
*/
|
|
hoverTacButton() {
|
|
return this.getTacButton().hover();
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
async assertNoTacIndicator() {
|
|
// Assert by checking neither of the known indicators are visible first. This will wait
|
|
// if it takes a little time to disappear, but the screenshot comparison won't.
|
|
await expect(this.getTacButton().locator("[data-indicator='success']")).not.toBeVisible();
|
|
await expect(this.getTacButton().locator("[data-indicator='critical']")).not.toBeVisible();
|
|
await 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" }>) {
|
|
// 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(`[data-notification-level="${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
|
|
* @param user - the user to mention in the first message
|
|
* @param hasMention - whether to include a mention in the first message
|
|
*/
|
|
async populateThreads(
|
|
room1: { name: string; roomId: string },
|
|
room2: { name: string; roomId: string },
|
|
msg: MessageBuilder,
|
|
user: Credentials,
|
|
hasMention = true,
|
|
) {
|
|
if (hasMention) {
|
|
await this.receiveMessages(room2, [
|
|
"Msg1",
|
|
msg.threadedOff("Msg1", {
|
|
"body": "User",
|
|
"format": "org.matrix.custom.html",
|
|
"formatted_body": `<a href="https://matrix.to/#/${user.userId}">User</a>`,
|
|
"m.mentions": {
|
|
user_ids: [user.userId],
|
|
},
|
|
}),
|
|
]);
|
|
}
|
|
await this.receiveMessages(room2, ["Msg2", msg.threadedOff("Msg2", "Resp2")]);
|
|
await this.receiveMessages(room1, ["Msg3", msg.threadedOff("Msg3", "Resp3")]);
|
|
}
|
|
|
|
/**
|
|
* Get the space panel
|
|
*/
|
|
getSpacePanel() {
|
|
return this.page.getByRole("navigation", { name: "Spaces" });
|
|
}
|
|
|
|
/**
|
|
* Expand the space panel
|
|
*/
|
|
expandSpacePanel() {
|
|
return this.page.getByRole("navigation", { name: "Spaces" }).getByRole("button", { name: "Expand" }).click();
|
|
}
|
|
|
|
/**
|
|
* Clicks the button to mark all threads as read in the current room
|
|
*/
|
|
clickMarkAllThreadsRead() {
|
|
return this.page.locator("#thread-panel").getByRole("button", { name: "Mark all as read" }).click();
|
|
}
|
|
}
|
|
|
|
export { expect };
|