diff --git a/playwright/e2e/pinned-messages/index.ts b/playwright/e2e/pinned-messages/index.ts new file mode 100644 index 0000000000..a67df09d86 --- /dev/null +++ b/playwright/e2e/pinned-messages/index.ts @@ -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 }; diff --git a/playwright/e2e/pinned-messages/pinned-messages.spec.ts b/playwright/e2e/pinned-messages/pinned-messages.spec.ts new file mode 100644 index 0000000000..be1c92223f --- /dev/null +++ b/playwright/e2e/pinned-messages/pinned-messages.spec.ts @@ -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); + }); +}); diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-empty-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-empty-linux.png new file mode 100644 index 0000000000..28099d338c Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-empty-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-2-linux.png new file mode 100644 index 0000000000..82666b0d95 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-2-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-3-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-3-linux.png new file mode 100644 index 0000000000..98e804d897 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-3-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/unpin-all-dialog-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/unpin-all-dialog-linux.png new file mode 100644 index 0000000000..e6f1005395 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/unpin-all-dialog-linux.png differ diff --git a/res/css/_common.pcss b/res/css/_common.pcss index de698e2324..605ad41b81 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -604,7 +604,7 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button), + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button), .mx_Dialog input[type="submit"], .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons input[type="submit"] { @@ -624,14 +624,14 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):last-child { + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):last-child { margin-right: 0px; } .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .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_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons input[type="submit"]:focus { @@ -643,7 +643,7 @@ legend { .mx_Dialog_buttons button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( .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 { color: var(--cpd-color-text-on-solid-primary); background-color: var(--cpd-color-bg-action-primary-rest); @@ -656,7 +656,7 @@ legend { .mx_Dialog_buttons button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not( .mx_ThemeChoicePanel_CustomTheme button - ), + ):not(.mx_UnpinAllDialog button), .mx_Dialog_buttons input[type="submit"].danger { background-color: var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary); @@ -672,7 +672,7 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .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_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons input[type="submit"]:disabled { diff --git a/res/css/_components.pcss b/res/css/_components.pcss index e273667218..96c285bc0a 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -167,6 +167,7 @@ @import "./views/dialogs/_SpaceSettingsDialog.pcss"; @import "./views/dialogs/_SpotlightDialog.pcss"; @import "./views/dialogs/_TermsDialog.pcss"; +@import "./views/dialogs/_UnpinAllDialog.pcss"; @import "./views/dialogs/_UntrustedDeviceDialog.pcss"; @import "./views/dialogs/_UploadConfirmDialog.pcss"; @import "./views/dialogs/_UserSettingsDialog.pcss"; diff --git a/res/css/views/dialogs/_UnpinAllDialog.pcss b/res/css/views/dialogs/_UnpinAllDialog.pcss new file mode 100644 index 0000000000..fb05809523 --- /dev/null +++ b/res/css/views/dialogs/_UnpinAllDialog.pcss @@ -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%; + } + } +} diff --git a/res/css/views/right_panel/_PinnedMessagesCard.pcss b/res/css/views/right_panel/_PinnedMessagesCard.pcss index 5cdafcf7c5..23e23bae85 100644 --- a/res/css/views/right_panel/_PinnedMessagesCard.pcss +++ b/res/css/views/right_panel/_PinnedMessagesCard.pcss @@ -15,50 +15,40 @@ limitations under the License. */ .mx_PinnedMessagesCard { - .mx_PinnedMessagesCard_empty_wrapper { + --unpin-height: 76px; + + .mx_PinnedMessagesCard_wrapper { display: flex; - height: 100%; + flex-direction: column; + padding: var(--cpd-space-4x); + gap: var(--cpd-space-6x); + overflow-y: auto; - .mx_PinnedMessagesCard_empty { - height: max-content; - text-align: center; - margin: auto 40px; - - .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_Separator { + min-height: 1px; + /* Override default compound value */ + margin-block: 0; } } + .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 { word-break: break-word; } diff --git a/res/css/views/rooms/_PinnedEventTile.pcss b/res/css/views/rooms/_PinnedEventTile.pcss index e755c3a71d..b42de75649 100644 --- a/res/css/views/rooms/_PinnedEventTile.pcss +++ b/res/css/views/rooms/_PinnedEventTile.pcss @@ -15,95 +15,27 @@ limitations under the License. */ .mx_PinnedEventTile { - min-height: 40px; - width: 100%; - padding: 0 4px 12px; + display: flex; + gap: var(--cpd-space-4x); + align-items: flex-start; - display: grid; - grid-template-areas: - "avatar name remove" - "content content content" - "footer footer footer"; - grid-template-rows: max-content auto max-content; - grid-template-columns: 24px auto 24px; - grid-row-gap: 12px; - grid-column-gap: 8px; + .mx_PinnedEventTile_wrapper { + display: flex; + flex-direction: column; + gap: var(--cpd-space-1x); + width: 100%; - & + .mx_PinnedEventTile { - padding: 12px 4px; - border-top: 1px solid $menu-border-color; - } + .mx_PinnedEventTile_top { + display: flex; + gap: var(--cpd-space-1x); + justify-content: space-between; + align-items: center; - .mx_PinnedEventTile_senderAvatar, - .mx_PinnedEventTile_sender, - .mx_PinnedEventTile_unpinButton, - .mx_PinnedEventTile_message, - .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; + .mx_PinnedEventTile_sender { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } } } } diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 9e228de611..8f1ba7aecf 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -34,7 +34,7 @@ import ThreadView from "./ThreadView"; import ThreadPanel from "./ThreadPanel"; import NotificationPanel from "./NotificationPanel"; 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 { E2EStatus } from "../../utils/ShieldUtils"; import TimelineCard from "../views/right_panel/TimelineCard"; diff --git a/src/components/views/dialogs/UnpinAllDialog.tsx b/src/components/views/dialogs/UnpinAllDialog.tsx new file mode 100644 index 0000000000..ef7439d858 --- /dev/null +++ b/src/components/views/dialogs/UnpinAllDialog.tsx @@ -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 ( + + {_t("right_panel|pinned_messages|unpin_all|content")} + + { + try { + await matrixClient.sendStateEvent(roomId, EventType.RoomPinnedEvents, { pinned: [] }, ""); + } catch (e) { + logger.error("Failed to unpin all events:", e); + } + onFinished(); + }} + > + {_t("action|continue")} + + + {_t("action|cancel")} + + + + ); +} diff --git a/src/components/views/right_panel/PinnedMessagesCard.tsx b/src/components/views/right_panel/PinnedMessagesCard.tsx index f813dd3427..85be2e6d03 100644 --- a/src/components/views/right_panel/PinnedMessagesCard.tsx +++ b/src/components/views/right_panel/PinnedMessagesCard.tsx @@ -14,41 +14,62 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useContext, useEffect, useState } from "react"; -import { Room, RoomEvent, RoomStateEvent, MatrixEvent, EventType, RelationType } from "matrix-js-sdk/src/matrix"; +import React, { useCallback, useEffect, useState, JSX } from "react"; +import { + Room, + RoomEvent, + RoomStateEvent, + MatrixEvent, + EventType, + RelationType, + EventTimeline, +} from "matrix-js-sdk/src/matrix"; 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 BaseCard from "./BaseCard"; import Spinner from "../elements/Spinner"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import PinningUtils from "../../../utils/PinningUtils"; import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; -import PinnedEventTile from "../rooms/PinnedEventTile"; +import { PinnedEventTile } from "../rooms/PinnedEventTile"; import { useRoomState } from "../../../hooks/useRoomState"; -import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; +import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext"; import { ReadPinsEventId } from "./types"; import Heading from "../typography/Heading"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { filterBoolean } from "../../../utils/arrays"; +import Modal from "../../../Modal"; +import { UnpinAllDialog } from "../dialogs/UnpinAllDialog"; +import EmptyState from "./EmptyState"; -interface IProps { - room: Room; - permalinkCreator: RoomPermalinkCreator; - onClose(): void; -} - +/** + * Get the pinned event IDs from a room. + * @param room + */ 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[] => { const [pinnedEvents, setPinnedEvents] = useState(getPinnedEventIds(room)); + // Update the pinned events when the room state changes + // Filter out events that are not pinned events const update = useCallback( (ev?: MatrixEvent) => { if (ev && ev.getType() !== EventType.RoomPinnedEvents) return; @@ -57,7 +78,7 @@ export const usePinnedEvents = (room?: Room): string[] => { [room], ); - useTypedEventEmitter(room?.currentState, RoomStateEvent.Events, update); + useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update); useEffect(() => { setPinnedEvents(getPinnedEventIds(room)); return () => { @@ -67,13 +88,23 @@ export const usePinnedEvents = (room?: Room): string[] => { return pinnedEvents; }; +/** + * Get the read pinned event IDs from a room. + * @param room + */ function getReadPinnedEventIds(room?: Room): Set { 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 => { const [readPinnedEvents, setReadPinnedEvents] = useState>(new Set()); + // Update the read pinned events when the room state changes + // Filter out events that are not read pinned events const update = useCallback( (ev?: MatrixEvent) => { if (ev && ev.getType() !== ReadPinsEventId) return; @@ -92,36 +123,36 @@ export const useReadPinnedEvents = (room?: Room): Set => { return readPinnedEvents; }; -const PinnedMessagesCard: React.FC = ({ room, onClose, permalinkCreator }) => { - const cli = useContext(MatrixClientContext); - const roomContext = useContext(RoomContext); - const canUnpin = useRoomState(room, (state) => state.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli)); - const pinnedEventIds = usePinnedEvents(room); - const readPinnedEvents = useReadPinnedEvents(room); +/** + * Fetch the pinned events + * @param room + * @param pinnedEventIds + */ +function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array | null { + const cli = useMatrixClientContext(); - 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]); - - const pinnedEvents = useAsyncMemo( + return useAsyncMemo( () => { const promises = pinnedEventIds.map(async (eventId): Promise => { const timelineSet = room.getUnfilteredTimelineSet(); + // Get the event from the local timeline const localEvent = timelineSet ?.getTimelineForEvent(eventId) ?.getEvents() .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; 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 [ evJson, { @@ -131,10 +162,15 @@ const PinnedMessagesCard: React.FC = ({ room, onClose, permalinkCreator cli.fetchRoomEvent(room.roomId, eventId), cli.relations(room.roomId, eventId, RelationType.Replace, null, { limit: 1 }), ]); + const event = new MatrixEvent(evJson); + + // Decrypt the event if it's encrypted if (event.isEncrypted()) { - await cli.decryptEventIfNeeded(event); // TODO await? + await cli.decryptEventIfNeeded(event); } + + // Handle poll events await room.processPollEvents([event]); const senderUserId = event.getSender(); @@ -158,62 +194,59 @@ const PinnedMessagesCard: React.FC = ({ room, onClose, permalinkCreator [cli, room, pinnedEventIds], 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) { content = ( - - - {/* XXX: We reuse the classes for simplicity, but deliberately not the components for non-interactivity. */} - - - - - - - - - - - - - - {_t("right_panel|pinned_messages|empty")} - - {_t( - "right_panel|pinned_messages|explainer", - {}, - { - b: (sub) => {sub}, - }, - )} - - + ); } else if (pinnedEvents?.length) { - const onUnpinClicked = async (event: MatrixEvent): Promise => { - const pinnedEvents = room.currentState.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 cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, ""); - } - } - }; - - // show them in reverse, with latest pinned at the top - content = filterBoolean(pinnedEvents) - .reverse() - .map((ev) => ( - onUnpinClicked(ev) : undefined} - permalinkCreator={permalinkCreator} - /> - )); + content = ( + + ); } else { content = ; } @@ -223,7 +256,7 @@ const PinnedMessagesCard: React.FC = ({ room, onClose, permalinkCreator header={ - {_t("right_panel|pinned_messages|title")} + {_t("right_panel|pinned_messages|header", { count: pinnedEventIds.length })} } @@ -240,6 +273,79 @@ const PinnedMessagesCard: React.FC = ({ room, onClose, permalinkCreator ); -}; +} -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 => { + Modal.createDialog(UnpinAllDialog, { + roomId: room.roomId, + matrixClient, + }); + }, [room, matrixClient]); + + return ( + <> + + {events.reverse().map((event, i) => ( + <> + + {/* Add a separator if this isn't the last pinned message */} + {events.length - 1 !== i && ( + + )} + > + ))} + + {canUnpin && ( + + + {_t("right_panel|pinned_messages|unpin_all|button")} + + + )} + > + ); +} diff --git a/src/components/views/rooms/PinnedEventTile.tsx b/src/components/views/rooms/PinnedEventTile.tsx index 6c63efc352..5252e5124d 100644 --- a/src/components/views/rooms/PinnedEventTile.tsx +++ b/src/components/views/rooms/PinnedEventTile.tsx @@ -15,112 +15,206 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; -import { MatrixEvent, EventType, RelationType, Relations } from "matrix-js-sdk/src/matrix"; +import React, { JSX, useCallback, useState } from "react"; +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 { Action } from "../../../dispatcher/actions"; -import AccessibleButton from "../elements/AccessibleButton"; import MessageEvent from "../messages/MessageEvent"; import MemberAvatar from "../avatars/MemberAvatar"; import { _t } from "../../../languageHandler"; -import { formatDate } from "../../../DateUtils"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { getUserNameColorClass } from "../../../utils/FormattingUtils"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; 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; + /** + * The permalink creator to use. + */ 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 { - public static contextType = MatrixClientContext; - public declare context: React.ContextType; - - private onTileClicked = (): void => { - dis.dispatch({ - 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>(); - 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 = ( - - ); - } - - return ( - + return ( + + - - - {this.props.event.sender?.name || sender} - - - {unpinButton} - - - {}} // we need to give this, apparently - permalinkCreator={this.props.permalinkCreator} - replacingEventId={this.props.event.replacingEventId()} - /> - - - - - {formatDate(new Date(this.props.event.getTs()))} - - - - {_t("common|view_message")} - - - ); - } + + + + {event.sender?.name || sender} + + + + {}} // we need to give this, apparently + permalinkCreator={permalinkCreator} + replacingEventId={event.replacingEventId()} + /> + + + ); +} + +/** + * 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({ + 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 => { + 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({ + 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 ( + + + + } + > + + {canUnpin && } + {forwardableEvent && } + {canRedact && ( + <> + + + > + )} + + ); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8bae5b5bad..1b7b1f2ed9 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1839,12 +1839,24 @@ "files_button": "Files", "info": "Info", "pinned_messages": { - "empty": "Nothing pinned, yet", - "explainer": "If you have permissions, open the menu on any message and select Pin to stick them here.", + "empty_description": "Select a message and choose “%(pinAction)s” to it include 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": { "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", "poll": { diff --git a/test/components/views/dialogs/UnpinAllDialog-test.tsx b/test/components/views/dialogs/UnpinAllDialog-test.tsx new file mode 100644 index 0000000000..95018cc72d --- /dev/null +++ b/test/components/views/dialogs/UnpinAllDialog-test.tsx @@ -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("", () => { + const client = createTestClient(); + const roomId = "!room:example.org"; + + function renderDialog(onFinished = jest.fn()) { + return render(); + } + + 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(); + }); +}); diff --git a/test/components/views/dialogs/__snapshots__/UnpinAllDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/UnpinAllDialog-test.tsx.snap new file mode 100644 index 0000000000..8213a9b917 --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/UnpinAllDialog-test.tsx.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render 1`] = ` + + + + + + Unpin all messages? + + + + Make sure that you really want to remove all pinned messages. This action can’t be undone. + + + + Continue + + + Cancel + + + + + + +`; diff --git a/test/components/views/right_panel/PinnedMessagesCard-test.tsx b/test/components/views/right_panel/PinnedMessagesCard-test.tsx index bff9b69579..64961ca144 100644 --- a/test/components/views/right_panel/PinnedMessagesCard-test.tsx +++ b/test/components/views/right_panel/PinnedMessagesCard-test.tsx @@ -15,37 +15,44 @@ limitations under the License. */ import React from "react"; -import { render, act, RenderResult, fireEvent, waitForElementToBeRemoved, screen } from "@testing-library/react"; -import { mocked } from "jest-mock"; +import { render, act, RenderResult, waitForElementToBeRemoved, screen } from "@testing-library/react"; +import { mocked, MockedObject } from "jest-mock"; import { MatrixEvent, RoomStateEvent, IEvent, Room, - EventTimelineSet, IMinimalEvent, EventType, RelationType, MsgType, M_POLL_KIND_DISCLOSED, + EventTimeline, + MatrixClient, } from "matrix-js-sdk/src/matrix"; import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent"; import { PollEndEvent } from "matrix-js-sdk/src/extensible_events_v1/PollEndEvent"; import { sleep } from "matrix-js-sdk/src/utils"; +import userEvent from "@testing-library/user-event"; import { stubClient, mkEvent, mkMessage, flushPromises } from "../../../test-utils"; 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 { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; +import Modal from "../../../../src/Modal"; +import { UnpinAllDialog } from "../../../../src/components/views/dialogs/UnpinAllDialog"; describe("", () => { - stubClient(); - const cli = mocked(MatrixClientPeg.safeGet()); - cli.getUserId.mockReturnValue("@alice:example.org"); - cli.setRoomAccountData.mockResolvedValue({}); - cli.relations.mockResolvedValue({ originalEvent: {} as unknown as MatrixEvent, events: [] }); + let cli: MockedObject; + beforeEach(() => { + stubClient(); + cli = mocked(MatrixClientPeg.safeGet()); + 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 room = new Room("!room:example.org", cli, "@me:example.org"); @@ -53,27 +60,27 @@ describe("", () => { const pins = () => [...localPins, ...nonLocalPins]; // Insert pin IDs into room state - jest.spyOn(room.currentState, "getStateEvents").mockImplementation((): any => - mkEvent({ - event: true, - type: EventType.RoomPinnedEvents, - content: { - pinned: pins().map((e) => e.getId()), - }, - user: "@user:example.org", - room: "!room:example.org", - }), + jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockImplementation( + (): any => + mkEvent({ + event: true, + type: EventType.RoomPinnedEvents, + content: { + pinned: pins().map((e) => e.getId()), + }, + user: "@user:example.org", + room: "!room:example.org", + }), ); - jest.spyOn(room.currentState, "on"); - - // Insert local pins into local timeline set - room.getUnfilteredTimelineSet = () => - ({ - getTimelineForEvent: () => ({ - getEvents: () => localPins, - }), - }) as unknown as EventTimelineSet; + jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue( + true, + ); + // poll end event validates against this + jest.spyOn( + room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "maySendRedactionForEvent", + ).mockReturnValue(true); // Return all pins over fetchRoomEvent cli.fetchRoomEvent.mockImplementation((roomId, eventId) => { @@ -86,8 +93,8 @@ describe("", () => { return room; }; - const mountPins = async (room: Room): Promise => { - const pins = render( + async function renderMessagePinList(room: Room): Promise { + const renderResult = render( ", () => { // Wait a tick for state updates await act(() => sleep(0)); - return pins; - }; - - const emitPinUpdates = async (room: Room) => { - const pinListener = mocked(room.currentState).on.mock.calls.find( - ([eventName, listener]) => eventName === RoomStateEvent.Events, - )![1]; + return renderResult; + } + /** + * + * @param room + */ + async function emitPinUpdate(room: Room) { await act(async () => { - // Emit the update - // @ts-ignore what is going on here? - pinListener(room.currentState.getStateEvents()); - // Wait a tick for state updates - await sleep(0); + const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!; + roomState.emit( + RoomStateEvent.Events, + new MatrixEvent({ type: EventType.RoomPinnedEvents }), + 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({ event: true, @@ -129,75 +174,66 @@ describe("", () => { msg: "The second one", }); - it("updates when messages are pinned", async () => { + it("should show spinner whilst loading", async () => { + const room = mkRoom([], [pin1]); + render( + + + , + ); + + 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 - const localPins: MatrixEvent[] = []; - const nonLocalPins: MatrixEvent[] = []; - const room = mkRoom(localPins, nonLocalPins); - const pins = await mountPins(room); - expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(0); + const { addLocalPinEvent, addNonLocalPinEvent } = await initPinnedMessagesCard([], []); + + expect(screen.queryAllByRole("listitem")).toHaveLength(0); // Pin the first message - localPins.push(pin1); - await emitPinUpdates(room); - expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(1); + await addLocalPinEvent(pin1); + expect(screen.getAllByRole("listitem")).toHaveLength(1); // Pin the second message - nonLocalPins.push(pin2); - await emitPinUpdates(room); - expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(2); + await addNonLocalPinEvent(pin2); + expect(screen.getAllByRole("listitem")).toHaveLength(2); }); - it("updates when messages are unpinned", async () => { + it("should updates when messages are unpinned", async () => { // Start with two pins - const localPins = [pin1]; - const nonLocalPins = [pin2]; - const room = mkRoom(localPins, nonLocalPins); - const pins = await mountPins(room); - expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(2); + const { removeLastLocalPinEvent, removeLastNonLocalPinEvent } = await initPinnedMessagesCard([pin1], [pin2]); + expect(screen.getAllByRole("listitem")).toHaveLength(2); // Unpin the first message - localPins.pop(); - await emitPinUpdates(room); - expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(1); + await removeLastLocalPinEvent(); + expect(screen.getAllByRole("listitem")).toHaveLength(1); // Unpin the second message - nonLocalPins.pop(); - await emitPinUpdates(room); - expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(0); + await removeLastNonLocalPinEvent(); + expect(screen.queryAllByRole("listitem")).toHaveLength(0); }); - it("hides 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", - }); - - 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 () => { + it("should display an edited pinned event", async () => { const messageEvent = mkEvent({ event: true, type: EventType.RoomMessage, @@ -221,13 +257,78 @@ describe("", () => { events: [messageEvent], }); - const pins = await mountPins(mkRoom([], [pin1])); - const pinTile = pins.container.querySelectorAll(".mx_PinnedEventTile"); - expect(pinTile.length).toBe(1); - expect(pinTile[0].querySelector(".mx_EventTile_body")!).toHaveTextContent("First pinned message, edited"); + await initPinnedMessagesCard([], [pin1]); + expect(screen.getByText("First pinned message, edited")).toBeInTheDocument(); }); - 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( + + + , + ); + + // 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({ ...PollStartEvent.from("A poll", ["Option 1", "Option 2"], M_POLL_KIND_DISCLOSED).serialize(), event: true, @@ -270,11 +371,8 @@ describe("", () => { return { originalEvent: undefined as unknown as MatrixEvent, events: [] }; }); - const room = mkRoom([], [poll]); - // poll end event validates against this - jest.spyOn(room.currentState, "maySendRedactionForEvent").mockReturnValue(true); + const { room } = await initPinnedMessagesCard([], [poll]); - const pins = await mountPins(room); // two pages of results await flushPromises(); await flushPromises(); @@ -282,34 +380,12 @@ describe("", () => { const pollInstance = room.polls.get(poll.getId()!); expect(pollInstance).toBeTruthy(); - const pinTile = pins.container.querySelectorAll(".mx_MPollBody"); + expect(screen.getByText("A poll")).toBeInTheDocument(); - expect(pinTile).toHaveLength(1); - expect(pinTile[0].querySelectorAll(".mx_PollOption_ended")).toHaveLength(2); - expect(pinTile[0].querySelectorAll(".mx_PollOption_optionVoteCount")[0]).toHaveTextContent("2 votes"); - expect([...pinTile[0].querySelectorAll(".mx_PollOption_optionVoteCount")].at(-1)).toHaveTextContent("1 vote"); - }); + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("2 votes")).toBeInTheDocument(); - it("should allow admins to unpin messages", async () => { - const nonLocalPins = [pin1]; - 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")); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + expect(screen.getByText("1 vote")).toBeInTheDocument(); }); }); diff --git a/test/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap b/test/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap new file mode 100644 index 0000000000..f47de92ffc --- /dev/null +++ b/test/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap @@ -0,0 +1,457 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should show the empty state when there are no pins 1`] = ` + + + + + + Pinned message + + + + + + + + + + + + + + + + + Pin important messages so that they can be easily discovered + + + Select a message and choose “Pin” to it include here. + + + + + +`; + +exports[` should show two pinned messages 1`] = ` + + + + + + 2 Pinned messages + + + + + + + + + + + + + + + + a + + + + + + @alice:example.org + + + + + + + + + + The second one + + + + + + + + + a + + + + + + @alice:example.org + + + + + + + + + + First pinned message + + + + + + + + Unpin all messages + + + + + +`; + +exports[` unpin all should not allow to unpinall 1`] = ` + + + + + + 2 Pinned messages + + + + + + + + + + + + + + + + a + + + + + + @alice:example.org + + + + + + + + + + The second one + + + + + + + + + a + + + + + + @alice:example.org + + + + + + + + + + First pinned message + + + + + + + + +`; diff --git a/test/components/views/rooms/PinnedEventTile-test.tsx b/test/components/views/rooms/PinnedEventTile-test.tsx index 7febe0b4bd..ed27a4e495 100644 --- a/test/components/views/rooms/PinnedEventTile-test.tsx +++ b/test/components/views/rooms/PinnedEventTile-test.tsx @@ -15,32 +15,44 @@ limitations under the License. */ import React from "react"; -import { render } from "@testing-library/react"; -import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { render, screen, waitFor } from "@testing-library/react"; +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 PinnedEventTile from "../../../../src/components/views/rooms/PinnedEventTile"; -import { getMockClientWithEventEmitter } from "../../../test-utils"; +import { PinnedEventTile } from "../../../../src/components/views/rooms/PinnedEventTile"; +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("", () => { const userId = "@alice: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(); + let mockClient: MatrixClient; + let room: Room; + let permalinkCreator: RoomPermalinkCreator; 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({ - type: "m.room.message", + /** + * Create a pinned event with the given content. + * @param content + */ + function makePinEvent(content?: Partial) { + return new MatrixEvent({ + type: EventType.RoomMessage, sender: userId, content: { body: "First pinned message", @@ -48,25 +60,150 @@ describe("", () => { }, room_id: roomId, 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( + + + , + ); + } + /** + * 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(); }); - it("should throw when pinned event has no sender", () => { - const pin1 = new MatrixEvent({ - type: "m.room.message", - sender: undefined, - content: { - body: "First pinned message", - msgtype: "m.text", - }, - room_id: roomId, - origin_server_ts: 0, - }); + it("should render the menu without unpin and delete", async () => { + jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue( + false, + ); + jest.spyOn( + room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "maySendRedactionForEvent", + ).mockReturnValue(false); - 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, + }); }); }); diff --git a/test/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap b/test/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap index 960177a38e..b44b6a41a0 100644 --- a/test/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap +++ b/test/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap @@ -4,25 +4,52 @@ exports[` should render pinned event 1`] = ` - - a - - - @alice:server.org - + + + a + + + + + @alice:server.org + + + + + + + @@ -34,22 +61,250 @@ exports[` should render pinned event 1`] = ` - `; + +exports[` should render the menu with all the options 1`] = ` + + + + + View in timeline + + + + + + + + + Unpin + + + + + + + + + Forward + + + + + + + + + + Delete + + + + + + +`; + +exports[` should render the menu without unpin and delete 1`] = ` + + + + + View in timeline + + + + + + + + + Forward + + + + + + +`;
+ Pin important messages so that they can be easily discovered +
+ Select a message and choose “Pin” to it include here. +