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:
rbondesson
2026-06-01 15:36:37 +02:00
committed by GitHub
parent ffd524d8c2
commit 531fc76726
13 changed files with 1146 additions and 684 deletions
+50 -684
View File
@@ -9,18 +9,13 @@ Please see LICENSE files in the repository root for full details.
import React, {
createRef,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type JSX,
type Ref,
type FocusEvent,
type MouseEvent,
type ReactNode,
} from "react";
import classNames from "classnames";
import {
type EventStatus,
EventType,
@@ -28,7 +23,6 @@ import {
MatrixEventEvent,
type Relations,
type Room,
RelationsEvent,
RoomEvent,
type RoomMember,
type Thread,
@@ -36,22 +30,9 @@ import {
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call";
import { Tooltip } from "@vector-im/compound-web";
import { uniqueId, uniqBy } from "lodash";
import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import {
useCreateAutoDisposedViewModel,
ActionBarView,
E2eMessageSharedIconView,
MessageTimestampView,
PinnedMessageBadge,
ReactionsRowButtonView,
ReactionsRowView,
ThreadMessagePreviewView,
ThreadSummaryView,
TileErrorView,
useViewModel,
} from "@element-hq/web-shared-components";
import { uniqueId } from "lodash";
import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useCreateAutoDisposedViewModel, PinnedMessageBadge, TileErrorView } from "@element-hq/web-shared-components";
import ReplyChain from "../elements/ReplyChain";
import { _t } from "../../../languageHandler";
@@ -60,22 +41,16 @@ import { Layout } from "../../../settings/enums/Layout";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import RoomAvatar from "../avatars/RoomAvatar";
import MessageContextMenu from "../context_menus/MessageContextMenu";
import ContextMenu, { aboveLeftOf, aboveRightOf } from "../../structures/ContextMenu";
import { aboveRightOf } from "../../structures/ContextMenu";
import { objectHasDiff } from "../../../utils/objects";
import type EditorStateTransfer from "../../../utils/EditorStateTransfer";
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 ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions";
import PlatformPeg from "../../../PlatformPeg";
import MemberAvatar from "../avatars/MemberAvatar";
import SenderProfile from "../messages/SenderProfile";
import { type IReadReceiptPosition } from "./ReadReceiptMarker";
import ReactionPicker from "../emojipicker/ReactionPicker";
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
import { isContentActionable } from "../../../utils/EventUtils";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { copyPlaintext } from "../../../utils/strings";
@@ -83,22 +58,25 @@ import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import PosthogTrackers from "../../../PosthogTrackers";
import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory";
import { ReadReceiptGroup } from "./ReadReceiptGroup";
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
import PinningUtils from "../../../utils/PinningUtils";
import { EventPreview } from "./EventPreview";
import { ActionBarAdapter } from "./EventTile/ActionBarAdapter";
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
import SettingsStore from "../../../settings/SettingsStore";
import { CardContext } from "../right_panel/context";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter";
import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter";
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 {
EventTileViewModel,
type EventTileViewModelProps,
} from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
import { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel";
import {
getEventTileReceiptState,
type EventTileReceiptState,
@@ -121,28 +99,13 @@ import {
initialEventTileInteractionState,
type EventTileInteractionState,
} from "../../../viewmodels/room/timeline/event-tile/EventTileInteractionState";
import {
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 { type MessageTimestampViewModelProps } from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts";
import {
getEventTileReactionRelations,
isEventTileReactionRelation,
type GetRelationsForEvent,
} from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
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 { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
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">
<ThreadsIcon />
<span className="mx_ThreadPanel_replies_amount">{threadState.thread.length}</span>
<ThreadMessagePreviewWrapper thread={threadState.thread} />
<ThreadMessagePreviewAdapter eventTileViewModel={this.viewModel} thread={threadState.thread} />
</div>
);
}
@@ -521,7 +484,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode {
if (threadState.shouldShowThreadSummary && threadState.thread) {
return (
<ThreadSummaryWrapper
<ThreadSummaryAdapter
eventTileViewModel={this.viewModel}
mxEvent={this.props.mxEvent}
thread={threadState.thread}
data-testid="thread-summary"
@@ -712,7 +676,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
return null;
case "messageShared":
return (
<E2eMessageSharedIconWrapper
<E2eMessageSharedIconAdapter
eventTileViewModel={this.viewModel}
keyForwardingUserId={e2ePadlockViewState.keyForwardingUserId}
roomId={e2ePadlockViewState.roomId}
/>
@@ -1041,34 +1006,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// Local echos have a send "status".
const scrollToken = eventTileRenderState.root.scrollToken;
let avatar: JSX.Element | null = null;
let sender: JSX.Element | null = null;
const { avatarSize } = eventTileSnapshot.sender.profileState;
if (this.props.mxEvent.sender && avatarSize !== null) {
avatar = (
<div className="mx_EventTile_avatar">
<MemberAvatar
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 avatar = (
<EventTileAvatarAdapter mxEvent={this.props.mxEvent} senderSnapshot={eventTileSnapshot.sender} />
);
const sender = (
<EventTileSenderAdapter
mxEvent={this.props.mxEvent}
senderSnapshot={eventTileSnapshot.sender}
onSenderProfileClick={this.onSenderProfileClick}
/>
);
const actionBar = eventTileSnapshot.actionBar.show ? (
<ActionBarWrapper
<ActionBarAdapter
eventTileViewModel={this.viewModel}
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator}
@@ -1093,7 +1044,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
) : null;
const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp ? (
<MessageTimestampAdapter
vm={this.viewModel.getMessageTimestampViewModel(messageTimestampProps)}
eventTileViewModel={this.viewModel}
kind="plain"
timestampProps={messageTimestampProps}
/>
) : (
@@ -1101,7 +1053,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
);
const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp ? (
<MessageTimestampAdapter
vm={this.viewModel.getLinkedMessageTimestampViewModel(linkedMessageTimestampProps)}
eventTileViewModel={this.viewModel}
kind="linked"
timestampProps={linkedMessageTimestampProps}
/>
) : (
@@ -1116,7 +1069,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
let reactionsRow: JSX.Element | undefined;
if (hasReactionsRow) {
reactionsRow = (
<ReactionsRowWrapper
<ReactionsRowAdapter
eventTileViewModel={this.viewModel}
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
key="mx_EventTile_reactionsRow"
@@ -1129,21 +1083,18 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const groupPadlock = eventTileRenderState.e2ePadlock.showInGroupLine && this.renderE2EPadlock();
const ircPadlock = eventTileRenderState.e2ePadlock.showInIrcLine && this.renderE2EPadlock();
const receiptState = this.receiptState;
let msgOption: JSX.Element | undefined;
if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) {
msgOption = <SentReceipt messageState={this.props.eventSendStatus} />;
} else if (this.props.showReadReceipts) {
msgOption = (
<ReadReceiptGroup
readReceipts={this.props.readReceipts ?? []}
readReceiptMap={this.props.readReceiptMap ?? {}}
checkUnmounting={this.props.checkUnmounting}
suppressAnimation={this.suppressReadReceiptAnimation}
isTwelveHour={this.props.isTwelveHour}
/>
);
}
const msgOption = (
<ReceiptAdapter
receiptState={this.receiptState}
eventSendStatus={this.props.eventSendStatus}
showReadReceipts={this.props.showReadReceipts}
readReceipts={this.props.readReceipts}
readReceiptMap={this.props.readReceiptMap}
checkUnmounting={this.props.checkUnmounting}
suppressAnimation={this.suppressReadReceiptAnimation}
isTwelveHour={this.props.isTwelveHour}
/>
);
const replyChainState = getEventTileReplyChainState({
mxEvent: this.props.mxEvent,
@@ -1315,10 +1266,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
</div>
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
<ThreadListActionBarAdapter
vm={this.viewModel.getThreadListActionBarViewModel({
onViewInRoomClick: this.onViewInRoomClick,
onCopyLinkClick: this.onCopyLinkToThreadClick,
})}
eventTileViewModel={this.viewModel}
onViewInRoomClick={this.onViewInRoomClick}
onCopyLinkClick={this.onCopyLinkToThreadClick}
className="mx_ThreadActionBar"
@@ -1513,585 +1461,3 @@ const SafeEventTile = (props: EventTileProps): JSX.Element => {
);
};
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 { E2ePadlock, type E2ePadlockIcon } from "@element-hq/web-shared-components";
/**
* Props for the {@link E2eStandardPadlockIcon} component.
*/
interface E2eStandardPadlockIconProps {
/** Icon variant to render. */
icon: E2ePadlockIcon;
/** Accessible title for the icon. */
title: string;
}
/**
* Renders the standard end-to-end encryption padlock icon.
*/
export function E2eStandardPadlockIcon({ icon, title }: Readonly<E2eStandardPadlockIconProps>): JSX.Element {
return (
<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());
}
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 {
if (this.props.roomId === roomId) return;
@@ -34,10 +34,22 @@ import {
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
import { type Layout } from "../../../../settings/enums/Layout";
import { MessageTimestampViewModel, type MessageTimestampViewModelProps } from "./timestamp/MessageTimestampViewModel";
import {
ThreadMessagePreviewViewModel,
type ThreadMessagePreviewViewModelProps,
ThreadSummaryViewModel,
type ThreadSummaryViewModelProps,
} from "./ThreadSummaryViewModel.tsx";
import {
E2eMessageSharedIconViewModel,
type E2eMessageSharedIconViewModelProps,
} from "./E2eMessageSharedIconViewModel";
import {
ThreadListActionBarViewModel,
type ThreadListActionBarViewModelProps,
} from "../../ThreadListActionBarViewModel";
import { EventTileActionBarViewModel, type EventTileActionBarViewModelProps } from "../../EventTileActionBarViewModel";
import { ReactionsRowViewModel, type ReactionsRowViewModelProps } from "./reactions/ReactionsRowViewModel";
/** Event-level inputs for deriving the EventTile snapshot. */
export interface EventTileEventInput {
@@ -285,7 +297,12 @@ export interface EventTileRenderState {
export class EventTileViewModel extends BaseViewModel<EventTileRenderState, EventTileViewModelProps> {
private messageTimestampViewModel?: MessageTimestampViewModel;
private linkedMessageTimestampViewModel?: MessageTimestampViewModel;
private threadMessagePreviewViewModel?: ThreadMessagePreviewViewModel;
private threadSummaryViewModel?: ThreadSummaryViewModel;
private threadListActionBarViewModel?: ThreadListActionBarViewModel;
private e2eMessageSharedIconViewModel?: E2eMessageSharedIconViewModel;
private actionBarViewModel?: EventTileActionBarViewModel;
private reactionsRowViewModel?: ReactionsRowViewModel;
public constructor(props: EventTileViewModelProps) {
const initialRenderState = EventTileViewModel.createRenderState(props);
@@ -302,7 +319,12 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
public override dispose(): void {
this.messageTimestampViewModel?.dispose();
this.linkedMessageTimestampViewModel?.dispose();
this.threadMessagePreviewViewModel?.dispose();
this.threadSummaryViewModel?.dispose();
this.threadListActionBarViewModel?.dispose();
this.e2eMessageSharedIconViewModel?.dispose();
this.actionBarViewModel?.dispose();
this.reactionsRowViewModel?.dispose();
super.dispose();
}
@@ -318,12 +340,72 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
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. */
public getThreadListActionBarViewModel(props: ThreadListActionBarViewModelProps): ThreadListActionBarViewModel {
this.threadListActionBarViewModel ??= new ThreadListActionBarViewModel(props);
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. */
public static createRenderState(props: EventTileViewModelProps): EventTileRenderState {
const snapshot = EventTileViewModel.createSnapshot(props);