Display pinned messages on a banner at the top of a room (#12917)
* Move pinned message hooks to a dedicated file * Add a banner at the top of a room to display the pinned messages * Put the pinning banner behind labs pinning labs flag * Add redacted event support * Handle UTD in pinning message banner * Add tests for redaction * Make all the banner clickable * Add tests for PinnedMessageBanner.tsx * Add e2e tests for the pinned message banner * Review changes
This commit is contained in:
parent
8b2ded8a0e
commit
d16ab09866
29 changed files with 1130 additions and 180 deletions
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<string[]>(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<string> {
|
||||
return new Set(room?.getAccountData(ReadPinsEventId)?.getContent()?.event_ids ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the read pinned event IDs from a room.
|
||||
* @param room
|
||||
*/
|
||||
export const useReadPinnedEvents = (room?: Room): Set<string> => {
|
||||
const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
|
||||
|
||||
// Update the read pinned events when the room state changes
|
||||
// Filter out events that are not read pinned events
|
||||
const update = useCallback(
|
||||
(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<MatrixEvent | null> | null {
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
return useAsyncMemo(
|
||||
() => {
|
||||
const promises = pinnedEventIds.map(async (eventId): Promise<MatrixEvent | null> => {
|
||||
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.
|
||||
|
|
|
@ -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;
|
||||
|
|
252
src/components/views/rooms/PinnedMessageBanner.tsx
Normal file
252
src/components/views/rooms/PinnedMessageBanner.tsx
Normal file
|
@ -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<ViewRoomPayload>({
|
||||
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 (
|
||||
<div
|
||||
className="mx_PinnedMessageBanner"
|
||||
data-single-message={isSinglePinnedEvent}
|
||||
aria-label={_t("room|pinned_message_banner|description")}
|
||||
data-testid="pinned-message-banner"
|
||||
>
|
||||
<button
|
||||
aria-label={_t("room|pinned_message_banner|go_to_message")}
|
||||
type="button"
|
||||
className="mx_PinnedMessageBanner_main"
|
||||
onClick={onBannerClick}
|
||||
>
|
||||
<div className="mx_PinnedMessageBanner_content">
|
||||
{!isSinglePinnedEvent && <Indicators count={eventCount} currentIndex={currentEventIndex} />}
|
||||
<PinIcon width="20" className="mx_PinnedMessageBanner_PinIcon" />
|
||||
{!isSinglePinnedEvent && (
|
||||
<div className="mx_PinnedMessageBanner_title" data-testid="banner-counter">
|
||||
{_t(
|
||||
"room|pinned_message_banner|title",
|
||||
{
|
||||
index: currentEventIndex + 1,
|
||||
length: eventCount,
|
||||
},
|
||||
{ bold: (sub) => <span className="mx_PinnedMessageBanner_title_counter">{sub}</span> },
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{eventPreview && <span className="mx_PinnedMessageBanner_message">{eventPreview}</span>}
|
||||
{/* In case of redacted event, we want to display the nice sentence of the message event like in the timeline or in the pinned message list */}
|
||||
{shouldUseMessageEvent && (
|
||||
<div className="mx_PinnedMessageBanner_redactedMessage">
|
||||
<MessageEvent
|
||||
mxEvent={pinnedEvent}
|
||||
maxImageHeight={20}
|
||||
permalinkCreator={permalinkCreator}
|
||||
replacingEventId={pinnedEvent.replacingEventId()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{!isSinglePinnedEvent && <BannerButton room={room} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mx_PinnedMessageBanner_Indicators">
|
||||
{Array.from({ length: numberOfIndicators }).map((_, i) => (
|
||||
<Indicator key={i} active={i === index} hidden={isLastCycle && lastCycleIndex <= i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
data-testid="banner-indicator"
|
||||
className={classNames("mx_PinnedMessageBanner_Indicator", {
|
||||
"mx_PinnedMessageBanner_Indicator--active": active,
|
||||
"mx_PinnedMessageBanner_Indicator--hidden": hidden,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<RightPanelPhases | null>(getRightPanelPhase(room.roomId));
|
||||
useEventEmitter(RightPanelStore.instance, UPDATE_EVENT, () => setCurrentPhase(getRightPanelPhase(room.roomId)));
|
||||
const isPinnedMessagesPhase = currentPhase === RightPanelPhases.PinnedMessages;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="mx_PinnedMessageBanner_actions"
|
||||
kind="tertiary"
|
||||
onClick={() => {
|
||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.PinnedMessages);
|
||||
}}
|
||||
>
|
||||
{isPinnedMessagesPhase
|
||||
? _t("room|pinned_message_banner|button_close_list")
|
||||
: _t("room|pinned_message_banner|button_view_all")}
|
||||
</Button>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue