Refactor EventTile using the MVVM pattern - #7b (#33668)
* Normalize Leaf Adapter Pattern * Extract simple EventTile adapters * Move simple EventTile adapter VM ownership to EventTileViewModel * Release listener-owning EventTile child VMs on adapter unmount * Extract stateful EventTile adapters * Move action bar VM ownership to EventTileViewModel * Move reactions row VM ownership into EventTileViewModel * Extract EventTile receipt rendering * Extract EventTile sender identity rendering * Add missing TSDoc on adapters
This commit is contained in:
@@ -9,18 +9,13 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
createRef,
|
createRef,
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
type JSX,
|
type JSX,
|
||||||
type Ref,
|
type Ref,
|
||||||
type FocusEvent,
|
type FocusEvent,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import classNames from "classnames";
|
|
||||||
import {
|
import {
|
||||||
type EventStatus,
|
type EventStatus,
|
||||||
EventType,
|
EventType,
|
||||||
@@ -28,7 +23,6 @@ import {
|
|||||||
MatrixEventEvent,
|
MatrixEventEvent,
|
||||||
type Relations,
|
type Relations,
|
||||||
type Room,
|
type Room,
|
||||||
RelationsEvent,
|
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
type RoomMember,
|
type RoomMember,
|
||||||
type Thread,
|
type Thread,
|
||||||
@@ -36,22 +30,9 @@ import {
|
|||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call";
|
import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call";
|
||||||
import { Tooltip } from "@vector-im/compound-web";
|
import { uniqueId } from "lodash";
|
||||||
import { uniqueId, uniqBy } from "lodash";
|
import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
import { useCreateAutoDisposedViewModel, PinnedMessageBadge, TileErrorView } from "@element-hq/web-shared-components";
|
||||||
import {
|
|
||||||
useCreateAutoDisposedViewModel,
|
|
||||||
ActionBarView,
|
|
||||||
E2eMessageSharedIconView,
|
|
||||||
MessageTimestampView,
|
|
||||||
PinnedMessageBadge,
|
|
||||||
ReactionsRowButtonView,
|
|
||||||
ReactionsRowView,
|
|
||||||
ThreadMessagePreviewView,
|
|
||||||
ThreadSummaryView,
|
|
||||||
TileErrorView,
|
|
||||||
useViewModel,
|
|
||||||
} from "@element-hq/web-shared-components";
|
|
||||||
|
|
||||||
import ReplyChain from "../elements/ReplyChain";
|
import ReplyChain from "../elements/ReplyChain";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
@@ -60,22 +41,16 @@ import { Layout } from "../../../settings/enums/Layout";
|
|||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import RoomAvatar from "../avatars/RoomAvatar";
|
import RoomAvatar from "../avatars/RoomAvatar";
|
||||||
import MessageContextMenu from "../context_menus/MessageContextMenu";
|
import MessageContextMenu from "../context_menus/MessageContextMenu";
|
||||||
import ContextMenu, { aboveLeftOf, aboveRightOf } from "../../structures/ContextMenu";
|
import { aboveRightOf } from "../../structures/ContextMenu";
|
||||||
import { objectHasDiff } from "../../../utils/objects";
|
import { objectHasDiff } from "../../../utils/objects";
|
||||||
import type EditorStateTransfer from "../../../utils/EditorStateTransfer";
|
import type EditorStateTransfer from "../../../utils/EditorStateTransfer";
|
||||||
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||||
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
|
|
||||||
import NotificationBadge from "./NotificationBadge";
|
|
||||||
import type LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper";
|
import type LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper";
|
||||||
import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import PlatformPeg from "../../../PlatformPeg";
|
import PlatformPeg from "../../../PlatformPeg";
|
||||||
import MemberAvatar from "../avatars/MemberAvatar";
|
|
||||||
import SenderProfile from "../messages/SenderProfile";
|
|
||||||
import { type IReadReceiptPosition } from "./ReadReceiptMarker";
|
import { type IReadReceiptPosition } from "./ReadReceiptMarker";
|
||||||
import ReactionPicker from "../emojipicker/ReactionPicker";
|
|
||||||
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
|
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
|
||||||
import { isContentActionable } from "../../../utils/EventUtils";
|
|
||||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||||
import { copyPlaintext } from "../../../utils/strings";
|
import { copyPlaintext } from "../../../utils/strings";
|
||||||
@@ -83,22 +58,25 @@ import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
|||||||
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import PosthogTrackers from "../../../PosthogTrackers";
|
import PosthogTrackers from "../../../PosthogTrackers";
|
||||||
import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory";
|
import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory";
|
||||||
import { ReadReceiptGroup } from "./ReadReceiptGroup";
|
|
||||||
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
||||||
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
||||||
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
||||||
import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
|
|
||||||
import PinningUtils from "../../../utils/PinningUtils";
|
import PinningUtils from "../../../utils/PinningUtils";
|
||||||
import { EventPreview } from "./EventPreview";
|
import { EventPreview } from "./EventPreview";
|
||||||
|
import { ActionBarAdapter } from "./EventTile/ActionBarAdapter";
|
||||||
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter";
|
||||||
import { CardContext } from "../right_panel/context";
|
import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter";
|
||||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
import { ReactionsRowAdapter } from "./EventTile/ReactionsRowAdapter";
|
||||||
|
import { ReceiptAdapter } from "./EventTile/ReceiptAdapter";
|
||||||
|
import { EventTileAvatarAdapter, EventTileSenderAdapter } from "./EventTile/SenderIdentityAdapter";
|
||||||
|
import { ThreadListActionBarAdapter } from "./EventTile/ThreadListActionBarAdapter";
|
||||||
|
import { ThreadMessagePreviewAdapter } from "./EventTile/ThreadMessagePreviewAdapter";
|
||||||
|
import { ThreadSummaryAdapter } from "./EventTile/ThreadSummaryAdapter";
|
||||||
import {
|
import {
|
||||||
EventTileViewModel,
|
EventTileViewModel,
|
||||||
type EventTileViewModelProps,
|
type EventTileViewModelProps,
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
} from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
import { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel";
|
|
||||||
import {
|
import {
|
||||||
getEventTileReceiptState,
|
getEventTileReceiptState,
|
||||||
type EventTileReceiptState,
|
type EventTileReceiptState,
|
||||||
@@ -121,28 +99,13 @@ import {
|
|||||||
initialEventTileInteractionState,
|
initialEventTileInteractionState,
|
||||||
type EventTileInteractionState,
|
type EventTileInteractionState,
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/EventTileInteractionState";
|
} from "../../../viewmodels/room/timeline/event-tile/EventTileInteractionState";
|
||||||
import {
|
import { type MessageTimestampViewModelProps } from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts";
|
||||||
type MessageTimestampViewModel,
|
|
||||||
type MessageTimestampViewModelProps,
|
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts";
|
|
||||||
import {
|
|
||||||
ThreadMessagePreviewViewModel,
|
|
||||||
ThreadSummaryViewModel,
|
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx";
|
|
||||||
import { ReactionsRowButtonViewModel } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel";
|
|
||||||
import {
|
|
||||||
MAX_ITEMS_WHEN_LIMITED,
|
|
||||||
ReactionsRowViewModel,
|
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel";
|
|
||||||
import {
|
import {
|
||||||
getEventTileReactionRelations,
|
getEventTileReactionRelations,
|
||||||
isEventTileReactionRelation,
|
isEventTileReactionRelation,
|
||||||
type GetRelationsForEvent,
|
type GetRelationsForEvent,
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
} from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
||||||
import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel";
|
import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel";
|
||||||
import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel";
|
|
||||||
import { type ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel";
|
|
||||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
|
||||||
import { useSettingValue } from "../../../hooks/useSettings";
|
import { useSettingValue } from "../../../hooks/useSettings";
|
||||||
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
|
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
|
||||||
import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel";
|
import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel";
|
||||||
@@ -513,7 +476,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
<div className="mx_ThreadPanel_replies">
|
<div className="mx_ThreadPanel_replies">
|
||||||
<ThreadsIcon />
|
<ThreadsIcon />
|
||||||
<span className="mx_ThreadPanel_replies_amount">{threadState.thread.length}</span>
|
<span className="mx_ThreadPanel_replies_amount">{threadState.thread.length}</span>
|
||||||
<ThreadMessagePreviewWrapper thread={threadState.thread} />
|
<ThreadMessagePreviewAdapter eventTileViewModel={this.viewModel} thread={threadState.thread} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -521,7 +484,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode {
|
private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode {
|
||||||
if (threadState.shouldShowThreadSummary && threadState.thread) {
|
if (threadState.shouldShowThreadSummary && threadState.thread) {
|
||||||
return (
|
return (
|
||||||
<ThreadSummaryWrapper
|
<ThreadSummaryAdapter
|
||||||
|
eventTileViewModel={this.viewModel}
|
||||||
mxEvent={this.props.mxEvent}
|
mxEvent={this.props.mxEvent}
|
||||||
thread={threadState.thread}
|
thread={threadState.thread}
|
||||||
data-testid="thread-summary"
|
data-testid="thread-summary"
|
||||||
@@ -712,7 +676,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
return null;
|
return null;
|
||||||
case "messageShared":
|
case "messageShared":
|
||||||
return (
|
return (
|
||||||
<E2eMessageSharedIconWrapper
|
<E2eMessageSharedIconAdapter
|
||||||
|
eventTileViewModel={this.viewModel}
|
||||||
keyForwardingUserId={e2ePadlockViewState.keyForwardingUserId}
|
keyForwardingUserId={e2ePadlockViewState.keyForwardingUserId}
|
||||||
roomId={e2ePadlockViewState.roomId}
|
roomId={e2ePadlockViewState.roomId}
|
||||||
/>
|
/>
|
||||||
@@ -1041,34 +1006,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
// Local echos have a send "status".
|
// Local echos have a send "status".
|
||||||
const scrollToken = eventTileRenderState.root.scrollToken;
|
const scrollToken = eventTileRenderState.root.scrollToken;
|
||||||
|
|
||||||
let avatar: JSX.Element | null = null;
|
const avatar = (
|
||||||
let sender: JSX.Element | null = null;
|
<EventTileAvatarAdapter mxEvent={this.props.mxEvent} senderSnapshot={eventTileSnapshot.sender} />
|
||||||
const { avatarSize } = eventTileSnapshot.sender.profileState;
|
);
|
||||||
|
const sender = (
|
||||||
if (this.props.mxEvent.sender && avatarSize !== null) {
|
<EventTileSenderAdapter
|
||||||
avatar = (
|
mxEvent={this.props.mxEvent}
|
||||||
<div className="mx_EventTile_avatar">
|
senderSnapshot={eventTileSnapshot.sender}
|
||||||
<MemberAvatar
|
onSenderProfileClick={this.onSenderProfileClick}
|
||||||
member={eventTileSnapshot.sender.avatarMember}
|
/>
|
||||||
size={avatarSize}
|
);
|
||||||
viewUserOnClick={eventTileSnapshot.sender.viewUserOnClick}
|
|
||||||
forceHistorical={this.props.mxEvent.getType() === EventType.RoomMember}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const senderProfileMode = eventTileSnapshot.sender.profileMode;
|
|
||||||
if (senderProfileMode === "clickable") {
|
|
||||||
sender = <SenderProfile onClick={this.onSenderProfileClick} mxEvent={this.props.mxEvent} />;
|
|
||||||
} else if (senderProfileMode === "tooltip") {
|
|
||||||
sender = <SenderProfile mxEvent={this.props.mxEvent} withTooltip />;
|
|
||||||
} else if (senderProfileMode === "default") {
|
|
||||||
sender = <SenderProfile mxEvent={this.props.mxEvent} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionBar = eventTileSnapshot.actionBar.show ? (
|
const actionBar = eventTileSnapshot.actionBar.show ? (
|
||||||
<ActionBarWrapper
|
<ActionBarAdapter
|
||||||
|
eventTileViewModel={this.viewModel}
|
||||||
mxEvent={this.props.mxEvent}
|
mxEvent={this.props.mxEvent}
|
||||||
reactions={this.state.reactions}
|
reactions={this.state.reactions}
|
||||||
permalinkCreator={this.props.permalinkCreator}
|
permalinkCreator={this.props.permalinkCreator}
|
||||||
@@ -1093,7 +1044,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
) : null;
|
) : null;
|
||||||
const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp ? (
|
const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp ? (
|
||||||
<MessageTimestampAdapter
|
<MessageTimestampAdapter
|
||||||
vm={this.viewModel.getMessageTimestampViewModel(messageTimestampProps)}
|
eventTileViewModel={this.viewModel}
|
||||||
|
kind="plain"
|
||||||
timestampProps={messageTimestampProps}
|
timestampProps={messageTimestampProps}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1101,7 +1053,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
);
|
);
|
||||||
const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp ? (
|
const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp ? (
|
||||||
<MessageTimestampAdapter
|
<MessageTimestampAdapter
|
||||||
vm={this.viewModel.getLinkedMessageTimestampViewModel(linkedMessageTimestampProps)}
|
eventTileViewModel={this.viewModel}
|
||||||
|
kind="linked"
|
||||||
timestampProps={linkedMessageTimestampProps}
|
timestampProps={linkedMessageTimestampProps}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1116,7 +1069,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
let reactionsRow: JSX.Element | undefined;
|
let reactionsRow: JSX.Element | undefined;
|
||||||
if (hasReactionsRow) {
|
if (hasReactionsRow) {
|
||||||
reactionsRow = (
|
reactionsRow = (
|
||||||
<ReactionsRowWrapper
|
<ReactionsRowAdapter
|
||||||
|
eventTileViewModel={this.viewModel}
|
||||||
mxEvent={this.props.mxEvent}
|
mxEvent={this.props.mxEvent}
|
||||||
reactions={this.state.reactions}
|
reactions={this.state.reactions}
|
||||||
key="mx_EventTile_reactionsRow"
|
key="mx_EventTile_reactionsRow"
|
||||||
@@ -1129,21 +1083,18 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
const groupPadlock = eventTileRenderState.e2ePadlock.showInGroupLine && this.renderE2EPadlock();
|
const groupPadlock = eventTileRenderState.e2ePadlock.showInGroupLine && this.renderE2EPadlock();
|
||||||
const ircPadlock = eventTileRenderState.e2ePadlock.showInIrcLine && this.renderE2EPadlock();
|
const ircPadlock = eventTileRenderState.e2ePadlock.showInIrcLine && this.renderE2EPadlock();
|
||||||
|
|
||||||
const receiptState = this.receiptState;
|
const msgOption = (
|
||||||
let msgOption: JSX.Element | undefined;
|
<ReceiptAdapter
|
||||||
if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) {
|
receiptState={this.receiptState}
|
||||||
msgOption = <SentReceipt messageState={this.props.eventSendStatus} />;
|
eventSendStatus={this.props.eventSendStatus}
|
||||||
} else if (this.props.showReadReceipts) {
|
showReadReceipts={this.props.showReadReceipts}
|
||||||
msgOption = (
|
readReceipts={this.props.readReceipts}
|
||||||
<ReadReceiptGroup
|
readReceiptMap={this.props.readReceiptMap}
|
||||||
readReceipts={this.props.readReceipts ?? []}
|
checkUnmounting={this.props.checkUnmounting}
|
||||||
readReceiptMap={this.props.readReceiptMap ?? {}}
|
suppressAnimation={this.suppressReadReceiptAnimation}
|
||||||
checkUnmounting={this.props.checkUnmounting}
|
isTwelveHour={this.props.isTwelveHour}
|
||||||
suppressAnimation={this.suppressReadReceiptAnimation}
|
/>
|
||||||
isTwelveHour={this.props.isTwelveHour}
|
);
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const replyChainState = getEventTileReplyChainState({
|
const replyChainState = getEventTileReplyChainState({
|
||||||
mxEvent: this.props.mxEvent,
|
mxEvent: this.props.mxEvent,
|
||||||
@@ -1315,10 +1266,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
</div>
|
</div>
|
||||||
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
|
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
|
||||||
<ThreadListActionBarAdapter
|
<ThreadListActionBarAdapter
|
||||||
vm={this.viewModel.getThreadListActionBarViewModel({
|
eventTileViewModel={this.viewModel}
|
||||||
onViewInRoomClick: this.onViewInRoomClick,
|
|
||||||
onCopyLinkClick: this.onCopyLinkToThreadClick,
|
|
||||||
})}
|
|
||||||
onViewInRoomClick={this.onViewInRoomClick}
|
onViewInRoomClick={this.onViewInRoomClick}
|
||||||
onCopyLinkClick={this.onCopyLinkToThreadClick}
|
onCopyLinkClick={this.onCopyLinkToThreadClick}
|
||||||
className="mx_ThreadActionBar"
|
className="mx_ThreadActionBar"
|
||||||
@@ -1513,585 +1461,3 @@ const SafeEventTile = (props: EventTileProps): JSX.Element => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default SafeEventTile;
|
export default SafeEventTile;
|
||||||
|
|
||||||
interface ISentReceiptProps {
|
|
||||||
messageState: EventStatus | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element {
|
|
||||||
const isSent = !messageState || messageState === "sent";
|
|
||||||
const isFailed = messageState === "not_sent";
|
|
||||||
|
|
||||||
let icon: JSX.Element | undefined;
|
|
||||||
let label: string | undefined;
|
|
||||||
if (messageState === "encrypting") {
|
|
||||||
icon = <CircleIcon />;
|
|
||||||
label = _t("timeline|send_state_encrypting");
|
|
||||||
} else if (isSent) {
|
|
||||||
icon = <CheckCircleIcon />;
|
|
||||||
label = _t("timeline|send_state_sent");
|
|
||||||
} else if (isFailed) {
|
|
||||||
icon = <NotificationBadge notification={StaticNotificationState.RED_EXCLAMATION} />;
|
|
||||||
label = _t("timeline|send_state_failed");
|
|
||||||
} else {
|
|
||||||
icon = <CircleIcon />;
|
|
||||||
label = _t("timeline|send_state_sending");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx_EventTile_msgOption">
|
|
||||||
<div className="mx_ReadReceiptGroup">
|
|
||||||
<Tooltip label={label} placement="top-end">
|
|
||||||
<div className="mx_ReadReceiptGroup_button" role="status">
|
|
||||||
<span className="mx_ReadReceiptGroup_container">{icon}</span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageTimestampAdapterProps {
|
|
||||||
vm: MessageTimestampViewModel;
|
|
||||||
timestampProps: MessageTimestampViewModelProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageTimestampAdapter({ vm, timestampProps }: Readonly<MessageTimestampAdapterProps>): JSX.Element {
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setProps(timestampProps);
|
|
||||||
}, [vm, timestampProps]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{timestampProps.receivedTs ? (
|
|
||||||
<LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" />
|
|
||||||
) : undefined}
|
|
||||||
<MessageTimestampView vm={vm} className="mx_MessageTimestamp" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThreadMessagePreviewWrapperProps {
|
|
||||||
thread: Thread;
|
|
||||||
showDisplayName?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThreadMessagePreviewWrapper({
|
|
||||||
thread,
|
|
||||||
showDisplayName = false,
|
|
||||||
}: Readonly<ThreadMessagePreviewWrapperProps>): JSX.Element {
|
|
||||||
const cli = useMatrixClientContext();
|
|
||||||
const { room, timelineRenderingType, lowBandwidth } = useScopedRoomContext(
|
|
||||||
"room",
|
|
||||||
"timelineRenderingType",
|
|
||||||
"lowBandwidth",
|
|
||||||
);
|
|
||||||
const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles");
|
|
||||||
const vm = useCreateAutoDisposedViewModel(
|
|
||||||
() =>
|
|
||||||
new ThreadMessagePreviewViewModel({
|
|
||||||
cli,
|
|
||||||
thread,
|
|
||||||
room,
|
|
||||||
timelineRenderingType,
|
|
||||||
lowBandwidth,
|
|
||||||
useOnlyCurrentProfiles,
|
|
||||||
showDisplayName,
|
|
||||||
avatarClassName: "mx_BaseAvatar",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setClient(cli);
|
|
||||||
vm.setThread(thread);
|
|
||||||
vm.setRoom(room);
|
|
||||||
vm.setTimelineRenderingType(timelineRenderingType);
|
|
||||||
vm.setLowBandwidth(lowBandwidth);
|
|
||||||
vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles);
|
|
||||||
vm.setShowDisplayName(showDisplayName);
|
|
||||||
}, [vm, cli, thread, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles, showDisplayName]);
|
|
||||||
|
|
||||||
return <ThreadMessagePreviewView vm={vm} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThreadSummaryWrapperProps extends Omit<React.ComponentPropsWithoutRef<"button">, "aria-label" | "onClick"> {
|
|
||||||
mxEvent: MatrixEvent;
|
|
||||||
thread: Thread;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThreadSummaryWrapper({
|
|
||||||
mxEvent,
|
|
||||||
thread,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: Readonly<ThreadSummaryWrapperProps>): JSX.Element {
|
|
||||||
const cli = useMatrixClientContext();
|
|
||||||
const { isCard } = useContext(CardContext);
|
|
||||||
const { narrow, room, timelineRenderingType, lowBandwidth } = useScopedRoomContext(
|
|
||||||
"narrow",
|
|
||||||
"room",
|
|
||||||
"timelineRenderingType",
|
|
||||||
"lowBandwidth",
|
|
||||||
);
|
|
||||||
const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles");
|
|
||||||
const vm = useCreateAutoDisposedViewModel(
|
|
||||||
() =>
|
|
||||||
new ThreadSummaryViewModel({
|
|
||||||
cli,
|
|
||||||
mxEvent,
|
|
||||||
thread,
|
|
||||||
narrow,
|
|
||||||
isCard,
|
|
||||||
room,
|
|
||||||
timelineRenderingType,
|
|
||||||
lowBandwidth,
|
|
||||||
useOnlyCurrentProfiles,
|
|
||||||
avatarClassName: "mx_BaseAvatar",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setClient(cli);
|
|
||||||
vm.setRootEvent(mxEvent);
|
|
||||||
vm.setThread(thread);
|
|
||||||
vm.setNarrow(narrow);
|
|
||||||
vm.setIsCard(isCard);
|
|
||||||
vm.setRoom(room);
|
|
||||||
vm.setTimelineRenderingType(timelineRenderingType);
|
|
||||||
vm.setLowBandwidth(lowBandwidth);
|
|
||||||
vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles);
|
|
||||||
}, [vm, cli, mxEvent, thread, narrow, isCard, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles]);
|
|
||||||
|
|
||||||
return <ThreadSummaryView {...props} vm={vm} className={classNames("mx_ThreadSummary", className)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThreadListActionBarAdapterProps {
|
|
||||||
vm: ThreadListActionBarViewModel;
|
|
||||||
onViewInRoomClick: (anchor: HTMLElement | null) => void;
|
|
||||||
onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise<void>;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThreadListActionBarAdapter({
|
|
||||||
vm,
|
|
||||||
onViewInRoomClick,
|
|
||||||
onCopyLinkClick,
|
|
||||||
className,
|
|
||||||
}: Readonly<ThreadListActionBarAdapterProps>): JSX.Element {
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setProps({
|
|
||||||
onViewInRoomClick,
|
|
||||||
onCopyLinkClick,
|
|
||||||
});
|
|
||||||
}, [vm, onViewInRoomClick, onCopyLinkClick]);
|
|
||||||
|
|
||||||
return <ActionBarView vm={vm} className={className} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReactionsRowButtonItemProps {
|
|
||||||
mxEvent: MatrixEvent;
|
|
||||||
content: string;
|
|
||||||
count: number;
|
|
||||||
reactionEvents: MatrixEvent[];
|
|
||||||
myReactionEvent?: MatrixEvent;
|
|
||||||
disabled?: boolean;
|
|
||||||
customReactionImagesEnabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReactionsRowButtonItem(props: Readonly<ReactionsRowButtonItemProps>): JSX.Element {
|
|
||||||
const client = useMatrixClientContext();
|
|
||||||
|
|
||||||
const vm = useCreateAutoDisposedViewModel(
|
|
||||||
() =>
|
|
||||||
new ReactionsRowButtonViewModel({
|
|
||||||
client,
|
|
||||||
mxEvent: props.mxEvent,
|
|
||||||
content: props.content,
|
|
||||||
count: props.count,
|
|
||||||
reactionEvents: props.reactionEvents,
|
|
||||||
myReactionEvent: props.myReactionEvent,
|
|
||||||
disabled: props.disabled,
|
|
||||||
customReactionImagesEnabled: props.customReactionImagesEnabled,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled);
|
|
||||||
}, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setCount(props.count);
|
|
||||||
}, [props.count, vm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setMyReactionEvent(props.myReactionEvent);
|
|
||||||
}, [props.myReactionEvent, vm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setDisabled(props.disabled);
|
|
||||||
}, [props.disabled, vm]);
|
|
||||||
|
|
||||||
return <ReactionsRowButtonView vm={vm} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReactionGroup {
|
|
||||||
content: string;
|
|
||||||
events: MatrixEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] =>
|
|
||||||
reactions
|
|
||||||
?.getSortedAnnotationsByKey()
|
|
||||||
?.map(([content, events]) => ({
|
|
||||||
content,
|
|
||||||
events: [...events],
|
|
||||||
}))
|
|
||||||
.filter(({ events }) => events.length > 0) ?? [];
|
|
||||||
|
|
||||||
const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => {
|
|
||||||
if (!reactions || !userId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const myReactions = reactions.getAnnotationsBySender()?.[userId];
|
|
||||||
if (!myReactions) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...myReactions.values()];
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ReactionsRowWrapperProps {
|
|
||||||
mxEvent: MatrixEvent;
|
|
||||||
reactions?: Relations | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReactionsRowWrapper({ mxEvent, reactions }: Readonly<ReactionsRowWrapperProps>): JSX.Element | null {
|
|
||||||
const roomContext = useContext(RoomContext);
|
|
||||||
const userId = roomContext.room?.client.getUserId() ?? undefined;
|
|
||||||
const [reactionGroups, setReactionGroups] = useState<ReactionGroup[]>(() => getReactionGroups(reactions));
|
|
||||||
const [myReactions, setMyReactions] = useState<MatrixEvent[] | null>(() => getMyReactions(reactions, userId));
|
|
||||||
const [menuDisplayed, setMenuDisplayed] = useState(false);
|
|
||||||
const [menuAnchorRect, setMenuAnchorRect] = useState<DOMRect | null>(null);
|
|
||||||
|
|
||||||
const vm = useCreateAutoDisposedViewModel(
|
|
||||||
() =>
|
|
||||||
new ReactionsRowViewModel({
|
|
||||||
isActionable: isContentActionable(mxEvent),
|
|
||||||
reactionGroupCount: reactionGroups.length,
|
|
||||||
canReact: roomContext.canReact,
|
|
||||||
addReactionButtonActive: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const openReactionMenu = useCallback((event: React.MouseEvent<HTMLButtonElement>): void => {
|
|
||||||
setMenuAnchorRect(event.currentTarget.getBoundingClientRect());
|
|
||||||
setMenuDisplayed(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeReactionMenu = useCallback((): void => {
|
|
||||||
setMenuDisplayed(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateReactionsState = useCallback((): void => {
|
|
||||||
const nextReactionGroups = getReactionGroups(reactions);
|
|
||||||
setReactionGroups(nextReactionGroups);
|
|
||||||
setMyReactions(getMyReactions(reactions, userId));
|
|
||||||
vm.setReactionGroupCount(nextReactionGroups.length);
|
|
||||||
}, [reactions, userId, vm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setActionable(isContentActionable(mxEvent));
|
|
||||||
}, [mxEvent, vm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setCanReact(roomContext.canReact);
|
|
||||||
if (!roomContext.canReact && menuDisplayed) {
|
|
||||||
setMenuDisplayed(false);
|
|
||||||
}
|
|
||||||
}, [roomContext.canReact, menuDisplayed, vm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setAddReactionHandlers({
|
|
||||||
onAddReactionClick: openReactionMenu,
|
|
||||||
onAddReactionContextMenu: openReactionMenu,
|
|
||||||
});
|
|
||||||
}, [openReactionMenu, vm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setAddReactionButtonActive(menuDisplayed);
|
|
||||||
}, [menuDisplayed, vm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateReactionsState();
|
|
||||||
}, [updateReactionsState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!reactions) return;
|
|
||||||
|
|
||||||
reactions.on(RelationsEvent.Add, updateReactionsState);
|
|
||||||
reactions.on(RelationsEvent.Remove, updateReactionsState);
|
|
||||||
reactions.on(RelationsEvent.Redaction, updateReactionsState);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
reactions.off(RelationsEvent.Add, updateReactionsState);
|
|
||||||
reactions.off(RelationsEvent.Remove, updateReactionsState);
|
|
||||||
reactions.off(RelationsEvent.Redaction, updateReactionsState);
|
|
||||||
};
|
|
||||||
}, [reactions, updateReactionsState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onDecrypted = (): void => {
|
|
||||||
vm.setActionable(isContentActionable(mxEvent));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) {
|
|
||||||
mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted);
|
|
||||||
};
|
|
||||||
}, [mxEvent, vm]);
|
|
||||||
|
|
||||||
const snapshot = useViewModel(vm);
|
|
||||||
const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images");
|
|
||||||
const items = useMemo((): JSX.Element[] | undefined => {
|
|
||||||
const mappedItems = reactionGroups.map(({ content, events }) => {
|
|
||||||
// Deduplicate reaction events by sender per Matrix spec.
|
|
||||||
const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender());
|
|
||||||
const myReactionEvent = myReactions?.find((reactionEvent) => {
|
|
||||||
if (reactionEvent.isRedacted()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return reactionEvent.getRelation()?.key === content;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReactionsRowButtonItem
|
|
||||||
key={content}
|
|
||||||
content={content}
|
|
||||||
count={deduplicatedEvents.length}
|
|
||||||
mxEvent={mxEvent}
|
|
||||||
reactionEvents={deduplicatedEvents}
|
|
||||||
myReactionEvent={myReactionEvent}
|
|
||||||
customReactionImagesEnabled={customReactionImagesEnabled}
|
|
||||||
disabled={
|
|
||||||
!roomContext.canReact ||
|
|
||||||
(myReactionEvent && !myReactionEvent.isRedacted() && !roomContext.canSelfRedact)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!mappedItems.length) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems;
|
|
||||||
}, [
|
|
||||||
reactionGroups,
|
|
||||||
myReactions,
|
|
||||||
mxEvent,
|
|
||||||
customReactionImagesEnabled,
|
|
||||||
roomContext.canReact,
|
|
||||||
roomContext.canSelfRedact,
|
|
||||||
snapshot.showAllButtonVisible,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!snapshot.isVisible || !items?.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let contextMenu: JSX.Element | undefined;
|
|
||||||
if (menuDisplayed && menuAnchorRect && reactions && roomContext.canReact) {
|
|
||||||
contextMenu = (
|
|
||||||
<ContextMenu {...aboveLeftOf(menuAnchorRect)} onFinished={closeReactionMenu} managed={false} focusLock>
|
|
||||||
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeReactionMenu} />
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ReactionsRowView vm={vm} className="mx_ReactionsRow">
|
|
||||||
{items}
|
|
||||||
</ReactionsRowView>
|
|
||||||
{contextMenu}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActionBarWrapperProps {
|
|
||||||
mxEvent: MatrixEvent;
|
|
||||||
reactions?: Relations | null;
|
|
||||||
permalinkCreator?: RoomPermalinkCreator;
|
|
||||||
getTile: () => IEventTileType | null;
|
|
||||||
getReplyChain: () => ReplyChain | null;
|
|
||||||
onFocusChange?: (focused: boolean) => void;
|
|
||||||
isQuoteExpanded?: boolean;
|
|
||||||
toggleThreadExpanded: () => void;
|
|
||||||
getRelationsForEvent?: GetRelationsForEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionBarWrapper({
|
|
||||||
mxEvent,
|
|
||||||
reactions,
|
|
||||||
permalinkCreator,
|
|
||||||
getTile,
|
|
||||||
getReplyChain,
|
|
||||||
onFocusChange,
|
|
||||||
isQuoteExpanded,
|
|
||||||
toggleThreadExpanded,
|
|
||||||
getRelationsForEvent,
|
|
||||||
}: Readonly<ActionBarWrapperProps>): JSX.Element {
|
|
||||||
const roomContext = useContext(RoomContext);
|
|
||||||
const { isCard } = useContext(CardContext);
|
|
||||||
const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState<DOMRect | null>(null);
|
|
||||||
const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState<DOMRect | null>(null);
|
|
||||||
const isSearch = Boolean(roomContext.search);
|
|
||||||
const handleOptionsClick = useCallback((anchor: HTMLElement | null): void => {
|
|
||||||
setOptionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null);
|
|
||||||
}, []);
|
|
||||||
const handleReactionsClick = useCallback((anchor: HTMLElement | null): void => {
|
|
||||||
setReactionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null);
|
|
||||||
}, []);
|
|
||||||
const vm = useCreateAutoDisposedViewModel(
|
|
||||||
() =>
|
|
||||||
new EventTileActionBarViewModel({
|
|
||||||
mxEvent,
|
|
||||||
timelineRenderingType: roomContext.timelineRenderingType,
|
|
||||||
canSendMessages: roomContext.canSendMessages,
|
|
||||||
canReact: roomContext.canReact,
|
|
||||||
isSearch,
|
|
||||||
isCard,
|
|
||||||
isQuoteExpanded,
|
|
||||||
onToggleThreadExpanded: toggleThreadExpanded,
|
|
||||||
onOptionsClick: handleOptionsClick,
|
|
||||||
onReactionsClick: handleReactionsClick,
|
|
||||||
getRelationsForEvent,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setProps({
|
|
||||||
mxEvent,
|
|
||||||
timelineRenderingType: roomContext.timelineRenderingType,
|
|
||||||
canSendMessages: roomContext.canSendMessages,
|
|
||||||
canReact: roomContext.canReact,
|
|
||||||
isSearch,
|
|
||||||
isCard,
|
|
||||||
isQuoteExpanded,
|
|
||||||
getRelationsForEvent,
|
|
||||||
onToggleThreadExpanded: toggleThreadExpanded,
|
|
||||||
onOptionsClick: handleOptionsClick,
|
|
||||||
onReactionsClick: handleReactionsClick,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
vm,
|
|
||||||
mxEvent,
|
|
||||||
roomContext.timelineRenderingType,
|
|
||||||
roomContext.canSendMessages,
|
|
||||||
roomContext.canReact,
|
|
||||||
isSearch,
|
|
||||||
isCard,
|
|
||||||
isQuoteExpanded,
|
|
||||||
getRelationsForEvent,
|
|
||||||
handleOptionsClick,
|
|
||||||
handleReactionsClick,
|
|
||||||
toggleThreadExpanded,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onFocusChange?.(Boolean(optionsMenuAnchorRect || reactionsMenuAnchorRect));
|
|
||||||
}, [onFocusChange, optionsMenuAnchorRect, reactionsMenuAnchorRect]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOptionsMenuAnchorRect(null);
|
|
||||||
setReactionsMenuAnchorRect(null);
|
|
||||||
}, [mxEvent]);
|
|
||||||
|
|
||||||
const closeOptionsMenu = useCallback((): void => {
|
|
||||||
setOptionsMenuAnchorRect(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeReactionsMenu = useCallback((): void => {
|
|
||||||
setReactionsMenuAnchorRect(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tile = getTile();
|
|
||||||
const replyChain = getReplyChain();
|
|
||||||
const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined;
|
|
||||||
const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ActionBarView vm={vm} className="mx_MessageActionBar" />
|
|
||||||
{optionsMenuAnchorRect ? (
|
|
||||||
<MessageContextMenu
|
|
||||||
{...aboveLeftOf(optionsMenuAnchorRect)}
|
|
||||||
mxEvent={mxEvent}
|
|
||||||
permalinkCreator={permalinkCreator}
|
|
||||||
eventTileOps={eventTileOps}
|
|
||||||
collapseReplyChain={collapseReplyChain}
|
|
||||||
onFinished={closeOptionsMenu}
|
|
||||||
getRelationsForEvent={getRelationsForEvent}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{reactionsMenuAnchorRect ? (
|
|
||||||
<ContextMenu
|
|
||||||
{...aboveLeftOf(reactionsMenuAnchorRect)}
|
|
||||||
onFinished={closeReactionsMenu}
|
|
||||||
managed={false}
|
|
||||||
focusLock
|
|
||||||
>
|
|
||||||
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeReactionsMenu} />
|
|
||||||
</ContextMenu>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface E2eMessageSharedIconWrapperProps {
|
|
||||||
/**
|
|
||||||
* The ID of the room containing the event whose keys were shared.
|
|
||||||
*/
|
|
||||||
roomId: string;
|
|
||||||
/**
|
|
||||||
* The ID of the user who shared the keys.
|
|
||||||
*/
|
|
||||||
keyForwardingUserId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function E2eMessageSharedIconWrapper({
|
|
||||||
roomId,
|
|
||||||
keyForwardingUserId,
|
|
||||||
}: Readonly<E2eMessageSharedIconWrapperProps>): JSX.Element {
|
|
||||||
const client = useMatrixClientContext();
|
|
||||||
const vm = useCreateAutoDisposedViewModel(
|
|
||||||
() =>
|
|
||||||
new E2eMessageSharedIconViewModel({
|
|
||||||
client,
|
|
||||||
roomId,
|
|
||||||
keyForwardingUserId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setRoomId(roomId);
|
|
||||||
}, [roomId, vm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
vm.setKeyForwardingUserId(keyForwardingUserId);
|
|
||||||
}, [keyForwardingUserId, vm]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<E2eMessageSharedIconView
|
|
||||||
vm={vm}
|
|
||||||
className={
|
|
||||||
// Timeline PCSS uses this app class as a layout hook for positioning and layout variants.
|
|
||||||
"mx_EventTile_e2eIcon"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useContext, useEffect, useState, type JSX } from "react";
|
||||||
|
import { type MatrixEvent, type Relations } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { ActionBarView } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import type ReplyChain from "../../elements/ReplyChain";
|
||||||
|
import ReactionPicker from "../../emojipicker/ReactionPicker";
|
||||||
|
import MessageContextMenu from "../../context_menus/MessageContextMenu";
|
||||||
|
import ContextMenu, { aboveLeftOf } from "../../../structures/ContextMenu";
|
||||||
|
import RoomContext from "../../../../contexts/RoomContext";
|
||||||
|
import { CardContext } from "../../right_panel/context";
|
||||||
|
import { type RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks";
|
||||||
|
import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
|
import { type GetRelationsForEvent } from "../../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations exposed by the event tile for action bar interactions.
|
||||||
|
*/
|
||||||
|
interface ActionBarEventTileOps {
|
||||||
|
/** Returns whether the widget is currently hidden. */
|
||||||
|
isWidgetHidden(): boolean;
|
||||||
|
/** Unhides the widget if it was previously hidden. */
|
||||||
|
unhideWidget(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event tile handle used by the action bar to query tile-level operations.
|
||||||
|
*/
|
||||||
|
interface ActionBarEventTile {
|
||||||
|
/** Returns the available tile operations, if any. */
|
||||||
|
getEventTileOps?(): ActionBarEventTileOps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link ActionBarAdapter} component.
|
||||||
|
*/
|
||||||
|
interface ActionBarAdapterProps {
|
||||||
|
/** View model backing the event tile action bar. */
|
||||||
|
eventTileViewModel: EventTileViewModel;
|
||||||
|
/** Matrix event rendered by this tile. */
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
/** Reaction relation state for the event, if available. */
|
||||||
|
reactions?: Relations | null;
|
||||||
|
/** Creates permalinks for the room, when link actions are shown. */
|
||||||
|
permalinkCreator?: RoomPermalinkCreator;
|
||||||
|
/** Returns the current tile instance for event tile operations. */
|
||||||
|
getTile: () => ActionBarEventTile | null;
|
||||||
|
/** Returns the current reply chain for the tile, if any. */
|
||||||
|
getReplyChain: () => ReplyChain | null;
|
||||||
|
/** Notifies the parent when the action bar gains or loses focus. */
|
||||||
|
onFocusChange?: (focused: boolean) => void;
|
||||||
|
/** Indicates whether the event quote is currently expanded. */
|
||||||
|
isQuoteExpanded?: boolean;
|
||||||
|
/** Toggles the thread expansion state for this tile. */
|
||||||
|
toggleThreadExpanded: () => void;
|
||||||
|
/** Looks up relation data for the current event. */
|
||||||
|
getRelationsForEvent?: GetRelationsForEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the event tile action bar and its context menus.
|
||||||
|
*/
|
||||||
|
export function ActionBarAdapter({
|
||||||
|
eventTileViewModel,
|
||||||
|
mxEvent,
|
||||||
|
reactions,
|
||||||
|
permalinkCreator,
|
||||||
|
getTile,
|
||||||
|
getReplyChain,
|
||||||
|
onFocusChange,
|
||||||
|
isQuoteExpanded,
|
||||||
|
toggleThreadExpanded,
|
||||||
|
getRelationsForEvent,
|
||||||
|
}: Readonly<ActionBarAdapterProps>): JSX.Element {
|
||||||
|
const roomContext = useContext(RoomContext);
|
||||||
|
const { isCard } = useContext(CardContext);
|
||||||
|
const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState<DOMRect | null>(null);
|
||||||
|
const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState<DOMRect | null>(null);
|
||||||
|
const isSearch = Boolean(roomContext.search);
|
||||||
|
const handleOptionsClick = useCallback((anchor: HTMLElement | null): void => {
|
||||||
|
setOptionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null);
|
||||||
|
}, []);
|
||||||
|
const handleReactionsClick = useCallback((anchor: HTMLElement | null): void => {
|
||||||
|
setReactionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null);
|
||||||
|
}, []);
|
||||||
|
const vm = eventTileViewModel.getActionBarViewModel({
|
||||||
|
mxEvent,
|
||||||
|
timelineRenderingType: roomContext.timelineRenderingType,
|
||||||
|
canSendMessages: roomContext.canSendMessages,
|
||||||
|
canReact: roomContext.canReact,
|
||||||
|
isSearch,
|
||||||
|
isCard,
|
||||||
|
isQuoteExpanded,
|
||||||
|
onToggleThreadExpanded: toggleThreadExpanded,
|
||||||
|
onOptionsClick: handleOptionsClick,
|
||||||
|
onReactionsClick: handleReactionsClick,
|
||||||
|
getRelationsForEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This child VM owns Matrix and settings listeners, so release it when the view using it leaves the tree.
|
||||||
|
return () => eventTileViewModel.releaseActionBarViewModel();
|
||||||
|
}, [eventTileViewModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setProps({
|
||||||
|
mxEvent,
|
||||||
|
timelineRenderingType: roomContext.timelineRenderingType,
|
||||||
|
canSendMessages: roomContext.canSendMessages,
|
||||||
|
canReact: roomContext.canReact,
|
||||||
|
isSearch,
|
||||||
|
isCard,
|
||||||
|
isQuoteExpanded,
|
||||||
|
getRelationsForEvent,
|
||||||
|
onToggleThreadExpanded: toggleThreadExpanded,
|
||||||
|
onOptionsClick: handleOptionsClick,
|
||||||
|
onReactionsClick: handleReactionsClick,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
vm,
|
||||||
|
mxEvent,
|
||||||
|
roomContext.timelineRenderingType,
|
||||||
|
roomContext.canSendMessages,
|
||||||
|
roomContext.canReact,
|
||||||
|
isSearch,
|
||||||
|
isCard,
|
||||||
|
isQuoteExpanded,
|
||||||
|
getRelationsForEvent,
|
||||||
|
handleOptionsClick,
|
||||||
|
handleReactionsClick,
|
||||||
|
toggleThreadExpanded,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFocusChange?.(Boolean(optionsMenuAnchorRect || reactionsMenuAnchorRect));
|
||||||
|
}, [onFocusChange, optionsMenuAnchorRect, reactionsMenuAnchorRect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOptionsMenuAnchorRect(null);
|
||||||
|
setReactionsMenuAnchorRect(null);
|
||||||
|
}, [mxEvent]);
|
||||||
|
|
||||||
|
const closeOptionsMenu = useCallback((): void => {
|
||||||
|
setOptionsMenuAnchorRect(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeReactionsMenu = useCallback((): void => {
|
||||||
|
setReactionsMenuAnchorRect(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tile = getTile();
|
||||||
|
const replyChain = getReplyChain();
|
||||||
|
const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined;
|
||||||
|
const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ActionBarView vm={vm} className="mx_MessageActionBar" />
|
||||||
|
{optionsMenuAnchorRect ? (
|
||||||
|
<MessageContextMenu
|
||||||
|
{...aboveLeftOf(optionsMenuAnchorRect)}
|
||||||
|
mxEvent={mxEvent}
|
||||||
|
permalinkCreator={permalinkCreator}
|
||||||
|
eventTileOps={eventTileOps}
|
||||||
|
collapseReplyChain={collapseReplyChain}
|
||||||
|
onFinished={closeOptionsMenu}
|
||||||
|
getRelationsForEvent={getRelationsForEvent}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{reactionsMenuAnchorRect ? (
|
||||||
|
<ContextMenu
|
||||||
|
{...aboveLeftOf(reactionsMenuAnchorRect)}
|
||||||
|
onFinished={closeReactionsMenu}
|
||||||
|
managed={false}
|
||||||
|
focusLock
|
||||||
|
>
|
||||||
|
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeReactionsMenu} />
|
||||||
|
</ContextMenu>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, type JSX } from "react";
|
||||||
|
import { E2eMessageSharedIconView } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||||
|
import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link E2eMessageSharedIconAdapter} component.
|
||||||
|
*/
|
||||||
|
interface E2eMessageSharedIconAdapterProps {
|
||||||
|
/** View model backing the shared-key indicator. */
|
||||||
|
eventTileViewModel: EventTileViewModel;
|
||||||
|
/** The ID of the room containing the event whose keys were shared. */
|
||||||
|
roomId: string;
|
||||||
|
/** The ID of the user who shared the keys. */
|
||||||
|
keyForwardingUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the end-to-end encryption key-sharing indicator.
|
||||||
|
*/
|
||||||
|
export function E2eMessageSharedIconAdapter({
|
||||||
|
eventTileViewModel,
|
||||||
|
roomId,
|
||||||
|
keyForwardingUserId,
|
||||||
|
}: Readonly<E2eMessageSharedIconAdapterProps>): JSX.Element {
|
||||||
|
const client = useMatrixClientContext();
|
||||||
|
const vm = eventTileViewModel.getE2eMessageSharedIconViewModel({
|
||||||
|
client,
|
||||||
|
roomId,
|
||||||
|
keyForwardingUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This child VM owns Matrix listeners, so release it when the view using it leaves the tree.
|
||||||
|
return () => eventTileViewModel.releaseE2eMessageSharedIconViewModel();
|
||||||
|
}, [eventTileViewModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setClient(client);
|
||||||
|
}, [client, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setRoomId(roomId);
|
||||||
|
}, [roomId, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setKeyForwardingUserId(keyForwardingUserId);
|
||||||
|
}, [keyForwardingUserId, vm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<E2eMessageSharedIconView
|
||||||
|
vm={vm}
|
||||||
|
className={
|
||||||
|
// Timeline PCSS uses this app class as a layout hook for positioning and layout variants.
|
||||||
|
"mx_EventTile_e2eIcon"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,11 +8,19 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
import React, { type JSX } from "react";
|
import React, { type JSX } from "react";
|
||||||
import { E2ePadlock, type E2ePadlockIcon } from "@element-hq/web-shared-components";
|
import { E2ePadlock, type E2ePadlockIcon } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link E2eStandardPadlockIcon} component.
|
||||||
|
*/
|
||||||
interface E2eStandardPadlockIconProps {
|
interface E2eStandardPadlockIconProps {
|
||||||
|
/** Icon variant to render. */
|
||||||
icon: E2ePadlockIcon;
|
icon: E2ePadlockIcon;
|
||||||
|
/** Accessible title for the icon. */
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the standard end-to-end encryption padlock icon.
|
||||||
|
*/
|
||||||
export function E2eStandardPadlockIcon({ icon, title }: Readonly<E2eStandardPadlockIconProps>): JSX.Element {
|
export function E2eStandardPadlockIcon({ icon, title }: Readonly<E2eStandardPadlockIconProps>): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<E2ePadlock
|
<E2ePadlock
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, type JSX } from "react";
|
||||||
|
import { MessageTimestampView } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
|
import { type MessageTimestampViewModelProps } from "../../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts";
|
||||||
|
import { Icon as LateIcon } from "../../../../../res/img/sensor.svg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link MessageTimestampAdapter} component.
|
||||||
|
*/
|
||||||
|
interface MessageTimestampAdapterProps {
|
||||||
|
/** View model backing the event tile timestamp. */
|
||||||
|
eventTileViewModel: EventTileViewModel;
|
||||||
|
/** Chooses the plain or linked timestamp view model. */
|
||||||
|
kind: "plain" | "linked";
|
||||||
|
/** Props forwarded into the timestamp view model. */
|
||||||
|
timestampProps: MessageTimestampViewModelProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the timestamp badge for an event tile.
|
||||||
|
*/
|
||||||
|
export function MessageTimestampAdapter({
|
||||||
|
eventTileViewModel,
|
||||||
|
kind,
|
||||||
|
timestampProps,
|
||||||
|
}: Readonly<MessageTimestampAdapterProps>): JSX.Element {
|
||||||
|
const vm =
|
||||||
|
kind === "linked"
|
||||||
|
? eventTileViewModel.getLinkedMessageTimestampViewModel(timestampProps)
|
||||||
|
: eventTileViewModel.getMessageTimestampViewModel(timestampProps);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setProps(timestampProps);
|
||||||
|
}, [vm, timestampProps]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{timestampProps.receivedTs ? (
|
||||||
|
<LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" />
|
||||||
|
) : undefined}
|
||||||
|
<MessageTimestampView vm={vm} className="mx_MessageTimestamp" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useContext, useEffect, useMemo, useState, type JSX } from "react";
|
||||||
|
import { MatrixEventEvent, type MatrixEvent, type Relations, RelationsEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import {
|
||||||
|
ReactionsRowButtonView,
|
||||||
|
ReactionsRowView,
|
||||||
|
useCreateAutoDisposedViewModel,
|
||||||
|
useViewModel,
|
||||||
|
} from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import ContextMenu, { aboveLeftOf } from "../../../structures/ContextMenu";
|
||||||
|
import ReactionPicker from "../../emojipicker/ReactionPicker";
|
||||||
|
import RoomContext from "../../../../contexts/RoomContext";
|
||||||
|
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||||
|
import SettingsStore from "../../../../settings/SettingsStore";
|
||||||
|
import { isContentActionable } from "../../../../utils/EventUtils";
|
||||||
|
import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
|
import { ReactionsRowButtonViewModel } from "../../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel";
|
||||||
|
import { MAX_ITEMS_WHEN_LIMITED } from "../../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link ReactionsRowButtonAdapter} component.
|
||||||
|
*/
|
||||||
|
interface ReactionsRowButtonAdapterProps {
|
||||||
|
/** Matrix event whose reaction button is being rendered. */
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
/** Reaction emoji or custom content key for this button. */
|
||||||
|
content: string;
|
||||||
|
/** Total number of reactions in this group. */
|
||||||
|
count: number;
|
||||||
|
/** Reaction events belonging to this group. */
|
||||||
|
reactionEvents: MatrixEvent[];
|
||||||
|
/** The current user's reaction event, if present. */
|
||||||
|
myReactionEvent?: MatrixEvent;
|
||||||
|
/** Disables interaction when true. */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Enables rendering custom reaction images. */
|
||||||
|
customReactionImagesEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a single reaction button within the event tile reaction row.
|
||||||
|
*/
|
||||||
|
function ReactionsRowButtonAdapter(props: Readonly<ReactionsRowButtonAdapterProps>): JSX.Element {
|
||||||
|
const client = useMatrixClientContext();
|
||||||
|
|
||||||
|
const vm = useCreateAutoDisposedViewModel(
|
||||||
|
() =>
|
||||||
|
new ReactionsRowButtonViewModel({
|
||||||
|
client,
|
||||||
|
mxEvent: props.mxEvent,
|
||||||
|
content: props.content,
|
||||||
|
count: props.count,
|
||||||
|
reactionEvents: props.reactionEvents,
|
||||||
|
myReactionEvent: props.myReactionEvent,
|
||||||
|
disabled: props.disabled,
|
||||||
|
customReactionImagesEnabled: props.customReactionImagesEnabled,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled);
|
||||||
|
}, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setCount(props.count);
|
||||||
|
}, [props.count, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setMyReactionEvent(props.myReactionEvent);
|
||||||
|
}, [props.myReactionEvent, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setDisabled(props.disabled);
|
||||||
|
}, [props.disabled, vm]);
|
||||||
|
|
||||||
|
return <ReactionsRowButtonView vm={vm} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReactionGroup {
|
||||||
|
content: string;
|
||||||
|
events: MatrixEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] =>
|
||||||
|
reactions
|
||||||
|
?.getSortedAnnotationsByKey()
|
||||||
|
?.map(([content, events]) => ({
|
||||||
|
content,
|
||||||
|
events: [...events],
|
||||||
|
}))
|
||||||
|
.filter(({ events }) => events.length > 0) ?? [];
|
||||||
|
|
||||||
|
const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => {
|
||||||
|
if (!reactions || !userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const myReactions = reactions.getAnnotationsBySender()?.[userId];
|
||||||
|
if (!myReactions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...myReactions.values()];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link ReactionsRowAdapter} component.
|
||||||
|
*/
|
||||||
|
interface ReactionsRowAdapterProps {
|
||||||
|
/** View model backing the event tile reaction row. */
|
||||||
|
eventTileViewModel: EventTileViewModel;
|
||||||
|
/** Matrix event whose reactions are being rendered. */
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
/** Current reaction relations for the event, if available. */
|
||||||
|
reactions?: Relations | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the reaction row and reaction picker for an event tile.
|
||||||
|
*/
|
||||||
|
export function ReactionsRowAdapter({
|
||||||
|
eventTileViewModel,
|
||||||
|
mxEvent,
|
||||||
|
reactions,
|
||||||
|
}: Readonly<ReactionsRowAdapterProps>): JSX.Element | null {
|
||||||
|
const roomContext = useContext(RoomContext);
|
||||||
|
const userId = roomContext.room?.client.getUserId() ?? undefined;
|
||||||
|
const [reactionGroups, setReactionGroups] = useState<ReactionGroup[]>(() => getReactionGroups(reactions));
|
||||||
|
const [myReactions, setMyReactions] = useState<MatrixEvent[] | null>(() => getMyReactions(reactions, userId));
|
||||||
|
const [menuDisplayed, setMenuDisplayed] = useState(false);
|
||||||
|
const [menuAnchorRect, setMenuAnchorRect] = useState<DOMRect | null>(null);
|
||||||
|
|
||||||
|
const vm = eventTileViewModel.getReactionsRowViewModel({
|
||||||
|
isActionable: isContentActionable(mxEvent),
|
||||||
|
reactionGroupCount: reactionGroups.length,
|
||||||
|
canReact: roomContext.canReact,
|
||||||
|
addReactionButtonActive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This child VM is owned by EventTileViewModel, but scoped to this rendered adapter surface.
|
||||||
|
return () => eventTileViewModel.releaseReactionsRowViewModel();
|
||||||
|
}, [eventTileViewModel]);
|
||||||
|
|
||||||
|
const openReactionMenu = useCallback((event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||||
|
setMenuAnchorRect(event.currentTarget.getBoundingClientRect());
|
||||||
|
setMenuDisplayed(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeReactionMenu = useCallback((): void => {
|
||||||
|
setMenuDisplayed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateReactionsState = useCallback((): void => {
|
||||||
|
const nextReactionGroups = getReactionGroups(reactions);
|
||||||
|
setReactionGroups(nextReactionGroups);
|
||||||
|
setMyReactions(getMyReactions(reactions, userId));
|
||||||
|
vm.setReactionGroupCount(nextReactionGroups.length);
|
||||||
|
}, [reactions, userId, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setActionable(isContentActionable(mxEvent));
|
||||||
|
}, [mxEvent, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setCanReact(roomContext.canReact);
|
||||||
|
if (!roomContext.canReact && menuDisplayed) {
|
||||||
|
setMenuDisplayed(false);
|
||||||
|
}
|
||||||
|
}, [roomContext.canReact, menuDisplayed, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setAddReactionHandlers({
|
||||||
|
onAddReactionClick: openReactionMenu,
|
||||||
|
onAddReactionContextMenu: openReactionMenu,
|
||||||
|
});
|
||||||
|
}, [openReactionMenu, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setAddReactionButtonActive(menuDisplayed);
|
||||||
|
}, [menuDisplayed, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateReactionsState();
|
||||||
|
}, [updateReactionsState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reactions) return;
|
||||||
|
|
||||||
|
reactions.on(RelationsEvent.Add, updateReactionsState);
|
||||||
|
reactions.on(RelationsEvent.Remove, updateReactionsState);
|
||||||
|
reactions.on(RelationsEvent.Redaction, updateReactionsState);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
reactions.off(RelationsEvent.Add, updateReactionsState);
|
||||||
|
reactions.off(RelationsEvent.Remove, updateReactionsState);
|
||||||
|
reactions.off(RelationsEvent.Redaction, updateReactionsState);
|
||||||
|
};
|
||||||
|
}, [reactions, updateReactionsState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDecrypted = (): void => {
|
||||||
|
vm.setActionable(isContentActionable(mxEvent));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) {
|
||||||
|
mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted);
|
||||||
|
};
|
||||||
|
}, [mxEvent, vm]);
|
||||||
|
|
||||||
|
const snapshot = useViewModel(vm);
|
||||||
|
const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images");
|
||||||
|
const items = useMemo((): JSX.Element[] | undefined => {
|
||||||
|
const mappedItems = reactionGroups.map(({ content, events }) => {
|
||||||
|
// Deduplicate reaction events by sender per Matrix spec.
|
||||||
|
const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender());
|
||||||
|
const myReactionEvent = myReactions?.find((reactionEvent) => {
|
||||||
|
if (reactionEvent.isRedacted()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return reactionEvent.getRelation()?.key === content;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactionsRowButtonAdapter
|
||||||
|
key={content}
|
||||||
|
content={content}
|
||||||
|
count={deduplicatedEvents.length}
|
||||||
|
mxEvent={mxEvent}
|
||||||
|
reactionEvents={deduplicatedEvents}
|
||||||
|
myReactionEvent={myReactionEvent}
|
||||||
|
customReactionImagesEnabled={customReactionImagesEnabled}
|
||||||
|
disabled={
|
||||||
|
!roomContext.canReact ||
|
||||||
|
(myReactionEvent && !myReactionEvent.isRedacted() && !roomContext.canSelfRedact)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mappedItems.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems;
|
||||||
|
}, [
|
||||||
|
reactionGroups,
|
||||||
|
myReactions,
|
||||||
|
mxEvent,
|
||||||
|
customReactionImagesEnabled,
|
||||||
|
roomContext.canReact,
|
||||||
|
roomContext.canSelfRedact,
|
||||||
|
snapshot.showAllButtonVisible,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!snapshot.isVisible || !items?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contextMenu: JSX.Element | undefined;
|
||||||
|
if (menuDisplayed && menuAnchorRect && reactions && roomContext.canReact) {
|
||||||
|
contextMenu = (
|
||||||
|
<ContextMenu {...aboveLeftOf(menuAnchorRect)} onFinished={closeReactionMenu} managed={false} focusLock>
|
||||||
|
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeReactionMenu} />
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReactionsRowView vm={vm} className="mx_ReactionsRow">
|
||||||
|
{items}
|
||||||
|
</ReactionsRowView>
|
||||||
|
{contextMenu}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { type JSX } from "react";
|
||||||
|
import { type EventStatus, type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { Tooltip } from "@vector-im/compound-web";
|
||||||
|
import { CheckCircleIcon, CircleIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
|
import { _t } from "../../../../languageHandler";
|
||||||
|
import { StaticNotificationState } from "../../../../stores/notifications/StaticNotificationState";
|
||||||
|
import NotificationBadge from "../NotificationBadge";
|
||||||
|
import { ReadReceiptGroup } from "../ReadReceiptGroup";
|
||||||
|
import { type IReadReceiptPosition } from "../ReadReceiptMarker";
|
||||||
|
import { type EventTileReceiptState } from "../../../../viewmodels/room/timeline/event-tile/EventTileReceiptState";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single read receipt entry displayed in the event tile receipt row.
|
||||||
|
*/
|
||||||
|
interface ReadReceiptProps {
|
||||||
|
/** The user ID associated with the receipt. */
|
||||||
|
userId: string;
|
||||||
|
/** The room member metadata for the user, if available. */
|
||||||
|
roomMember: RoomMember | null;
|
||||||
|
/** Timestamp of the receipt in milliseconds since the Unix epoch. */
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link ReceiptAdapter} component.
|
||||||
|
*/
|
||||||
|
interface ReceiptAdapterProps {
|
||||||
|
/** Current receipt state for the event tile. */
|
||||||
|
receiptState: EventTileReceiptState;
|
||||||
|
/** Send status used when rendering the sent-state badge. */
|
||||||
|
eventSendStatus?: EventStatus;
|
||||||
|
/** Whether read receipts should be shown. */
|
||||||
|
showReadReceipts?: boolean;
|
||||||
|
/** Read receipts to render when the group is visible. */
|
||||||
|
readReceipts?: ReadReceiptProps[];
|
||||||
|
/** Read receipt positions keyed by user ID. */
|
||||||
|
readReceiptMap?: { [userId: string]: IReadReceiptPosition };
|
||||||
|
/** Checks whether the receipt row is unmounting. */
|
||||||
|
checkUnmounting?: () => boolean;
|
||||||
|
/** Suppresses receipt animation when true. */
|
||||||
|
suppressAnimation: boolean;
|
||||||
|
/** Whether timestamps should use a 12-hour clock. */
|
||||||
|
isTwelveHour?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the send-state or read-receipt indicator for an event tile.
|
||||||
|
*/
|
||||||
|
export function ReceiptAdapter({
|
||||||
|
receiptState,
|
||||||
|
eventSendStatus,
|
||||||
|
showReadReceipts,
|
||||||
|
readReceipts,
|
||||||
|
readReceiptMap,
|
||||||
|
checkUnmounting,
|
||||||
|
suppressAnimation,
|
||||||
|
isTwelveHour,
|
||||||
|
}: Readonly<ReceiptAdapterProps>): JSX.Element | null {
|
||||||
|
if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) {
|
||||||
|
return <SentReceipt messageState={eventSendStatus} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showReadReceipts) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReadReceiptGroup
|
||||||
|
readReceipts={readReceipts ?? []}
|
||||||
|
readReceiptMap={readReceiptMap ?? {}}
|
||||||
|
checkUnmounting={checkUnmounting}
|
||||||
|
suppressAnimation={suppressAnimation}
|
||||||
|
isTwelveHour={isTwelveHour}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SentReceiptProps {
|
||||||
|
messageState: EventStatus | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SentReceipt({ messageState }: Readonly<SentReceiptProps>): JSX.Element {
|
||||||
|
const isSent = !messageState || messageState === "sent";
|
||||||
|
const isFailed = messageState === "not_sent";
|
||||||
|
|
||||||
|
let icon: JSX.Element | undefined;
|
||||||
|
let label: string | undefined;
|
||||||
|
if (messageState === "encrypting") {
|
||||||
|
icon = <CircleIcon />;
|
||||||
|
label = _t("timeline|send_state_encrypting");
|
||||||
|
} else if (isSent) {
|
||||||
|
icon = <CheckCircleIcon />;
|
||||||
|
label = _t("timeline|send_state_sent");
|
||||||
|
} else if (isFailed) {
|
||||||
|
icon = <NotificationBadge notification={StaticNotificationState.RED_EXCLAMATION} />;
|
||||||
|
label = _t("timeline|send_state_failed");
|
||||||
|
} else {
|
||||||
|
icon = <CircleIcon />;
|
||||||
|
label = _t("timeline|send_state_sending");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx_EventTile_msgOption">
|
||||||
|
<div className="mx_ReadReceiptGroup">
|
||||||
|
<Tooltip label={label} placement="top-end">
|
||||||
|
<div className="mx_ReadReceiptGroup_button" role="status">
|
||||||
|
<span className="mx_ReadReceiptGroup_container">{icon}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { type JSX } from "react";
|
||||||
|
import { EventType, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import MemberAvatar from "../../avatars/MemberAvatar";
|
||||||
|
import SenderProfile from "../../messages/SenderProfile";
|
||||||
|
import { type EventTileSenderSnapshot } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link EventTileAvatarAdapter} component.
|
||||||
|
*/
|
||||||
|
interface EventTileAvatarAdapterProps {
|
||||||
|
/** Matrix event whose sender avatar is being rendered. */
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
/** Snapshot of the sender identity state for this tile. */
|
||||||
|
senderSnapshot: EventTileSenderSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the sender avatar for an event tile.
|
||||||
|
*/
|
||||||
|
export function EventTileAvatarAdapter({
|
||||||
|
mxEvent,
|
||||||
|
senderSnapshot,
|
||||||
|
}: Readonly<EventTileAvatarAdapterProps>): JSX.Element | null {
|
||||||
|
const { avatarSize } = senderSnapshot.profileState;
|
||||||
|
|
||||||
|
if (!mxEvent.sender || avatarSize === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx_EventTile_avatar">
|
||||||
|
<MemberAvatar
|
||||||
|
member={senderSnapshot.avatarMember}
|
||||||
|
size={avatarSize}
|
||||||
|
viewUserOnClick={senderSnapshot.viewUserOnClick}
|
||||||
|
forceHistorical={mxEvent.getType() === EventType.RoomMember}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link EventTileSenderAdapter} component.
|
||||||
|
*/
|
||||||
|
interface EventTileSenderAdapterProps {
|
||||||
|
/** Matrix event whose sender identity is being rendered. */
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
/** Snapshot of the sender identity state for this tile. */
|
||||||
|
senderSnapshot: EventTileSenderSnapshot;
|
||||||
|
/** Invoked when the sender profile is clicked. */
|
||||||
|
onSenderProfileClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the sender identity display for an event tile.
|
||||||
|
*/
|
||||||
|
export function EventTileSenderAdapter({
|
||||||
|
mxEvent,
|
||||||
|
senderSnapshot,
|
||||||
|
onSenderProfileClick,
|
||||||
|
}: Readonly<EventTileSenderAdapterProps>): JSX.Element | null {
|
||||||
|
switch (senderSnapshot.profileMode) {
|
||||||
|
case "clickable":
|
||||||
|
return <SenderProfile onClick={onSenderProfileClick} mxEvent={mxEvent} />;
|
||||||
|
case "tooltip":
|
||||||
|
return <SenderProfile mxEvent={mxEvent} withTooltip />;
|
||||||
|
case "default":
|
||||||
|
return <SenderProfile mxEvent={mxEvent} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, type JSX } from "react";
|
||||||
|
import { ActionBarView } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link ThreadListActionBarAdapter} component.
|
||||||
|
*/
|
||||||
|
interface ThreadListActionBarAdapterProps {
|
||||||
|
/** View model backing the thread list action bar. */
|
||||||
|
eventTileViewModel: EventTileViewModel;
|
||||||
|
/** Opens the event in the room timeline. */
|
||||||
|
onViewInRoomClick: (anchor: HTMLElement | null) => void;
|
||||||
|
/** Copies the event permalink to the clipboard. */
|
||||||
|
onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise<void>;
|
||||||
|
/** Optional class name applied to the action bar. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the action bar used by the thread list view.
|
||||||
|
*/
|
||||||
|
export function ThreadListActionBarAdapter({
|
||||||
|
eventTileViewModel,
|
||||||
|
onViewInRoomClick,
|
||||||
|
onCopyLinkClick,
|
||||||
|
className,
|
||||||
|
}: Readonly<ThreadListActionBarAdapterProps>): JSX.Element {
|
||||||
|
const vm = eventTileViewModel.getThreadListActionBarViewModel({
|
||||||
|
onViewInRoomClick,
|
||||||
|
onCopyLinkClick,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setProps({
|
||||||
|
onViewInRoomClick,
|
||||||
|
onCopyLinkClick,
|
||||||
|
});
|
||||||
|
}, [vm, onViewInRoomClick, onCopyLinkClick]);
|
||||||
|
|
||||||
|
return <ActionBarView vm={vm} className={className} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, type JSX } from "react";
|
||||||
|
import { type Thread } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { ThreadMessagePreviewView } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||||
|
import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx";
|
||||||
|
import { useSettingValue } from "../../../../hooks/useSettings";
|
||||||
|
import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link ThreadMessagePreviewAdapter} component.
|
||||||
|
*/
|
||||||
|
interface ThreadMessagePreviewAdapterProps {
|
||||||
|
/** View model backing the thread preview. */
|
||||||
|
eventTileViewModel: EventTileViewModel;
|
||||||
|
/** Thread whose message preview is rendered. */
|
||||||
|
thread: Thread;
|
||||||
|
/** Whether the sender display name should be shown. */
|
||||||
|
showDisplayName?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the preview shown for a thread message.
|
||||||
|
*/
|
||||||
|
export function ThreadMessagePreviewAdapter({
|
||||||
|
eventTileViewModel,
|
||||||
|
thread,
|
||||||
|
showDisplayName = false,
|
||||||
|
}: Readonly<ThreadMessagePreviewAdapterProps>): JSX.Element {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
const { room, timelineRenderingType, lowBandwidth } = useScopedRoomContext(
|
||||||
|
"room",
|
||||||
|
"timelineRenderingType",
|
||||||
|
"lowBandwidth",
|
||||||
|
);
|
||||||
|
const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles");
|
||||||
|
const vm = eventTileViewModel.getThreadMessagePreviewViewModel({
|
||||||
|
cli,
|
||||||
|
thread,
|
||||||
|
room,
|
||||||
|
timelineRenderingType,
|
||||||
|
lowBandwidth,
|
||||||
|
useOnlyCurrentProfiles,
|
||||||
|
showDisplayName,
|
||||||
|
avatarClassName: "mx_BaseAvatar",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This child VM owns Matrix listeners, so release it when the view using it leaves the tree.
|
||||||
|
return () => eventTileViewModel.releaseThreadMessagePreviewViewModel();
|
||||||
|
}, [eventTileViewModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setClient(cli);
|
||||||
|
vm.setThread(thread);
|
||||||
|
vm.setRoom(room);
|
||||||
|
vm.setTimelineRenderingType(timelineRenderingType);
|
||||||
|
vm.setLowBandwidth(lowBandwidth);
|
||||||
|
vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles);
|
||||||
|
vm.setShowDisplayName(showDisplayName);
|
||||||
|
}, [vm, cli, thread, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles, showDisplayName]);
|
||||||
|
|
||||||
|
return <ThreadMessagePreviewView vm={vm} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useContext, useEffect, type JSX } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { type MatrixEvent, type Thread } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { ThreadSummaryView } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import { CardContext } from "../../right_panel/context";
|
||||||
|
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||||
|
import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx";
|
||||||
|
import { useSettingValue } from "../../../../hooks/useSettings";
|
||||||
|
import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link ThreadSummaryAdapter} component.
|
||||||
|
*/
|
||||||
|
interface ThreadSummaryAdapterProps extends Omit<React.ComponentPropsWithoutRef<"button">, "aria-label" | "onClick"> {
|
||||||
|
/** View model backing the thread summary tile. */
|
||||||
|
eventTileViewModel: EventTileViewModel;
|
||||||
|
/** Root event for the thread summary. */
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
/** Thread represented by the summary tile. */
|
||||||
|
thread: Thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the thread summary tile for an event.
|
||||||
|
*/
|
||||||
|
export function ThreadSummaryAdapter({
|
||||||
|
eventTileViewModel,
|
||||||
|
mxEvent,
|
||||||
|
thread,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: Readonly<ThreadSummaryAdapterProps>): JSX.Element {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
const { isCard } = useContext(CardContext);
|
||||||
|
const { narrow, room, timelineRenderingType, lowBandwidth } = useScopedRoomContext(
|
||||||
|
"narrow",
|
||||||
|
"room",
|
||||||
|
"timelineRenderingType",
|
||||||
|
"lowBandwidth",
|
||||||
|
);
|
||||||
|
const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles");
|
||||||
|
const vm = eventTileViewModel.getThreadSummaryViewModel({
|
||||||
|
cli,
|
||||||
|
mxEvent,
|
||||||
|
thread,
|
||||||
|
narrow,
|
||||||
|
isCard,
|
||||||
|
room,
|
||||||
|
timelineRenderingType,
|
||||||
|
lowBandwidth,
|
||||||
|
useOnlyCurrentProfiles,
|
||||||
|
avatarClassName: "mx_BaseAvatar",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This child VM owns Matrix listeners, so release it when the view using it leaves the tree.
|
||||||
|
return () => eventTileViewModel.releaseThreadSummaryViewModel();
|
||||||
|
}, [eventTileViewModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setClient(cli);
|
||||||
|
vm.setRootEvent(mxEvent);
|
||||||
|
vm.setThread(thread);
|
||||||
|
vm.setNarrow(narrow);
|
||||||
|
vm.setIsCard(isCard);
|
||||||
|
vm.setRoom(room);
|
||||||
|
vm.setTimelineRenderingType(timelineRenderingType);
|
||||||
|
vm.setLowBandwidth(lowBandwidth);
|
||||||
|
vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles);
|
||||||
|
}, [vm, cli, mxEvent, thread, narrow, isCard, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles]);
|
||||||
|
|
||||||
|
return <ThreadSummaryView {...props} vm={vm} className={classNames("mx_ThreadSummary", className)} />;
|
||||||
|
}
|
||||||
@@ -33,6 +33,14 @@ export class E2eMessageSharedIconViewModel
|
|||||||
this.disposables.track(() => this.teardownRoomStateListener());
|
this.disposables.track(() => this.teardownRoomStateListener());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setClient(client: MatrixClient): void {
|
||||||
|
if (this.props.client === client) return;
|
||||||
|
|
||||||
|
this.props = { ...this.props, client };
|
||||||
|
this.setupRoomStateListener();
|
||||||
|
this.updateSnapshotFromProps();
|
||||||
|
}
|
||||||
|
|
||||||
public setRoomId(roomId: string): void {
|
public setRoomId(roomId: string): void {
|
||||||
if (this.props.roomId === roomId) return;
|
if (this.props.roomId === roomId) return;
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,22 @@ import {
|
|||||||
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
|
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
|
||||||
import { type Layout } from "../../../../settings/enums/Layout";
|
import { type Layout } from "../../../../settings/enums/Layout";
|
||||||
import { MessageTimestampViewModel, type MessageTimestampViewModelProps } from "./timestamp/MessageTimestampViewModel";
|
import { MessageTimestampViewModel, type MessageTimestampViewModelProps } from "./timestamp/MessageTimestampViewModel";
|
||||||
|
import {
|
||||||
|
ThreadMessagePreviewViewModel,
|
||||||
|
type ThreadMessagePreviewViewModelProps,
|
||||||
|
ThreadSummaryViewModel,
|
||||||
|
type ThreadSummaryViewModelProps,
|
||||||
|
} from "./ThreadSummaryViewModel.tsx";
|
||||||
|
import {
|
||||||
|
E2eMessageSharedIconViewModel,
|
||||||
|
type E2eMessageSharedIconViewModelProps,
|
||||||
|
} from "./E2eMessageSharedIconViewModel";
|
||||||
import {
|
import {
|
||||||
ThreadListActionBarViewModel,
|
ThreadListActionBarViewModel,
|
||||||
type ThreadListActionBarViewModelProps,
|
type ThreadListActionBarViewModelProps,
|
||||||
} from "../../ThreadListActionBarViewModel";
|
} from "../../ThreadListActionBarViewModel";
|
||||||
|
import { EventTileActionBarViewModel, type EventTileActionBarViewModelProps } from "../../EventTileActionBarViewModel";
|
||||||
|
import { ReactionsRowViewModel, type ReactionsRowViewModelProps } from "./reactions/ReactionsRowViewModel";
|
||||||
|
|
||||||
/** Event-level inputs for deriving the EventTile snapshot. */
|
/** Event-level inputs for deriving the EventTile snapshot. */
|
||||||
export interface EventTileEventInput {
|
export interface EventTileEventInput {
|
||||||
@@ -285,7 +297,12 @@ export interface EventTileRenderState {
|
|||||||
export class EventTileViewModel extends BaseViewModel<EventTileRenderState, EventTileViewModelProps> {
|
export class EventTileViewModel extends BaseViewModel<EventTileRenderState, EventTileViewModelProps> {
|
||||||
private messageTimestampViewModel?: MessageTimestampViewModel;
|
private messageTimestampViewModel?: MessageTimestampViewModel;
|
||||||
private linkedMessageTimestampViewModel?: MessageTimestampViewModel;
|
private linkedMessageTimestampViewModel?: MessageTimestampViewModel;
|
||||||
|
private threadMessagePreviewViewModel?: ThreadMessagePreviewViewModel;
|
||||||
|
private threadSummaryViewModel?: ThreadSummaryViewModel;
|
||||||
private threadListActionBarViewModel?: ThreadListActionBarViewModel;
|
private threadListActionBarViewModel?: ThreadListActionBarViewModel;
|
||||||
|
private e2eMessageSharedIconViewModel?: E2eMessageSharedIconViewModel;
|
||||||
|
private actionBarViewModel?: EventTileActionBarViewModel;
|
||||||
|
private reactionsRowViewModel?: ReactionsRowViewModel;
|
||||||
|
|
||||||
public constructor(props: EventTileViewModelProps) {
|
public constructor(props: EventTileViewModelProps) {
|
||||||
const initialRenderState = EventTileViewModel.createRenderState(props);
|
const initialRenderState = EventTileViewModel.createRenderState(props);
|
||||||
@@ -302,7 +319,12 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
public override dispose(): void {
|
public override dispose(): void {
|
||||||
this.messageTimestampViewModel?.dispose();
|
this.messageTimestampViewModel?.dispose();
|
||||||
this.linkedMessageTimestampViewModel?.dispose();
|
this.linkedMessageTimestampViewModel?.dispose();
|
||||||
|
this.threadMessagePreviewViewModel?.dispose();
|
||||||
|
this.threadSummaryViewModel?.dispose();
|
||||||
this.threadListActionBarViewModel?.dispose();
|
this.threadListActionBarViewModel?.dispose();
|
||||||
|
this.e2eMessageSharedIconViewModel?.dispose();
|
||||||
|
this.actionBarViewModel?.dispose();
|
||||||
|
this.reactionsRowViewModel?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,12 +340,72 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
return this.linkedMessageTimestampViewModel;
|
return this.linkedMessageTimestampViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lazily creates and returns the thread message preview child view model. */
|
||||||
|
public getThreadMessagePreviewViewModel(props: ThreadMessagePreviewViewModelProps): ThreadMessagePreviewViewModel {
|
||||||
|
this.threadMessagePreviewViewModel ??= new ThreadMessagePreviewViewModel(props);
|
||||||
|
return this.threadMessagePreviewViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases the thread message preview child view model when its adapter unmounts. */
|
||||||
|
public releaseThreadMessagePreviewViewModel(): void {
|
||||||
|
this.threadMessagePreviewViewModel?.dispose();
|
||||||
|
this.threadMessagePreviewViewModel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazily creates and returns the thread summary child view model. */
|
||||||
|
public getThreadSummaryViewModel(props: ThreadSummaryViewModelProps): ThreadSummaryViewModel {
|
||||||
|
this.threadSummaryViewModel ??= new ThreadSummaryViewModel(props);
|
||||||
|
return this.threadSummaryViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases the thread summary child view model when its adapter unmounts. */
|
||||||
|
public releaseThreadSummaryViewModel(): void {
|
||||||
|
this.threadSummaryViewModel?.dispose();
|
||||||
|
this.threadSummaryViewModel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Lazily creates and returns the thread-list action bar child view model. */
|
/** Lazily creates and returns the thread-list action bar child view model. */
|
||||||
public getThreadListActionBarViewModel(props: ThreadListActionBarViewModelProps): ThreadListActionBarViewModel {
|
public getThreadListActionBarViewModel(props: ThreadListActionBarViewModelProps): ThreadListActionBarViewModel {
|
||||||
this.threadListActionBarViewModel ??= new ThreadListActionBarViewModel(props);
|
this.threadListActionBarViewModel ??= new ThreadListActionBarViewModel(props);
|
||||||
return this.threadListActionBarViewModel;
|
return this.threadListActionBarViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lazily creates and returns the E2E message-shared icon child view model. */
|
||||||
|
public getE2eMessageSharedIconViewModel(props: E2eMessageSharedIconViewModelProps): E2eMessageSharedIconViewModel {
|
||||||
|
this.e2eMessageSharedIconViewModel ??= new E2eMessageSharedIconViewModel(props);
|
||||||
|
return this.e2eMessageSharedIconViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases the E2E message-shared icon child view model when its adapter unmounts. */
|
||||||
|
public releaseE2eMessageSharedIconViewModel(): void {
|
||||||
|
this.e2eMessageSharedIconViewModel?.dispose();
|
||||||
|
this.e2eMessageSharedIconViewModel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazily creates and returns the event action bar child view model. */
|
||||||
|
public getActionBarViewModel(props: EventTileActionBarViewModelProps): EventTileActionBarViewModel {
|
||||||
|
this.actionBarViewModel ??= new EventTileActionBarViewModel(props);
|
||||||
|
return this.actionBarViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases the event action bar child view model when its adapter unmounts. */
|
||||||
|
public releaseActionBarViewModel(): void {
|
||||||
|
this.actionBarViewModel?.dispose();
|
||||||
|
this.actionBarViewModel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazily creates and returns the reactions row child view model. */
|
||||||
|
public getReactionsRowViewModel(props: ReactionsRowViewModelProps): ReactionsRowViewModel {
|
||||||
|
this.reactionsRowViewModel ??= new ReactionsRowViewModel(props);
|
||||||
|
return this.reactionsRowViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases the reactions row child view model when its adapter unmounts. */
|
||||||
|
public releaseReactionsRowViewModel(): void {
|
||||||
|
this.reactionsRowViewModel?.dispose();
|
||||||
|
this.reactionsRowViewModel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Derives render-ready EventTile state from component-owned inputs. */
|
/** Derives render-ready EventTile state from component-owned inputs. */
|
||||||
public static createRenderState(props: EventTileViewModelProps): EventTileRenderState {
|
public static createRenderState(props: EventTileViewModelProps): EventTileRenderState {
|
||||||
const snapshot = EventTileViewModel.createSnapshot(props);
|
const snapshot = EventTileViewModel.createSnapshot(props);
|
||||||
|
|||||||
Reference in New Issue
Block a user