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:
@@ -34,7 +34,7 @@ import ThreadView from "./ThreadView";
|
||||
import ThreadPanel from "./ThreadPanel";
|
||||
import NotificationPanel from "./NotificationPanel";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
|
||||
import { PinnedMessagesCard } from "../views/right_panel/PinnedMessagesCard";
|
||||
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
import { E2EStatus } from "../../utils/ShieldUtils";
|
||||
import TimelineCard from "../views/right_panel/TimelineCard";
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { JSX } from "react";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
import { EventType, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import BaseDialog from "../dialogs/BaseDialog";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
/**
|
||||
* Properties for {@link UnpinAllDialog}.
|
||||
*/
|
||||
interface UnpinAllDialogProps {
|
||||
/*
|
||||
* The matrix client to use.
|
||||
*/
|
||||
matrixClient: MatrixClient;
|
||||
/*
|
||||
* The room ID to unpin all events in.
|
||||
*/
|
||||
roomId: string;
|
||||
/*
|
||||
* Callback for when the dialog is closed.
|
||||
*/
|
||||
onFinished: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dialog that asks the user to confirm unpinning all events in a room.
|
||||
*/
|
||||
export function UnpinAllDialog({ matrixClient, roomId, onFinished }: UnpinAllDialogProps): JSX.Element {
|
||||
return (
|
||||
<BaseDialog
|
||||
hasCancel={true}
|
||||
title={_t("right_panel|pinned_messages|unpin_all|title")}
|
||||
titleClass="mx_UnpinAllDialog_title"
|
||||
className="mx_UnpinAllDialog"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<Text as="span">{_t("right_panel|pinned_messages|unpin_all|content")}</Text>
|
||||
<div className="mx_UnpinAllDialog_buttons">
|
||||
<Button
|
||||
destructive={true}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await matrixClient.sendStateEvent(roomId, EventType.RoomPinnedEvents, { pinned: [] }, "");
|
||||
} catch (e) {
|
||||
logger.error("Failed to unpin all events:", e);
|
||||
}
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{_t("action|continue")}
|
||||
</Button>
|
||||
<Button kind="tertiary" onClick={onFinished}>
|
||||
{_t("action|cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,112 +15,206 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MatrixEvent, EventType, RelationType, Relations } from "matrix-js-sdk/src/matrix";
|
||||
import React, { JSX, useCallback, useState } from "react";
|
||||
import { EventTimeline, EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { IconButton, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
|
||||
import { Icon as ViewIcon } from "@vector-im/compound-design-tokens/icons/visibility-on.svg";
|
||||
import { Icon as UnpinIcon } from "@vector-im/compound-design-tokens/icons/unpin.svg";
|
||||
import { Icon as ForwardIcon } from "@vector-im/compound-design-tokens/icons/forward.svg";
|
||||
import { Icon as TriggerIcon } from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg";
|
||||
import { Icon as DeleteIcon } from "@vector-im/compound-design-tokens/icons/delete.svg";
|
||||
import classNames from "classnames";
|
||||
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import MessageEvent from "../messages/MessageEvent";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { formatDate } from "../../../DateUtils";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import { isContentActionable } from "../../../utils/EventUtils";
|
||||
import { getForwardableEvent } from "../../../events";
|
||||
import { OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload";
|
||||
import { createRedactEventDialog } from "../dialogs/ConfirmRedactDialog";
|
||||
|
||||
interface IProps {
|
||||
const AVATAR_SIZE = "32px";
|
||||
|
||||
/**
|
||||
* Properties for {@link PinnedEventTile}.
|
||||
*/
|
||||
interface PinnedEventTileProps {
|
||||
/**
|
||||
* The event to display.
|
||||
*/
|
||||
event: MatrixEvent;
|
||||
/**
|
||||
* The permalink creator to use.
|
||||
*/
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
onUnpinClicked?(): void;
|
||||
/**
|
||||
* The room the event is in.
|
||||
*/
|
||||
room: Room;
|
||||
}
|
||||
|
||||
const AVATAR_SIZE = "24px";
|
||||
/**
|
||||
* A pinned event tile.
|
||||
*/
|
||||
export function PinnedEventTile({ event, room, permalinkCreator }: PinnedEventTileProps): JSX.Element {
|
||||
const sender = event.getSender();
|
||||
if (!sender) {
|
||||
throw new Error("Pinned event unexpectedly has no sender");
|
||||
}
|
||||
|
||||
export default class PinnedEventTile extends React.Component<IProps> {
|
||||
public static contextType = MatrixClientContext;
|
||||
public declare context: React.ContextType<typeof MatrixClientContext>;
|
||||
|
||||
private onTileClicked = (): void => {
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
event_id: this.props.event.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.event.getRoomId(),
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
};
|
||||
|
||||
// For event types like polls that use relations, we fetch those manually on
|
||||
// mount and store them here, exposing them through getRelationsForEvent
|
||||
private relations = new Map<string, Map<string, Relations>>();
|
||||
private getRelationsForEvent = (
|
||||
eventId: string,
|
||||
relationType: RelationType | string,
|
||||
eventType: EventType | string,
|
||||
): Relations | undefined => {
|
||||
if (eventId === this.props.event.getId()) {
|
||||
return this.relations.get(relationType)?.get(eventType);
|
||||
}
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const sender = this.props.event.getSender();
|
||||
|
||||
if (!sender) {
|
||||
throw new Error("Pinned event unexpectedly has no sender");
|
||||
}
|
||||
|
||||
let unpinButton: JSX.Element | undefined;
|
||||
if (this.props.onUnpinClicked) {
|
||||
unpinButton = (
|
||||
<AccessibleButton
|
||||
onClick={this.props.onUnpinClicked}
|
||||
className="mx_PinnedEventTile_unpinButton"
|
||||
title={_t("action|unpin")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_PinnedEventTile">
|
||||
return (
|
||||
<div className="mx_PinnedEventTile" role="listitem">
|
||||
<div>
|
||||
<MemberAvatar
|
||||
className="mx_PinnedEventTile_senderAvatar"
|
||||
member={this.props.event.sender}
|
||||
member={event.sender}
|
||||
size={AVATAR_SIZE}
|
||||
fallbackUserId={sender}
|
||||
/>
|
||||
|
||||
<span className={"mx_PinnedEventTile_sender " + getUserNameColorClass(sender)}>
|
||||
{this.props.event.sender?.name || sender}
|
||||
</span>
|
||||
|
||||
{unpinButton}
|
||||
|
||||
<div className="mx_PinnedEventTile_message">
|
||||
<MessageEvent
|
||||
mxEvent={this.props.event}
|
||||
getRelationsForEvent={this.getRelationsForEvent}
|
||||
// @ts-ignore - complaining that className is invalid when it's not
|
||||
className="mx_PinnedEventTile_body"
|
||||
maxImageHeight={150}
|
||||
onHeightChanged={() => {}} // we need to give this, apparently
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
replacingEventId={this.props.event.replacingEventId()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx_PinnedEventTile_footer">
|
||||
<span className="mx_MessageTimestamp mx_PinnedEventTile_timestamp">
|
||||
{formatDate(new Date(this.props.event.getTs()))}
|
||||
</span>
|
||||
|
||||
<AccessibleButton onClick={this.onTileClicked} kind="link">
|
||||
{_t("common|view_message")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className="mx_PinnedEventTile_wrapper">
|
||||
<div className="mx_PinnedEventTile_top">
|
||||
<Text
|
||||
weight="semibold"
|
||||
className={classNames("mx_PinnedEventTile_sender", getUserNameColorClass(sender))}
|
||||
as="span"
|
||||
>
|
||||
{event.sender?.name || sender}
|
||||
</Text>
|
||||
<PinMenu event={event} room={room} permalinkCreator={permalinkCreator} />
|
||||
</div>
|
||||
<MessageEvent
|
||||
mxEvent={event}
|
||||
maxImageHeight={150}
|
||||
onHeightChanged={() => {}} // we need to give this, apparently
|
||||
permalinkCreator={permalinkCreator}
|
||||
replacingEventId={event.replacingEventId()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties for {@link PinMenu}.
|
||||
*/
|
||||
interface PinMenuProps extends PinnedEventTileProps {}
|
||||
|
||||
/**
|
||||
* A popover menu with actions on the pinned event
|
||||
*/
|
||||
function PinMenu({ event, room, permalinkCreator }: PinMenuProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
/**
|
||||
* View the event in the timeline.
|
||||
*/
|
||||
const onViewInTimeline = useCallback(() => {
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
event_id: event.getId(),
|
||||
highlighted: true,
|
||||
room_id: event.getRoomId(),
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
}, [event]);
|
||||
|
||||
/**
|
||||
* Whether the client can unpin the event.
|
||||
* Pin and unpin are using the same permission.
|
||||
*/
|
||||
const canUnpin = useRoomState(room, (state) =>
|
||||
state.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient),
|
||||
);
|
||||
|
||||
/**
|
||||
* Unpin the event.
|
||||
* @param event
|
||||
*/
|
||||
const onUnpin = useCallback(async (): Promise<void> => {
|
||||
const pinnedEvents = room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.getStateEvents(EventType.RoomPinnedEvents, "");
|
||||
if (pinnedEvents?.getContent()?.pinned) {
|
||||
const pinned = pinnedEvents.getContent().pinned;
|
||||
const index = pinned.indexOf(event.getId());
|
||||
if (index !== -1) {
|
||||
pinned.splice(index, 1);
|
||||
await matrixClient.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, "");
|
||||
}
|
||||
}
|
||||
}, [event, room, matrixClient]);
|
||||
|
||||
const contentActionable = isContentActionable(event);
|
||||
// Get the forwardable event for the given event
|
||||
const forwardableEvent = contentActionable && getForwardableEvent(event, matrixClient);
|
||||
/**
|
||||
* Open the forward dialog.
|
||||
*/
|
||||
const onForward = useCallback(() => {
|
||||
if (forwardableEvent) {
|
||||
dis.dispatch<OpenForwardDialogPayload>({
|
||||
action: Action.OpenForwardDialog,
|
||||
event: forwardableEvent,
|
||||
permalinkCreator: permalinkCreator,
|
||||
});
|
||||
}
|
||||
}, [forwardableEvent, permalinkCreator]);
|
||||
|
||||
/**
|
||||
* Whether the client can redact the event.
|
||||
*/
|
||||
const canRedact =
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.maySendRedactionForEvent(event, matrixClient.getSafeUserId()) &&
|
||||
event.getType() !== EventType.RoomServerAcl &&
|
||||
event.getType() !== EventType.RoomEncryption;
|
||||
|
||||
/**
|
||||
* Redact the event.
|
||||
*/
|
||||
const onRedact = useCallback(
|
||||
(): void =>
|
||||
createRedactEventDialog({
|
||||
mxEvent: event,
|
||||
}),
|
||||
[event],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
showTitle={false}
|
||||
title={_t("right_panel|pinned_messages|menu")}
|
||||
side="right"
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton size="24px" aria-label={_t("right_panel|pinned_messages|menu")}>
|
||||
<TriggerIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuItem Icon={ViewIcon} label={_t("right_panel|pinned_messages|view")} onSelect={onViewInTimeline} />
|
||||
{canUnpin && <MenuItem Icon={UnpinIcon} label={_t("action|unpin")} onSelect={onUnpin} />}
|
||||
{forwardableEvent && <MenuItem Icon={ForwardIcon} label={_t("action|forward")} onSelect={onForward} />}
|
||||
{canRedact && (
|
||||
<>
|
||||
<Separator />
|
||||
<MenuItem kind="critical" Icon={DeleteIcon} label={_t("action|delete")} onSelect={onRedact} />
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1839,12 +1839,24 @@
|
||||
"files_button": "Files",
|
||||
"info": "Info",
|
||||
"pinned_messages": {
|
||||
"empty": "Nothing pinned, yet",
|
||||
"explainer": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
|
||||
"empty_description": "Select a message and choose “%(pinAction)s” to it include here.",
|
||||
"empty_title": "Pin important messages so that they can be easily discovered",
|
||||
"header": {
|
||||
"one": "1 Pinned message",
|
||||
"other": "%(count)s Pinned messages",
|
||||
"zero": "Pinned message"
|
||||
},
|
||||
"limits": {
|
||||
"other": "You can only pin up to %(count)s widgets"
|
||||
},
|
||||
"title": "Pinned messages"
|
||||
"menu": "Open menu",
|
||||
"title": "Pinned messages",
|
||||
"unpin_all": {
|
||||
"button": "Unpin all messages",
|
||||
"content": "Make sure that you really want to remove all pinned messages. This action can’t be undone.",
|
||||
"title": "Unpin all messages?"
|
||||
},
|
||||
"view": "View in timeline"
|
||||
},
|
||||
"pinned_messages_button": "Pinned messages",
|
||||
"poll": {
|
||||
|
||||
Reference in New Issue
Block a user