diff --git a/playwright/e2e/pinned-messages/index.ts b/playwright/e2e/pinned-messages/index.ts index 5e61b11e85..bb7705ba98 100644 --- a/playwright/e2e/pinned-messages/index.ts +++ b/playwright/e2e/pinned-messages/index.ts @@ -168,9 +168,8 @@ export class Helpers { /** * Return the right panel - * @private */ - private getRightPanel() { + public getRightPanel() { return this.page.locator("#mx_RightPanel"); } @@ -183,7 +182,6 @@ export class Helpers { 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); @@ -243,6 +241,36 @@ export class Helpers { await item.getByRole("button").click(); await this.page.getByRole("menu", { name: "Open menu" }).getByRole("menuitem", { name: "Unpin" }).click(); } + + /** + * Return the banner + * @private + */ + public getBanner() { + return this.page.getByTestId("pinned-message-banner"); + } + + /** + * Assert that the banner contains the given message + * @param msg + */ + async assertMessageInBanner(msg: string) { + await expect(this.getBanner().getByText(msg)).toBeVisible(); + } + + /** + * Return the view all button + */ + public getViewAllButton() { + return this.page.getByRole("button", { name: "View all" }); + } + + /** + * Return the close list button + */ + public getCloseListButton() { + return this.page.getByRole("button", { name: "Close list" }); + } } export { expect }; diff --git a/playwright/e2e/pinned-messages/pinned-messages.spec.ts b/playwright/e2e/pinned-messages/pinned-messages.spec.ts index 53f657ea7f..339c3b1f0e 100644 --- a/playwright/e2e/pinned-messages/pinned-messages.spec.ts +++ b/playwright/e2e/pinned-messages/pinned-messages.spec.ts @@ -48,6 +48,7 @@ test.describe("Pinned messages", () => { await util.openRoomInfo(); await util.openPinnedMessagesList(); await util.assertPinnedMessagesList(["Msg1", "Msg2", "Msg4"]); + await expect(util.getRightPanel()).toMatchScreenshot(`pinned-messages-list-pin-3.png`); }); test("should unpin one message", async ({ page, app, room1, util }) => { @@ -59,6 +60,7 @@ test.describe("Pinned messages", () => { await util.openPinnedMessagesList(); await util.unpinMessageFromMessageList("Msg2"); await util.assertPinnedMessagesList(["Msg1", "Msg4"]); + await expect(util.getRightPanel()).toMatchScreenshot(`pinned-messages-list-unpin-2.png`); await util.backPinnedMessagesList(); await util.assertPinnedCountInRoomInfo(2); }); @@ -87,4 +89,65 @@ test.describe("Pinned messages", () => { await util.pinMessagesFromQuickActions(["Msg1"], true); await util.assertPinnedCountInRoomInfo(0); }); + + test("should display one message in the banner", async ({ page, app, room1, util }) => { + await util.goTo(room1); + await util.receiveMessages(room1, ["Msg1"]); + await util.pinMessages(["Msg1"]); + await util.assertMessageInBanner("Msg1"); + await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-1-Msg1.png"); + }); + + test("should display 2 messages in the banner", async ({ page, app, room1, util }) => { + await util.goTo(room1); + await util.receiveMessages(room1, ["Msg1", "Msg2"]); + await util.pinMessages(["Msg1", "Msg2"]); + + await util.assertMessageInBanner("Msg1"); + await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg1.png"); + + await util.getBanner().click(); + await util.assertMessageInBanner("Msg2"); + await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg2.png"); + + await util.getBanner().click(); + await util.assertMessageInBanner("Msg1"); + await expect(util.getBanner()).toMatchScreenshot("pinned-message-banner-2-Msg1.png"); + }); + + test("should display 4 messages in the banner", async ({ page, app, room1, util }) => { + await util.goTo(room1); + await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]); + await util.pinMessages(["Msg1", "Msg2", "Msg3", "Msg4"]); + + for (const msg of ["Msg1", "Msg4", "Msg3", "Msg2"]) { + await util.assertMessageInBanner(msg); + await expect(util.getBanner()).toMatchScreenshot(`pinned-message-banner-4-${msg}.png`); + await util.getBanner().click(); + } + }); + + test("should open the pinned messages list from the banner", async ({ page, app, room1, util }) => { + await util.goTo(room1); + await util.receiveMessages(room1, ["Msg1", "Msg2"]); + await util.pinMessages(["Msg1", "Msg2"]); + + await util.getViewAllButton().click(); + await util.assertPinnedMessagesList(["Msg1", "Msg2"]); + await expect(util.getRightPanel()).toMatchScreenshot("pinned-message-banner-2.png"); + + await expect(util.getCloseListButton()).toBeVisible(); + }); + + test("banner should listen to pinned message list", async ({ page, app, room1, util }) => { + await util.goTo(room1); + await util.receiveMessages(room1, ["Msg1", "Msg2"]); + await util.pinMessages(["Msg1", "Msg2"]); + + await expect(util.getViewAllButton()).toBeVisible(); + + await util.openRoomInfo(); + await util.openPinnedMessagesList(); + await expect(util.getCloseListButton()).toBeVisible(); + }); }); diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-1-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-1-Msg1-linux.png new file mode 100644 index 0000000000..d6892c5bbf Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-1-Msg1-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg1-linux.png new file mode 100644 index 0000000000..153ad2d07f Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg1-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg2-linux.png new file mode 100644 index 0000000000..98396be1f8 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-Msg2-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-linux.png new file mode 100644 index 0000000000..f583649aaa Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-2-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg1-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg1-linux.png new file mode 100644 index 0000000000..8169ca9cfd Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg1-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg2-linux.png new file mode 100644 index 0000000000..7b6c994821 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg2-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg3-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg3-linux.png new file mode 100644 index 0000000000..dfec23cb59 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg3-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg4-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg4-linux.png new file mode 100644 index 0000000000..72abbbb260 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-banner-4-Msg4-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 deleted file mode 100644 index 82666b0d95..0000000000 Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-2-linux.png and /dev/null 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 deleted file mode 100644 index 98e804d897..0000000000 Binary files a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-3-linux.png and /dev/null differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-pin-3-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-pin-3-linux.png new file mode 100644 index 0000000000..b3176cc698 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-pin-3-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-unpin-2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-unpin-2-linux.png new file mode 100644 index 0000000000..4eede005d8 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-unpin-2-linux.png differ diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 96c285bc0a..bfcab19879 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -298,6 +298,7 @@ @import "./views/rooms/_NewRoomIntro.pcss"; @import "./views/rooms/_NotificationBadge.pcss"; @import "./views/rooms/_PinnedEventTile.pcss"; +@import "./views/rooms/_PinnedMessageBanner.pcss"; @import "./views/rooms/_PresenceLabel.pcss"; @import "./views/rooms/_ReadReceiptGroup.pcss"; @import "./views/rooms/_ReplyPreview.pcss"; diff --git a/res/css/views/rooms/_PinnedMessageBanner.pcss b/res/css/views/rooms/_PinnedMessageBanner.pcss new file mode 100644 index 0000000000..c6889aba75 --- /dev/null +++ b/res/css/views/rooms/_PinnedMessageBanner.pcss @@ -0,0 +1,119 @@ +/* + * 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_PinnedMessageBanner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--cpd-space-4x); + /* 80px = 79px + 1px from the bottom border */ + height: 79px; + padding: 0 var(--cpd-space-4x); + + background-color: var(--cpd-color-bg-canvas-default); + border-bottom: 1px solid var(--cpd-color-gray-400); + + /* From figma */ + box-shadow: 0 var(--cpd-space-2x) var(--cpd-space-6x) calc(var(--cpd-space-2x) * -1) rgba(27, 29, 34, 0.1); + + .mx_PinnedMessageBanner_main { + background: transparent; + border: none; + text-align: start; + cursor: pointer; + + height: 100%; + flex-grow: 1; + display: flex; + align-items: center; + + .mx_PinnedMessageBanner_content { + display: grid; + grid-template: + "indicators pinIcon title" auto + "indicators pinIcon message" auto; + column-gap: var(--cpd-space-2x); + } + + .mx_PinnedMessageBanner_Indicators { + grid-area: indicators; + display: flex; + flex-direction: column; + gap: var(--cpd-space-0-5x); + height: 100%; + + .mx_PinnedMessageBanner_Indicator { + width: var(--cpd-space-0-5x); + background-color: var(--cpd-color-gray-600); + height: 100%; + } + + .mx_PinnedMessageBanner_Indicator--active { + background-color: var(--cpd-color-icon-accent-primary); + } + + .mx_PinnedMessageBanner_Indicator--hidden { + background-color: transparent; + } + } + + .mx_PinnedMessageBanner_PinIcon { + grid-area: pinIcon; + align-self: center; + fill: var(--cpd-color-icon-secondary-alpha); + } + + .mx_PinnedMessageBanner_title { + grid-area: title; + font: var(--cpd-font-body-sm-regular); + color: var(--cpd-color-text-action-accent); + height: 20px; + + .mx_PinnedMessageBanner_title_counter { + font: var(--cpd-font-body-sm-semibold); + } + } + + .mx_PinnedMessageBanner_message { + grid-area: message; + font: var(--cpd-font-body-sm-regular); + height: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mx_PinnedMessageBanner_redactedMessage { + grid-area: message; + height: 20px; + display: flex; + align-items: center; + } + } + + .mx_PinnedMessageBanner_actions { + white-space: nowrap; + } +} + +.mx_PinnedMessageBanner[data-single-message="true"] { + /* 64px = 63px + 1px from the bottom border */ + height: 63px; + + .mx_PinnedMessageBanner_content { + grid-template: "pinIcon message" auto; + } +} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 70ee16b542..9c7469346d 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -133,6 +133,7 @@ import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoi import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import { onView3pidInvite } from "../../stores/right-panel/action-handlers"; import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel"; +import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -2409,6 +2410,14 @@ export class RoomView extends React.Component { ); + const isPinningEnabled = SettingsStore.getValue("feature_pinning"); + let pinnedMessageBanner; + if (isPinningEnabled) { + pinnedMessageBanner = ( + + ); + } + let messageComposer; const showComposer = // joined and not showing search results @@ -2537,6 +2546,7 @@ export class RoomView extends React.Component { )} {auxPanel} + {pinnedMessageBanner}
{topUnreadMessagesBar} diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx index 0dbd4b5395..6c105dd0cc 100644 --- a/src/components/views/context_menus/RoomContextMenu.tsx +++ b/src/components/views/context_menus/RoomContextMenu.tsx @@ -35,7 +35,6 @@ import { RoomNotifState } from "../../../RoomNotifs"; import Modal from "../../../Modal"; import ExportDialog from "../dialogs/ExportDialog"; import { useFeatureEnabled } from "../../../hooks/useSettings"; -import { usePinnedEvents } from "../right_panel/PinnedMessagesCard"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; @@ -53,6 +52,7 @@ import { UIComponent } from "../../../settings/UIFeature"; import { DeveloperToolsOption } from "./DeveloperToolsOption"; import { tagRoom } from "../../../utils/room/tagRoom"; import { useIsVideoRoom } from "../../../utils/video-rooms"; +import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; interface IProps extends IContextMenuProps { room: Room; diff --git a/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx b/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx index 207c97ec7b..c3fc1fe95d 100644 --- a/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx +++ b/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx @@ -28,7 +28,6 @@ import HeaderButtons, { HeaderKind } from "./HeaderButtons"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import { ActionPayload } from "../../../dispatcher/payloads"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; -import { useReadPinnedEvents, usePinnedEvents } from "./PinnedMessagesCard"; import { showThreadPanel } from "../../../dispatcher/dispatch-actions/threads"; import SettingsStore from "../../../settings/SettingsStore"; import { @@ -40,6 +39,7 @@ import { SummarizedNotificationState } from "../../../stores/notifications/Summa import PosthogTrackers from "../../../PosthogTrackers"; import { ButtonEvent } from "../elements/AccessibleButton"; import { doesRoomOrThreadHaveUnreadMessages } from "../../../Unread"; +import { usePinnedEvents, useReadPinnedEvents } from "../../../hooks/usePinnedEvents"; const ROOM_INFO_PHASES = [ RightPanelPhases.RoomSummary, diff --git a/src/components/views/right_panel/PinnedMessagesCard.tsx b/src/components/views/right_panel/PinnedMessagesCard.tsx index 85be2e6d03..0f1f856786 100644 --- a/src/components/views/right_panel/PinnedMessagesCard.tsx +++ b/src/components/views/right_panel/PinnedMessagesCard.tsx @@ -14,17 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -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 React, { useCallback, useEffect, JSX } from "react"; +import { Room, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; import { Button, Separator } from "@vector-im/compound-web"; import classNames from "classnames"; import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin"; @@ -33,9 +24,6 @@ import { _t } from "../../../languageHandler"; import BaseCard from "./BaseCard"; import Spinner from "../elements/Spinner"; 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 { useRoomState } from "../../../hooks/useRoomState"; import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext"; @@ -46,155 +34,7 @@ import { filterBoolean } from "../../../utils/arrays"; import Modal from "../../../Modal"; import { UnpinAllDialog } from "../dialogs/UnpinAllDialog"; import EmptyState from "./EmptyState"; - -/** - * Get the pinned event IDs from a room. - * @param room - */ -function getPinnedEventIds(room?: Room): string[] { - 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; - setPinnedEvents(getPinnedEventIds(room)); - }, - [room], - ); - - useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update); - useEffect(() => { - setPinnedEvents(getPinnedEventIds(room)); - return () => { - setPinnedEvents([]); - }; - }, [room]); - 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; - setReadPinnedEvents(getReadPinnedEventIds(room)); - }, - [room], - ); - - useTypedEventEmitter(room, RoomEvent.AccountData, update); - useEffect(() => { - setReadPinnedEvents(getReadPinnedEventIds(room)); - return () => { - setReadPinnedEvents(new Set()); - }; - }, [room]); - return readPinnedEvents; -}; - -/** - * Fetch the pinned events - * @param room - * @param pinnedEventIds - */ -function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array | null { - const cli = useMatrixClientContext(); - - 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 { - // The event is not available locally, so we fetch the event and latest edit in parallel - const [ - evJson, - { - events: [edit], - }, - ] = await Promise.all([ - 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); - } - - // Handle poll events - await room.processPollEvents([event]); - - const senderUserId = event.getSender(); - if (senderUserId && PinningUtils.isPinnable(event)) { - // Inject sender information - event.sender = room.getMember(senderUserId); - // Also inject any edits we've found - if (edit) event.makeReplaced(edit); - - return event; - } - } catch (err) { - logger.error("Error looking up pinned event " + eventId + " in room " + room.roomId); - logger.error(err); - } - return null; - }); - - return Promise.all(promises); - }, - [cli, room, pinnedEventIds], - null, - ); -} +import { useFetchedPinnedEvents, usePinnedEvents, useReadPinnedEvents } from "../../../hooks/usePinnedEvents"; /** * List the pinned messages in a room inside a Card. diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index 9dfe049a52..ece150495e 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -58,7 +58,6 @@ import { E2EStatus } from "../../../utils/ShieldUtils"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { useFeatureEnabled } from "../../../hooks/useSettings"; -import { usePinnedEvents } from "./PinnedMessagesCard"; import RoomName from "../elements/RoomName"; import ExportDialog from "../dialogs/ExportDialog"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; @@ -81,6 +80,7 @@ import { Action } from "../../../dispatcher/actions"; import { Key } from "../../../Keyboard"; import { useTransition } from "../../../hooks/useTransition"; import { useIsVideoRoom } from "../../../utils/video-rooms"; +import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; interface IProps { room: Room; diff --git a/src/components/views/rooms/PinnedMessageBanner.tsx b/src/components/views/rooms/PinnedMessageBanner.tsx new file mode 100644 index 0000000000..f7010b8838 --- /dev/null +++ b/src/components/views/rooms/PinnedMessageBanner.tsx @@ -0,0 +1,252 @@ +/* + * 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, useEffect, useMemo, useState } from "react"; +import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin-solid.svg"; +import { Button } from "@vector-im/compound-web"; +import { Room } from "matrix-js-sdk/src/matrix"; +import classNames from "classnames"; + +import { usePinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents"; +import { _t } from "../../../languageHandler"; +import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; +import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; +import dis from "../../../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../../../dispatcher/actions"; +import MessageEvent from "../messages/MessageEvent"; + +/** + * The props for the {@link PinnedMessageBanner} component. + */ +interface PinnedMessageBannerProps { + /** + * The permalink creator to use. + */ + permalinkCreator: RoomPermalinkCreator; + /** + * The room where the banner is displayed + */ + room: Room; +} + +/** + * A banner that displays the pinned messages in a room. + */ +export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBannerProps): JSX.Element | null { + const pinnedEventIds = usePinnedEvents(room); + const pinnedEvents = useSortedFetchedPinnedEvents(room, pinnedEventIds); + const eventCount = pinnedEvents.length; + const isSinglePinnedEvent = eventCount === 1; + + const [currentEventIndex, setCurrentEventIndex] = useState(eventCount - 1); + // If the list of pinned events changes, we need to make sure the current index isn't out of bound + useEffect(() => { + setCurrentEventIndex((currentEventIndex) => { + // If the current index is out of bound, we set it to the last index + if (currentEventIndex < 0 || currentEventIndex >= eventCount) return eventCount - 1; + return currentEventIndex; + }); + }, [eventCount]); + + const pinnedEvent = pinnedEvents[currentEventIndex]; + // Generate a preview for the pinned event + const eventPreview = useMemo(() => { + if (!pinnedEvent || pinnedEvent.isRedacted() || pinnedEvent.isDecryptionFailure()) return null; + return MessagePreviewStore.instance.generatePreviewForEvent(pinnedEvent); + }, [pinnedEvent]); + + if (!pinnedEvent) return null; + + const shouldUseMessageEvent = pinnedEvent.isRedacted() || pinnedEvent.isDecryptionFailure(); + + const onBannerClick = (): void => { + // Scroll to the pinned message + dis.dispatch({ + action: Action.ViewRoom, + event_id: pinnedEvent.getId(), + highlighted: true, + room_id: room.roomId, + metricsTrigger: undefined, // room doesn't change + }); + + // Cycle through the pinned messages + // When we reach the first message, we go back to the last message + setCurrentEventIndex((currentEventIndex) => (--currentEventIndex === -1 ? eventCount - 1 : currentEventIndex)); + }; + + return ( +
+ + {!isSinglePinnedEvent && } +
+ ); +} + +const MAX_INDICATORS = 3; + +/** + * The props for the {@link IndicatorsProps} component. + */ +interface IndicatorsProps { + /** + * The number of messages pinned + */ + count: number; + /** + * The current index of the pinned message + */ + currentIndex: number; +} + +/** + * A component that displays vertical indicators for the pinned messages. + */ +function Indicators({ count, currentIndex }: IndicatorsProps): JSX.Element { + // We only display a maximum of 3 indicators at one time. + // When there is more than 3 messages pinned, we will cycle through the indicators + + // If there is only 2 messages pinned, we will display 2 indicators + // In case of 1 message pinned, the indicators are not displayed, see {@link PinnedMessageBanner} logic. + const numberOfIndicators = Math.min(count, MAX_INDICATORS); + // The index of the active indicator + const index = currentIndex % numberOfIndicators; + + // We hide the indicators when we are on the last cycle and there are less than 3 remaining messages pinned + const numberOfCycles = Math.ceil(count / numberOfIndicators); + // If the current index is greater than the last cycle index, we are on the last cycle + const isLastCycle = currentIndex >= (numberOfCycles - 1) * MAX_INDICATORS; + // The index of the last message in the last cycle + const lastCycleIndex = numberOfIndicators - (numberOfCycles * numberOfIndicators - count); + + return ( +
+ {Array.from({ length: numberOfIndicators }).map((_, i) => ( +
+ ); +} + +/** + * The props for the {@link Indicator} component. + */ +interface IndicatorProps { + /** + * Whether the indicator is active + */ + active: boolean; + /** + * Whether the indicator is hidden + */ + hidden: boolean; +} + +/** + * A component that displays a vertical indicator for a pinned message. + */ +function Indicator({ active, hidden }: IndicatorProps): JSX.Element { + return ( +
+ ); +} + +function getRightPanelPhase(roomId: string): RightPanelPhases | null { + if (!RightPanelStore.instance.isOpenForRoom(roomId)) return null; + return RightPanelStore.instance.currentCard.phase; +} + +/** + * The props for the {@link BannerButton} component. + */ +interface BannerButtonProps { + /** + * The room where the banner is displayed + */ + room: Room; +} + +/** + * A button that allows the user to view or close the list of pinned messages. + */ +function BannerButton({ room }: BannerButtonProps): JSX.Element { + const [currentPhase, setCurrentPhase] = useState(getRightPanelPhase(room.roomId)); + useEventEmitter(RightPanelStore.instance, UPDATE_EVENT, () => setCurrentPhase(getRightPanelPhase(room.roomId))); + const isPinnedMessagesPhase = currentPhase === RightPanelPhases.PinnedMessages; + + return ( + + ); +} diff --git a/src/hooks/usePinnedEvents.ts b/src/hooks/usePinnedEvents.ts new file mode 100644 index 0000000000..eb53151138 --- /dev/null +++ b/src/hooks/usePinnedEvents.ts @@ -0,0 +1,212 @@ +/* + * 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 { useCallback, useEffect, useMemo, useState } from "react"; +import { + Room, + RoomEvent, + RoomStateEvent, + MatrixEvent, + EventType, + RelationType, + EventTimeline, + MatrixClient, +} from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useTypedEventEmitter } from "./useEventEmitter"; +import { ReadPinsEventId } from "../components/views/right_panel/types"; +import { useMatrixClientContext } from "../contexts/MatrixClientContext"; +import { useAsyncMemo } from "./useAsyncMemo"; +import PinningUtils from "../utils/PinningUtils"; + +/** + * Get the pinned event IDs from a room. + * @param room + */ +function getPinnedEventIds(room?: Room): string[] { + 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; + setPinnedEvents(getPinnedEventIds(room)); + }, + [room], + ); + + useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update); + useEffect(() => { + setPinnedEvents(getPinnedEventIds(room)); + return () => { + setPinnedEvents([]); + }; + }, [room]); + 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; + setReadPinnedEvents(getReadPinnedEventIds(room)); + }, + [room], + ); + + useTypedEventEmitter(room, RoomEvent.AccountData, update); + useEffect(() => { + setReadPinnedEvents(getReadPinnedEventIds(room)); + return () => { + setReadPinnedEvents(new Set()); + }; + }, [room]); + return readPinnedEvents; +}; + +/** + * Fetch the pinned event + * @param room + * @param pinnedEventId + * @param cli + */ +async function fetchPinnedEvent(room: Room, pinnedEventId: string, cli: MatrixClient): Promise { + const timelineSet = room.getUnfilteredTimelineSet(); + // Get the event from the local timeline + const localEvent = timelineSet + ?.getTimelineForEvent(pinnedEventId) + ?.getEvents() + .find((e) => e.getId() === pinnedEventId); + + // 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 + // or if it's redacted (to show the redacted event and to be able to unpin it) + // Otherwise, return null + if (localEvent) return PinningUtils.isUnpinnable(localEvent) ? localEvent : null; + + try { + // The event is not available locally, so we fetch the event and latest edit in parallel + const [ + evJson, + { + events: [edit], + }, + ] = await Promise.all([ + cli.fetchRoomEvent(room.roomId, pinnedEventId), + cli.relations(room.roomId, pinnedEventId, RelationType.Replace, null, { limit: 1 }), + ]); + + const event = new MatrixEvent(evJson); + + // Decrypt the event if it's encrypted + if (event.isEncrypted()) { + await cli.decryptEventIfNeeded(event); + } + + // Handle poll events + await room.processPollEvents([event]); + + const senderUserId = event.getSender(); + if (senderUserId && PinningUtils.isUnpinnable(event)) { + // Inject sender information + event.sender = room.getMember(senderUserId); + // Also inject any edits we've found + if (edit) event.makeReplaced(edit); + + return event; + } + } catch (err) { + logger.error(`Error looking up pinned event ${pinnedEventId} in room ${room.roomId}`); + logger.error(err); + } + return null; +} + +/** + * Fetch the pinned events + * @param room + * @param pinnedEventIds + */ +export function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array | null { + const cli = useMatrixClientContext(); + + return useAsyncMemo( + () => + Promise.all( + pinnedEventIds.map( + async (eventId): Promise => fetchPinnedEvent(room, eventId, cli), + ), + ), + [cli, room, pinnedEventIds], + null, + ); +} + +/** + * Fetch the pinned events and sort them by from the oldest to the newest + * The order is determined by the event timestamp + * @param room + * @param pinnedEventIds + */ +export function useSortedFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array { + const pinnedEvents = useFetchedPinnedEvents(room, pinnedEventIds); + return useMemo(() => { + if (!pinnedEvents) return []; + + return pinnedEvents.sort((a, b) => { + if (!a) return -1; + if (!b) return 1; + return a.getTs() - b.getTs(); + }); + }, [pinnedEvents]); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 889fc157e9..352ab43762 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2048,6 +2048,13 @@ "not_found_title": "This room or space does not exist.", "not_found_title_name": "%(roomName)s does not exist.", "peek_join_prompt": "You're previewing %(roomName)s. Want to join it?", + "pinned_message_banner": { + "button_close_list": "Close list", + "button_view_all": "View all", + "description": "This room has pinned messages. Click to view them.", + "go_to_message": "View the pinned message in the timeline.", + "title": "%(index)s of %(length)s Pinned messages" + }, "read_topic": "Click to read topic", "rejecting": "Rejecting inviteā€¦", "rejoin_button": "Re-join", diff --git a/src/utils/PinningUtils.ts b/src/utils/PinningUtils.ts index 22db64a6f1..9a20a721b9 100644 --- a/src/utils/PinningUtils.ts +++ b/src/utils/PinningUtils.ts @@ -37,11 +37,19 @@ export default class PinningUtils { * @return {boolean} True if the event may be pinned, false otherwise. */ public static isPinnable(event: MatrixEvent): boolean { - if (!event) return false; - if (!this.PINNABLE_EVENT_TYPES.includes(event.getType())) return false; if (event.isRedacted()) return false; + return PinningUtils.isUnpinnable(event); + } - return true; + /** + * Determines if the given event may be unpinned. + * @param {MatrixEvent} event The event to check. + * @return {boolean} True if the event may be unpinned, false otherwise. + */ + public static isUnpinnable(event: MatrixEvent): boolean { + if (!event) return false; + if (event.isRedacted()) return true; + return this.PINNABLE_EVENT_TYPES.includes(event.getType()); } /** diff --git a/test/components/views/right_panel/PinnedMessagesCard-test.tsx b/test/components/views/right_panel/PinnedMessagesCard-test.tsx index 64961ca144..cfa32fa490 100644 --- a/test/components/views/right_panel/PinnedMessagesCard-test.tsx +++ b/test/components/views/right_panel/PinnedMessagesCard-test.tsx @@ -20,7 +20,6 @@ import { mocked, MockedObject } from "jest-mock"; import { MatrixEvent, RoomStateEvent, - IEvent, Room, IMinimalEvent, EventType, @@ -266,9 +265,8 @@ describe("", () => { // Redacted messages are unpinnable const pin = mkEvent({ event: true, - type: EventType.RoomMessage, + type: EventType.RoomCreate, content: {}, - unsigned: { redacted_because: {} as unknown as IEvent }, room: "!room:example.org", user: "@alice:example.org", }); @@ -280,9 +278,8 @@ describe("", () => { // Redacted messages are unpinnable const pin = mkEvent({ event: true, - type: EventType.RoomMessage, + type: EventType.RoomCreate, content: {}, - unsigned: { redacted_because: {} as unknown as IEvent }, room: "!room:example.org", user: "@alice:example.org", }); diff --git a/test/components/views/rooms/PinnedMessageBanner-test.tsx b/test/components/views/rooms/PinnedMessageBanner-test.tsx new file mode 100644 index 0000000000..4df0127d82 --- /dev/null +++ b/test/components/views/rooms/PinnedMessageBanner-test.tsx @@ -0,0 +1,235 @@ +/* + * 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 { act, screen, render } from "@testing-library/react"; +import React from "react"; +import { EventType, IEvent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import userEvent from "@testing-library/user-event"; + +import * as pinnedEventHooks from "../../../../src/hooks/usePinnedEvents"; +import { PinnedMessageBanner } from "../../../../src/components/views/rooms/PinnedMessageBanner"; +import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; +import { stubClient } from "../../../test-utils"; +import dis from "../../../../src/dispatcher/dispatcher"; +import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore"; +import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases"; +import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore"; +import { Action } from "../../../../src/dispatcher/actions"; + +describe("", () => { + const userId = "@alice:server.org"; + const roomId = "!room:server.org"; + + let mockClient: MatrixClient; + let room: Room; + let permalinkCreator: RoomPermalinkCreator; + beforeEach(() => { + mockClient = stubClient(); + room = new Room(roomId, mockClient, userId); + permalinkCreator = new RoomPermalinkCreator(room); + jest.spyOn(dis, "dispatch").mockReturnValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + /** + * 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", + msgtype: "m.text", + }, + room_id: roomId, + origin_server_ts: 0, + event_id: "$eventId", + ...content, + }); + } + + const event1 = makePinEvent(); + const event2 = makePinEvent({ + event_id: "$eventId2", + content: { body: "Second pinned message" }, + }); + const event3 = makePinEvent({ + event_id: "$eventId3", + content: { body: "Third pinned message" }, + }); + const event4 = makePinEvent({ + event_id: "$eventId4", + content: { body: "Fourth pinned message" }, + }); + + /** + * Render the banner + */ + function renderBanner() { + return render(); + } + + it("should render nothing when there are no pinned events", async () => { + jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([]); + jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([]); + const { container } = renderBanner(); + expect(container).toBeEmptyDOMElement(); + }); + + it("should render a single pinned event", async () => { + jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!]); + jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1]); + + const { asFragment } = renderBanner(); + + expect(screen.getByText("First pinned message")).toBeVisible(); + expect(screen.queryByRole("button", { name: "View all" })).toBeNull(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render 2 pinned event", async () => { + jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]); + jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]); + + const { asFragment } = renderBanner(); + + expect(screen.getByText("Second pinned message")).toBeVisible(); + expect(screen.getByTestId("banner-counter")).toHaveTextContent("2 of 2 Pinned messages"); + expect(screen.getAllByTestId("banner-indicator")).toHaveLength(2); + expect(screen.queryByRole("button", { name: "View all" })).toBeVisible(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render 4 pinned event", async () => { + jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([ + event1.getId()!, + event2.getId()!, + event3.getId()!, + event4.getId()!, + ]); + jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2, event3, event4]); + + const { asFragment } = renderBanner(); + + expect(screen.getByText("Fourth pinned message")).toBeVisible(); + expect(screen.getByTestId("banner-counter")).toHaveTextContent("4 of 4 Pinned messages"); + expect(screen.getAllByTestId("banner-indicator")).toHaveLength(3); + expect(screen.queryByRole("button", { name: "View all" })).toBeVisible(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should rotate the pinned events when the banner is clicked", async () => { + jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]); + jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]); + + renderBanner(); + expect(screen.getByText("Second pinned message")).toBeVisible(); + + await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." })); + expect(screen.getByText("First pinned message")).toBeVisible(); + expect(screen.getByTestId("banner-counter")).toHaveTextContent("1 of 2 Pinned messages"); + expect(dis.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + event_id: event2.getId(), + highlighted: true, + room_id: room.roomId, + metricsTrigger: undefined, // room doesn't change + }); + + await userEvent.click(screen.getByRole("button", { name: "View the pinned message in the timeline." })); + expect(screen.getByText("Second pinned message")).toBeVisible(); + expect(screen.getByTestId("banner-counter")).toHaveTextContent("2 of 2 Pinned messages"); + expect(dis.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + event_id: event1.getId(), + highlighted: true, + room_id: room.roomId, + metricsTrigger: undefined, // room doesn't change + }); + }); + + describe("Right button", () => { + beforeEach(() => { + jest.spyOn(pinnedEventHooks, "usePinnedEvents").mockReturnValue([event1.getId()!, event2.getId()!]); + jest.spyOn(pinnedEventHooks, "useSortedFetchedPinnedEvents").mockReturnValue([event1, event2]); + }); + + it("should display View all button if the right panel is closed", async () => { + // The Right panel is closed + jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(false); + + renderBanner(); + expect(screen.getByRole("button", { name: "View all" })).toBeVisible(); + }); + + it("should display View all button if the right panel is not opened on the pinned message list", async () => { + // The Right panel is opened on another card + jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true); + jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({ + phase: RightPanelPhases.RoomMemberList, + }); + + renderBanner(); + expect(screen.getByRole("button", { name: "View all" })).toBeVisible(); + }); + + it("should display Close list button if the message pinning list is displayed", async () => { + // The Right panel is closed + jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true); + jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({ + phase: RightPanelPhases.PinnedMessages, + }); + + renderBanner(); + expect(screen.getByRole("button", { name: "Close list" })).toBeVisible(); + }); + + it("should open or close the message pinning list", async () => { + // The Right panel is closed + jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true); + jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({ + phase: RightPanelPhases.PinnedMessages, + }); + jest.spyOn(RightPanelStore.instance, "showOrHidePhase").mockReturnValue(); + + renderBanner(); + await userEvent.click(screen.getByRole("button", { name: "Close list" })); + expect(RightPanelStore.instance.showOrHidePhase).toHaveBeenCalledWith(RightPanelPhases.PinnedMessages); + }); + + it("should listen to the right panel", async () => { + // The Right panel is closed + jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(true); + jest.spyOn(RightPanelStore.instance, "currentCard", "get").mockReturnValue({ + phase: RightPanelPhases.PinnedMessages, + }); + + renderBanner(); + expect(screen.getByRole("button", { name: "Close list" })).toBeVisible(); + + jest.spyOn(RightPanelStore.instance, "isOpenForRoom").mockReturnValue(false); + act(() => { + RightPanelStore.instance.emit(UPDATE_EVENT); + }); + expect(screen.getByRole("button", { name: "View all" })).toBeVisible(); + }); + }); +}); diff --git a/test/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap b/test/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap new file mode 100644 index 0000000000..fa4c793d90 --- /dev/null +++ b/test/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap @@ -0,0 +1,166 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render 2 pinned event 1`] = ` + +
+ + +
+
+`; + +exports[` should render 4 pinned event 1`] = ` + +
+ + +
+
+`; + +exports[` should render a single pinned event 1`] = ` + +
+ +
+
+`; diff --git a/test/utils/PinningUtils-test.ts b/test/utils/PinningUtils-test.ts index 47434c4fca..adfd268bf1 100644 --- a/test/utils/PinningUtils-test.ts +++ b/test/utils/PinningUtils-test.ts @@ -73,15 +73,27 @@ describe("PinningUtils", () => { ).mockReturnValue(true); }); - describe("isPinnable", () => { + describe("isUnpinnable", () => { test.each(PinningUtils.PINNABLE_EVENT_TYPES)("should return true for pinnable event types", (eventType) => { const event = makePinEvent({ type: eventType }); - expect(PinningUtils.isPinnable(event)).toBe(true); + expect(PinningUtils.isUnpinnable(event)).toBe(true); }); test("should return false for a non pinnable event type", () => { const event = makePinEvent({ type: EventType.RoomCreate }); - expect(PinningUtils.isPinnable(event)).toBe(false); + expect(PinningUtils.isUnpinnable(event)).toBe(false); + }); + + test("should return true for a redacted event", () => { + const event = makePinEvent({ unsigned: { redacted_because: "because" as unknown as IEvent } }); + expect(PinningUtils.isUnpinnable(event)).toBe(true); + }); + }); + + describe("isPinnable", () => { + test.each(PinningUtils.PINNABLE_EVENT_TYPES)("should return true for pinnable event types", (eventType) => { + const event = makePinEvent({ type: eventType }); + expect(PinningUtils.isPinnable(event)).toBe(true); }); test("should return false for a redacted event", () => {