From 03360eb260fec186a4b24aaf4ac705c90974321d Mon Sep 17 00:00:00 2001 From: rbondesson Date: Fri, 22 May 2026 20:25:32 +0200 Subject: [PATCH] Refactor EventTile using the MVVM pattern - #5a (#33587) * Extract EventTile receipt state * Extract EventTile thread state * Extract EventTile reaction relation state * Extract EventTile reply-chain state * Keep reply chain collapse wiring in EventTile * Reuse message event eligibility for receipts --- .../components/structures/MessagePanel.tsx | 2 +- .../src/components/views/rooms/EventTile.tsx | 209 +++++++----------- .../event-tile/EventTileReceiptState.ts | 80 +++++++ .../event-tile/EventTileReplyChainState.ts | 34 +++ .../event-tile/EventTileThreadState.ts | 96 ++++++++ .../reactions/EventTileReactionState.ts | 52 +++++ .../EventTileReactionState-test.ts | 97 ++++++++ .../event-tiles/EventTileReceiptState-test.ts | 140 ++++++++++++ .../EventTileReplyChainState-test.ts | 79 +++++++ .../event-tiles/EventTileThreadState-test.ts | 164 ++++++++++++++ 10 files changed, 824 insertions(+), 129 deletions(-) create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/EventTileReceiptState.ts create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/EventTileReplyChainState.ts create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/EventTileThreadState.ts create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/reactions/EventTileReactionState.ts create mode 100644 apps/web/test/viewmodels/event-tiles/EventTileReactionState-test.ts create mode 100644 apps/web/test/viewmodels/event-tiles/EventTileReceiptState-test.ts create mode 100644 apps/web/test/viewmodels/event-tiles/EventTileReplyChainState-test.ts create mode 100644 apps/web/test/viewmodels/event-tiles/EventTileThreadState-test.ts diff --git a/apps/web/src/components/structures/MessagePanel.tsx b/apps/web/src/components/structures/MessagePanel.tsx index a38f264aab..73136b16c2 100644 --- a/apps/web/src/components/structures/MessagePanel.tsx +++ b/apps/web/src/components/structures/MessagePanel.tsx @@ -34,7 +34,6 @@ import { Layout } from "../../settings/enums/Layout"; import EventTile, { type GetRelationsForEvent, type IReadReceiptProps, - isEligibleForSpecialReceipt, type UnwrappedEventTile, } from "../views/rooms/EventTile"; import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer"; @@ -58,6 +57,7 @@ import { CreationGrouper } from "./grouper/CreationGrouper"; import { _t } from "../../languageHandler"; import { getLateEventInfo } from "./grouper/LateEventGrouper"; import { DateSeparatorViewModel } from "../../viewmodels/room/timeline/DateSeparatorViewModel"; +import { isEligibleForSpecialReceipt } from "../../viewmodels/room/timeline/event-tile/EventTileReceiptState"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 1b84f9e7fa..cec0ac9cc2 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -26,9 +26,7 @@ import { EventType, type MatrixEvent, MatrixEventEvent, - type NotificationCountType, type Relations, - type RelationType, type Room, RelationsEvent, RoomEvent, @@ -89,7 +87,6 @@ import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { copyPlaintext } from "../../../utils/strings"; import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { shouldDisplayReply } from "../../../utils/Reply"; import PosthogTrackers from "../../../PosthogTrackers"; import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory"; import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; @@ -105,6 +102,16 @@ import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx"; import SettingsStore from "../../../settings/SettingsStore"; import { CardContext } from "../right_panel/context"; import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; +import { + getEventTileReceiptState, + type EventTileReceiptState, +} from "../../../viewmodels/room/timeline/event-tile/EventTileReceiptState"; +import { + getEventTileThread, + getEventTileThreadState, + type EventTileThreadState, +} from "../../../viewmodels/room/timeline/event-tile/EventTileThreadState"; +import { getEventTileReplyChainState } from "../../../viewmodels/room/timeline/event-tile/EventTileReplyChainState"; import { eventTileActionBarFocusChange, eventTileBlurWithin, @@ -126,6 +133,11 @@ import { MAX_ITEMS_WHEN_LIMITED, ReactionsRowViewModel, } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel"; +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 { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel"; @@ -133,11 +145,8 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useSettingValue } from "../../../hooks/useSettings"; import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory"; -export type GetRelationsForEvent = ( - eventId: string, - relationType: RelationType | string, - eventType: EventType | string, -) => Relations | null | undefined; +/** Relation lookup type retained for EventTile consumers. */ +export type { GetRelationsForEvent } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; // Our component structure for EventTiles on the timeline is: // @@ -302,22 +311,6 @@ interface IState { isQuoteExpanded?: boolean; thread: Thread | null; - threadNotification?: NotificationCountType; -} - -/** - * When true, the tile qualifies for some sort of special read receipt. - * This could be a 'sending' or 'sent' receipt, for example. - * @returns {boolean} - */ -export function isEligibleForSpecialReceipt(event: MatrixEvent): boolean { - // Determine if the type is relevant to the user. - // This notably excludes state events and pretty much anything that can't be sent by the composer as a message. - // For those we rely on local echo giving the impression of things changing, and expect them to be quick. - if (!isMessageEvent(event) && event.getType() !== EventType.RoomMessageEncrypted) return false; - - // Default case - return true; } // MUST be rendered within a RoomContext with a set timelineRenderingType @@ -344,7 +337,10 @@ export class UnwrappedEventTile extends React.Component public constructor(props: EventTileProps, context: React.ContextType) { super(props, context); - const thread = this.thread; + const thread = getEventTileThread( + this.props.mxEvent, + MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()), + ); this.state = { interaction: initialEventTileInteractionState, @@ -369,62 +365,18 @@ export class UnwrappedEventTile extends React.Component this.isListeningForReceipts = false; } - /** - * When true, the tile qualifies for some sort of special read receipt. This could be a 'sending' - * or 'sent' receipt, for example. - * @returns {boolean} - */ - private get isEligibleForSpecialReceipt(): boolean { - // First, if there are other read receipts then just short-circuit this. - if (this.props.readReceipts && this.props.readReceipts.length > 0) return false; - if (!this.props.mxEvent) return false; + private get receiptState(): EventTileReceiptState { + const client = MatrixClientPeg.safeGet(); - // Sanity check (should never happen, but we shouldn't explode if it does) - const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()); - if (!room) return false; - - // Quickly check to see if the event was sent by us. If it wasn't, it won't qualify for - // special read receipts. - const myUserId = MatrixClientPeg.safeGet().getSafeUserId(); - // Check to see if the event was sent by us. If it wasn't, it won't qualify for special read receipts. - if (this.props.mxEvent.getSender() !== myUserId) return false; - return isEligibleForSpecialReceipt(this.props.mxEvent); - } - - private get shouldShowSentReceipt(): boolean { - // If we're not even eligible, don't show the receipt. - if (!this.isEligibleForSpecialReceipt) return false; - - // We only show the 'sent' receipt on the last successful event. - if (!this.props.lastSuccessful) return false; - - // Don't show this in the thread view as it conflicts with the thread counter. - if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) return false; - - // Check to make sure the sending state is appropriate. A null/undefined send status means - // that the message is 'sent', so we're just double checking that it's explicitly not sent. - if (this.props.eventSendStatus && this.props.eventSendStatus !== EventStatus.SENT) return false; - - // If anyone has read the event besides us, we don't want to show a sent receipt. - const receipts = this.props.readReceipts || []; - const myUserId = MatrixClientPeg.safeGet().getUserId(); - if (receipts.some((r) => r.userId !== myUserId)) return false; - - // Finally, we should show a receipt. - return true; - } - - private get shouldShowSendingReceipt(): boolean { - // If we're not even eligible, don't show the receipt. - if (!this.isEligibleForSpecialReceipt) return false; - - // Check the event send status to see if we are pending. Null/undefined status means the - // message was sent, so check for that and 'sent' explicitly. - if (!this.props.eventSendStatus || this.props.eventSendStatus === EventStatus.SENT) return false; - - // Default to showing - there's no other event properties/behaviours we care about at - // this point. - return true; + return getEventTileReceiptState({ + mxEvent: this.props.mxEvent, + readReceipts: this.props.readReceipts, + hasRoom: !!client.getRoom(this.props.mxEvent.getRoomId()), + ownUserId: client.getSafeUserId(), + lastSuccessful: this.props.lastSuccessful, + eventSendStatus: this.props.eventSendStatus, + timelineRenderingType: this.context.timelineRenderingType, + }); } public componentDidMount(): void { @@ -440,7 +392,7 @@ export class UnwrappedEventTile extends React.Component this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); } - if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { + if (this.receiptState.shouldListenForReceipts) { client.on(RoomEvent.Receipt, this.onRoomReceipt); this.isListeningForReceipts = true; } @@ -498,7 +450,7 @@ export class UnwrappedEventTile extends React.Component } // If we're not listening for receipts and expect to be, register a listener. - if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) { + if (!this.isListeningForReceipts && this.receiptState.shouldListenForReceipts) { MatrixClientPeg.safeGet().on(RoomEvent.Receipt, this.onRoomReceipt); this.isListeningForReceipts = true; } @@ -531,52 +483,46 @@ export class UnwrappedEventTile extends React.Component } }; - private get thread(): Thread | null { - let thread: Thread | undefined = this.props.mxEvent.getThread(); - /** - * Accessing the threads value through the room due to a race condition - * that will be solved when there are proper backend support for threads - * We currently have no reliable way to discover than an event is a thread - * when we are at the sync stage - */ - if (!thread) { - const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()); - thread = room?.findThreadForEvent(this.props.mxEvent) ?? undefined; - } - return thread ?? null; + private get threadState(): EventTileThreadState { + return getEventTileThreadState({ + mxEvent: this.props.mxEvent, + thread: this.state.thread, + timelineRenderingType: this.context.timelineRenderingType, + highlightLink: this.props.highlightLink, + }); } - private renderThreadPanelSummary(): JSX.Element | null { - if (!this.state.thread) { + private renderThreadPanelSummary(threadState: EventTileThreadState): JSX.Element | null { + if (!threadState.shouldShowThreadPanelSummary || !threadState.thread) { return null; } return (
- {this.state.thread.length} - + {threadState.thread.length} +
); } - private renderThreadInfo(): React.ReactNode { - if (this.state.thread && this.state.thread.id === this.props.mxEvent.getId()) { + private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode { + if (threadState.shouldShowThreadSummary && threadState.thread) { return ( - + ); } - if (this.context.timelineRenderingType === TimelineRenderingType.Search && this.props.mxEvent.threadRootId) { - if (this.props.highlightLink) { - return ( - - - {_t("timeline|thread_info_basic")} - - ); - } + if (threadState.searchThreadInfo.kind === "link") { + return ( + + + {_t("timeline|thread_info_basic")} + + ); + } + if (threadState.searchThreadInfo.kind === "text") { return (

@@ -608,7 +554,7 @@ export class UnwrappedEventTile extends React.Component const tileRoom = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()); if (room !== tileRoom) return; - if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt && !this.isListeningForReceipts) { + if (!this.receiptState.shouldListenForReceipts && !this.isListeningForReceipts) { return; } @@ -616,7 +562,7 @@ export class UnwrappedEventTile extends React.Component // the getters we use here to determine what needs rendering. this.forceUpdate(() => { // Per elsewhere in this file, we can remove the listener once we will have no further purpose for it. - if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt) { + if (!this.receiptState.shouldListenForReceipts) { MatrixClientPeg.safeGet().removeListener(RoomEvent.Receipt, this.onRoomReceipt); this.isListeningForReceipts = false; } @@ -965,15 +911,15 @@ export class UnwrappedEventTile extends React.Component private readonly getReplyChain = (): ReplyChain | null => this.replyChain.current; private readonly getReactions = (): Relations | null => { - if (!this.props.showReactions || !this.props.getRelationsForEvent) { - return null; - } - const eventId = this.props.mxEvent.getId()!; - return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction") ?? null; + return getEventTileReactionRelations({ + mxEvent: this.props.mxEvent, + showReactions: this.props.showReactions, + getRelationsForEvent: this.props.getRelationsForEvent, + }); }; private readonly onReactionsCreated = (relationType: string, eventType: string): void => { - if (relationType !== "m.annotation" || eventType !== "m.reaction") { + if (!isEventTileReactionRelation(relationType, eventType)) { return; } this.setState({ @@ -1106,6 +1052,7 @@ export class UnwrappedEventTile extends React.Component const isEditing = !!this.props.editState; const hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent); const hasReactionsRow = !isRedacted; + const threadState = this.threadState; // Use `getSender()` because searched events might not have a proper `sender`. const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId(); @@ -1148,7 +1095,7 @@ export class UnwrappedEventTile extends React.Component timestamp: { alwaysShowTimestamps: this.props.alwaysShowTimestamps, hideTimestamp: this.props.hideTimestamp, - threadReplyEventTs: this.state.thread?.replyToEvent?.getTs(), + threadReplyEventTs: threadState.threadReplyEventTs, }, footer: { isOwnEvent, @@ -1258,8 +1205,9 @@ export class UnwrappedEventTile extends React.Component const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); + const receiptState = this.receiptState; let msgOption: JSX.Element | undefined; - if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { + if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) { msgOption = ; } else if (this.props.showReadReceipts) { msgOption = ( @@ -1273,11 +1221,16 @@ export class UnwrappedEventTile extends React.Component ); } + const replyChainState = getEventTileReplyChainState({ + mxEvent: this.props.mxEvent, + hasRenderer: haveRendererForEvent( + this.props.mxEvent, + MatrixClientPeg.safeGet(), + this.context.showHiddenEvents, + ), + }); let replyChain: JSX.Element | undefined; - if ( - haveRendererForEvent(this.props.mxEvent, MatrixClientPeg.safeGet(), this.context.showHiddenEvents) && - shouldDisplayReply(this.props.mxEvent) - ) { + if (replyChainState.shouldShowReplyChain) { replyChain = ( )} - {this.renderThreadPanelSummary()} + {this.renderThreadPanelSummary(threadState)} {this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && ( {reactionsRow} )} - {this.renderThreadInfo()} + {this.renderThreadInfo(threadState)} )} @@ -1560,7 +1513,7 @@ export class UnwrappedEventTile extends React.Component {showBubblePinnedMessageBadge && pinnedMessageBadge} )} - {this.renderThreadInfo()} + {this.renderThreadInfo(threadState)} )} {msgOption} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileReceiptState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileReceiptState.ts new file mode 100644 index 0000000000..5f321213ef --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileReceiptState.ts @@ -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 { EventStatus, EventType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { TimelineRenderingType } from "../../../../contexts/RoomContext"; +import { isMessageEvent } from "../../../../events/EventTileFactory"; + +interface ReadReceiptLike { + userId: string; +} + +/** Inputs for deriving EventTile receipt display state. */ +export interface EventTileReceiptStateInput { + /** The Matrix event rendered by the tile. */ + mxEvent: MatrixEvent; + /** Read receipts supplied for the tile. */ + readReceipts?: ReadReceiptLike[]; + /** Whether the event room is known locally. */ + hasRoom: boolean; + /** Current user's safe user ID, used for sender eligibility. */ + ownUserId: string; + /** Whether this is the last successful event sent by the current user. */ + lastSuccessful?: boolean; + /** Current event send status. */ + eventSendStatus?: EventStatus | null; + /** The current timeline rendering mode. */ + timelineRenderingType: TimelineRenderingType; +} + +/** EventTile receipt display state. */ +export interface EventTileReceiptState { + /** Whether the event is eligible for a sent/sending receipt. */ + isEligibleForSpecialReceipt: boolean; + /** Whether EventTile should render the sent receipt. */ + shouldShowSentReceipt: boolean; + /** Whether EventTile should render the sending receipt. */ + shouldShowSendingReceipt: boolean; + /** Whether EventTile should listen for receipt updates. */ + shouldListenForReceipts: boolean; +} + +/** + * Whether the event type qualifies for a sent/sending receipt. + * This excludes state events and other events that are not sent by the composer. + */ +export function isEligibleForSpecialReceipt(mxEvent: MatrixEvent): boolean { + return isMessageEvent(mxEvent) || mxEvent.getType() === EventType.RoomMessageEncrypted; +} + +/** Derives receipt display state for EventTile. */ +export function getEventTileReceiptState({ + mxEvent, + readReceipts, + hasRoom, + ownUserId, + lastSuccessful, + eventSendStatus, + timelineRenderingType, +}: EventTileReceiptStateInput): EventTileReceiptState { + const isEligible = + !readReceipts?.length && hasRoom && mxEvent.getSender() === ownUserId && isEligibleForSpecialReceipt(mxEvent); + const shouldShowSentReceipt = + isEligible && + !!lastSuccessful && + timelineRenderingType !== TimelineRenderingType.ThreadsList && + (!eventSendStatus || eventSendStatus === EventStatus.SENT); + const shouldShowSendingReceipt = isEligible && !!eventSendStatus && eventSendStatus !== EventStatus.SENT; + + return { + isEligibleForSpecialReceipt: isEligible, + shouldShowSentReceipt, + shouldShowSendingReceipt, + shouldListenForReceipts: shouldShowSentReceipt || shouldShowSendingReceipt, + }; +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileReplyChainState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileReplyChainState.ts new file mode 100644 index 0000000000..45b20808d8 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileReplyChainState.ts @@ -0,0 +1,34 @@ +/* +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 { type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { shouldDisplayReply } from "../../../../utils/Reply"; + +/** Inputs for deriving EventTile reply-chain display state. */ +export interface EventTileReplyChainStateInput { + /** Matrix event rendered by the tile. */ + mxEvent: MatrixEvent; + /** Whether the event has a renderer in the current timeline context. */ + hasRenderer: boolean; +} + +/** EventTile reply-chain display state. */ +export interface EventTileReplyChainState { + /** Whether EventTile should render ReplyChain. */ + shouldShowReplyChain: boolean; +} + +/** Derives reply-chain display state for EventTile. */ +export function getEventTileReplyChainState({ + mxEvent, + hasRenderer, +}: EventTileReplyChainStateInput): EventTileReplyChainState { + return { + shouldShowReplyChain: hasRenderer && shouldDisplayReply(mxEvent), + }; +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileThreadState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileThreadState.ts new file mode 100644 index 0000000000..e701da998c --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileThreadState.ts @@ -0,0 +1,96 @@ +/* +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 { type MatrixEvent, type Thread } from "matrix-js-sdk/src/matrix"; + +import { TimelineRenderingType } from "../../../../contexts/RoomContext"; + +/** Minimal room surface used to look up a thread for an event. */ +export interface EventTileThreadLookup { + /** Finds the thread associated with an event. */ + findThreadForEvent(mxEvent: MatrixEvent): Thread | null | undefined; +} + +/** Search thread-info rendering kind. */ +export type SearchThreadInfoKind = "none" | "text" | "link"; + +/** Search timeline thread-info display state. */ +export interface EventTileSearchThreadInfo { + /** Kind of search thread info to render. */ + kind: SearchThreadInfoKind; + /** Link target when rendering linked thread info. */ + href?: string; +} + +/** Inputs for deriving EventTile thread display state. */ +export interface EventTileThreadStateInput { + /** Matrix event rendered by the tile. */ + mxEvent: MatrixEvent; + /** Thread associated with the event, when available. */ + thread: Thread | null; + /** Current timeline rendering mode. */ + timelineRenderingType: TimelineRenderingType; + /** Optional search-result link for thread info. */ + highlightLink?: string; +} + +/** EventTile thread display state. */ +export interface EventTileThreadState { + /** Thread associated with the event, when available. */ + thread: Thread | null; + /** Whether EventTile should render the main thread summary. */ + shouldShowThreadSummary: boolean; + /** Whether EventTile should render the thread panel reply summary. */ + shouldShowThreadPanelSummary: boolean; + /** Timestamp of the latest thread reply, when available. */ + threadReplyEventTs?: number; + /** Search timeline thread-info display state. */ + searchThreadInfo: EventTileSearchThreadInfo; +} + +/** + * Finds the thread associated with an event. + * + * Accessing the thread through the room covers a race where the event has not + * discovered its thread yet during sync. + */ +export function getEventTileThread(mxEvent: MatrixEvent, room?: EventTileThreadLookup | null): Thread | null { + return mxEvent.getThread() ?? room?.findThreadForEvent(mxEvent) ?? null; +} + +function getSearchThreadInfo(shouldShowSearchThreadInfo: boolean, highlightLink?: string): EventTileSearchThreadInfo { + if (!shouldShowSearchThreadInfo) { + return { kind: "none" }; + } + + if (highlightLink) { + return { kind: "link", href: highlightLink }; + } + + return { kind: "text" }; +} + +/** Derives thread display state for EventTile. */ +export function getEventTileThreadState({ + mxEvent, + thread, + timelineRenderingType, + highlightLink, +}: EventTileThreadStateInput): EventTileThreadState { + const shouldShowThreadSummary = !!thread && thread.id === mxEvent.getId(); + const shouldShowSearchThreadInfo = + !shouldShowThreadSummary && timelineRenderingType === TimelineRenderingType.Search && !!mxEvent.threadRootId; + const searchThreadInfo = getSearchThreadInfo(shouldShowSearchThreadInfo, highlightLink); + + return { + thread, + shouldShowThreadSummary, + shouldShowThreadPanelSummary: !!thread, + threadReplyEventTs: thread?.replyToEvent?.getTs(), + searchThreadInfo, + }; +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/reactions/EventTileReactionState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/reactions/EventTileReactionState.ts new file mode 100644 index 0000000000..f477614b23 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/reactions/EventTileReactionState.ts @@ -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 { EventType, type MatrixEvent, type Relations, type RelationType } from "matrix-js-sdk/src/matrix"; + +/** Looks up relations for an event by relation and event type. */ +export type GetRelationsForEvent = ( + eventId: string, + relationType: RelationType | string, + eventType: EventType | string, +) => Relations | null | undefined; + +/** Inputs for fetching EventTile reaction relations. */ +export interface EventTileReactionRelationsInput { + /** Matrix event rendered by the tile. */ + mxEvent: MatrixEvent; + /** Whether reactions are enabled for the tile. */ + showReactions?: boolean; + /** Relation lookup function supplied by the timeline. */ + getRelationsForEvent?: GetRelationsForEvent; +} + +/** Relation type used for EventTile reaction annotations. */ +export const EVENT_TILE_REACTION_RELATION_TYPE = "m.annotation"; + +/** Event type used for EventTile reaction annotations. */ +export const EVENT_TILE_REACTION_EVENT_TYPE = EventType.Reaction; + +/** Whether a created relation event should refresh EventTile reactions. */ +export function isEventTileReactionRelation(relationType: string, eventType: string): boolean { + return relationType === EVENT_TILE_REACTION_RELATION_TYPE && eventType === EVENT_TILE_REACTION_EVENT_TYPE; +} + +/** Fetches reaction relations for EventTile when reactions are enabled. */ +export function getEventTileReactionRelations({ + mxEvent, + showReactions, + getRelationsForEvent, +}: EventTileReactionRelationsInput): Relations | null { + if (!showReactions || !getRelationsForEvent) { + return null; + } + + return ( + getRelationsForEvent(mxEvent.getId()!, EVENT_TILE_REACTION_RELATION_TYPE, EVENT_TILE_REACTION_EVENT_TYPE) ?? + null + ); +} diff --git a/apps/web/test/viewmodels/event-tiles/EventTileReactionState-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileReactionState-test.ts new file mode 100644 index 0000000000..559c5597d5 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/EventTileReactionState-test.ts @@ -0,0 +1,97 @@ +/* +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 { EventType, type MatrixEvent, type Relations, MsgType, RelationType } from "matrix-js-sdk/src/matrix"; + +import { mkEvent } from "../../test-utils"; +import { + EVENT_TILE_REACTION_EVENT_TYPE, + EVENT_TILE_REACTION_RELATION_TYPE, + getEventTileReactionRelations, + isEventTileReactionRelation, + type GetRelationsForEvent, +} from "../../../src/viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; + +const roomId = "!room:example.org"; +const userId = "@alice:example.org"; + +function makeEvent(): MatrixEvent { + return mkEvent({ + event: true, + id: "$event", + type: EventType.RoomMessage, + room: roomId, + user: userId, + content: { + msgtype: MsgType.Text, + body: "Hello", + }, + }); +} + +describe("EventTileReactionState", () => { + it("gets annotation reaction relations when reactions are enabled", () => { + const relations = {} as Relations; + const getRelationsForEvent: jest.MockedFunction = jest.fn().mockReturnValue(relations); + + expect( + getEventTileReactionRelations({ + mxEvent: makeEvent(), + showReactions: true, + getRelationsForEvent, + }), + ).toBe(relations); + expect(getRelationsForEvent).toHaveBeenCalledWith( + "$event", + EVENT_TILE_REACTION_RELATION_TYPE, + EVENT_TILE_REACTION_EVENT_TYPE, + ); + }); + + it("does not get relations when reactions are disabled", () => { + const getRelationsForEvent: jest.MockedFunction = jest.fn(); + + expect( + getEventTileReactionRelations({ + mxEvent: makeEvent(), + showReactions: false, + getRelationsForEvent, + }), + ).toBeNull(); + expect(getRelationsForEvent).not.toHaveBeenCalled(); + }); + + it("does not get relations without a relation lookup", () => { + expect( + getEventTileReactionRelations({ + mxEvent: makeEvent(), + showReactions: true, + }), + ).toBeNull(); + }); + + it("normalizes missing relations to null", () => { + const getRelationsForEvent: jest.MockedFunction = jest.fn().mockReturnValue(undefined); + + expect( + getEventTileReactionRelations({ + mxEvent: makeEvent(), + showReactions: true, + getRelationsForEvent, + }), + ).toBeNull(); + }); + + it("matches reaction relation creation events", () => { + expect(isEventTileReactionRelation(RelationType.Annotation, EventType.Reaction)).toBe(true); + }); + + it("does not match unrelated relation creation events", () => { + expect(isEventTileReactionRelation(RelationType.Reference, EventType.RoomMessage)).toBe(false); + expect(isEventTileReactionRelation(RelationType.Annotation, EventType.RoomMessage)).toBe(false); + }); +}); diff --git a/apps/web/test/viewmodels/event-tiles/EventTileReceiptState-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileReceiptState-test.ts new file mode 100644 index 0000000000..f780a31913 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/EventTileReceiptState-test.ts @@ -0,0 +1,140 @@ +/* +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 { EventStatus, EventType, M_POLL_END, M_POLL_START, type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; + +import { mkEvent } from "../../test-utils"; +import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; +import { + getEventTileReceiptState, + isEligibleForSpecialReceipt, + type EventTileReceiptStateInput, +} from "../../../src/viewmodels/room/timeline/event-tile/EventTileReceiptState"; + +const roomId = "!room:example.org"; +const ownUserId = "@alice:example.org"; +const otherUserId = "@bob:example.org"; + +function makeEvent({ + type = EventType.RoomMessage, + user = ownUserId, +}: { + type?: string; + user?: string; +} = {}): MatrixEvent { + return mkEvent({ + event: true, + type, + room: roomId, + user, + content: { + msgtype: MsgType.Text, + body: "Hello", + }, + }); +} + +function makeInput(overrides: Partial = {}): EventTileReceiptStateInput { + return { + mxEvent: makeEvent(), + hasRoom: true, + ownUserId, + lastSuccessful: true, + timelineRenderingType: TimelineRenderingType.Room, + ...overrides, + }; +} + +describe("EventTileReceiptState", () => { + it.each([ + EventType.RoomMessage, + EventType.RoomMessageEncrypted, + EventType.Sticker, + M_POLL_START.name, + M_POLL_END.name, + ])("treats %s as eligible for special receipts", (type) => { + expect(isEligibleForSpecialReceipt(makeEvent({ type }))).toBe(true); + }); + + it("does not treat state events as eligible for special receipts", () => { + expect(isEligibleForSpecialReceipt(makeEvent({ type: EventType.RoomName }))).toBe(false); + }); + + it("shows a sent receipt for the last successful own event", () => { + const state = getEventTileReceiptState(makeInput()); + + expect(state).toMatchObject({ + isEligibleForSpecialReceipt: true, + shouldShowSentReceipt: true, + shouldShowSendingReceipt: false, + shouldListenForReceipts: true, + }); + }); + + it("shows a sent receipt for an explicitly sent event", () => { + const state = getEventTileReceiptState(makeInput({ eventSendStatus: EventStatus.SENT })); + + expect(state.shouldShowSentReceipt).toBe(true); + expect(state.shouldListenForReceipts).toBe(true); + }); + + it("shows a sending receipt for pending send states", () => { + for (const eventSendStatus of [EventStatus.QUEUED, EventStatus.SENDING, EventStatus.ENCRYPTING]) { + const state = getEventTileReceiptState(makeInput({ eventSendStatus })); + + expect(state.shouldShowSentReceipt).toBe(false); + expect(state.shouldShowSendingReceipt).toBe(true); + expect(state.shouldListenForReceipts).toBe(true); + } + }); + + it("does not show a sent receipt in the thread list", () => { + const state = getEventTileReceiptState( + makeInput({ + timelineRenderingType: TimelineRenderingType.ThreadsList, + }), + ); + + expect(state.shouldShowSentReceipt).toBe(false); + expect(state.shouldListenForReceipts).toBe(false); + }); + + it("does not show special receipts once read receipts are present", () => { + const state = getEventTileReceiptState( + makeInput({ + readReceipts: [ + { + userId: otherUserId, + }, + ], + }), + ); + + expect(state.isEligibleForSpecialReceipt).toBe(false); + expect(state.shouldShowSentReceipt).toBe(false); + expect(state.shouldShowSendingReceipt).toBe(false); + expect(state.shouldListenForReceipts).toBe(false); + }); + + it("does not show special receipts for another sender", () => { + const state = getEventTileReceiptState( + makeInput({ + mxEvent: makeEvent({ user: otherUserId }), + }), + ); + + expect(state.isEligibleForSpecialReceipt).toBe(false); + expect(state.shouldListenForReceipts).toBe(false); + }); + + it("does not show special receipts when the room is not available", () => { + const state = getEventTileReceiptState(makeInput({ hasRoom: false })); + + expect(state.isEligibleForSpecialReceipt).toBe(false); + expect(state.shouldListenForReceipts).toBe(false); + }); +}); diff --git a/apps/web/test/viewmodels/event-tiles/EventTileReplyChainState-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileReplyChainState-test.ts new file mode 100644 index 0000000000..3bcb8adff9 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/EventTileReplyChainState-test.ts @@ -0,0 +1,79 @@ +/* +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 { type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { mkMessage } from "../../test-utils"; +import { getEventTileReplyChainState } from "../../../src/viewmodels/room/timeline/event-tile/EventTileReplyChainState"; + +const roomId = "!room:example.org"; + +function makeMessage(): MatrixEvent { + return mkMessage({ + room: roomId, + user: "@alice:example.org", + msg: "Message", + event: true, + }); +} + +function makeReply(): MatrixEvent { + const parentEvent = makeMessage(); + + return mkMessage({ + room: roomId, + user: "@bob:example.org", + msg: "Reply", + event: true, + relatesTo: { + "m.in_reply_to": { + event_id: parentEvent.getId(), + }, + }, + }); +} + +describe("EventTileReplyChainState", () => { + it("does not show a reply chain when the event has no renderer", () => { + const state = getEventTileReplyChainState({ + mxEvent: makeReply(), + hasRenderer: false, + }); + + expect(state.shouldShowReplyChain).toBe(false); + }); + + it("does not show a reply chain for non-reply events", () => { + const state = getEventTileReplyChainState({ + mxEvent: makeMessage(), + hasRenderer: true, + }); + + expect(state.shouldShowReplyChain).toBe(false); + }); + + it("shows a reply chain for reply events with a renderer", () => { + const state = getEventTileReplyChainState({ + mxEvent: makeReply(), + hasRenderer: true, + }); + + expect(state.shouldShowReplyChain).toBe(true); + }); + + it("does not show a reply chain for redacted reply events", () => { + const replyEvent = makeReply(); + jest.spyOn(replyEvent, "isRedacted").mockReturnValue(true); + + const state = getEventTileReplyChainState({ + mxEvent: replyEvent, + hasRenderer: true, + }); + + expect(state.shouldShowReplyChain).toBe(false); + }); +}); diff --git a/apps/web/test/viewmodels/event-tiles/EventTileThreadState-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileThreadState-test.ts new file mode 100644 index 0000000000..541597e805 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/EventTileThreadState-test.ts @@ -0,0 +1,164 @@ +/* +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 { EventType, type MatrixEvent, MsgType, type Thread } from "matrix-js-sdk/src/matrix"; + +import { mkEvent } from "../../test-utils"; +import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; +import { + getEventTileThread, + getEventTileThreadState, + type EventTileThreadLookup, + type EventTileThreadStateInput, +} from "../../../src/viewmodels/room/timeline/event-tile/EventTileThreadState"; + +const roomId = "!room:example.org"; +const userId = "@alice:example.org"; + +function makeEvent({ id = "$event" }: { id?: string } = {}): MatrixEvent { + return mkEvent({ + event: true, + id, + type: EventType.RoomMessage, + room: roomId, + user: userId, + content: { + msgtype: MsgType.Text, + body: "Hello", + }, + }); +} + +function makeThread({ + id = "$event", + replyToEvent, +}: { + id?: string; + replyToEvent?: MatrixEvent; +} = {}): Thread { + return { + id, + length: 2, + replyToEvent, + } as Thread; +} + +function makeInput(overrides: Partial = {}): EventTileThreadStateInput { + return { + mxEvent: makeEvent(), + thread: null, + timelineRenderingType: TimelineRenderingType.Room, + ...overrides, + }; +} + +function setThreadRootId(mxEvent: MatrixEvent, threadRootId: string): void { + Object.defineProperty(mxEvent, "threadRootId", { + configurable: true, + value: threadRootId, + }); +} + +describe("EventTileThreadState", () => { + it("uses the event thread when it is already available", () => { + const mxEvent = makeEvent(); + const thread = makeThread(); + jest.spyOn(mxEvent, "getThread").mockReturnValue(thread); + const room: EventTileThreadLookup = { + findThreadForEvent: jest.fn(), + }; + + expect(getEventTileThread(mxEvent, room)).toBe(thread); + expect(room.findThreadForEvent).not.toHaveBeenCalled(); + }); + + it("falls back to the room thread lookup", () => { + const mxEvent = makeEvent(); + const thread = makeThread(); + jest.spyOn(mxEvent, "getThread").mockReturnValue(undefined); + const room: EventTileThreadLookup = { + findThreadForEvent: jest.fn().mockReturnValue(thread), + }; + + expect(getEventTileThread(mxEvent, room)).toBe(thread); + expect(room.findThreadForEvent).toHaveBeenCalledWith(mxEvent); + }); + + it("returns null when no thread can be found", () => { + const mxEvent = makeEvent(); + jest.spyOn(mxEvent, "getThread").mockReturnValue(undefined); + + expect(getEventTileThread(mxEvent, null)).toBeNull(); + }); + + it("shows the thread summary for thread root events", () => { + const mxEvent = makeEvent({ id: "$thread-root" }); + const thread = makeThread({ id: "$thread-root" }); + + const state = getEventTileThreadState(makeInput({ mxEvent, thread })); + + expect(state.shouldShowThreadSummary).toBe(true); + expect(state.shouldShowThreadPanelSummary).toBe(true); + expect(state.searchThreadInfo.kind).toBe("none"); + }); + + it("derives the thread panel timestamp from the latest reply", () => { + const replyToEvent = makeEvent({ id: "$reply" }); + jest.spyOn(replyToEvent, "getTs").mockReturnValue(123); + + const state = getEventTileThreadState( + makeInput({ + thread: makeThread({ replyToEvent }), + }), + ); + + expect(state.threadReplyEventTs).toBe(123); + }); + + it("shows linked search thread info for thread replies with a highlight link", () => { + const mxEvent = makeEvent(); + setThreadRootId(mxEvent, "$thread-root"); + + const state = getEventTileThreadState( + makeInput({ + mxEvent, + timelineRenderingType: TimelineRenderingType.Search, + highlightLink: "https://example.org/thread", + }), + ); + + expect(state.searchThreadInfo).toEqual({ + kind: "link", + href: "https://example.org/thread", + }); + }); + + it("shows text search thread info for thread replies without a highlight link", () => { + const mxEvent = makeEvent(); + setThreadRootId(mxEvent, "$thread-root"); + + const state = getEventTileThreadState( + makeInput({ + mxEvent, + timelineRenderingType: TimelineRenderingType.Search, + }), + ); + + expect(state.searchThreadInfo).toEqual({ + kind: "text", + }); + }); + + it("does not show search thread info outside search timelines", () => { + const mxEvent = makeEvent(); + setThreadRootId(mxEvent, "$thread-root"); + + const state = getEventTileThreadState(makeInput({ mxEvent })); + + expect(state.searchThreadInfo.kind).toBe("none"); + }); +});