diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 907612b0ce4..2644edc7103 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -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
{threadState.thread.length} - +
); } @@ -521,7 +484,8 @@ export class UnwrappedEventTile extends React.Component private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode { if (threadState.shouldShowThreadSummary && threadState.thread) { return ( - return null; case "messageShared": return ( - @@ -1041,34 +1006,20 @@ export class UnwrappedEventTile extends React.Component // 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 = ( -
- -
- ); - } - - const senderProfileMode = eventTileSnapshot.sender.profileMode; - if (senderProfileMode === "clickable") { - sender = ; - } else if (senderProfileMode === "tooltip") { - sender = ; - } else if (senderProfileMode === "default") { - sender = ; - } + const avatar = ( + + ); + const sender = ( + + ); const actionBar = eventTileSnapshot.actionBar.show ? ( - ) : null; const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp ? ( ) : ( @@ -1101,7 +1053,8 @@ export class UnwrappedEventTile extends React.Component ); const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp ? ( ) : ( @@ -1116,7 +1069,8 @@ export class UnwrappedEventTile extends React.Component let reactionsRow: JSX.Element | undefined; if (hasReactionsRow) { reactionsRow = ( - 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 = ; - } else if (this.props.showReadReceipts) { - msgOption = ( - - ); - } + const msgOption = ( + + ); const replyChainState = getEventTileReplyChainState({ mxEvent: this.props.mxEvent, @@ -1315,10 +1266,7 @@ export class UnwrappedEventTile extends React.Component {this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && ( { ); }; 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 = ; - label = _t("timeline|send_state_encrypting"); - } else if (isSent) { - icon = ; - label = _t("timeline|send_state_sent"); - } else if (isFailed) { - icon = ; - label = _t("timeline|send_state_failed"); - } else { - icon = ; - label = _t("timeline|send_state_sending"); - } - - return ( -
-
- -
- {icon} -
-
-
-
- ); -} - -interface MessageTimestampAdapterProps { - vm: MessageTimestampViewModel; - timestampProps: MessageTimestampViewModelProps; -} - -function MessageTimestampAdapter({ vm, timestampProps }: Readonly): JSX.Element { - useEffect(() => { - vm.setProps(timestampProps); - }, [vm, timestampProps]); - - return ( - <> - {timestampProps.receivedTs ? ( - - ) : undefined} - - - ); -} - -interface ThreadMessagePreviewWrapperProps { - thread: Thread; - showDisplayName?: boolean; -} - -function ThreadMessagePreviewWrapper({ - thread, - showDisplayName = false, -}: Readonly): 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 ; -} - -interface ThreadSummaryWrapperProps extends Omit, "aria-label" | "onClick"> { - mxEvent: MatrixEvent; - thread: Thread; -} - -function ThreadSummaryWrapper({ - mxEvent, - thread, - className, - ...props -}: Readonly): 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 ; -} - -interface ThreadListActionBarAdapterProps { - vm: ThreadListActionBarViewModel; - onViewInRoomClick: (anchor: HTMLElement | null) => void; - onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; - className?: string; -} - -function ThreadListActionBarAdapter({ - vm, - onViewInRoomClick, - onCopyLinkClick, - className, -}: Readonly): JSX.Element { - useEffect(() => { - vm.setProps({ - onViewInRoomClick, - onCopyLinkClick, - }); - }, [vm, onViewInRoomClick, onCopyLinkClick]); - - return ; -} - -interface ReactionsRowButtonItemProps { - mxEvent: MatrixEvent; - content: string; - count: number; - reactionEvents: MatrixEvent[]; - myReactionEvent?: MatrixEvent; - disabled?: boolean; - customReactionImagesEnabled?: boolean; -} - -function ReactionsRowButtonItem(props: Readonly): 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 ; -} - -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): JSX.Element | null { - const roomContext = useContext(RoomContext); - const userId = roomContext.room?.client.getUserId() ?? undefined; - const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); - const [myReactions, setMyReactions] = useState(() => getMyReactions(reactions, userId)); - const [menuDisplayed, setMenuDisplayed] = useState(false); - const [menuAnchorRect, setMenuAnchorRect] = useState(null); - - const vm = useCreateAutoDisposedViewModel( - () => - new ReactionsRowViewModel({ - isActionable: isContentActionable(mxEvent), - reactionGroupCount: reactionGroups.length, - canReact: roomContext.canReact, - addReactionButtonActive: false, - }), - ); - - const openReactionMenu = useCallback((event: React.MouseEvent): 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 ( - - ); - }); - - 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 = ( - - - - ); - } - - return ( - <> - - {items} - - {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): JSX.Element { - const roomContext = useContext(RoomContext); - const { isCard } = useContext(CardContext); - const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState(null); - const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState(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 ( - <> - - {optionsMenuAnchorRect ? ( - - ) : null} - {reactionsMenuAnchorRect ? ( - - - - ) : 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): 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 ( - - ); -} diff --git a/apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx new file mode 100644 index 00000000000..207768f98a1 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ActionBarAdapter.tsx @@ -0,0 +1,163 @@ +/* +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"; + +interface ActionBarEventTileOps { + isWidgetHidden(): boolean; + unhideWidget(): void; +} + +interface ActionBarEventTile { + getEventTileOps?(): ActionBarEventTileOps; +} + +interface ActionBarAdapterProps { + eventTileViewModel: EventTileViewModel; + mxEvent: MatrixEvent; + reactions?: Relations | null; + permalinkCreator?: RoomPermalinkCreator; + getTile: () => ActionBarEventTile | null; + getReplyChain: () => ReplyChain | null; + onFocusChange?: (focused: boolean) => void; + isQuoteExpanded?: boolean; + toggleThreadExpanded: () => void; + getRelationsForEvent?: GetRelationsForEvent; +} + +export function ActionBarAdapter({ + eventTileViewModel, + mxEvent, + reactions, + permalinkCreator, + getTile, + getReplyChain, + onFocusChange, + isQuoteExpanded, + toggleThreadExpanded, + getRelationsForEvent, +}: Readonly): JSX.Element { + const roomContext = useContext(RoomContext); + const { isCard } = useContext(CardContext); + const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState(null); + const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState(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 ( + <> + + {optionsMenuAnchorRect ? ( + + ) : null} + {reactionsMenuAnchorRect ? ( + + + + ) : null} + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx new file mode 100644 index 00000000000..a1e38e68d75 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/E2eMessageSharedIconAdapter.tsx @@ -0,0 +1,64 @@ +/* +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"; + +interface E2eMessageSharedIconAdapterProps { + 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; +} + +export function E2eMessageSharedIconAdapter({ + eventTileViewModel, + roomId, + keyForwardingUserId, +}: Readonly): 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 ( + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx new file mode 100644 index 00000000000..255ac6ccd98 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/MessageTimestampAdapter.tsx @@ -0,0 +1,43 @@ +/* +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"; + +interface MessageTimestampAdapterProps { + eventTileViewModel: EventTileViewModel; + kind: "plain" | "linked"; + timestampProps: MessageTimestampViewModelProps; +} + +export function MessageTimestampAdapter({ + eventTileViewModel, + kind, + timestampProps, +}: Readonly): JSX.Element { + const vm = + kind === "linked" + ? eventTileViewModel.getLinkedMessageTimestampViewModel(timestampProps) + : eventTileViewModel.getMessageTimestampViewModel(timestampProps); + + useEffect(() => { + vm.setProps(timestampProps); + }, [vm, timestampProps]); + + return ( + <> + {timestampProps.receivedTs ? ( + + ) : undefined} + + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx new file mode 100644 index 00000000000..3dcb3239cc0 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ReactionsRowAdapter.tsx @@ -0,0 +1,267 @@ +/* +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"; + +interface ReactionsRowButtonAdapterProps { + mxEvent: MatrixEvent; + content: string; + count: number; + reactionEvents: MatrixEvent[]; + myReactionEvent?: MatrixEvent; + disabled?: boolean; + customReactionImagesEnabled?: boolean; +} + +function ReactionsRowButtonAdapter(props: Readonly): 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 ; +} + +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 ReactionsRowAdapterProps { + eventTileViewModel: EventTileViewModel; + mxEvent: MatrixEvent; + reactions?: Relations | null; +} + +export function ReactionsRowAdapter({ + eventTileViewModel, + mxEvent, + reactions, +}: Readonly): JSX.Element | null { + const roomContext = useContext(RoomContext); + const userId = roomContext.room?.client.getUserId() ?? undefined; + const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); + const [myReactions, setMyReactions] = useState(() => getMyReactions(reactions, userId)); + const [menuDisplayed, setMenuDisplayed] = useState(false); + const [menuAnchorRect, setMenuAnchorRect] = useState(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): 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 ( + + ); + }); + + 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 = ( + + + + ); + } + + return ( + <> + + {items} + + {contextMenu} + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx new file mode 100644 index 00000000000..1803583a93d --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ReceiptAdapter.tsx @@ -0,0 +1,101 @@ +/* +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"; + +interface ReadReceiptProps { + userId: string; + roomMember: RoomMember | null; + ts: number; +} + +interface ReceiptAdapterProps { + receiptState: EventTileReceiptState; + eventSendStatus?: EventStatus; + showReadReceipts?: boolean; + readReceipts?: ReadReceiptProps[]; + readReceiptMap?: { [userId: string]: IReadReceiptPosition }; + checkUnmounting?: () => boolean; + suppressAnimation: boolean; + isTwelveHour?: boolean; +} + +export function ReceiptAdapter({ + receiptState, + eventSendStatus, + showReadReceipts, + readReceipts, + readReceiptMap, + checkUnmounting, + suppressAnimation, + isTwelveHour, +}: Readonly): JSX.Element | null { + if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) { + return ; + } + + if (!showReadReceipts) { + return null; + } + + return ( + + ); +} + +interface SentReceiptProps { + messageState: EventStatus | undefined; +} + +function SentReceipt({ messageState }: Readonly): 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 = ; + label = _t("timeline|send_state_encrypting"); + } else if (isSent) { + icon = ; + label = _t("timeline|send_state_sent"); + } else if (isFailed) { + icon = ; + label = _t("timeline|send_state_failed"); + } else { + icon = ; + label = _t("timeline|send_state_sending"); + } + + return ( +
+
+ +
+ {icon} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx new file mode 100644 index 00000000000..4145c104071 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/SenderIdentityAdapter.tsx @@ -0,0 +1,63 @@ +/* +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"; + +interface EventTileAvatarAdapterProps { + mxEvent: MatrixEvent; + senderSnapshot: EventTileSenderSnapshot; +} + +export function EventTileAvatarAdapter({ + mxEvent, + senderSnapshot, +}: Readonly): JSX.Element | null { + const { avatarSize } = senderSnapshot.profileState; + + if (!mxEvent.sender || avatarSize === null) { + return null; + } + + return ( +
+ +
+ ); +} + +interface EventTileSenderAdapterProps { + mxEvent: MatrixEvent; + senderSnapshot: EventTileSenderSnapshot; + onSenderProfileClick: () => void; +} + +export function EventTileSenderAdapter({ + mxEvent, + senderSnapshot, + onSenderProfileClick, +}: Readonly): JSX.Element | null { + switch (senderSnapshot.profileMode) { + case "clickable": + return ; + case "tooltip": + return ; + case "default": + return ; + default: + return null; + } +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx new file mode 100644 index 00000000000..d33cc83b312 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadListActionBarAdapter.tsx @@ -0,0 +1,39 @@ +/* +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"; + +interface ThreadListActionBarAdapterProps { + eventTileViewModel: EventTileViewModel; + onViewInRoomClick: (anchor: HTMLElement | null) => void; + onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; + className?: string; +} + +export function ThreadListActionBarAdapter({ + eventTileViewModel, + onViewInRoomClick, + onCopyLinkClick, + className, +}: Readonly): JSX.Element { + const vm = eventTileViewModel.getThreadListActionBarViewModel({ + onViewInRoomClick, + onCopyLinkClick, + }); + + useEffect(() => { + vm.setProps({ + onViewInRoomClick, + onCopyLinkClick, + }); + }, [vm, onViewInRoomClick, onCopyLinkClick]); + + return ; +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx new file mode 100644 index 00000000000..83d61a30493 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadMessagePreviewAdapter.tsx @@ -0,0 +1,62 @@ +/* +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"; + +interface ThreadMessagePreviewAdapterProps { + eventTileViewModel: EventTileViewModel; + thread: Thread; + showDisplayName?: boolean; +} + +export function ThreadMessagePreviewAdapter({ + eventTileViewModel, + thread, + showDisplayName = false, +}: Readonly): 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 ; +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx new file mode 100644 index 00000000000..7aba814a244 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadSummaryAdapter.tsx @@ -0,0 +1,72 @@ +/* +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"; + +interface ThreadSummaryAdapterProps extends Omit, "aria-label" | "onClick"> { + eventTileViewModel: EventTileViewModel; + mxEvent: MatrixEvent; + thread: Thread; +} + +export function ThreadSummaryAdapter({ + eventTileViewModel, + mxEvent, + thread, + className, + ...props +}: Readonly): 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 ; +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts index c992b4f54be..70e5464c0d4 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel.ts @@ -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; diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts index ac6800bee97..f707582036b 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -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 { 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