element-web/playwright/e2e/spaces/threads-activity-centre/index.ts
R Midhun Suresh cf8b87fd14
Add tabs to the right panel (#12672)
* Create new method for header button behaviour

With the introduction of tabs, the behaviour of the header buttons is
changed as follows:
- Close any right panel if open
- Open the correct right panel if no panel was open before

The old method (and behaviour) is retained as showOrHidePhase.

* Implement tabs in the right panel

There are three tabs: Info, People and Threads

* Remove unwanted code from RoomSummaryCard

- Remove the menu item for opening the memberlist since that is now
  taken of by the tabs.
- Remove the close button

* Remove code for focusing close button from tac item

See https://github.com/matrix-org/matrix-react-sdk/pull/12410

There's no longer a close button to focus so we instead focus the thread
tab. This is done in RightPaneltabs.tsx so we just need to remove this
code.

* Introduce a room info icon to the header

This was previously present in the legacy room header but not in the new
header.

* BaseCard changes

- Adds id, ariaLabelledBy and role props to implement tab accessibility.
- Adds hideHeaderButtons prop to hide header buttons (think back and
  close buttons).
- Change confusing header rendering code:
  header is not rendered ONLY when no header is passed AND
  hideHeaderButtons is true.

* Refactor repeated code into function

Created a new function createSpaceScopeHeader which returns the
component if the room is a space room. Previously this code was
duplicated in every component that uses SpaceScopeHeader component.

* Pass BaseCard attributes and use helper function

Actually using the code from the last two commits

* Add, update and remove tests/screenshots/snapshots

* Fix distance between search bar and tabs

* Update compound

* Update screenshots/snapshots
2024-07-09 11:36:50 +00:00

398 lines
14 KiB
TypeScript

/*
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<{
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] });
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 use({ name, roomId });
},
msg: async ({ page, app, util }, use) => {
await use(new MessageBuilder(page, app, util));
},
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 {
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();
}
/**
* 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 checkng 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" }>) {
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();
}
/**
* Assert that the thread tab is focused
*/
assertThreadTabFocused() {
return expect(this.page.locator("#thread-panel-tab")).toBeFocused();
}
/**
* Populate the rooms with messages and threads
* @param room1
* @param room2
* @param msg - MessageBuilder
* @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,
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: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")]);
}
/**
* Get the space panel
*/
getSpacePanel() {
return this.page.getByRole("navigation", { name: "Spaces" });
}
/**
* Expand the space panel
*/
expandSpacePanel() {
return this.page.getByRole("button", { name: "Expand" }).click();
}
/**
* Clicks the button to mark all threads as read in the current room
*/
clickMarkAllThreadsRead() {
return this.page.getByLabel("Mark all as read").click();
}
}
export { expect };