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
This commit is contained in:
rbondesson
2026-05-22 20:25:32 +02:00
committed by GitHub
parent 6574863fcd
commit 03360eb260
10 changed files with 824 additions and 129 deletions
@@ -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];
+81 -128
View File
@@ -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<EventTileProps, IState>
public constructor(props: EventTileProps, context: React.ContextType<typeof RoomContext>) {
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<EventTileProps, IState>
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<EventTileProps, IState>
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<EventTileProps, IState>
}
// 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<EventTileProps, IState>
}
};
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 (
<div className="mx_ThreadPanel_replies">
<ThreadsIcon />
<span className="mx_ThreadPanel_replies_amount">{this.state.thread.length}</span>
<ThreadMessagePreview thread={this.state.thread} />
<span className="mx_ThreadPanel_replies_amount">{threadState.thread.length}</span>
<ThreadMessagePreview thread={threadState.thread} />
</div>
);
}
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 (
<ThreadSummary mxEvent={this.props.mxEvent} thread={this.state.thread} data-testid="thread-summary" />
<ThreadSummary mxEvent={this.props.mxEvent} thread={threadState.thread} data-testid="thread-summary" />
);
}
if (this.context.timelineRenderingType === TimelineRenderingType.Search && this.props.mxEvent.threadRootId) {
if (this.props.highlightLink) {
return (
<a className="mx_ThreadSummary_icon" href={this.props.highlightLink}>
<ThreadsIcon />
{_t("timeline|thread_info_basic")}
</a>
);
}
if (threadState.searchThreadInfo.kind === "link") {
return (
<a className="mx_ThreadSummary_icon" href={threadState.searchThreadInfo.href}>
<ThreadsIcon />
{_t("timeline|thread_info_basic")}
</a>
);
}
if (threadState.searchThreadInfo.kind === "text") {
return (
<p className="mx_ThreadSummary_icon">
<ThreadsIcon />
@@ -608,7 +554,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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<EventTileProps, IState>
// 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<EventTileProps, IState>
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<EventTileProps, IState>
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<EventTileProps, IState>
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<EventTileProps, IState>
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 = <SentReceipt messageState={this.props.eventSendStatus} />;
} else if (this.props.showReadReceipts) {
msgOption = (
@@ -1273,11 +1221,16 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
);
}
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 = (
<ReplyChain
parentEv={this.props.mxEvent}
@@ -1434,7 +1387,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
<EventPreview mxEvent={this.props.mxEvent} />
)}
</div>
{this.renderThreadPanelSummary()}
{this.renderThreadPanelSummary(threadState)}
</div>
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
<ThreadListActionBarWrapper
@@ -1547,7 +1500,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{reactionsRow}
</div>
)}
{this.renderThreadInfo()}
{this.renderThreadInfo(threadState)}
</>
)}
</div>
@@ -1560,7 +1513,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{showBubblePinnedMessageBadge && pinnedMessageBadge}
</div>
)}
{this.renderThreadInfo()}
{this.renderThreadInfo(threadState)}
</>
)}
{msgOption}
@@ -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,
};
}
@@ -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),
};
}
@@ -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,
};
}
@@ -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
);
}
@@ -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<GetRelationsForEvent> = 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<GetRelationsForEvent> = 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<GetRelationsForEvent> = 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);
});
});
@@ -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> = {}): 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);
});
});
@@ -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);
});
});
@@ -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> = {}): 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");
});
});