mirror of
https://github.com/Novaloop-AG/healthchat-web.git
synced 2026-11-07 23:18:21 +00:00
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, {
|
import EventTile, {
|
||||||
type GetRelationsForEvent,
|
type GetRelationsForEvent,
|
||||||
type IReadReceiptProps,
|
type IReadReceiptProps,
|
||||||
isEligibleForSpecialReceipt,
|
|
||||||
type UnwrappedEventTile,
|
type UnwrappedEventTile,
|
||||||
} from "../views/rooms/EventTile";
|
} from "../views/rooms/EventTile";
|
||||||
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
|
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
|
||||||
@@ -58,6 +57,7 @@ import { CreationGrouper } from "./grouper/CreationGrouper";
|
|||||||
import { _t } from "../../languageHandler";
|
import { _t } from "../../languageHandler";
|
||||||
import { getLateEventInfo } from "./grouper/LateEventGrouper";
|
import { getLateEventInfo } from "./grouper/LateEventGrouper";
|
||||||
import { DateSeparatorViewModel } from "../../viewmodels/room/timeline/DateSeparatorViewModel";
|
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 CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||||
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
|
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ import {
|
|||||||
EventType,
|
EventType,
|
||||||
type MatrixEvent,
|
type MatrixEvent,
|
||||||
MatrixEventEvent,
|
MatrixEventEvent,
|
||||||
type NotificationCountType,
|
|
||||||
type Relations,
|
type Relations,
|
||||||
type RelationType,
|
|
||||||
type Room,
|
type Room,
|
||||||
RelationsEvent,
|
RelationsEvent,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
@@ -89,7 +87,6 @@ import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
|||||||
import { copyPlaintext } from "../../../utils/strings";
|
import { copyPlaintext } from "../../../utils/strings";
|
||||||
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
|
||||||
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import { shouldDisplayReply } from "../../../utils/Reply";
|
|
||||||
import PosthogTrackers from "../../../PosthogTrackers";
|
import PosthogTrackers from "../../../PosthogTrackers";
|
||||||
import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory";
|
import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory";
|
||||||
import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary";
|
import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary";
|
||||||
@@ -105,6 +102,16 @@ import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx";
|
|||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import { CardContext } from "../right_panel/context";
|
import { CardContext } from "../right_panel/context";
|
||||||
import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
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 {
|
import {
|
||||||
eventTileActionBarFocusChange,
|
eventTileActionBarFocusChange,
|
||||||
eventTileBlurWithin,
|
eventTileBlurWithin,
|
||||||
@@ -126,6 +133,11 @@ import {
|
|||||||
MAX_ITEMS_WHEN_LIMITED,
|
MAX_ITEMS_WHEN_LIMITED,
|
||||||
ReactionsRowViewModel,
|
ReactionsRowViewModel,
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/reactions/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 { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel";
|
||||||
import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel";
|
import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel";
|
||||||
import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel";
|
import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel";
|
||||||
@@ -133,11 +145,8 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
|||||||
import { useSettingValue } from "../../../hooks/useSettings";
|
import { useSettingValue } from "../../../hooks/useSettings";
|
||||||
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
|
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
|
||||||
|
|
||||||
export type GetRelationsForEvent = (
|
/** Relation lookup type retained for EventTile consumers. */
|
||||||
eventId: string,
|
export type { GetRelationsForEvent } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
||||||
relationType: RelationType | string,
|
|
||||||
eventType: EventType | string,
|
|
||||||
) => Relations | null | undefined;
|
|
||||||
|
|
||||||
// Our component structure for EventTiles on the timeline is:
|
// Our component structure for EventTiles on the timeline is:
|
||||||
//
|
//
|
||||||
@@ -302,22 +311,6 @@ interface IState {
|
|||||||
isQuoteExpanded?: boolean;
|
isQuoteExpanded?: boolean;
|
||||||
|
|
||||||
thread: Thread | null;
|
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
|
// 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>) {
|
public constructor(props: EventTileProps, context: React.ContextType<typeof RoomContext>) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
|
|
||||||
const thread = this.thread;
|
const thread = getEventTileThread(
|
||||||
|
this.props.mxEvent,
|
||||||
|
MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()),
|
||||||
|
);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
interaction: initialEventTileInteractionState,
|
interaction: initialEventTileInteractionState,
|
||||||
@@ -369,62 +365,18 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
this.isListeningForReceipts = false;
|
this.isListeningForReceipts = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private get receiptState(): EventTileReceiptState {
|
||||||
* When true, the tile qualifies for some sort of special read receipt. This could be a 'sending'
|
const client = MatrixClientPeg.safeGet();
|
||||||
* 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;
|
|
||||||
|
|
||||||
// Sanity check (should never happen, but we shouldn't explode if it does)
|
return getEventTileReceiptState({
|
||||||
const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
|
mxEvent: this.props.mxEvent,
|
||||||
if (!room) return false;
|
readReceipts: this.props.readReceipts,
|
||||||
|
hasRoom: !!client.getRoom(this.props.mxEvent.getRoomId()),
|
||||||
// Quickly check to see if the event was sent by us. If it wasn't, it won't qualify for
|
ownUserId: client.getSafeUserId(),
|
||||||
// special read receipts.
|
lastSuccessful: this.props.lastSuccessful,
|
||||||
const myUserId = MatrixClientPeg.safeGet().getSafeUserId();
|
eventSendStatus: this.props.eventSendStatus,
|
||||||
// Check to see if the event was sent by us. If it wasn't, it won't qualify for special read receipts.
|
timelineRenderingType: this.context.timelineRenderingType,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidMount(): void {
|
public componentDidMount(): void {
|
||||||
@@ -440,7 +392,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onReactionsCreated);
|
this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onReactionsCreated);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
|
if (this.receiptState.shouldListenForReceipts) {
|
||||||
client.on(RoomEvent.Receipt, this.onRoomReceipt);
|
client.on(RoomEvent.Receipt, this.onRoomReceipt);
|
||||||
this.isListeningForReceipts = true;
|
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 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);
|
MatrixClientPeg.safeGet().on(RoomEvent.Receipt, this.onRoomReceipt);
|
||||||
this.isListeningForReceipts = true;
|
this.isListeningForReceipts = true;
|
||||||
}
|
}
|
||||||
@@ -531,52 +483,46 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private get thread(): Thread | null {
|
private get threadState(): EventTileThreadState {
|
||||||
let thread: Thread | undefined = this.props.mxEvent.getThread();
|
return getEventTileThreadState({
|
||||||
/**
|
mxEvent: this.props.mxEvent,
|
||||||
* Accessing the threads value through the room due to a race condition
|
thread: this.state.thread,
|
||||||
* that will be solved when there are proper backend support for threads
|
timelineRenderingType: this.context.timelineRenderingType,
|
||||||
* We currently have no reliable way to discover than an event is a thread
|
highlightLink: this.props.highlightLink,
|
||||||
* 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 renderThreadPanelSummary(): JSX.Element | null {
|
private renderThreadPanelSummary(threadState: EventTileThreadState): JSX.Element | null {
|
||||||
if (!this.state.thread) {
|
if (!threadState.shouldShowThreadPanelSummary || !threadState.thread) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_ThreadPanel_replies">
|
<div className="mx_ThreadPanel_replies">
|
||||||
<ThreadsIcon />
|
<ThreadsIcon />
|
||||||
<span className="mx_ThreadPanel_replies_amount">{this.state.thread.length}</span>
|
<span className="mx_ThreadPanel_replies_amount">{threadState.thread.length}</span>
|
||||||
<ThreadMessagePreview thread={this.state.thread} />
|
<ThreadMessagePreview thread={threadState.thread} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderThreadInfo(): React.ReactNode {
|
private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode {
|
||||||
if (this.state.thread && this.state.thread.id === this.props.mxEvent.getId()) {
|
if (threadState.shouldShowThreadSummary && threadState.thread) {
|
||||||
return (
|
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 (threadState.searchThreadInfo.kind === "link") {
|
||||||
if (this.props.highlightLink) {
|
return (
|
||||||
return (
|
<a className="mx_ThreadSummary_icon" href={threadState.searchThreadInfo.href}>
|
||||||
<a className="mx_ThreadSummary_icon" href={this.props.highlightLink}>
|
<ThreadsIcon />
|
||||||
<ThreadsIcon />
|
{_t("timeline|thread_info_basic")}
|
||||||
{_t("timeline|thread_info_basic")}
|
</a>
|
||||||
</a>
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
if (threadState.searchThreadInfo.kind === "text") {
|
||||||
return (
|
return (
|
||||||
<p className="mx_ThreadSummary_icon">
|
<p className="mx_ThreadSummary_icon">
|
||||||
<ThreadsIcon />
|
<ThreadsIcon />
|
||||||
@@ -608,7 +554,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
const tileRoom = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
|
const tileRoom = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
|
||||||
if (room !== tileRoom) return;
|
if (room !== tileRoom) return;
|
||||||
|
|
||||||
if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt && !this.isListeningForReceipts) {
|
if (!this.receiptState.shouldListenForReceipts && !this.isListeningForReceipts) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,7 +562,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
// the getters we use here to determine what needs rendering.
|
// the getters we use here to determine what needs rendering.
|
||||||
this.forceUpdate(() => {
|
this.forceUpdate(() => {
|
||||||
// Per elsewhere in this file, we can remove the listener once we will have no further purpose for it.
|
// 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);
|
MatrixClientPeg.safeGet().removeListener(RoomEvent.Receipt, this.onRoomReceipt);
|
||||||
this.isListeningForReceipts = false;
|
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 getReplyChain = (): ReplyChain | null => this.replyChain.current;
|
||||||
|
|
||||||
private readonly getReactions = (): Relations | null => {
|
private readonly getReactions = (): Relations | null => {
|
||||||
if (!this.props.showReactions || !this.props.getRelationsForEvent) {
|
return getEventTileReactionRelations({
|
||||||
return null;
|
mxEvent: this.props.mxEvent,
|
||||||
}
|
showReactions: this.props.showReactions,
|
||||||
const eventId = this.props.mxEvent.getId()!;
|
getRelationsForEvent: this.props.getRelationsForEvent,
|
||||||
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction") ?? null;
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly onReactionsCreated = (relationType: string, eventType: string): void => {
|
private readonly onReactionsCreated = (relationType: string, eventType: string): void => {
|
||||||
if (relationType !== "m.annotation" || eventType !== "m.reaction") {
|
if (!isEventTileReactionRelation(relationType, eventType)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -1106,6 +1052,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
const isEditing = !!this.props.editState;
|
const isEditing = !!this.props.editState;
|
||||||
const hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
const hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||||
const hasReactionsRow = !isRedacted;
|
const hasReactionsRow = !isRedacted;
|
||||||
|
const threadState = this.threadState;
|
||||||
// Use `getSender()` because searched events might not have a proper `sender`.
|
// Use `getSender()` because searched events might not have a proper `sender`.
|
||||||
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId();
|
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId();
|
||||||
|
|
||||||
@@ -1148,7 +1095,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
timestamp: {
|
timestamp: {
|
||||||
alwaysShowTimestamps: this.props.alwaysShowTimestamps,
|
alwaysShowTimestamps: this.props.alwaysShowTimestamps,
|
||||||
hideTimestamp: this.props.hideTimestamp,
|
hideTimestamp: this.props.hideTimestamp,
|
||||||
threadReplyEventTs: this.state.thread?.replyToEvent?.getTs(),
|
threadReplyEventTs: threadState.threadReplyEventTs,
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
isOwnEvent,
|
isOwnEvent,
|
||||||
@@ -1258,8 +1205,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
|
const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
|
||||||
const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
|
const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
|
||||||
|
|
||||||
|
const receiptState = this.receiptState;
|
||||||
let msgOption: JSX.Element | undefined;
|
let msgOption: JSX.Element | undefined;
|
||||||
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
|
if (receiptState.shouldShowSentReceipt || receiptState.shouldShowSendingReceipt) {
|
||||||
msgOption = <SentReceipt messageState={this.props.eventSendStatus} />;
|
msgOption = <SentReceipt messageState={this.props.eventSendStatus} />;
|
||||||
} else if (this.props.showReadReceipts) {
|
} else if (this.props.showReadReceipts) {
|
||||||
msgOption = (
|
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;
|
let replyChain: JSX.Element | undefined;
|
||||||
if (
|
if (replyChainState.shouldShowReplyChain) {
|
||||||
haveRendererForEvent(this.props.mxEvent, MatrixClientPeg.safeGet(), this.context.showHiddenEvents) &&
|
|
||||||
shouldDisplayReply(this.props.mxEvent)
|
|
||||||
) {
|
|
||||||
replyChain = (
|
replyChain = (
|
||||||
<ReplyChain
|
<ReplyChain
|
||||||
parentEv={this.props.mxEvent}
|
parentEv={this.props.mxEvent}
|
||||||
@@ -1434,7 +1387,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
<EventPreview mxEvent={this.props.mxEvent} />
|
<EventPreview mxEvent={this.props.mxEvent} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{this.renderThreadPanelSummary()}
|
{this.renderThreadPanelSummary(threadState)}
|
||||||
</div>
|
</div>
|
||||||
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
|
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
|
||||||
<ThreadListActionBarWrapper
|
<ThreadListActionBarWrapper
|
||||||
@@ -1547,7 +1500,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
{reactionsRow}
|
{reactionsRow}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{this.renderThreadInfo()}
|
{this.renderThreadInfo(threadState)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1560,7 +1513,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
{showBubblePinnedMessageBadge && pinnedMessageBadge}
|
{showBubblePinnedMessageBadge && pinnedMessageBadge}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{this.renderThreadInfo()}
|
{this.renderThreadInfo(threadState)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{msgOption}
|
{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