Message Pinning: rework the message pinning list in the right panel (#12825)

* Fix pinning event loading after restart

* Update deps

* Replace pinned event list

* Add a dialog to confirm to unpin all messages

* Use `EmptyState` when there is no pinned messages

* Rework `PinnedEventTile` tests

* Add comments and refactor `PinnedMessageCard`

* Rework `PinnedMessageCard` tests

* Add tests for `UnpinAllDialog`

* Add e2e tests for pinned messages

* Replace 3px custom gap by 4px gap

* Use string interpolation for `Pin` action.

* Update playright sceenshot for empty state
This commit is contained in:
Florian Duros 2024-08-16 14:16:06 +02:00 committed by GitHub
parent 88cf643cbd
commit 6f3dc30693
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2099 additions and 507 deletions

View file

@ -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<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;
@ -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<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;
@ -92,36 +123,36 @@ export const useReadPinnedEvents = (room?: Room): Set<string> => {
return readPinnedEvents;
};
const PinnedMessagesCard: React.FC<IProps> = ({ 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<MatrixEvent | null> | 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<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 {
// 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<IProps> = ({ 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<IProps> = ({ 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 = (
<div className="mx_PinnedMessagesCard_empty_wrapper">
<div className="mx_PinnedMessagesCard_empty">
{/* XXX: We reuse the classes for simplicity, but deliberately not the components for non-interactivity. */}
<div className="mx_MessageActionBar mx_PinnedMessagesCard_MessageActionBar">
<div className="mx_MessageActionBar_iconButton">
<EmojiIcon />
</div>
<div className="mx_MessageActionBar_iconButton">
<ReplyIcon />
</div>
<div className="mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton">
<ContextMenuIcon />
</div>
</div>
<Heading size="4" className="mx_PinnedMessagesCard_empty_header">
{_t("right_panel|pinned_messages|empty")}
</Heading>
{_t(
"right_panel|pinned_messages|explainer",
{},
{
b: (sub) => <b>{sub}</b>,
},
)}
</div>
</div>
<EmptyState
Icon={PinIcon}
title={_t("right_panel|pinned_messages|empty_title")}
description={_t("right_panel|pinned_messages|empty_description", {
pinAction: _t("action|pin"),
})}
/>
);
} else if (pinnedEvents?.length) {
const onUnpinClicked = async (event: MatrixEvent): Promise<void> => {
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) => (
<PinnedEventTile
key={ev.getId()}
event={ev}
onUnpinClicked={canUnpin ? () => onUnpinClicked(ev) : undefined}
permalinkCreator={permalinkCreator}
/>
));
content = (
<PinnedMessages events={filterBoolean(pinnedEvents)} room={room} permalinkCreator={permalinkCreator} />
);
} else {
content = <Spinner />;
}
@ -223,7 +256,7 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
header={
<div className="mx_BaseCard_header_title">
<Heading size="4" className="mx_BaseCard_header_title_heading">
{_t("right_panel|pinned_messages|title")}
{_t("right_panel|pinned_messages|header", { count: pinnedEventIds.length })}
</Heading>
</div>
}
@ -240,6 +273,79 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
</RoomContext.Provider>
</BaseCard>
);
};
}
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<void> => {
Modal.createDialog(UnpinAllDialog, {
roomId: room.roomId,
matrixClient,
});
}, [room, matrixClient]);
return (
<>
<div
className={classNames("mx_PinnedMessagesCard_wrapper", {
mx_PinnedMessagesCard_wrapper_unpin_all: canUnpin,
})}
role="list"
>
{events.reverse().map((event, i) => (
<>
<PinnedEventTile
key={event.getId()}
event={event}
permalinkCreator={permalinkCreator}
room={room}
/>
{/* Add a separator if this isn't the last pinned message */}
{events.length - 1 !== i && (
<Separator key={`separator-${event.getId()}`} className="mx_PinnedMessagesCard_Separator" />
)}
</>
))}
</div>
{canUnpin && (
<div className="mx_PinnedMessagesCard_unpin">
<Button kind="tertiary" onClick={onUnpinAll}>
{_t("right_panel|pinned_messages|unpin_all|button")}
</Button>
</div>
)}
</>
);
}