From f341ee2c3b1708f51554e3a79e5024db71c23808 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Thu, 22 Aug 2024 15:18:28 +0200 Subject: [PATCH] Add a banner at the top of a room to display the pinned messages --- res/css/_components.pcss | 1 + res/css/views/rooms/_PinnedMessageBanner.pcss | 105 +++++++++ src/components/structures/RoomView.tsx | 6 + .../views/rooms/PinnedMessageBanner.tsx | 208 ++++++++++++++++++ src/hooks/usePinnedEvents.ts | 129 ++++++----- src/i18n/strings/en_EN.json | 6 + 6 files changed, 401 insertions(+), 54 deletions(-) create mode 100644 res/css/views/rooms/_PinnedMessageBanner.pcss create mode 100644 src/components/views/rooms/PinnedMessageBanner.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 96c285bc0a6c..bfcab19879d1 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 000000000000..1e72bea28111 --- /dev/null +++ b/res/css/views/rooms/_PinnedMessageBanner.pcss @@ -0,0 +1,105 @@ +/* + * 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; + + 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--hided { + 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_actions { + white-space: nowrap; + } +} + +.mx_PinnedMessageBanner[data-single-message="true"] { + /* 64px = 63px + 1px from the bottom border */ + height: 63px; + + .mx_PinnedMessageBanner_main { + grid-template: "pinIcon message" auto; + } +} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 70ee16b542a5..6e9de1a9b6a9 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,10 @@ export class RoomView extends React.Component { ); + const pinnedMessageBanner = ( + + ); + let messageComposer; const showComposer = // joined and not showing search results @@ -2537,6 +2542,7 @@ export class RoomView extends React.Component { )} {auxPanel} + {pinnedMessageBanner}
{topUnreadMessagesBar} diff --git a/src/components/views/rooms/PinnedMessageBanner.tsx b/src/components/views/rooms/PinnedMessageBanner.tsx new file mode 100644 index 000000000000..167cd05e062e --- /dev/null +++ b/src/components/views/rooms/PinnedMessageBanner.tsx @@ -0,0 +1,208 @@ +/* + * 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 { useFetchPinnedEvent, usePinnedEvents } 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"; + +/** + * 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 }: PinnedMessageBannerProps): JSX.Element | null { + const pinnedEventIds = usePinnedEvents(room); + const eventCount = pinnedEventIds.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) => Math.min(currentEventIndex, eventCount - 1)); + }, [eventCount]); + + // Fetch the pinned event + const pinnedEvent = useFetchPinnedEvent(room, pinnedEventIds[currentEventIndex]); + // Generate a preview for the pinned event + const eventPreview = useMemo(() => { + if (!pinnedEvent) return null; + return MessagePreviewStore.instance.generatePreviewForEvent(pinnedEvent); + }, [pinnedEvent]); + + if (!pinnedEvent) return null; + + 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 hided + */ + hided: boolean; +} + +/** + * A component that displays a vertical indicator for a pinned message. + */ +function Indicator({ active, hided }: 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 index 94d5e7b84c9d..33731148e4ea 100644 --- a/src/hooks/usePinnedEvents.ts +++ b/src/hooks/usePinnedEvents.ts @@ -23,6 +23,7 @@ import { EventType, RelationType, EventTimeline, + MatrixClient, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -108,6 +109,79 @@ export const useReadPinnedEvents = (room?: Room): Set => { 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 + // 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, 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.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 ${pinnedEventId} in room ${room.roomId}`); + logger.error(err); + } + return null; +} + +/** + * Fetch the pinned event + * @param room + * @param pinnedEventId + */ +export function useFetchPinnedEvent(room: Room, pinnedEventId: string): MatrixEvent | null { + const cli = useMatrixClientContext(); + + return useAsyncMemo(() => fetchPinnedEvent(room, pinnedEventId, cli), [cli, room, pinnedEventId], null); +} + /** * Fetch the pinned events * @param room @@ -119,61 +193,8 @@ export function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Ar 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 fetchPinnedEvent(room, eventId, cli); }); - return Promise.all(promises); }, [cli, room, pinnedEventIds], diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 889fc157e9e2..be1355161ad0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2048,6 +2048,12 @@ "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", + "go_to_message": "Go to the pinned message", + "title": "%(index)s of %(length)s Pinned messages" + }, "read_topic": "Click to read topic", "rejecting": "Rejecting inviteā€¦", "rejoin_button": "Re-join",