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")} +
+ + +
+
+ ); +} 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 && ( +
+ +
+ )} + + ); +} 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`] = ` + +
+