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:
@@ -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];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user