Message Pinning: rework the message pinning list in the right panel (#12825)
* Fix pinning event loading after restart * Update deps * Replace pinned event list * Add a dialog to confirm to unpin all messages * Use `EmptyState` when there is no pinned messages * Rework `PinnedEventTile` tests * Add comments and refactor `PinnedMessageCard` * Rework `PinnedMessageCard` tests * Add tests for `UnpinAllDialog` * Add e2e tests for pinned messages * Replace 3px custom gap by 4px gap * Use string interpolation for `Pin` action. * Update playright sceenshot for empty state
This commit is contained in:
parent
88cf643cbd
commit
6f3dc30693
22 changed files with 2099 additions and 507 deletions
226
playwright/e2e/pinned-messages/index.ts
Normal file
226
playwright/e2e/pinned-messages/index.ts
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
/*
|
||||||
|
* 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 { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { test as base, expect } from "../../element-web-test";
|
||||||
|
import { Client } from "../../pages/client";
|
||||||
|
import { ElementAppPage } from "../../pages/ElementAppPage";
|
||||||
|
import { Bot } from "../../pages/bot";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up for pinned message tests.
|
||||||
|
*/
|
||||||
|
export const test = base.extend<{
|
||||||
|
room1Name?: string;
|
||||||
|
room1: { name: string; roomId: string };
|
||||||
|
util: Helpers;
|
||||||
|
}>({
|
||||||
|
displayName: "Alice",
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
|
||||||
|
util: async ({ page, app, bot }, use) => {
|
||||||
|
await use(new Helpers(page, app, bot));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export class Helpers {
|
||||||
|
constructor(
|
||||||
|
private page: Page,
|
||||||
|
private app: ElementAppPage,
|
||||||
|
private bot: Bot,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: string[]) {
|
||||||
|
await this.sendMessageAsClient(this.bot, room, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use the supplied client to send messages or perform actions as specified by
|
||||||
|
* the supplied {@link Message} items.
|
||||||
|
*/
|
||||||
|
private async sendMessageAsClient(cli: Client, roomName: string | { name: string }, messages: string[]) {
|
||||||
|
const room = await this.findRoomByName(typeof roomName === "string" ? roomName : roomName.name);
|
||||||
|
const roomId = await room.evaluate((room) => room.roomId);
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
await cli.sendMessage(roomId, { body: message, msgtype: "m.text" });
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a room by its name
|
||||||
|
* @param roomName
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async findRoomByName(roomName: string) {
|
||||||
|
return this.app.client.evaluateHandle((cli, roomName) => {
|
||||||
|
return cli.getRooms().find((r) => r.name === roomName);
|
||||||
|
}, roomName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the room with the supplied name.
|
||||||
|
*/
|
||||||
|
async goTo(room: string | { name: string }) {
|
||||||
|
await this.app.viewRoomByName(typeof room === "string" ? room : room.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the given message
|
||||||
|
* @param message
|
||||||
|
*/
|
||||||
|
async pinMessage(message: string) {
|
||||||
|
const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message });
|
||||||
|
await timelineMessage.click({ button: "right" });
|
||||||
|
await this.page.getByRole("menuitem", { name: "Pin" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the given messages
|
||||||
|
* @param messages
|
||||||
|
*/
|
||||||
|
async pinMessages(messages: string[]) {
|
||||||
|
for (const message of messages) {
|
||||||
|
await this.pinMessage(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the room info panel
|
||||||
|
*/
|
||||||
|
async openRoomInfo() {
|
||||||
|
await this.page.getByRole("button", { name: "Room info" }).nth(1).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that the pinned count in the room info is correct
|
||||||
|
* Open the room info and check the pinned count
|
||||||
|
* @param count
|
||||||
|
*/
|
||||||
|
async assertPinnedCountInRoomInfo(count: number) {
|
||||||
|
await expect(this.page.getByRole("menuitem", { name: "Pinned messages" })).toHaveText(
|
||||||
|
`Pinned messages${count}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the pinned messages list
|
||||||
|
*/
|
||||||
|
async openPinnedMessagesList() {
|
||||||
|
await this.page.getByRole("menuitem", { name: "Pinned messages" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the right panel
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private getRightPanel() {
|
||||||
|
return this.page.locator("#mx_RightPanel");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that the pinned message list contains the given messages
|
||||||
|
* @param messages
|
||||||
|
*/
|
||||||
|
async assertPinnedMessagesList(messages: string[]) {
|
||||||
|
const rightPanel = this.getRightPanel();
|
||||||
|
await expect(rightPanel.getByRole("heading", { name: "Pinned messages" })).toHaveText(
|
||||||
|
`${messages.length} Pinned messages`,
|
||||||
|
);
|
||||||
|
await expect(rightPanel).toMatchScreenshot(`pinned-messages-list-messages-${messages.length}.png`);
|
||||||
|
|
||||||
|
const list = rightPanel.getByRole("list");
|
||||||
|
await expect(list.getByRole("listitem")).toHaveCount(messages.length);
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
await expect(list.getByText(message)).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that the pinned message list is empty
|
||||||
|
*/
|
||||||
|
async assertEmptyPinnedMessagesList() {
|
||||||
|
const rightPanel = this.getRightPanel();
|
||||||
|
await expect(rightPanel).toMatchScreenshot(`pinned-messages-list-empty.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the unpin all dialog
|
||||||
|
*/
|
||||||
|
async openUnpinAllDialog() {
|
||||||
|
await this.openRoomInfo();
|
||||||
|
await this.openPinnedMessagesList();
|
||||||
|
await this.page.getByRole("button", { name: "Unpin all" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the unpin all dialog
|
||||||
|
*/
|
||||||
|
getUnpinAllDialog() {
|
||||||
|
return this.page.locator(".mx_Dialog", { hasText: "Unpin all messages?" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click on the Continue button of the unoin all dialog
|
||||||
|
*/
|
||||||
|
async confirmUnpinAllDialog() {
|
||||||
|
await this.getUnpinAllDialog().getByRole("button", { name: "Continue" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go back from the pinned messages list
|
||||||
|
*/
|
||||||
|
async backPinnedMessagesList() {
|
||||||
|
await this.page.locator("#mx_RightPanel").getByTestId("base-card-back-button").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the contextual menu of a message in the pin message list and click on unpin
|
||||||
|
* @param message
|
||||||
|
*/
|
||||||
|
async unpinMessageFromMessageList(message: string) {
|
||||||
|
const item = this.getRightPanel().getByRole("list").getByRole("listitem").filter({
|
||||||
|
hasText: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
await item.getByRole("button").click();
|
||||||
|
await this.page.getByRole("menu", { name: "Open menu" }).getByRole("menuitem", { name: "Unpin" }).click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { expect };
|
79
playwright/e2e/pinned-messages/pinned-messages.spec.ts
Normal file
79
playwright/e2e/pinned-messages/pinned-messages.spec.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
/*
|
||||||
|
* 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 { test } from "./index";
|
||||||
|
import { expect } from "../../element-web-test";
|
||||||
|
|
||||||
|
test.describe("Pinned messages", () => {
|
||||||
|
test.use({
|
||||||
|
labsFlags: ["feature_pinning"],
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show the empty state when there are no pinned messages", async ({ page, app, room1, util }) => {
|
||||||
|
await util.goTo(room1);
|
||||||
|
await util.openRoomInfo();
|
||||||
|
await util.assertPinnedCountInRoomInfo(0);
|
||||||
|
await util.openPinnedMessagesList();
|
||||||
|
await util.assertEmptyPinnedMessagesList();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should pin messages and show them in the room info panel", async ({ page, app, room1, util }) => {
|
||||||
|
await util.goTo(room1);
|
||||||
|
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
|
||||||
|
|
||||||
|
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
|
||||||
|
await util.openRoomInfo();
|
||||||
|
await util.assertPinnedCountInRoomInfo(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should pin messages and show them in the pinned message panel", async ({ page, app, room1, util }) => {
|
||||||
|
await util.goTo(room1);
|
||||||
|
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
|
||||||
|
|
||||||
|
// Pin the messages
|
||||||
|
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
|
||||||
|
await util.openRoomInfo();
|
||||||
|
await util.openPinnedMessagesList();
|
||||||
|
await util.assertPinnedMessagesList(["Msg1", "Msg2", "Msg4"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should unpin one message", async ({ page, app, room1, util }) => {
|
||||||
|
await util.goTo(room1);
|
||||||
|
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
|
||||||
|
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
|
||||||
|
|
||||||
|
await util.openRoomInfo();
|
||||||
|
await util.openPinnedMessagesList();
|
||||||
|
await util.unpinMessageFromMessageList("Msg2");
|
||||||
|
await util.assertPinnedMessagesList(["Msg1", "Msg4"]);
|
||||||
|
await util.backPinnedMessagesList();
|
||||||
|
await util.assertPinnedCountInRoomInfo(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should unpin all messages", async ({ page, app, room1, util }) => {
|
||||||
|
await util.goTo(room1);
|
||||||
|
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
|
||||||
|
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
|
||||||
|
|
||||||
|
await util.openUnpinAllDialog();
|
||||||
|
await expect(util.getUnpinAllDialog()).toMatchScreenshot("unpin-all-dialog.png");
|
||||||
|
await util.confirmUnpinAllDialog();
|
||||||
|
|
||||||
|
await util.assertEmptyPinnedMessagesList();
|
||||||
|
await util.backPinnedMessagesList();
|
||||||
|
await util.assertPinnedCountInRoomInfo(0);
|
||||||
|
});
|
||||||
|
});
|
Binary file not shown.
After Width: | Height: | Size: 79 KiB |
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
|
@ -604,7 +604,7 @@ legend {
|
||||||
.mx_Dialog
|
.mx_Dialog
|
||||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||||
.mx_UserProfileSettings button
|
.mx_UserProfileSettings button
|
||||||
):not(.mx_ThemeChoicePanel_CustomTheme button),
|
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button),
|
||||||
.mx_Dialog input[type="submit"],
|
.mx_Dialog input[type="submit"],
|
||||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
|
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
|
||||||
.mx_Dialog_buttons input[type="submit"] {
|
.mx_Dialog_buttons input[type="submit"] {
|
||||||
|
@ -624,14 +624,14 @@ legend {
|
||||||
.mx_Dialog
|
.mx_Dialog
|
||||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||||
.mx_UserProfileSettings button
|
.mx_UserProfileSettings button
|
||||||
):not(.mx_ThemeChoicePanel_CustomTheme button):last-child {
|
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):last-child {
|
||||||
margin-right: 0px;
|
margin-right: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_Dialog
|
.mx_Dialog
|
||||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||||
.mx_UserProfileSettings button
|
.mx_UserProfileSettings button
|
||||||
):not(.mx_ThemeChoicePanel_CustomTheme button):focus,
|
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):focus,
|
||||||
.mx_Dialog input[type="submit"]:focus,
|
.mx_Dialog input[type="submit"]:focus,
|
||||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
|
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
|
||||||
.mx_Dialog_buttons input[type="submit"]:focus {
|
.mx_Dialog_buttons input[type="submit"]:focus {
|
||||||
|
@ -643,7 +643,7 @@ legend {
|
||||||
.mx_Dialog_buttons
|
.mx_Dialog_buttons
|
||||||
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
|
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
|
||||||
.mx_UserProfileSettings button
|
.mx_UserProfileSettings button
|
||||||
):not(.mx_ThemeChoicePanel_CustomTheme button),
|
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button),
|
||||||
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
|
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
|
||||||
color: var(--cpd-color-text-on-solid-primary);
|
color: var(--cpd-color-text-on-solid-primary);
|
||||||
background-color: var(--cpd-color-bg-action-primary-rest);
|
background-color: var(--cpd-color-bg-action-primary-rest);
|
||||||
|
@ -656,7 +656,7 @@ legend {
|
||||||
.mx_Dialog_buttons
|
.mx_Dialog_buttons
|
||||||
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
|
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
|
||||||
.mx_ThemeChoicePanel_CustomTheme button
|
.mx_ThemeChoicePanel_CustomTheme button
|
||||||
),
|
):not(.mx_UnpinAllDialog button),
|
||||||
.mx_Dialog_buttons input[type="submit"].danger {
|
.mx_Dialog_buttons input[type="submit"].danger {
|
||||||
background-color: var(--cpd-color-bg-critical-primary);
|
background-color: var(--cpd-color-bg-critical-primary);
|
||||||
border: solid 1px var(--cpd-color-bg-critical-primary);
|
border: solid 1px var(--cpd-color-bg-critical-primary);
|
||||||
|
@ -672,7 +672,7 @@ legend {
|
||||||
.mx_Dialog
|
.mx_Dialog
|
||||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||||
.mx_UserProfileSettings button
|
.mx_UserProfileSettings button
|
||||||
):not(.mx_ThemeChoicePanel_CustomTheme button):disabled,
|
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):disabled,
|
||||||
.mx_Dialog input[type="submit"]:disabled,
|
.mx_Dialog input[type="submit"]:disabled,
|
||||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
|
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
|
||||||
.mx_Dialog_buttons input[type="submit"]:disabled {
|
.mx_Dialog_buttons input[type="submit"]:disabled {
|
||||||
|
|
|
@ -167,6 +167,7 @@
|
||||||
@import "./views/dialogs/_SpaceSettingsDialog.pcss";
|
@import "./views/dialogs/_SpaceSettingsDialog.pcss";
|
||||||
@import "./views/dialogs/_SpotlightDialog.pcss";
|
@import "./views/dialogs/_SpotlightDialog.pcss";
|
||||||
@import "./views/dialogs/_TermsDialog.pcss";
|
@import "./views/dialogs/_TermsDialog.pcss";
|
||||||
|
@import "./views/dialogs/_UnpinAllDialog.pcss";
|
||||||
@import "./views/dialogs/_UntrustedDeviceDialog.pcss";
|
@import "./views/dialogs/_UntrustedDeviceDialog.pcss";
|
||||||
@import "./views/dialogs/_UploadConfirmDialog.pcss";
|
@import "./views/dialogs/_UploadConfirmDialog.pcss";
|
||||||
@import "./views/dialogs/_UserSettingsDialog.pcss";
|
@import "./views/dialogs/_UserSettingsDialog.pcss";
|
||||||
|
|
38
res/css/views/dialogs/_UnpinAllDialog.pcss
Normal file
38
res/css/views/dialogs/_UnpinAllDialog.pcss
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* 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_UnpinAllDialog {
|
||||||
|
/* 396 is coming from figma and we remove the left and right paddings of the dialog */
|
||||||
|
width: calc(396px - (var(--cpd-space-10x) * 2));
|
||||||
|
padding-bottom: var(--cpd-space-2x);
|
||||||
|
|
||||||
|
.mx_UnpinAllDialog_title {
|
||||||
|
/* Override the default heading style */
|
||||||
|
font: var(--cpd-font-heading-sm-semibold) !important;
|
||||||
|
margin-bottom: var(--cpd-space-3x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UnpinAllDialog_buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--cpd-space-4x);
|
||||||
|
margin: var(--cpd-space-8x) var(--cpd-space-2x) 0 var(--cpd-space-2x);
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,50 +15,40 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.mx_PinnedMessagesCard {
|
.mx_PinnedMessagesCard {
|
||||||
.mx_PinnedMessagesCard_empty_wrapper {
|
--unpin-height: 76px;
|
||||||
|
|
||||||
|
.mx_PinnedMessagesCard_wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
flex-direction: column;
|
||||||
|
padding: var(--cpd-space-4x);
|
||||||
|
gap: var(--cpd-space-6x);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
.mx_PinnedMessagesCard_empty {
|
.mx_PinnedMessagesCard_Separator {
|
||||||
height: max-content;
|
min-height: 1px;
|
||||||
text-align: center;
|
/* Override default compound value */
|
||||||
margin: auto 40px;
|
margin-block: 0;
|
||||||
|
|
||||||
.mx_PinnedMessagesCard_MessageActionBar {
|
|
||||||
pointer-events: none;
|
|
||||||
width: max-content;
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
/* Cancel the default values for non-interactivity */
|
|
||||||
position: unset;
|
|
||||||
visibility: visible;
|
|
||||||
cursor: unset;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_MessageActionBar_optionsButton {
|
|
||||||
background: var(--MessageActionBar-item-hover-background);
|
|
||||||
border-radius: var(--MessageActionBar-item-hover-borderRadius);
|
|
||||||
z-index: var(--MessageActionBar-item-hover-zIndex);
|
|
||||||
color: var(--cpd-color-icon-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_PinnedMessagesCard_empty_header {
|
|
||||||
color: $primary-content;
|
|
||||||
margin-block: $spacing-24 $spacing-20;
|
|
||||||
}
|
|
||||||
|
|
||||||
> span {
|
|
||||||
font-size: $font-12px;
|
|
||||||
line-height: $font-15px;
|
|
||||||
color: $secondary-content;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_PinnedMessagesCard_wrapper_unpin_all {
|
||||||
|
/* Remove the unpin all button height and the top and bottom padding */
|
||||||
|
height: calc(100% - var(--unpin-height) - calc(var(--cpd-space-4x) * 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_PinnedMessagesCard_unpin {
|
||||||
|
/* Make it float at the bottom of the unpin panel */
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--unpin-height);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 4px 24px 0 rgba(27, 29, 34, 0.1);
|
||||||
|
background: var(--cpd-color-bg-canvas-default);
|
||||||
|
}
|
||||||
|
|
||||||
.mx_EventTile_body {
|
.mx_EventTile_body {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,95 +15,27 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.mx_PinnedEventTile {
|
.mx_PinnedEventTile {
|
||||||
min-height: 40px;
|
display: flex;
|
||||||
width: 100%;
|
gap: var(--cpd-space-4x);
|
||||||
padding: 0 4px 12px;
|
align-items: flex-start;
|
||||||
|
|
||||||
display: grid;
|
.mx_PinnedEventTile_wrapper {
|
||||||
grid-template-areas:
|
display: flex;
|
||||||
"avatar name remove"
|
flex-direction: column;
|
||||||
"content content content"
|
gap: var(--cpd-space-1x);
|
||||||
"footer footer footer";
|
width: 100%;
|
||||||
grid-template-rows: max-content auto max-content;
|
|
||||||
grid-template-columns: 24px auto 24px;
|
|
||||||
grid-row-gap: 12px;
|
|
||||||
grid-column-gap: 8px;
|
|
||||||
|
|
||||||
& + .mx_PinnedEventTile {
|
.mx_PinnedEventTile_top {
|
||||||
padding: 12px 4px;
|
display: flex;
|
||||||
border-top: 1px solid $menu-border-color;
|
gap: var(--cpd-space-1x);
|
||||||
}
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
.mx_PinnedEventTile_senderAvatar,
|
.mx_PinnedEventTile_sender {
|
||||||
.mx_PinnedEventTile_sender,
|
text-overflow: ellipsis;
|
||||||
.mx_PinnedEventTile_unpinButton,
|
overflow: hidden;
|
||||||
.mx_PinnedEventTile_message,
|
white-space: nowrap;
|
||||||
.mx_PinnedEventTile_footer {
|
}
|
||||||
min-width: 0; /* Prevent a grid blowout */
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_PinnedEventTile_senderAvatar {
|
|
||||||
grid-area: avatar;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_PinnedEventTile_sender {
|
|
||||||
grid-area: name;
|
|
||||||
font-weight: var(--cpd-font-weight-semibold);
|
|
||||||
font-size: $font-15px;
|
|
||||||
line-height: $font-24px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_PinnedEventTile_unpinButton {
|
|
||||||
visibility: hidden;
|
|
||||||
grid-area: remove;
|
|
||||||
position: relative;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $roomheader-addroom-bg-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
height: inherit;
|
|
||||||
width: inherit;
|
|
||||||
background: $secondary-content;
|
|
||||||
mask-position: center;
|
|
||||||
mask-size: 8px;
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
mask-image: url("$(res)/img/image-view/close.svg");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_PinnedEventTile_message {
|
|
||||||
grid-area: content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_PinnedEventTile_footer {
|
|
||||||
grid-area: footer;
|
|
||||||
font-size: $font-10px;
|
|
||||||
line-height: 12px;
|
|
||||||
|
|
||||||
.mx_PinnedEventTile_timestamp {
|
|
||||||
color: $secondary-content;
|
|
||||||
display: unset;
|
|
||||||
width: unset; /* Cancel the default width value */
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_AccessibleButton_kind_link {
|
|
||||||
margin-left: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.mx_PinnedEventTile_unpinButton {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@ import ThreadView from "./ThreadView";
|
||||||
import ThreadPanel from "./ThreadPanel";
|
import ThreadPanel from "./ThreadPanel";
|
||||||
import NotificationPanel from "./NotificationPanel";
|
import NotificationPanel from "./NotificationPanel";
|
||||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||||
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
|
import { PinnedMessagesCard } from "../views/right_panel/PinnedMessagesCard";
|
||||||
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||||
import { E2EStatus } from "../../utils/ShieldUtils";
|
import { E2EStatus } from "../../utils/ShieldUtils";
|
||||||
import TimelineCard from "../views/right_panel/TimelineCard";
|
import TimelineCard from "../views/right_panel/TimelineCard";
|
||||||
|
|
77
src/components/views/dialogs/UnpinAllDialog.tsx
Normal file
77
src/components/views/dialogs/UnpinAllDialog.tsx
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
/*
|
||||||
|
* 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 } from "react";
|
||||||
|
import { Button, Text } from "@vector-im/compound-web";
|
||||||
|
import { EventType, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
|
import BaseDialog from "../dialogs/BaseDialog";
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties for {@link UnpinAllDialog}.
|
||||||
|
*/
|
||||||
|
interface UnpinAllDialogProps {
|
||||||
|
/*
|
||||||
|
* The matrix client to use.
|
||||||
|
*/
|
||||||
|
matrixClient: MatrixClient;
|
||||||
|
/*
|
||||||
|
* The room ID to unpin all events in.
|
||||||
|
*/
|
||||||
|
roomId: string;
|
||||||
|
/*
|
||||||
|
* Callback for when the dialog is closed.
|
||||||
|
*/
|
||||||
|
onFinished: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that asks the user to confirm unpinning all events in a room.
|
||||||
|
*/
|
||||||
|
export function UnpinAllDialog({ matrixClient, roomId, onFinished }: UnpinAllDialogProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<BaseDialog
|
||||||
|
hasCancel={true}
|
||||||
|
title={_t("right_panel|pinned_messages|unpin_all|title")}
|
||||||
|
titleClass="mx_UnpinAllDialog_title"
|
||||||
|
className="mx_UnpinAllDialog"
|
||||||
|
onFinished={onFinished}
|
||||||
|
fixedWidth={false}
|
||||||
|
>
|
||||||
|
<Text as="span">{_t("right_panel|pinned_messages|unpin_all|content")}</Text>
|
||||||
|
<div className="mx_UnpinAllDialog_buttons">
|
||||||
|
<Button
|
||||||
|
destructive={true}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await matrixClient.sendStateEvent(roomId, EventType.RoomPinnedEvents, { pinned: [] }, "");
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to unpin all events:", e);
|
||||||
|
}
|
||||||
|
onFinished();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{_t("action|continue")}
|
||||||
|
</Button>
|
||||||
|
<Button kind="tertiary" onClick={onFinished}>
|
||||||
|
{_t("action|cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</BaseDialog>
|
||||||
|
);
|
||||||
|
}
|
|
@ -14,41 +14,62 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useContext, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState, JSX } from "react";
|
||||||
import { Room, RoomEvent, RoomStateEvent, MatrixEvent, EventType, RelationType } from "matrix-js-sdk/src/matrix";
|
import {
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomStateEvent,
|
||||||
|
MatrixEvent,
|
||||||
|
EventType,
|
||||||
|
RelationType,
|
||||||
|
EventTimeline,
|
||||||
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { Button, Separator } from "@vector-im/compound-web";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin";
|
||||||
|
|
||||||
import { Icon as ContextMenuIcon } from "../../../../res/img/element-icons/context-menu.svg";
|
|
||||||
import { Icon as EmojiIcon } from "../../../../res/img/element-icons/room/message-bar/emoji.svg";
|
|
||||||
import { Icon as ReplyIcon } from "../../../../res/img/element-icons/room/message-bar/reply.svg";
|
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import BaseCard from "./BaseCard";
|
import BaseCard from "./BaseCard";
|
||||||
import Spinner from "../elements/Spinner";
|
import Spinner from "../elements/Spinner";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||||
import PinningUtils from "../../../utils/PinningUtils";
|
import PinningUtils from "../../../utils/PinningUtils";
|
||||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||||
import PinnedEventTile from "../rooms/PinnedEventTile";
|
import { PinnedEventTile } from "../rooms/PinnedEventTile";
|
||||||
import { useRoomState } from "../../../hooks/useRoomState";
|
import { useRoomState } from "../../../hooks/useRoomState";
|
||||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext";
|
||||||
import { ReadPinsEventId } from "./types";
|
import { ReadPinsEventId } from "./types";
|
||||||
import Heading from "../typography/Heading";
|
import Heading from "../typography/Heading";
|
||||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||||
import { filterBoolean } from "../../../utils/arrays";
|
import { filterBoolean } from "../../../utils/arrays";
|
||||||
|
import Modal from "../../../Modal";
|
||||||
|
import { UnpinAllDialog } from "../dialogs/UnpinAllDialog";
|
||||||
|
import EmptyState from "./EmptyState";
|
||||||
|
|
||||||
interface IProps {
|
/**
|
||||||
room: Room;
|
* Get the pinned event IDs from a room.
|
||||||
permalinkCreator: RoomPermalinkCreator;
|
* @param room
|
||||||
onClose(): void;
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
function getPinnedEventIds(room?: Room): string[] {
|
function getPinnedEventIds(room?: Room): string[] {
|
||||||
return room?.currentState.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent()?.pinned ?? [];
|
return (
|
||||||
|
room
|
||||||
|
?.getLiveTimeline()
|
||||||
|
.getState(EventTimeline.FORWARDS)
|
||||||
|
?.getStateEvents(EventType.RoomPinnedEvents, "")
|
||||||
|
?.getContent()?.pinned ?? []
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pinned event IDs from a room.
|
||||||
|
* @param room
|
||||||
|
*/
|
||||||
export const usePinnedEvents = (room?: Room): string[] => {
|
export const usePinnedEvents = (room?: Room): string[] => {
|
||||||
const [pinnedEvents, setPinnedEvents] = useState<string[]>(getPinnedEventIds(room));
|
const [pinnedEvents, setPinnedEvents] = useState<string[]>(getPinnedEventIds(room));
|
||||||
|
|
||||||
|
// Update the pinned events when the room state changes
|
||||||
|
// Filter out events that are not pinned events
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(ev?: MatrixEvent) => {
|
(ev?: MatrixEvent) => {
|
||||||
if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
|
if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
|
||||||
|
@ -57,7 +78,7 @@ export const usePinnedEvents = (room?: Room): string[] => {
|
||||||
[room],
|
[room],
|
||||||
);
|
);
|
||||||
|
|
||||||
useTypedEventEmitter(room?.currentState, RoomStateEvent.Events, update);
|
useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPinnedEvents(getPinnedEventIds(room));
|
setPinnedEvents(getPinnedEventIds(room));
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -67,13 +88,23 @@ export const usePinnedEvents = (room?: Room): string[] => {
|
||||||
return pinnedEvents;
|
return pinnedEvents;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the read pinned event IDs from a room.
|
||||||
|
* @param room
|
||||||
|
*/
|
||||||
function getReadPinnedEventIds(room?: Room): Set<string> {
|
function getReadPinnedEventIds(room?: Room): Set<string> {
|
||||||
return new Set(room?.getAccountData(ReadPinsEventId)?.getContent()?.event_ids ?? []);
|
return new Set(room?.getAccountData(ReadPinsEventId)?.getContent()?.event_ids ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the read pinned event IDs from a room.
|
||||||
|
* @param room
|
||||||
|
*/
|
||||||
export const useReadPinnedEvents = (room?: Room): Set<string> => {
|
export const useReadPinnedEvents = (room?: Room): Set<string> => {
|
||||||
const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
|
const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Update the read pinned events when the room state changes
|
||||||
|
// Filter out events that are not read pinned events
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(ev?: MatrixEvent) => {
|
(ev?: MatrixEvent) => {
|
||||||
if (ev && ev.getType() !== ReadPinsEventId) return;
|
if (ev && ev.getType() !== ReadPinsEventId) return;
|
||||||
|
@ -92,36 +123,36 @@ export const useReadPinnedEvents = (room?: Room): Set<string> => {
|
||||||
return readPinnedEvents;
|
return readPinnedEvents;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator }) => {
|
/**
|
||||||
const cli = useContext(MatrixClientContext);
|
* Fetch the pinned events
|
||||||
const roomContext = useContext(RoomContext);
|
* @param room
|
||||||
const canUnpin = useRoomState(room, (state) => state.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli));
|
* @param pinnedEventIds
|
||||||
const pinnedEventIds = usePinnedEvents(room);
|
*/
|
||||||
const readPinnedEvents = useReadPinnedEvents(room);
|
function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array<MatrixEvent | null> | null {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
|
||||||
useEffect(() => {
|
return useAsyncMemo(
|
||||||
if (!cli || cli.isGuest()) return; // nothing to do
|
|
||||||
const newlyRead = pinnedEventIds.filter((id) => !readPinnedEvents.has(id));
|
|
||||||
if (newlyRead.length > 0) {
|
|
||||||
// clear out any read pinned events which no longer are pinned
|
|
||||||
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
|
|
||||||
event_ids: pinnedEventIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [cli, room.roomId, pinnedEventIds, readPinnedEvents]);
|
|
||||||
|
|
||||||
const pinnedEvents = useAsyncMemo(
|
|
||||||
() => {
|
() => {
|
||||||
const promises = pinnedEventIds.map(async (eventId): Promise<MatrixEvent | null> => {
|
const promises = pinnedEventIds.map(async (eventId): Promise<MatrixEvent | null> => {
|
||||||
const timelineSet = room.getUnfilteredTimelineSet();
|
const timelineSet = room.getUnfilteredTimelineSet();
|
||||||
|
// Get the event from the local timeline
|
||||||
const localEvent = timelineSet
|
const localEvent = timelineSet
|
||||||
?.getTimelineForEvent(eventId)
|
?.getTimelineForEvent(eventId)
|
||||||
?.getEvents()
|
?.getEvents()
|
||||||
.find((e) => e.getId() === eventId);
|
.find((e) => e.getId() === eventId);
|
||||||
|
|
||||||
|
// Decrypt the event if it's encrypted
|
||||||
|
// Can happen when the tab is refreshed and the pinned events card is opened directly
|
||||||
|
if (localEvent?.isEncrypted()) {
|
||||||
|
await cli.decryptEventIfNeeded(localEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the event is available locally, return it if it's pinnable
|
||||||
|
// Otherwise, return null
|
||||||
if (localEvent) return PinningUtils.isPinnable(localEvent) ? localEvent : null;
|
if (localEvent) return PinningUtils.isPinnable(localEvent) ? localEvent : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch the event and latest edit in parallel
|
// The event is not available locally, so we fetch the event and latest edit in parallel
|
||||||
const [
|
const [
|
||||||
evJson,
|
evJson,
|
||||||
{
|
{
|
||||||
|
@ -131,10 +162,15 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
|
||||||
cli.fetchRoomEvent(room.roomId, eventId),
|
cli.fetchRoomEvent(room.roomId, eventId),
|
||||||
cli.relations(room.roomId, eventId, RelationType.Replace, null, { limit: 1 }),
|
cli.relations(room.roomId, eventId, RelationType.Replace, null, { limit: 1 }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const event = new MatrixEvent(evJson);
|
const event = new MatrixEvent(evJson);
|
||||||
|
|
||||||
|
// Decrypt the event if it's encrypted
|
||||||
if (event.isEncrypted()) {
|
if (event.isEncrypted()) {
|
||||||
await cli.decryptEventIfNeeded(event); // TODO await?
|
await cli.decryptEventIfNeeded(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle poll events
|
||||||
await room.processPollEvents([event]);
|
await room.processPollEvents([event]);
|
||||||
|
|
||||||
const senderUserId = event.getSender();
|
const senderUserId = event.getSender();
|
||||||
|
@ -158,62 +194,59 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
|
||||||
[cli, room, pinnedEventIds],
|
[cli, room, pinnedEventIds],
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let content: JSX.Element[] | JSX.Element | undefined;
|
/**
|
||||||
|
* List the pinned messages in a room inside a Card.
|
||||||
|
*/
|
||||||
|
interface PinnedMessagesCardProps {
|
||||||
|
/**
|
||||||
|
* The room to list the pinned messages for.
|
||||||
|
*/
|
||||||
|
room: Room;
|
||||||
|
/**
|
||||||
|
* Permalink of the room.
|
||||||
|
*/
|
||||||
|
permalinkCreator: RoomPermalinkCreator;
|
||||||
|
/**
|
||||||
|
* Callback for when the card is closed.
|
||||||
|
*/
|
||||||
|
onClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PinnedMessagesCard({ room, onClose, permalinkCreator }: PinnedMessagesCardProps): JSX.Element {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
const roomContext = useRoomContext();
|
||||||
|
const pinnedEventIds = usePinnedEvents(room);
|
||||||
|
const readPinnedEvents = useReadPinnedEvents(room);
|
||||||
|
const pinnedEvents = useFetchedPinnedEvents(room, pinnedEventIds);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cli || cli.isGuest()) return; // nothing to do
|
||||||
|
const newlyRead = pinnedEventIds.filter((id) => !readPinnedEvents.has(id));
|
||||||
|
if (newlyRead.length > 0) {
|
||||||
|
// clear out any read pinned events which no longer are pinned
|
||||||
|
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
|
||||||
|
event_ids: pinnedEventIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [cli, room.roomId, pinnedEventIds, readPinnedEvents]);
|
||||||
|
|
||||||
|
let content: JSX.Element;
|
||||||
if (!pinnedEventIds.length) {
|
if (!pinnedEventIds.length) {
|
||||||
content = (
|
content = (
|
||||||
<div className="mx_PinnedMessagesCard_empty_wrapper">
|
<EmptyState
|
||||||
<div className="mx_PinnedMessagesCard_empty">
|
Icon={PinIcon}
|
||||||
{/* XXX: We reuse the classes for simplicity, but deliberately not the components for non-interactivity. */}
|
title={_t("right_panel|pinned_messages|empty_title")}
|
||||||
<div className="mx_MessageActionBar mx_PinnedMessagesCard_MessageActionBar">
|
description={_t("right_panel|pinned_messages|empty_description", {
|
||||||
<div className="mx_MessageActionBar_iconButton">
|
pinAction: _t("action|pin"),
|
||||||
<EmojiIcon />
|
})}
|
||||||
</div>
|
/>
|
||||||
<div className="mx_MessageActionBar_iconButton">
|
|
||||||
<ReplyIcon />
|
|
||||||
</div>
|
|
||||||
<div className="mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton">
|
|
||||||
<ContextMenuIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Heading size="4" className="mx_PinnedMessagesCard_empty_header">
|
|
||||||
{_t("right_panel|pinned_messages|empty")}
|
|
||||||
</Heading>
|
|
||||||
{_t(
|
|
||||||
"right_panel|pinned_messages|explainer",
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
b: (sub) => <b>{sub}</b>,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
} else if (pinnedEvents?.length) {
|
} else if (pinnedEvents?.length) {
|
||||||
const onUnpinClicked = async (event: MatrixEvent): Promise<void> => {
|
content = (
|
||||||
const pinnedEvents = room.currentState.getStateEvents(EventType.RoomPinnedEvents, "");
|
<PinnedMessages events={filterBoolean(pinnedEvents)} room={room} permalinkCreator={permalinkCreator} />
|
||||||
if (pinnedEvents?.getContent()?.pinned) {
|
);
|
||||||
const pinned = pinnedEvents.getContent().pinned;
|
|
||||||
const index = pinned.indexOf(event.getId());
|
|
||||||
if (index !== -1) {
|
|
||||||
pinned.splice(index, 1);
|
|
||||||
await cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// show them in reverse, with latest pinned at the top
|
|
||||||
content = filterBoolean(pinnedEvents)
|
|
||||||
.reverse()
|
|
||||||
.map((ev) => (
|
|
||||||
<PinnedEventTile
|
|
||||||
key={ev.getId()}
|
|
||||||
event={ev}
|
|
||||||
onUnpinClicked={canUnpin ? () => onUnpinClicked(ev) : undefined}
|
|
||||||
permalinkCreator={permalinkCreator}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
content = <Spinner />;
|
content = <Spinner />;
|
||||||
}
|
}
|
||||||
|
@ -223,7 +256,7 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
|
||||||
header={
|
header={
|
||||||
<div className="mx_BaseCard_header_title">
|
<div className="mx_BaseCard_header_title">
|
||||||
<Heading size="4" className="mx_BaseCard_header_title_heading">
|
<Heading size="4" className="mx_BaseCard_header_title_heading">
|
||||||
{_t("right_panel|pinned_messages|title")}
|
{_t("right_panel|pinned_messages|header", { count: pinnedEventIds.length })}
|
||||||
</Heading>
|
</Heading>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -240,6 +273,79 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
|
||||||
</RoomContext.Provider>
|
</RoomContext.Provider>
|
||||||
</BaseCard>
|
</BaseCard>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default PinnedMessagesCard;
|
/**
|
||||||
|
* The pinned messages in a room.
|
||||||
|
*/
|
||||||
|
interface PinnedMessagesProps {
|
||||||
|
/**
|
||||||
|
* The pinned events.
|
||||||
|
*/
|
||||||
|
events: MatrixEvent[];
|
||||||
|
/**
|
||||||
|
* The room the events are in.
|
||||||
|
*/
|
||||||
|
room: Room;
|
||||||
|
/**
|
||||||
|
* The permalink creator to use.
|
||||||
|
*/
|
||||||
|
permalinkCreator: RoomPermalinkCreator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pinned messages in a room.
|
||||||
|
*/
|
||||||
|
function PinnedMessages({ events, room, permalinkCreator }: PinnedMessagesProps): JSX.Element {
|
||||||
|
const matrixClient = useMatrixClientContext();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the client can unpin events from the room.
|
||||||
|
*/
|
||||||
|
const canUnpin = useRoomState(room, (state) =>
|
||||||
|
state.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the unpin all dialog.
|
||||||
|
*/
|
||||||
|
const onUnpinAll = useCallback(async (): Promise<void> => {
|
||||||
|
Modal.createDialog(UnpinAllDialog, {
|
||||||
|
roomId: room.roomId,
|
||||||
|
matrixClient,
|
||||||
|
});
|
||||||
|
}, [room, matrixClient]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={classNames("mx_PinnedMessagesCard_wrapper", {
|
||||||
|
mx_PinnedMessagesCard_wrapper_unpin_all: canUnpin,
|
||||||
|
})}
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
{events.reverse().map((event, i) => (
|
||||||
|
<>
|
||||||
|
<PinnedEventTile
|
||||||
|
key={event.getId()}
|
||||||
|
event={event}
|
||||||
|
permalinkCreator={permalinkCreator}
|
||||||
|
room={room}
|
||||||
|
/>
|
||||||
|
{/* Add a separator if this isn't the last pinned message */}
|
||||||
|
{events.length - 1 !== i && (
|
||||||
|
<Separator key={`separator-${event.getId()}`} className="mx_PinnedMessagesCard_Separator" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{canUnpin && (
|
||||||
|
<div className="mx_PinnedMessagesCard_unpin">
|
||||||
|
<Button kind="tertiary" onClick={onUnpinAll}>
|
||||||
|
{_t("right_panel|pinned_messages|unpin_all|button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -15,112 +15,206 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, { JSX, useCallback, useState } from "react";
|
||||||
import { MatrixEvent, EventType, RelationType, Relations } from "matrix-js-sdk/src/matrix";
|
import { EventTimeline, EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { IconButton, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
|
||||||
|
import { Icon as ViewIcon } from "@vector-im/compound-design-tokens/icons/visibility-on.svg";
|
||||||
|
import { Icon as UnpinIcon } from "@vector-im/compound-design-tokens/icons/unpin.svg";
|
||||||
|
import { Icon as ForwardIcon } from "@vector-im/compound-design-tokens/icons/forward.svg";
|
||||||
|
import { Icon as TriggerIcon } from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg";
|
||||||
|
import { Icon as DeleteIcon } from "@vector-im/compound-design-tokens/icons/delete.svg";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
import dis from "../../../dispatcher/dispatcher";
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import AccessibleButton from "../elements/AccessibleButton";
|
|
||||||
import MessageEvent from "../messages/MessageEvent";
|
import MessageEvent from "../messages/MessageEvent";
|
||||||
import MemberAvatar from "../avatars/MemberAvatar";
|
import MemberAvatar from "../avatars/MemberAvatar";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { formatDate } from "../../../DateUtils";
|
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
|
||||||
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
|
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
|
||||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||||
|
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||||
|
import { useRoomState } from "../../../hooks/useRoomState";
|
||||||
|
import { isContentActionable } from "../../../utils/EventUtils";
|
||||||
|
import { getForwardableEvent } from "../../../events";
|
||||||
|
import { OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload";
|
||||||
|
import { createRedactEventDialog } from "../dialogs/ConfirmRedactDialog";
|
||||||
|
|
||||||
interface IProps {
|
const AVATAR_SIZE = "32px";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties for {@link PinnedEventTile}.
|
||||||
|
*/
|
||||||
|
interface PinnedEventTileProps {
|
||||||
|
/**
|
||||||
|
* The event to display.
|
||||||
|
*/
|
||||||
event: MatrixEvent;
|
event: MatrixEvent;
|
||||||
|
/**
|
||||||
|
* The permalink creator to use.
|
||||||
|
*/
|
||||||
permalinkCreator: RoomPermalinkCreator;
|
permalinkCreator: RoomPermalinkCreator;
|
||||||
onUnpinClicked?(): void;
|
/**
|
||||||
|
* The room the event is in.
|
||||||
|
*/
|
||||||
|
room: Room;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AVATAR_SIZE = "24px";
|
/**
|
||||||
|
* A pinned event tile.
|
||||||
|
*/
|
||||||
|
export function PinnedEventTile({ event, room, permalinkCreator }: PinnedEventTileProps): JSX.Element {
|
||||||
|
const sender = event.getSender();
|
||||||
|
if (!sender) {
|
||||||
|
throw new Error("Pinned event unexpectedly has no sender");
|
||||||
|
}
|
||||||
|
|
||||||
export default class PinnedEventTile extends React.Component<IProps> {
|
return (
|
||||||
public static contextType = MatrixClientContext;
|
<div className="mx_PinnedEventTile" role="listitem">
|
||||||
public declare context: React.ContextType<typeof MatrixClientContext>;
|
<div>
|
||||||
|
|
||||||
private onTileClicked = (): void => {
|
|
||||||
dis.dispatch<ViewRoomPayload>({
|
|
||||||
action: Action.ViewRoom,
|
|
||||||
event_id: this.props.event.getId(),
|
|
||||||
highlighted: true,
|
|
||||||
room_id: this.props.event.getRoomId(),
|
|
||||||
metricsTrigger: undefined, // room doesn't change
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// For event types like polls that use relations, we fetch those manually on
|
|
||||||
// mount and store them here, exposing them through getRelationsForEvent
|
|
||||||
private relations = new Map<string, Map<string, Relations>>();
|
|
||||||
private getRelationsForEvent = (
|
|
||||||
eventId: string,
|
|
||||||
relationType: RelationType | string,
|
|
||||||
eventType: EventType | string,
|
|
||||||
): Relations | undefined => {
|
|
||||||
if (eventId === this.props.event.getId()) {
|
|
||||||
return this.relations.get(relationType)?.get(eventType);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
|
||||||
const sender = this.props.event.getSender();
|
|
||||||
|
|
||||||
if (!sender) {
|
|
||||||
throw new Error("Pinned event unexpectedly has no sender");
|
|
||||||
}
|
|
||||||
|
|
||||||
let unpinButton: JSX.Element | undefined;
|
|
||||||
if (this.props.onUnpinClicked) {
|
|
||||||
unpinButton = (
|
|
||||||
<AccessibleButton
|
|
||||||
onClick={this.props.onUnpinClicked}
|
|
||||||
className="mx_PinnedEventTile_unpinButton"
|
|
||||||
title={_t("action|unpin")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx_PinnedEventTile">
|
|
||||||
<MemberAvatar
|
<MemberAvatar
|
||||||
className="mx_PinnedEventTile_senderAvatar"
|
className="mx_PinnedEventTile_senderAvatar"
|
||||||
member={this.props.event.sender}
|
member={event.sender}
|
||||||
size={AVATAR_SIZE}
|
size={AVATAR_SIZE}
|
||||||
fallbackUserId={sender}
|
fallbackUserId={sender}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className={"mx_PinnedEventTile_sender " + getUserNameColorClass(sender)}>
|
|
||||||
{this.props.event.sender?.name || sender}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{unpinButton}
|
|
||||||
|
|
||||||
<div className="mx_PinnedEventTile_message">
|
|
||||||
<MessageEvent
|
|
||||||
mxEvent={this.props.event}
|
|
||||||
getRelationsForEvent={this.getRelationsForEvent}
|
|
||||||
// @ts-ignore - complaining that className is invalid when it's not
|
|
||||||
className="mx_PinnedEventTile_body"
|
|
||||||
maxImageHeight={150}
|
|
||||||
onHeightChanged={() => {}} // we need to give this, apparently
|
|
||||||
permalinkCreator={this.props.permalinkCreator}
|
|
||||||
replacingEventId={this.props.event.replacingEventId()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mx_PinnedEventTile_footer">
|
|
||||||
<span className="mx_MessageTimestamp mx_PinnedEventTile_timestamp">
|
|
||||||
{formatDate(new Date(this.props.event.getTs()))}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<AccessibleButton onClick={this.onTileClicked} kind="link">
|
|
||||||
{_t("common|view_message")}
|
|
||||||
</AccessibleButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="mx_PinnedEventTile_wrapper">
|
||||||
}
|
<div className="mx_PinnedEventTile_top">
|
||||||
|
<Text
|
||||||
|
weight="semibold"
|
||||||
|
className={classNames("mx_PinnedEventTile_sender", getUserNameColorClass(sender))}
|
||||||
|
as="span"
|
||||||
|
>
|
||||||
|
{event.sender?.name || sender}
|
||||||
|
</Text>
|
||||||
|
<PinMenu event={event} room={room} permalinkCreator={permalinkCreator} />
|
||||||
|
</div>
|
||||||
|
<MessageEvent
|
||||||
|
mxEvent={event}
|
||||||
|
maxImageHeight={150}
|
||||||
|
onHeightChanged={() => {}} // we need to give this, apparently
|
||||||
|
permalinkCreator={permalinkCreator}
|
||||||
|
replacingEventId={event.replacingEventId()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties for {@link PinMenu}.
|
||||||
|
*/
|
||||||
|
interface PinMenuProps extends PinnedEventTileProps {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A popover menu with actions on the pinned event
|
||||||
|
*/
|
||||||
|
function PinMenu({ event, room, permalinkCreator }: PinMenuProps): JSX.Element {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const matrixClient = useMatrixClientContext();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View the event in the timeline.
|
||||||
|
*/
|
||||||
|
const onViewInTimeline = useCallback(() => {
|
||||||
|
dis.dispatch<ViewRoomPayload>({
|
||||||
|
action: Action.ViewRoom,
|
||||||
|
event_id: event.getId(),
|
||||||
|
highlighted: true,
|
||||||
|
room_id: event.getRoomId(),
|
||||||
|
metricsTrigger: undefined, // room doesn't change
|
||||||
|
});
|
||||||
|
}, [event]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the client can unpin the event.
|
||||||
|
* Pin and unpin are using the same permission.
|
||||||
|
*/
|
||||||
|
const canUnpin = useRoomState(room, (state) =>
|
||||||
|
state.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpin the event.
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
const onUnpin = useCallback(async (): Promise<void> => {
|
||||||
|
const pinnedEvents = room
|
||||||
|
.getLiveTimeline()
|
||||||
|
.getState(EventTimeline.FORWARDS)
|
||||||
|
?.getStateEvents(EventType.RoomPinnedEvents, "");
|
||||||
|
if (pinnedEvents?.getContent()?.pinned) {
|
||||||
|
const pinned = pinnedEvents.getContent().pinned;
|
||||||
|
const index = pinned.indexOf(event.getId());
|
||||||
|
if (index !== -1) {
|
||||||
|
pinned.splice(index, 1);
|
||||||
|
await matrixClient.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [event, room, matrixClient]);
|
||||||
|
|
||||||
|
const contentActionable = isContentActionable(event);
|
||||||
|
// Get the forwardable event for the given event
|
||||||
|
const forwardableEvent = contentActionable && getForwardableEvent(event, matrixClient);
|
||||||
|
/**
|
||||||
|
* Open the forward dialog.
|
||||||
|
*/
|
||||||
|
const onForward = useCallback(() => {
|
||||||
|
if (forwardableEvent) {
|
||||||
|
dis.dispatch<OpenForwardDialogPayload>({
|
||||||
|
action: Action.OpenForwardDialog,
|
||||||
|
event: forwardableEvent,
|
||||||
|
permalinkCreator: permalinkCreator,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [forwardableEvent, permalinkCreator]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the client can redact the event.
|
||||||
|
*/
|
||||||
|
const canRedact =
|
||||||
|
room
|
||||||
|
.getLiveTimeline()
|
||||||
|
.getState(EventTimeline.FORWARDS)
|
||||||
|
?.maySendRedactionForEvent(event, matrixClient.getSafeUserId()) &&
|
||||||
|
event.getType() !== EventType.RoomServerAcl &&
|
||||||
|
event.getType() !== EventType.RoomEncryption;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact the event.
|
||||||
|
*/
|
||||||
|
const onRedact = useCallback(
|
||||||
|
(): void =>
|
||||||
|
createRedactEventDialog({
|
||||||
|
mxEvent: event,
|
||||||
|
}),
|
||||||
|
[event],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
showTitle={false}
|
||||||
|
title={_t("right_panel|pinned_messages|menu")}
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
trigger={
|
||||||
|
<IconButton size="24px" aria-label={_t("right_panel|pinned_messages|menu")}>
|
||||||
|
<TriggerIcon />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem Icon={ViewIcon} label={_t("right_panel|pinned_messages|view")} onSelect={onViewInTimeline} />
|
||||||
|
{canUnpin && <MenuItem Icon={UnpinIcon} label={_t("action|unpin")} onSelect={onUnpin} />}
|
||||||
|
{forwardableEvent && <MenuItem Icon={ForwardIcon} label={_t("action|forward")} onSelect={onForward} />}
|
||||||
|
{canRedact && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<MenuItem kind="critical" Icon={DeleteIcon} label={_t("action|delete")} onSelect={onRedact} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1839,12 +1839,24 @@
|
||||||
"files_button": "Files",
|
"files_button": "Files",
|
||||||
"info": "Info",
|
"info": "Info",
|
||||||
"pinned_messages": {
|
"pinned_messages": {
|
||||||
"empty": "Nothing pinned, yet",
|
"empty_description": "Select a message and choose “%(pinAction)s” to it include here.",
|
||||||
"explainer": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
|
"empty_title": "Pin important messages so that they can be easily discovered",
|
||||||
|
"header": {
|
||||||
|
"one": "1 Pinned message",
|
||||||
|
"other": "%(count)s Pinned messages",
|
||||||
|
"zero": "Pinned message"
|
||||||
|
},
|
||||||
"limits": {
|
"limits": {
|
||||||
"other": "You can only pin up to %(count)s widgets"
|
"other": "You can only pin up to %(count)s widgets"
|
||||||
},
|
},
|
||||||
"title": "Pinned messages"
|
"menu": "Open menu",
|
||||||
|
"title": "Pinned messages",
|
||||||
|
"unpin_all": {
|
||||||
|
"button": "Unpin all messages",
|
||||||
|
"content": "Make sure that you really want to remove all pinned messages. This action can’t be undone.",
|
||||||
|
"title": "Unpin all messages?"
|
||||||
|
},
|
||||||
|
"view": "View in timeline"
|
||||||
},
|
},
|
||||||
"pinned_messages_button": "Pinned messages",
|
"pinned_messages_button": "Pinned messages",
|
||||||
"poll": {
|
"poll": {
|
||||||
|
|
46
test/components/views/dialogs/UnpinAllDialog-test.tsx
Normal file
46
test/components/views/dialogs/UnpinAllDialog-test.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* 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 { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { EventType } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import { UnpinAllDialog } from "../../../../src/components/views/dialogs/UnpinAllDialog";
|
||||||
|
import { createTestClient } from "../../../test-utils";
|
||||||
|
|
||||||
|
describe("<UnpinAllDialog />", () => {
|
||||||
|
const client = createTestClient();
|
||||||
|
const roomId = "!room:example.org";
|
||||||
|
|
||||||
|
function renderDialog(onFinished = jest.fn()) {
|
||||||
|
return render(<UnpinAllDialog matrixClient={client} roomId={roomId} onFinished={onFinished} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should render", () => {
|
||||||
|
const { asFragment } = renderDialog();
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove all pinned events when clicked on Continue", async () => {
|
||||||
|
const onFinished = jest.fn();
|
||||||
|
renderDialog(onFinished);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Continue"));
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledWith(roomId, EventType.RoomPinnedEvents, { pinned: [] }, "");
|
||||||
|
expect(onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,66 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`<UnpinAllDialog /> should render 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
data-focus-guard="true"
|
||||||
|
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
|
||||||
|
tabindex="0"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-labelledby="mx_BaseDialog_title"
|
||||||
|
class="mx_UnpinAllDialog"
|
||||||
|
data-focus-lock-disabled="false"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_Dialog_header"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
class="mx_Heading_h3 mx_Dialog_title mx_UnpinAllDialog_title"
|
||||||
|
id="mx_BaseDialog_title"
|
||||||
|
>
|
||||||
|
Unpin all messages?
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
|
||||||
|
>
|
||||||
|
Make sure that you really want to remove all pinned messages. This action can’t be undone.
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="mx_UnpinAllDialog_buttons"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="_button_zt6rp_17 _destructive_zt6rp_111"
|
||||||
|
data-kind="primary"
|
||||||
|
data-size="lg"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="_button_zt6rp_17"
|
||||||
|
data-kind="tertiary"
|
||||||
|
data-size="lg"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label="Close dialog"
|
||||||
|
class="mx_AccessibleButton mx_Dialog_cancelButton"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-focus-guard="true"
|
||||||
|
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
|
||||||
|
tabindex="0"
|
||||||
|
/>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
|
@ -15,37 +15,44 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, act, RenderResult, fireEvent, waitForElementToBeRemoved, screen } from "@testing-library/react";
|
import { render, act, RenderResult, waitForElementToBeRemoved, screen } from "@testing-library/react";
|
||||||
import { mocked } from "jest-mock";
|
import { mocked, MockedObject } from "jest-mock";
|
||||||
import {
|
import {
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
RoomStateEvent,
|
RoomStateEvent,
|
||||||
IEvent,
|
IEvent,
|
||||||
Room,
|
Room,
|
||||||
EventTimelineSet,
|
|
||||||
IMinimalEvent,
|
IMinimalEvent,
|
||||||
EventType,
|
EventType,
|
||||||
RelationType,
|
RelationType,
|
||||||
MsgType,
|
MsgType,
|
||||||
M_POLL_KIND_DISCLOSED,
|
M_POLL_KIND_DISCLOSED,
|
||||||
|
EventTimeline,
|
||||||
|
MatrixClient,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
|
import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
|
||||||
import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
|
import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
|
||||||
import { PollEndEvent } from "matrix-js-sdk/src/extensible_events_v1/PollEndEvent";
|
import { PollEndEvent } from "matrix-js-sdk/src/extensible_events_v1/PollEndEvent";
|
||||||
import { sleep } from "matrix-js-sdk/src/utils";
|
import { sleep } from "matrix-js-sdk/src/utils";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
import { stubClient, mkEvent, mkMessage, flushPromises } from "../../../test-utils";
|
import { stubClient, mkEvent, mkMessage, flushPromises } from "../../../test-utils";
|
||||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||||
import PinnedMessagesCard from "../../../../src/components/views/right_panel/PinnedMessagesCard";
|
import { PinnedMessagesCard } from "../../../../src/components/views/right_panel/PinnedMessagesCard";
|
||||||
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
|
||||||
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
|
||||||
|
import Modal from "../../../../src/Modal";
|
||||||
|
import { UnpinAllDialog } from "../../../../src/components/views/dialogs/UnpinAllDialog";
|
||||||
|
|
||||||
describe("<PinnedMessagesCard />", () => {
|
describe("<PinnedMessagesCard />", () => {
|
||||||
stubClient();
|
let cli: MockedObject<MatrixClient>;
|
||||||
const cli = mocked(MatrixClientPeg.safeGet());
|
beforeEach(() => {
|
||||||
cli.getUserId.mockReturnValue("@alice:example.org");
|
stubClient();
|
||||||
cli.setRoomAccountData.mockResolvedValue({});
|
cli = mocked(MatrixClientPeg.safeGet());
|
||||||
cli.relations.mockResolvedValue({ originalEvent: {} as unknown as MatrixEvent, events: [] });
|
cli.getUserId.mockReturnValue("@alice:example.org");
|
||||||
|
cli.setRoomAccountData.mockResolvedValue({});
|
||||||
|
cli.relations.mockResolvedValue({ originalEvent: {} as unknown as MatrixEvent, events: [] });
|
||||||
|
});
|
||||||
|
|
||||||
const mkRoom = (localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]): Room => {
|
const mkRoom = (localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]): Room => {
|
||||||
const room = new Room("!room:example.org", cli, "@me:example.org");
|
const room = new Room("!room:example.org", cli, "@me:example.org");
|
||||||
|
@ -53,27 +60,27 @@ describe("<PinnedMessagesCard />", () => {
|
||||||
const pins = () => [...localPins, ...nonLocalPins];
|
const pins = () => [...localPins, ...nonLocalPins];
|
||||||
|
|
||||||
// Insert pin IDs into room state
|
// Insert pin IDs into room state
|
||||||
jest.spyOn(room.currentState, "getStateEvents").mockImplementation((): any =>
|
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockImplementation(
|
||||||
mkEvent({
|
(): any =>
|
||||||
event: true,
|
mkEvent({
|
||||||
type: EventType.RoomPinnedEvents,
|
event: true,
|
||||||
content: {
|
type: EventType.RoomPinnedEvents,
|
||||||
pinned: pins().map((e) => e.getId()),
|
content: {
|
||||||
},
|
pinned: pins().map((e) => e.getId()),
|
||||||
user: "@user:example.org",
|
},
|
||||||
room: "!room:example.org",
|
user: "@user:example.org",
|
||||||
}),
|
room: "!room:example.org",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
jest.spyOn(room.currentState, "on");
|
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
|
||||||
|
true,
|
||||||
// Insert local pins into local timeline set
|
);
|
||||||
room.getUnfilteredTimelineSet = () =>
|
// poll end event validates against this
|
||||||
({
|
jest.spyOn(
|
||||||
getTimelineForEvent: () => ({
|
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
||||||
getEvents: () => localPins,
|
"maySendRedactionForEvent",
|
||||||
}),
|
).mockReturnValue(true);
|
||||||
}) as unknown as EventTimelineSet;
|
|
||||||
|
|
||||||
// Return all pins over fetchRoomEvent
|
// Return all pins over fetchRoomEvent
|
||||||
cli.fetchRoomEvent.mockImplementation((roomId, eventId) => {
|
cli.fetchRoomEvent.mockImplementation((roomId, eventId) => {
|
||||||
|
@ -86,8 +93,8 @@ describe("<PinnedMessagesCard />", () => {
|
||||||
return room;
|
return room;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mountPins = async (room: Room): Promise<RenderResult> => {
|
async function renderMessagePinList(room: Room): Promise<RenderResult> {
|
||||||
const pins = render(
|
const renderResult = render(
|
||||||
<MatrixClientContext.Provider value={cli}>
|
<MatrixClientContext.Provider value={cli}>
|
||||||
<PinnedMessagesCard
|
<PinnedMessagesCard
|
||||||
room={room}
|
room={room}
|
||||||
|
@ -99,22 +106,60 @@ describe("<PinnedMessagesCard />", () => {
|
||||||
// Wait a tick for state updates
|
// Wait a tick for state updates
|
||||||
await act(() => sleep(0));
|
await act(() => sleep(0));
|
||||||
|
|
||||||
return pins;
|
return renderResult;
|
||||||
};
|
}
|
||||||
|
|
||||||
const emitPinUpdates = async (room: Room) => {
|
|
||||||
const pinListener = mocked(room.currentState).on.mock.calls.find(
|
|
||||||
([eventName, listener]) => eventName === RoomStateEvent.Events,
|
|
||||||
)![1];
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param room
|
||||||
|
*/
|
||||||
|
async function emitPinUpdate(room: Room) {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
// Emit the update
|
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||||
// @ts-ignore what is going on here?
|
roomState.emit(
|
||||||
pinListener(room.currentState.getStateEvents());
|
RoomStateEvent.Events,
|
||||||
// Wait a tick for state updates
|
new MatrixEvent({ type: EventType.RoomPinnedEvents }),
|
||||||
await sleep(0);
|
roomState,
|
||||||
|
null,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the pinned messages card with the given pinned messages.
|
||||||
|
* Return the room, testing library helpers and functions to add and remove pinned messages.
|
||||||
|
* @param localPins
|
||||||
|
* @param nonLocalPins
|
||||||
|
*/
|
||||||
|
async function initPinnedMessagesCard(localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]) {
|
||||||
|
const room = mkRoom(localPins, nonLocalPins);
|
||||||
|
const addLocalPinEvent = async (event: MatrixEvent) => {
|
||||||
|
localPins.push(event);
|
||||||
|
await emitPinUpdate(room);
|
||||||
|
};
|
||||||
|
const removeLastLocalPinEvent = async () => {
|
||||||
|
localPins.pop();
|
||||||
|
await emitPinUpdate(room);
|
||||||
|
};
|
||||||
|
const addNonLocalPinEvent = async (event: MatrixEvent) => {
|
||||||
|
nonLocalPins.push(event);
|
||||||
|
await emitPinUpdate(room);
|
||||||
|
};
|
||||||
|
const removeLastNonLocalPinEvent = async () => {
|
||||||
|
nonLocalPins.pop();
|
||||||
|
await emitPinUpdate(room);
|
||||||
|
};
|
||||||
|
const renderResult = await renderMessagePinList(room);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...renderResult,
|
||||||
|
addLocalPinEvent,
|
||||||
|
removeLastLocalPinEvent,
|
||||||
|
addNonLocalPinEvent,
|
||||||
|
removeLastNonLocalPinEvent,
|
||||||
|
room,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const pin1 = mkMessage({
|
const pin1 = mkMessage({
|
||||||
event: true,
|
event: true,
|
||||||
|
@ -129,75 +174,66 @@ describe("<PinnedMessagesCard />", () => {
|
||||||
msg: "The second one",
|
msg: "The second one",
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates when messages are pinned", async () => {
|
it("should show spinner whilst loading", async () => {
|
||||||
|
const room = mkRoom([], [pin1]);
|
||||||
|
render(
|
||||||
|
<MatrixClientContext.Provider value={cli}>
|
||||||
|
<PinnedMessagesCard
|
||||||
|
room={room}
|
||||||
|
onClose={jest.fn()}
|
||||||
|
permalinkCreator={new RoomPermalinkCreator(room, room.roomId)}
|
||||||
|
/>
|
||||||
|
</MatrixClientContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the empty state when there are no pins", async () => {
|
||||||
|
const { asFragment } = await initPinnedMessagesCard([], []);
|
||||||
|
|
||||||
|
expect(screen.getByText("Pin important messages so that they can be easily discovered")).toBeInTheDocument();
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show two pinned messages", async () => {
|
||||||
|
//const room = mkRoom([pin1], [pin2]);
|
||||||
|
const { asFragment } = await initPinnedMessagesCard([pin1], [pin2]);
|
||||||
|
|
||||||
|
expect(screen.queryAllByRole("listitem")).toHaveLength(2);
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should updates when messages are pinned", async () => {
|
||||||
// Start with nothing pinned
|
// Start with nothing pinned
|
||||||
const localPins: MatrixEvent[] = [];
|
const { addLocalPinEvent, addNonLocalPinEvent } = await initPinnedMessagesCard([], []);
|
||||||
const nonLocalPins: MatrixEvent[] = [];
|
|
||||||
const room = mkRoom(localPins, nonLocalPins);
|
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
|
||||||
const pins = await mountPins(room);
|
|
||||||
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(0);
|
|
||||||
|
|
||||||
// Pin the first message
|
// Pin the first message
|
||||||
localPins.push(pin1);
|
await addLocalPinEvent(pin1);
|
||||||
await emitPinUpdates(room);
|
expect(screen.getAllByRole("listitem")).toHaveLength(1);
|
||||||
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(1);
|
|
||||||
|
|
||||||
// Pin the second message
|
// Pin the second message
|
||||||
nonLocalPins.push(pin2);
|
await addNonLocalPinEvent(pin2);
|
||||||
await emitPinUpdates(room);
|
expect(screen.getAllByRole("listitem")).toHaveLength(2);
|
||||||
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates when messages are unpinned", async () => {
|
it("should updates when messages are unpinned", async () => {
|
||||||
// Start with two pins
|
// Start with two pins
|
||||||
const localPins = [pin1];
|
const { removeLastLocalPinEvent, removeLastNonLocalPinEvent } = await initPinnedMessagesCard([pin1], [pin2]);
|
||||||
const nonLocalPins = [pin2];
|
expect(screen.getAllByRole("listitem")).toHaveLength(2);
|
||||||
const room = mkRoom(localPins, nonLocalPins);
|
|
||||||
const pins = await mountPins(room);
|
|
||||||
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(2);
|
|
||||||
|
|
||||||
// Unpin the first message
|
// Unpin the first message
|
||||||
localPins.pop();
|
await removeLastLocalPinEvent();
|
||||||
await emitPinUpdates(room);
|
expect(screen.getAllByRole("listitem")).toHaveLength(1);
|
||||||
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(1);
|
|
||||||
|
|
||||||
// Unpin the second message
|
// Unpin the second message
|
||||||
nonLocalPins.pop();
|
await removeLastNonLocalPinEvent();
|
||||||
await emitPinUpdates(room);
|
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
|
||||||
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides unpinnable events found in local timeline", async () => {
|
it("should display an edited pinned event", async () => {
|
||||||
// Redacted messages are unpinnable
|
|
||||||
const pin = mkEvent({
|
|
||||||
event: true,
|
|
||||||
type: EventType.RoomMessage,
|
|
||||||
content: {},
|
|
||||||
unsigned: { redacted_because: {} as unknown as IEvent },
|
|
||||||
room: "!room:example.org",
|
|
||||||
user: "@alice:example.org",
|
|
||||||
});
|
|
||||||
|
|
||||||
const pins = await mountPins(mkRoom([pin], []));
|
|
||||||
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides unpinnable events not found in local timeline", async () => {
|
|
||||||
// Redacted messages are unpinnable
|
|
||||||
const pin = mkEvent({
|
|
||||||
event: true,
|
|
||||||
type: EventType.RoomMessage,
|
|
||||||
content: {},
|
|
||||||
unsigned: { redacted_because: {} as unknown as IEvent },
|
|
||||||
room: "!room:example.org",
|
|
||||||
user: "@alice:example.org",
|
|
||||||
});
|
|
||||||
|
|
||||||
const pins = await mountPins(mkRoom([], [pin]));
|
|
||||||
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accounts for edits", async () => {
|
|
||||||
const messageEvent = mkEvent({
|
const messageEvent = mkEvent({
|
||||||
event: true,
|
event: true,
|
||||||
type: EventType.RoomMessage,
|
type: EventType.RoomMessage,
|
||||||
|
@ -221,13 +257,78 @@ describe("<PinnedMessagesCard />", () => {
|
||||||
events: [messageEvent],
|
events: [messageEvent],
|
||||||
});
|
});
|
||||||
|
|
||||||
const pins = await mountPins(mkRoom([], [pin1]));
|
await initPinnedMessagesCard([], [pin1]);
|
||||||
const pinTile = pins.container.querySelectorAll(".mx_PinnedEventTile");
|
expect(screen.getByText("First pinned message, edited")).toBeInTheDocument();
|
||||||
expect(pinTile.length).toBe(1);
|
|
||||||
expect(pinTile[0].querySelector(".mx_EventTile_body")!).toHaveTextContent("First pinned message, edited");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays votes on polls not found in local timeline", async () => {
|
describe("unpinnable event", () => {
|
||||||
|
it("should hide unpinnable events found in local timeline", async () => {
|
||||||
|
// Redacted messages are unpinnable
|
||||||
|
const pin = mkEvent({
|
||||||
|
event: true,
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
content: {},
|
||||||
|
unsigned: { redacted_because: {} as unknown as IEvent },
|
||||||
|
room: "!room:example.org",
|
||||||
|
user: "@alice:example.org",
|
||||||
|
});
|
||||||
|
await initPinnedMessagesCard([pin], []);
|
||||||
|
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides unpinnable events not found in local timeline", async () => {
|
||||||
|
// Redacted messages are unpinnable
|
||||||
|
const pin = mkEvent({
|
||||||
|
event: true,
|
||||||
|
type: EventType.RoomMessage,
|
||||||
|
content: {},
|
||||||
|
unsigned: { redacted_because: {} as unknown as IEvent },
|
||||||
|
room: "!room:example.org",
|
||||||
|
user: "@alice:example.org",
|
||||||
|
});
|
||||||
|
await initPinnedMessagesCard([], [pin]);
|
||||||
|
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unpin all", () => {
|
||||||
|
it("should not allow to unpinall", async () => {
|
||||||
|
const room = mkRoom([pin1], [pin2]);
|
||||||
|
jest.spyOn(
|
||||||
|
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
||||||
|
"mayClientSendStateEvent",
|
||||||
|
).mockReturnValue(false);
|
||||||
|
|
||||||
|
const { asFragment } = render(
|
||||||
|
<MatrixClientContext.Provider value={cli}>
|
||||||
|
<PinnedMessagesCard
|
||||||
|
room={room}
|
||||||
|
onClose={jest.fn()}
|
||||||
|
permalinkCreator={new RoomPermalinkCreator(room, room.roomId)}
|
||||||
|
/>
|
||||||
|
</MatrixClientContext.Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait a tick for state updates
|
||||||
|
await act(() => sleep(0));
|
||||||
|
|
||||||
|
expect(screen.queryByText("Unpin all messages")).toBeNull();
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow unpinning all messages", async () => {
|
||||||
|
jest.spyOn(Modal, "createDialog");
|
||||||
|
|
||||||
|
const { room } = await initPinnedMessagesCard([pin1], [pin2]);
|
||||||
|
expect(screen.getByText("Unpin all messages")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Unpin all messages"));
|
||||||
|
// Should open the UnpinAllDialog dialog
|
||||||
|
expect(Modal.createDialog).toHaveBeenCalledWith(UnpinAllDialog, { roomId: room.roomId, matrixClient: cli });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should displays votes on polls not found in local timeline", async () => {
|
||||||
const poll = mkEvent({
|
const poll = mkEvent({
|
||||||
...PollStartEvent.from("A poll", ["Option 1", "Option 2"], M_POLL_KIND_DISCLOSED).serialize(),
|
...PollStartEvent.from("A poll", ["Option 1", "Option 2"], M_POLL_KIND_DISCLOSED).serialize(),
|
||||||
event: true,
|
event: true,
|
||||||
|
@ -270,11 +371,8 @@ describe("<PinnedMessagesCard />", () => {
|
||||||
return { originalEvent: undefined as unknown as MatrixEvent, events: [] };
|
return { originalEvent: undefined as unknown as MatrixEvent, events: [] };
|
||||||
});
|
});
|
||||||
|
|
||||||
const room = mkRoom([], [poll]);
|
const { room } = await initPinnedMessagesCard([], [poll]);
|
||||||
// poll end event validates against this
|
|
||||||
jest.spyOn(room.currentState, "maySendRedactionForEvent").mockReturnValue(true);
|
|
||||||
|
|
||||||
const pins = await mountPins(room);
|
|
||||||
// two pages of results
|
// two pages of results
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
@ -282,34 +380,12 @@ describe("<PinnedMessagesCard />", () => {
|
||||||
const pollInstance = room.polls.get(poll.getId()!);
|
const pollInstance = room.polls.get(poll.getId()!);
|
||||||
expect(pollInstance).toBeTruthy();
|
expect(pollInstance).toBeTruthy();
|
||||||
|
|
||||||
const pinTile = pins.container.querySelectorAll(".mx_MPollBody");
|
expect(screen.getByText("A poll")).toBeInTheDocument();
|
||||||
|
|
||||||
expect(pinTile).toHaveLength(1);
|
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||||
expect(pinTile[0].querySelectorAll(".mx_PollOption_ended")).toHaveLength(2);
|
expect(screen.getByText("2 votes")).toBeInTheDocument();
|
||||||
expect(pinTile[0].querySelectorAll(".mx_PollOption_optionVoteCount")[0]).toHaveTextContent("2 votes");
|
|
||||||
expect([...pinTile[0].querySelectorAll(".mx_PollOption_optionVoteCount")].at(-1)).toHaveTextContent("1 vote");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow admins to unpin messages", async () => {
|
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||||
const nonLocalPins = [pin1];
|
expect(screen.getByText("1 vote")).toBeInTheDocument();
|
||||||
const room = mkRoom([], nonLocalPins);
|
|
||||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
|
||||||
const sendStateEvent = jest.spyOn(cli, "sendStateEvent");
|
|
||||||
|
|
||||||
const pins = await mountPins(room);
|
|
||||||
const pinTile = pins.container.querySelectorAll(".mx_PinnedEventTile");
|
|
||||||
expect(pinTile).toHaveLength(1);
|
|
||||||
|
|
||||||
fireEvent.click(pinTile[0].querySelector(".mx_PinnedEventTile_unpinButton")!);
|
|
||||||
expect(sendStateEvent).toHaveBeenCalledWith(room.roomId, "m.room.pinned_events", { pinned: [] }, "");
|
|
||||||
|
|
||||||
nonLocalPins.pop();
|
|
||||||
await Promise.all([waitForElementToBeRemoved(pinTile[0]), emitPinUpdates(room)]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show spinner whilst loading", async () => {
|
|
||||||
const room = mkRoom([], [pin1]);
|
|
||||||
mountPins(room);
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar"));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,457 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`<PinnedMessagesCard /> should show the empty state when there are no pins 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="mx_BaseCard mx_PinnedMessagesCard"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_BaseCard_header"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_BaseCard_header_title"
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
class="mx_Heading_h4 mx_BaseCard_header_title_heading"
|
||||||
|
>
|
||||||
|
Pinned message
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
|
||||||
|
data-testid="base-card-close-button"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 28px;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_133tf_26"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_AutoHideScrollbar"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_Flex mx_EmptyState"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x);"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="32px"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="32px"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M6.119 2a.5.5 0 0 0-.35.857L7.85 4.9a.5.5 0 0 1 .15.357v4.487a.5.5 0 0 1-.15.356l-3.7 3.644A.5.5 0 0 0 4 14.1v1.4a.5.5 0 0 0 .5.5H11v6a1 1 0 1 0 2 0v-6h6.5a.5.5 0 0 0 .5-.5v-1.4a.5.5 0 0 0-.15-.356l-3.7-3.644a.5.5 0 0 1-.15-.356V5.257a.5.5 0 0 1 .15-.357l2.081-2.043a.5.5 0 0 0-.35-.857H6.119ZM10 4h4v5.744a2.5 2.5 0 0 0 .746 1.781L17.26 14H6.74l2.514-2.475A2.5 2.5 0 0 0 10 9.744V4Z"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p
|
||||||
|
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83"
|
||||||
|
>
|
||||||
|
Pin important messages so that they can be easily discovered
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
|
||||||
|
>
|
||||||
|
Select a message and choose “Pin” to it include here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<PinnedMessagesCard /> should show two pinned messages 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="mx_BaseCard mx_PinnedMessagesCard"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_BaseCard_header"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_BaseCard_header_title"
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
class="mx_Heading_h4 mx_BaseCard_header_title_heading"
|
||||||
|
>
|
||||||
|
2 Pinned messages
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
|
||||||
|
data-testid="base-card-close-button"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 28px;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_133tf_26"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_AutoHideScrollbar"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessagesCard_wrapper mx_PinnedMessagesCard_wrapper_unpin_all"
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
|
||||||
|
data-color="3"
|
||||||
|
data-testid="avatar-img"
|
||||||
|
data-type="round"
|
||||||
|
role="presentation"
|
||||||
|
style="--cpd-avatar-size: 32px;"
|
||||||
|
>
|
||||||
|
a
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile_wrapper"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile_top"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 mx_PinnedEventTile_sender mx_Username_color3"
|
||||||
|
>
|
||||||
|
@alice:example.org
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="Open menu"
|
||||||
|
class="_icon-button_bh2qc_17"
|
||||||
|
data-state="closed"
|
||||||
|
id="radix-2"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
tabindex="0"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_133tf_26"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_MTextBody mx_EventTile_content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_EventTile_body translate"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
The second one
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="_separator_144s5_17 mx_PinnedMessagesCard_Separator"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="horizontal"
|
||||||
|
role="separator"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
|
||||||
|
data-color="3"
|
||||||
|
data-testid="avatar-img"
|
||||||
|
data-type="round"
|
||||||
|
role="presentation"
|
||||||
|
style="--cpd-avatar-size: 32px;"
|
||||||
|
>
|
||||||
|
a
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile_wrapper"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile_top"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 mx_PinnedEventTile_sender mx_Username_color3"
|
||||||
|
>
|
||||||
|
@alice:example.org
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="Open menu"
|
||||||
|
class="_icon-button_bh2qc_17"
|
||||||
|
data-state="closed"
|
||||||
|
id="radix-3"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
tabindex="0"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_133tf_26"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_MTextBody mx_EventTile_content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_EventTile_body translate"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
First pinned message
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessagesCard_unpin"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="_button_zt6rp_17"
|
||||||
|
data-kind="tertiary"
|
||||||
|
data-size="lg"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
Unpin all messages
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<PinnedMessagesCard /> unpin all should not allow to unpinall 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="mx_BaseCard mx_PinnedMessagesCard"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_BaseCard_header"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_BaseCard_header_title"
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
class="mx_Heading_h4 mx_BaseCard_header_title_heading"
|
||||||
|
>
|
||||||
|
2 Pinned messages
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
|
||||||
|
data-testid="base-card-close-button"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 28px;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_133tf_26"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_AutoHideScrollbar"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedMessagesCard_wrapper"
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
|
||||||
|
data-color="3"
|
||||||
|
data-testid="avatar-img"
|
||||||
|
data-type="round"
|
||||||
|
role="presentation"
|
||||||
|
style="--cpd-avatar-size: 32px;"
|
||||||
|
>
|
||||||
|
a
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile_wrapper"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile_top"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 mx_PinnedEventTile_sender mx_Username_color3"
|
||||||
|
>
|
||||||
|
@alice:example.org
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="Open menu"
|
||||||
|
class="_icon-button_bh2qc_17"
|
||||||
|
data-state="closed"
|
||||||
|
id="radix-18"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
tabindex="0"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_133tf_26"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_MTextBody mx_EventTile_content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_EventTile_body translate"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
The second one
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="_separator_144s5_17 mx_PinnedMessagesCard_Separator"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="horizontal"
|
||||||
|
role="separator"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
|
||||||
|
data-color="3"
|
||||||
|
data-testid="avatar-img"
|
||||||
|
data-type="round"
|
||||||
|
role="presentation"
|
||||||
|
style="--cpd-avatar-size: 32px;"
|
||||||
|
>
|
||||||
|
a
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile_wrapper"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile_top"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 mx_PinnedEventTile_sender mx_Username_color3"
|
||||||
|
>
|
||||||
|
@alice:example.org
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="Open menu"
|
||||||
|
class="_icon-button_bh2qc_17"
|
||||||
|
data-state="closed"
|
||||||
|
id="radix-19"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
tabindex="0"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_133tf_26"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mx_MTextBody mx_EventTile_content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx_EventTile_body translate"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
First pinned message
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
|
@ -15,32 +15,44 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
import { EventTimeline, EventType, IEvent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
|
||||||
import PinnedEventTile from "../../../../src/components/views/rooms/PinnedEventTile";
|
import { PinnedEventTile } from "../../../../src/components/views/rooms/PinnedEventTile";
|
||||||
import { getMockClientWithEventEmitter } from "../../../test-utils";
|
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
|
||||||
|
import { stubClient } from "../../../test-utils";
|
||||||
|
import dis from "../../../../src/dispatcher/dispatcher";
|
||||||
|
import { Action } from "../../../../src/dispatcher/actions";
|
||||||
|
import { getForwardableEvent } from "../../../../src/events";
|
||||||
|
import { createRedactEventDialog } from "../../../../src/components/views/dialogs/ConfirmRedactDialog";
|
||||||
|
|
||||||
|
jest.mock("../../../../src/components/views/dialogs/ConfirmRedactDialog", () => ({
|
||||||
|
createRedactEventDialog: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("<PinnedEventTile />", () => {
|
describe("<PinnedEventTile />", () => {
|
||||||
const userId = "@alice:server.org";
|
const userId = "@alice:server.org";
|
||||||
const roomId = "!room:server.org";
|
const roomId = "!room:server.org";
|
||||||
const mockClient = getMockClientWithEventEmitter({
|
|
||||||
getRoom: jest.fn(),
|
|
||||||
});
|
|
||||||
const room = new Room(roomId, mockClient, userId);
|
|
||||||
const permalinkCreator = new RoomPermalinkCreator(room);
|
|
||||||
|
|
||||||
const getComponent = (event: MatrixEvent) =>
|
|
||||||
render(<PinnedEventTile permalinkCreator={permalinkCreator} event={event} />);
|
|
||||||
|
|
||||||
|
let mockClient: MatrixClient;
|
||||||
|
let room: Room;
|
||||||
|
let permalinkCreator: RoomPermalinkCreator;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockClient.getRoom.mockReturnValue(room);
|
mockClient = stubClient();
|
||||||
|
room = new Room(roomId, mockClient, userId);
|
||||||
|
permalinkCreator = new RoomPermalinkCreator(room);
|
||||||
|
jest.spyOn(dis, "dispatch").mockReturnValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render pinned event", () => {
|
/**
|
||||||
const pin1 = new MatrixEvent({
|
* Create a pinned event with the given content.
|
||||||
type: "m.room.message",
|
* @param content
|
||||||
|
*/
|
||||||
|
function makePinEvent(content?: Partial<IEvent>) {
|
||||||
|
return new MatrixEvent({
|
||||||
|
type: EventType.RoomMessage,
|
||||||
sender: userId,
|
sender: userId,
|
||||||
content: {
|
content: {
|
||||||
body: "First pinned message",
|
body: "First pinned message",
|
||||||
|
@ -48,25 +60,150 @@ describe("<PinnedEventTile />", () => {
|
||||||
},
|
},
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
origin_server_ts: 0,
|
origin_server_ts: 0,
|
||||||
|
event_id: "$eventId",
|
||||||
|
...content,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { container } = getComponent(pin1);
|
/**
|
||||||
|
* Render the component with the given event.
|
||||||
|
* @param event - pinned event
|
||||||
|
*/
|
||||||
|
function renderComponent(event: MatrixEvent) {
|
||||||
|
return render(
|
||||||
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
|
<PinnedEventTile permalinkCreator={permalinkCreator} event={event} room={room} />
|
||||||
|
</MatrixClientContext.Provider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the component and open the menu.
|
||||||
|
*/
|
||||||
|
async function renderAndOpenMenu() {
|
||||||
|
const pinEvent = makePinEvent();
|
||||||
|
const renderResult = renderComponent(pinEvent);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||||
|
return { pinEvent, renderResult };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should throw when pinned event has no sender", () => {
|
||||||
|
const pinEventWithoutSender = makePinEvent({ sender: undefined });
|
||||||
|
expect(() => renderComponent(pinEventWithoutSender)).toThrow("Pinned event unexpectedly has no sender");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render pinned event", () => {
|
||||||
|
const { container } = renderComponent(makePinEvent());
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw when pinned event has no sender", () => {
|
it("should render the menu without unpin and delete", async () => {
|
||||||
const pin1 = new MatrixEvent({
|
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
|
||||||
type: "m.room.message",
|
false,
|
||||||
sender: undefined,
|
);
|
||||||
content: {
|
jest.spyOn(
|
||||||
body: "First pinned message",
|
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
||||||
msgtype: "m.text",
|
"maySendRedactionForEvent",
|
||||||
},
|
).mockReturnValue(false);
|
||||||
room_id: roomId,
|
|
||||||
origin_server_ts: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => getComponent(pin1)).toThrow("Pinned event unexpectedly has no sender");
|
await renderAndOpenMenu();
|
||||||
|
|
||||||
|
// Unpin and delete should not be present
|
||||||
|
await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument());
|
||||||
|
expect(screen.getByRole("menuitem", { name: "View in timeline" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("menuitem", { name: "Forward" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Unpin" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Delete" })).toBeNull();
|
||||||
|
expect(screen.getByRole("menu")).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the menu with all the options", async () => {
|
||||||
|
// Enable unpin
|
||||||
|
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// Enable redaction
|
||||||
|
jest.spyOn(
|
||||||
|
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
||||||
|
"maySendRedactionForEvent",
|
||||||
|
).mockReturnValue(true);
|
||||||
|
|
||||||
|
await renderAndOpenMenu();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument());
|
||||||
|
["View in timeline", "Forward", "Unpin", "Delete"].forEach((name) =>
|
||||||
|
expect(screen.getByRole("menuitem", { name })).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("menu")).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should view in the timeline", async () => {
|
||||||
|
const { pinEvent } = await renderAndOpenMenu();
|
||||||
|
|
||||||
|
// Test view in timeline button
|
||||||
|
await userEvent.click(screen.getByRole("menuitem", { name: "View in timeline" }));
|
||||||
|
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||||
|
action: Action.ViewRoom,
|
||||||
|
event_id: pinEvent.getId(),
|
||||||
|
highlighted: true,
|
||||||
|
room_id: pinEvent.getRoomId(),
|
||||||
|
metricsTrigger: undefined, // room doesn't change
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open forward dialog", async () => {
|
||||||
|
const { pinEvent } = await renderAndOpenMenu();
|
||||||
|
|
||||||
|
// Test forward button
|
||||||
|
await userEvent.click(screen.getByRole("menuitem", { name: "Forward" }));
|
||||||
|
expect(dis.dispatch).toHaveBeenCalledWith({
|
||||||
|
action: Action.OpenForwardDialog,
|
||||||
|
event: getForwardableEvent(pinEvent, mockClient),
|
||||||
|
permalinkCreator: permalinkCreator,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should unpin the event", async () => {
|
||||||
|
const { pinEvent } = await renderAndOpenMenu();
|
||||||
|
const pinEvent2 = makePinEvent({ event_id: "$eventId2" });
|
||||||
|
|
||||||
|
const stateEvent = {
|
||||||
|
getContent: jest.fn().mockReturnValue({ pinned: [pinEvent.getId(), pinEvent2.getId()] }),
|
||||||
|
} as unknown as MatrixEvent;
|
||||||
|
|
||||||
|
// Enable unpin
|
||||||
|
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// Mock the state event
|
||||||
|
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockReturnValue(
|
||||||
|
stateEvent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test unpin button
|
||||||
|
await userEvent.click(screen.getByRole("menuitem", { name: "Unpin" }));
|
||||||
|
expect(mockClient.sendStateEvent).toHaveBeenCalledWith(
|
||||||
|
room.roomId,
|
||||||
|
EventType.RoomPinnedEvents,
|
||||||
|
{
|
||||||
|
pinned: [pinEvent2.getId()],
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should delete the event", async () => {
|
||||||
|
// Enable redaction
|
||||||
|
jest.spyOn(
|
||||||
|
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
|
||||||
|
"maySendRedactionForEvent",
|
||||||
|
).mockReturnValue(true);
|
||||||
|
|
||||||
|
const { pinEvent } = await renderAndOpenMenu();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||||
|
expect(createRedactEventDialog).toHaveBeenCalledWith({
|
||||||
|
mxEvent: pinEvent,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,25 +4,52 @@ exports[`<PinnedEventTile /> should render pinned event 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="mx_PinnedEventTile"
|
class="mx_PinnedEventTile"
|
||||||
|
role="listitem"
|
||||||
>
|
>
|
||||||
<span
|
<div>
|
||||||
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
|
<span
|
||||||
data-color="2"
|
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
|
||||||
data-testid="avatar-img"
|
data-color="2"
|
||||||
data-type="round"
|
data-testid="avatar-img"
|
||||||
role="presentation"
|
data-type="round"
|
||||||
style="--cpd-avatar-size: 24px;"
|
role="presentation"
|
||||||
>
|
style="--cpd-avatar-size: 32px;"
|
||||||
a
|
>
|
||||||
</span>
|
a
|
||||||
<span
|
</span>
|
||||||
class="mx_PinnedEventTile_sender mx_Username_color2"
|
</div>
|
||||||
>
|
|
||||||
@alice:server.org
|
|
||||||
</span>
|
|
||||||
<div
|
<div
|
||||||
class="mx_PinnedEventTile_message"
|
class="mx_PinnedEventTile_wrapper"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
class="mx_PinnedEventTile_top"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 mx_PinnedEventTile_sender mx_Username_color2"
|
||||||
|
>
|
||||||
|
@alice:server.org
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="Open menu"
|
||||||
|
class="_icon-button_bh2qc_17"
|
||||||
|
data-state="closed"
|
||||||
|
id="radix-0"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
tabindex="0"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_133tf_26"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mx_MTextBody mx_EventTile_content"
|
class="mx_MTextBody mx_EventTile_content"
|
||||||
>
|
>
|
||||||
|
@ -34,22 +61,250 @@ exports[`<PinnedEventTile /> should render pinned event 1`] = `
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="mx_PinnedEventTile_footer"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="mx_MessageTimestamp mx_PinnedEventTile_timestamp"
|
|
||||||
>
|
|
||||||
Thu, Jan 1, 1970, 00:00
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
View message
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`<PinnedEventTile /> should render the menu with all the options 1`] = `
|
||||||
|
<div
|
||||||
|
aria-label="Open menu"
|
||||||
|
aria-labelledby="radix-6"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
class="_menu_1x5h1_17"
|
||||||
|
data-align="start"
|
||||||
|
data-orientation="vertical"
|
||||||
|
data-radix-menu-content=""
|
||||||
|
data-side="right"
|
||||||
|
data-state="open"
|
||||||
|
dir="ltr"
|
||||||
|
id="radix-7"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="_item_1gwvj_17 _interactive_1gwvj_36"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="vertical"
|
||||||
|
data-radix-collection-item=""
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_icon_1gwvj_44"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
|
||||||
|
>
|
||||||
|
View in timeline
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_nav-hint_1gwvj_60"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24"
|
||||||
|
viewBox="8 0 8 24"
|
||||||
|
width="8"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="_item_1gwvj_17 _interactive_1gwvj_36"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="vertical"
|
||||||
|
data-radix-collection-item=""
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_icon_1gwvj_44"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
|
||||||
|
>
|
||||||
|
Unpin
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_nav-hint_1gwvj_60"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24"
|
||||||
|
viewBox="8 0 8 24"
|
||||||
|
width="8"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="_item_1gwvj_17 _interactive_1gwvj_36"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="vertical"
|
||||||
|
data-radix-collection-item=""
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_icon_1gwvj_44"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
|
||||||
|
>
|
||||||
|
Forward
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_nav-hint_1gwvj_60"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24"
|
||||||
|
viewBox="8 0 8 24"
|
||||||
|
width="8"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="_separator_144s5_17"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="horizontal"
|
||||||
|
role="separator"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="_item_1gwvj_17 _interactive_1gwvj_36"
|
||||||
|
data-kind="critical"
|
||||||
|
data-orientation="vertical"
|
||||||
|
data-radix-collection-item=""
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_icon_1gwvj_44"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_nav-hint_1gwvj_60"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24"
|
||||||
|
viewBox="8 0 8 24"
|
||||||
|
width="8"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<PinnedEventTile /> should render the menu without unpin and delete 1`] = `
|
||||||
|
<div
|
||||||
|
aria-label="Open menu"
|
||||||
|
aria-labelledby="radix-2"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
class="_menu_1x5h1_17"
|
||||||
|
data-align="start"
|
||||||
|
data-orientation="vertical"
|
||||||
|
data-radix-menu-content=""
|
||||||
|
data-side="right"
|
||||||
|
data-state="open"
|
||||||
|
dir="ltr"
|
||||||
|
id="radix-3"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="_item_1gwvj_17 _interactive_1gwvj_36"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="vertical"
|
||||||
|
data-radix-collection-item=""
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_icon_1gwvj_44"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
|
||||||
|
>
|
||||||
|
View in timeline
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_nav-hint_1gwvj_60"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24"
|
||||||
|
viewBox="8 0 8 24"
|
||||||
|
width="8"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="_item_1gwvj_17 _interactive_1gwvj_36"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="vertical"
|
||||||
|
data-radix-collection-item=""
|
||||||
|
role="menuitem"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_icon_1gwvj_44"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
|
||||||
|
>
|
||||||
|
Forward
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="_nav-hint_1gwvj_60"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24"
|
||||||
|
viewBox="8 0 8 24"
|
||||||
|
width="8"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
Loading…
Reference in a new issue