diff --git a/apps/web/src/components/structures/MessagePanel.tsx b/apps/web/src/components/structures/MessagePanel.tsx index a38f264aab8..f9292610735 100644 --- a/apps/web/src/components/structures/MessagePanel.tsx +++ b/apps/web/src/components/structures/MessagePanel.tsx @@ -32,10 +32,9 @@ import SettingsStore from "../../settings/SettingsStore"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import { Layout } from "../../settings/enums/Layout"; import EventTile, { + type EventTileHandle, type GetRelationsForEvent, - type IReadReceiptProps, - isEligibleForSpecialReceipt, - type UnwrappedEventTile, + type ReadReceiptProps, } from "../views/rooms/EventTile"; import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -49,7 +48,7 @@ import type EditorStateTransfer from "../../utils/EditorStateTransfer"; import { Action } from "../../dispatcher/actions"; import { getEventDisplayInfo } from "../../utils/EventRenderingUtils"; import { type IReadReceiptPosition } from "../views/rooms/ReadReceiptMarker"; -import { haveRendererForEvent } from "../../events/EventTileFactory"; +import { haveRendererForEvent, isMessageEvent } from "../../events/EventTileFactory"; import { editorRoomKey } from "../../Editing"; import { hasThreadSummary } from "../../utils/EventUtils"; import { type BaseGrouper } from "./grouper/BaseGrouper"; @@ -219,7 +218,7 @@ interface IState { interface IReadReceiptForUser { lastShownEventId: string; - receipt: IReadReceiptProps; + receipt: ReadReceiptProps; } /* (almost) stateless UI component which builds the event tiles in the room timeline. @@ -248,7 +247,7 @@ export default class MessagePanel extends React.Component { // This is recomputed on each render. It's only stored on the component // for ease of passing the data around since it's computed in one pass // over all events. - private readReceiptsByEvent: Map = new Map(); + private readReceiptsByEvent: Map = new Map(); // Track read receipts by user ID. For each user ID we've ever shown a // a read receipt for, we store an object: @@ -277,7 +276,7 @@ export default class MessagePanel extends React.Component { public scrollPanel = createRef(); private showTypingNotificationsWatcherRef?: string; - private eventTiles: Record = {}; + private eventTiles: Record = {}; // A map to allow groupers to maintain consistent keys even if their first event is uprooted due to back-pagination. public grouperKeyMap = new WeakMap(); @@ -368,14 +367,10 @@ export default class MessagePanel extends React.Component { /* get the DOM node representing the given event */ public getNodeForEventId(eventId: string): HTMLElement | undefined { - if (!this.eventTiles) { - return undefined; - } - - return this.eventTiles[eventId]?.ref?.current ?? undefined; + return this.getTileForEventId(eventId)?.ref?.current ?? undefined; } - public getTileForEventId(eventId?: string): UnwrappedEventTile | undefined { + public getTileForEventId(eventId?: string): EventTileHandle | undefined { if (!this.eventTiles || !eventId) { return undefined; } @@ -619,6 +614,10 @@ export default class MessagePanel extends React.Component { return !status || status === EventStatus.SENT; } + private isEligibleForSpecialReceipt(event: MatrixEvent): boolean { + return isMessageEvent(event) || event.getType() === EventType.RoomMessageEncrypted; + } + private getEventTiles(): ReactNode[] { // first figure out which is the last event in the list which we're // actually going to show; this allows us to behave slightly @@ -646,7 +645,7 @@ export default class MessagePanel extends React.Component { lastShownEvent = event; } - if (!foundLastSuccessfulEvent && this.isSentState(event) && isEligibleForSpecialReceipt(event)) { + if (!foundLastSuccessfulEvent && this.isSentState(event) && this.isEligibleForSpecialReceipt(event)) { foundLastSuccessfulEvent = true; // If we are not sender of this last successful event eligible for special receipt then we stop here // As we do not want to render our sent receipt if there are more receipts below it and events sent @@ -869,7 +868,7 @@ export default class MessagePanel extends React.Component { // Get a list of read receipts that should be shown next to this event // Receipts are objects which have a 'userId', 'roomMember' and 'ts'. - private getReadReceiptsForEvent(event: MatrixEvent): IReadReceiptProps[] | null { + private getReadReceiptsForEvent(event: MatrixEvent): ReadReceiptProps[] | null { const myUserId = MatrixClientPeg.safeGet().credentials.userId; // get list of read receipts, sorted most recent first @@ -880,7 +879,7 @@ export default class MessagePanel extends React.Component { const receiptDestination = this.context.threadId ? room.getThread(this.context.threadId) : room; - const receipts: IReadReceiptProps[] = []; + const receipts: ReadReceiptProps[] = []; if (!receiptDestination) { logger.debug( @@ -909,8 +908,8 @@ export default class MessagePanel extends React.Component { // Get an object that maps from event ID to a list of read receipts that // should be shown next to that event. If a hidden event has read receipts, // they are folded into the receipts of the last shown event. - private getReadReceiptsByShownEvent(events: WrappedEvent[]): Map { - const receiptsByEvent: Map = new Map(); + private getReadReceiptsByShownEvent(events: WrappedEvent[]): Map { + const receiptsByEvent: Map = new Map(); const receiptsByUserId: Map = new Map(); let lastShownEventId: string | undefined; @@ -965,7 +964,7 @@ export default class MessagePanel extends React.Component { return receiptsByEvent; } - private collectEventTile = (eventId: string, node: UnwrappedEventTile): void => { + private readonly collectEventTile = (eventId: string, node: EventTileHandle): void => { this.eventTiles[eventId] = node; }; diff --git a/apps/web/src/components/structures/WaitingForThirdPartyRoomView.tsx b/apps/web/src/components/structures/WaitingForThirdPartyRoomView.tsx index 00228ed9a04..8fc4c1be980 100644 --- a/apps/web/src/components/structures/WaitingForThirdPartyRoomView.tsx +++ b/apps/web/src/components/structures/WaitingForThirdPartyRoomView.tsx @@ -16,7 +16,7 @@ import ErrorBoundary from "../views/elements/ErrorBoundary"; import RoomHeader from "../views/rooms/RoomHeader/RoomHeader.tsx"; import ScrollPanel from "./ScrollPanel"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; -import { UnwrappedEventTile } from "../views/rooms/EventTile"; +import EventTile from "../views/rooms/EventTile"; import { _t } from "../../languageHandler"; import SdkConfig from "../../SdkConfig"; import { useScopedRoomContext } from "../../contexts/ScopedRoomContext.tsx"; @@ -50,7 +50,7 @@ export const WaitingForThirdPartyRoomView: React.FC = ({ roomView, resize subtitle={_t("room|waiting_for_join_subtitle", { brand })} /> - + diff --git a/apps/web/src/components/views/context_menus/MessageContextMenu.tsx b/apps/web/src/components/views/context_menus/MessageContextMenu.tsx index b6f9821dd47..75298e76293 100644 --- a/apps/web/src/components/views/context_menus/MessageContextMenu.tsx +++ b/apps/web/src/components/views/context_menus/MessageContextMenu.tsx @@ -65,7 +65,7 @@ import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContex import EndPollDialog from "../dialogs/EndPollDialog"; import { isPollEnded } from "../messages/MPollBody"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTile"; +import { type EventTileOps, type GetRelationsForEvent } from "../rooms/EventTile"; import { type OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload"; import { type OpenReportEventDialogPayload } from "../../../dispatcher/payloads/OpenReportEventDialogPayload"; import { createMapSiteLinkFromEvent } from "../../../utils/location"; @@ -115,7 +115,7 @@ interface IProps extends MenuProps { /* the MatrixEvent associated with the context menu */ mxEvent: MatrixEvent; // An optional EventTileOps implementation that can be used to unhide preview widgets - eventTileOps?: IEventTileOps; + eventTileOps?: EventTileOps; // Callback called when the menu is dismissed permalinkCreator?: RoomPermalinkCreator; /* an optional function to be called when the user clicks collapse thread, if not provided hide button */ @@ -289,7 +289,7 @@ export default class MessageContextMenu extends React.Component }; private onUnhidePreviewClick = (): void => { - this.props.eventTileOps?.unhideWidget(); + this.props.eventTileOps?.unhideWidget?.(); this.closeMenu(); }; @@ -488,7 +488,7 @@ export default class MessageContextMenu extends React.Component ); let unhidePreviewButton: JSX.Element | undefined; - if (eventTileOps?.isWidgetHidden()) { + if (eventTileOps?.isWidgetHidden?.()) { unhidePreviewButton = ( } diff --git a/apps/web/src/components/views/messages/MessageEvent.tsx b/apps/web/src/components/views/messages/MessageEvent.tsx index 549f38226c6..c6fea48a4c6 100644 --- a/apps/web/src/components/views/messages/MessageEvent.tsx +++ b/apps/web/src/components/views/messages/MessageEvent.tsx @@ -33,7 +33,7 @@ import MPollBody from "./MPollBody"; import MLocationBody from "./MLocationBody"; import MjolnirBody from "./MjolnirBody"; import MBeaconBody from "./MBeaconBody"; -import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTile"; +import { type EventTileOps, type GetRelationsForEvent } from "../rooms/EventTile"; import { DecryptionFailureBodyFactory, FileBodyFactory, @@ -60,7 +60,7 @@ interface IProps extends Omit>([ @@ -126,7 +126,7 @@ export default class MessageEvent extends React.Component implements IMe } } - public getEventTileOps = (): IEventTileOps | null => { + public getEventTileOps = (): EventTileOps | null => { return (this.body.current as IOperableEventTile)?.getEventTileOps?.() || null; }; diff --git a/apps/web/src/components/views/messages/TextualBody.tsx b/apps/web/src/components/views/messages/TextualBody.tsx index caf5df344d3..acc25ff298e 100644 --- a/apps/web/src/components/views/messages/TextualBody.tsx +++ b/apps/web/src/components/views/messages/TextualBody.tsx @@ -35,7 +35,7 @@ import RoomContext from "../../../contexts/RoomContext"; import AccessibleButton from "../elements/AccessibleButton"; import { getParentEventId } from "../../../utils/Reply"; import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; -import { type IEventTileOps } from "../rooms/EventTile"; +import { type EventTileOps } from "../rooms/EventTile"; import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPreviewGroupViewModel.ts"; import { useMediaVisible } from "../../../hooks/useMediaVisible.ts"; import ImageView from "../elements/ImageView.tsx"; @@ -175,7 +175,7 @@ class InnerTextualBody extends React.Component { } }; - public getEventTileOps = (): IEventTileOps => ({ + public getEventTileOps = (): EventTileOps => ({ isWidgetHidden: () => { // This controls whether the Show preview button is visibile. return this.props.urlPreviewViewModel.isPreviewHiddenByUser; diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx deleted file mode 100644 index 01d98415f81..00000000000 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ /dev/null @@ -1,2114 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2015-2023 The Matrix.org Foundation C.I.C. -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { - createRef, - useCallback, - useContext, - useEffect, - useMemo, - useState, - type JSX, - type Ref, - type FocusEvent, - type MouseEvent, - type ReactNode, -} from "react"; -import classNames from "classnames"; -import { - EventStatus, - EventType, - type MatrixEvent, - MatrixEventEvent, - MsgType, - type NotificationCountType, - type Relations, - type RelationType, - type Room, - RelationsEvent, - RoomEvent, - type RoomMember, - type Thread, - ThreadEvent, -} from "matrix-js-sdk/src/matrix"; -import { logger } from "matrix-js-sdk/src/logger"; -import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; -import { - CryptoEvent, - DecryptionFailureCode, - EventShieldColour, - EventShieldReason, - type UserVerificationStatus, -} from "matrix-js-sdk/src/crypto-api"; -import { Tooltip } from "@vector-im/compound-web"; -import { uniqueId, uniqBy } from "lodash"; -import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { - useCreateAutoDisposedViewModel, - ActionBarView, - MessageTimestampView, - PinnedMessageBadge, - ReactionsRowButtonView, - ReactionsRowView, - TileErrorView, - type TileErrorViewLayout, - useViewModel, -} from "@element-hq/web-shared-components"; - -import ReplyChain from "../elements/ReplyChain"; -import { _t } from "../../../languageHandler"; -import dis from "../../../dispatcher/dispatcher"; -import { Layout } from "../../../settings/enums/Layout"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import RoomAvatar from "../avatars/RoomAvatar"; -import MessageContextMenu from "../context_menus/MessageContextMenu"; -import ContextMenu, { aboveLeftOf, aboveRightOf } from "../../structures/ContextMenu"; -import { objectHasDiff } from "../../../utils/objects"; -import type EditorStateTransfer from "../../../utils/EditorStateTransfer"; -import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; -import NotificationBadge from "./NotificationBadge"; -import type LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper"; -import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; -import { Action } from "../../../dispatcher/actions"; -import PlatformPeg from "../../../PlatformPeg"; -import MemberAvatar from "../avatars/MemberAvatar"; -import SenderProfile from "../messages/SenderProfile"; -import { type IReadReceiptPosition } from "./ReadReceiptMarker"; -import ReactionPicker from "../emojipicker/ReactionPicker"; -import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils"; -import { isContentActionable } from "../../../utils/EventUtils"; -import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; -import { MediaEventHelper } from "../../../utils/MediaEventHelper"; -import { copyPlaintext } from "../../../utils/strings"; -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"; -import { ReadReceiptGroup } from "./ReadReceiptGroup"; -import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; -import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; -import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; -import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; -import { Icon as LateIcon } from "../../../../res/img/sensor.svg"; -import PinningUtils from "../../../utils/PinningUtils"; -import { EventPreview } from "./EventPreview"; -import { ElementCallEventType } from "../../../call-types"; -import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx"; -import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx"; -import SettingsStore from "../../../settings/SettingsStore"; -import { CardContext } from "../right_panel/context"; -import { - MessageTimestampViewModel, - type MessageTimestampViewModelProps, -} from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; -import { ReactionsRowButtonViewModel } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel"; -import { - MAX_ITEMS_WHEN_LIMITED, - ReactionsRowViewModel, -} from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel"; -import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel"; -import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel"; -import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel"; -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; - -// Our component structure for EventTiles on the timeline is: -// -// .-EventTile------------------------------------------------. -// | MemberAvatar (SenderProfile) TimeStamp | -// | .-{Message,Textual}Event---------------. Read Avatars | -// | | .-MFooBody-------------------. | | -// | | | (only if MessageEvent) | | | -// | | '----------------------------' | | -// | '--------------------------------------' | -// '----------------------------------------------------------' - -export interface IReadReceiptProps { - userId: string; - roomMember: RoomMember | null; - ts: number; -} - -export interface IEventTileOps { - isWidgetHidden(): boolean; - unhideWidget(): void; -} - -export interface IEventTileType extends React.Component { - getEventTileOps?(): IEventTileOps; - getMediaHelper(): MediaEventHelper | undefined; -} - -export interface EventTileProps { - // the MatrixEvent to show - mxEvent: MatrixEvent; - - // true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted() - // might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent - // references the same this.props.mxEvent. - isRedacted?: boolean; - - // true if this is a continuation of the previous event (which has the - // effect of not showing another avatar/displayname - continuation?: boolean; - - // true if this is the last event in the timeline (which has the effect - // of always showing the timestamp) - last?: boolean; - - // true if the event is the last event in a section (adds a css class for - // targeting) - lastInSection?: boolean; - - // True if the event is the last successful (sent) event. - lastSuccessful?: boolean; - - // true if this is search context (which has the effect of greying out - // the text - contextual?: boolean; - - // a list of words to highlight, ordered by longest first - highlights?: string[]; - - // link URL for the highlights - highlightLink?: string; - - // should show URL previews for this event - showUrlPreview?: boolean; - - // is this the focused event - isSelectedEvent?: boolean; - - resizeObserver?: ResizeObserver; - - // a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'. - readReceipts?: IReadReceiptProps[]; - - // opaque readreceipt info for each userId; used by ReadReceiptMarker - // to manage its animations. Should be an empty object when the room - // first loads - readReceiptMap?: { [userId: string]: IReadReceiptPosition }; - - // A function which is used to check if the parent panel is being - // unmounted, to avoid unnecessary work. Should return true if we - // are being unmounted. - checkUnmounting?: () => boolean; - - // the status of this event - ie, mxEvent.status. Denormalised to here so - // that we can tell when it changes. - eventSendStatus?: EventStatus; - - forExport?: boolean; - - // show twelve hour timestamps - isTwelveHour?: boolean; - - // helper function to access relations for this event - getRelationsForEvent?: GetRelationsForEvent; - - // whether to show reactions for this event - showReactions?: boolean; - - // which layout to use - layout?: Layout; - - // whether or not to show read receipts - showReadReceipts?: boolean; - - // Used while editing, to pass the event, and to preserve editor state - // from one editor instance to another when remounting the editor - // upon receiving the remote echo for an unsent event. - editState?: EditorStateTransfer; - - // Event ID of the event replacing the content of this event, if any - replacingEventId?: string; - - // Helper to build permalinks for the room - permalinkCreator?: RoomPermalinkCreator; - - // LegacyCallEventGrouper for this event - callEventGrouper?: LegacyCallEventGrouper; - - // Symbol of the root node - as?: string; - - // whether or not to always show timestamps - alwaysShowTimestamps?: boolean; - - // whether or not to display the sender - hideSender?: boolean; - - // whether or not to display thread info - showThreadInfo?: boolean; - - // if specified and `true`, the message is being - // hidden for moderation from other users but is - // displayed to the current user either because they're - // the author or they are a moderator - isSeeingThroughMessageHiddenForModeration?: boolean; - - // The following properties are used by EventTilePreview to disable tab indexes within the event tile - hideTimestamp?: boolean; - inhibitInteraction?: boolean; - - ref?: Ref; -} - -interface IState { - // Whether the action bar is focused. - actionBarFocused: boolean; - showActionBarFromFocus: boolean; - - /** - * E2EE shield we should show for decryption problems. - * - * Note this will be `EventShieldColour.NONE` for all unencrypted events, **including those in encrypted rooms**. - */ - shieldColour: EventShieldColour; - - /** - * Reason code for the E2EE shield. `null` if `shieldColour` is `EventShieldColour.NONE` - */ - shieldReason: EventShieldReason | null; - - // The Relations model from the JS SDK for reactions to `mxEvent` - reactions?: Relations | null | undefined; - - hover: boolean; - focusWithin: boolean; - - // Position of the context menu - contextMenu?: { - position: Pick; - link?: string; - }; - - 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 -export class UnwrappedEventTile extends React.Component { - private suppressReadReceiptAnimation: boolean; - private isListeningForReceipts: boolean; - private tile = createRef(); - private replyChain = createRef(); - - public readonly ref = createRef(); - - public static defaultProps = { - forExport: false, - layout: Layout.Group, - }; - - public static contextType = RoomContext; - declare public context: React.ContextType; - - private unmounted = false; - private readonly id = uniqueId(); - - public constructor(props: EventTileProps, context: React.ContextType) { - super(props, context); - - const thread = this.thread; - - this.state = { - // Whether the action bar is focused. - actionBarFocused: false, - showActionBarFromFocus: false, - - shieldColour: EventShieldColour.NONE, - shieldReason: null, - - // The Relations model from the JS SDK for reactions to `mxEvent` - reactions: this.getReactions(), - - hover: false, - focusWithin: false, - - thread, - }; - - // don't do RR animations until we are mounted - this.suppressReadReceiptAnimation = true; - - // Throughout the component we manage a read receipt listener to see if our tile still - // qualifies for a "sent" or "sending" state (based on their relevant conditions). We - // don't want to over-subscribe to the read receipt events being fired, so we use a flag - // to determine if we've already subscribed and use a combination of other flags to find - // out if we should even be subscribed at all. - 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; - - // 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; - } - - public componentDidMount(): void { - this.unmounted = false; - this.suppressReadReceiptAnimation = false; - const client = MatrixClientPeg.safeGet(); - if (!this.props.forExport) { - client.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); - this.props.mxEvent.on(MatrixEventEvent.Decrypted, this.onDecrypted); - this.props.mxEvent.on(MatrixEventEvent.Replaced, this.onReplaced); - DecryptionFailureTracker.instance.addVisibleEvent(this.props.mxEvent); - if (this.props.showReactions) { - this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); - } - - if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { - client.on(RoomEvent.Receipt, this.onRoomReceipt); - this.isListeningForReceipts = true; - } - } - - this.props.mxEvent.on(ThreadEvent.Update, this.updateThread); - - client.decryptEventIfNeeded(this.props.mxEvent); - - const room = client.getRoom(this.props.mxEvent.getRoomId()); - room?.on(ThreadEvent.New, this.onNewThread); - - this.verifyEvent(); - } - - private readonly updateThread = (thread: Thread): void => { - this.setState({ thread }); - }; - - public shouldComponentUpdate(nextProps: EventTileProps, nextState: IState): boolean { - if (objectHasDiff(this.state, nextState)) { - return true; - } - - return !this.propsEqual(this.props, nextProps); - } - - public componentWillUnmount(): void { - const client = MatrixClientPeg.get(); - if (client) { - client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); - client.removeListener(RoomEvent.Receipt, this.onRoomReceipt); - const room = client.getRoom(this.props.mxEvent.getRoomId()); - room?.off(ThreadEvent.New, this.onNewThread); - } - this.isListeningForReceipts = false; - this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted); - this.props.mxEvent.removeListener(MatrixEventEvent.Replaced, this.onReplaced); - if (this.props.showReactions) { - this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); - } - this.props.mxEvent.off(ThreadEvent.Update, this.updateThread); - this.unmounted = false; - if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.unobserve(this.ref.current); - } - - public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { - // If we're not listening for receipts and expect to be, register a listener. - if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) { - MatrixClientPeg.safeGet().on(RoomEvent.Receipt, this.onRoomReceipt); - this.isListeningForReceipts = true; - } - // re-check the sender verification as outgoing events progress through the send process. - if (prevProps.eventSendStatus !== this.props.eventSendStatus) { - this.verifyEvent(); - } - - if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.observe(this.ref.current); - } - - private readonly onNewThread = (thread: Thread): void => { - if (thread.id === this.props.mxEvent.getId()) { - this.updateThread(thread); - const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()); - room?.off(ThreadEvent.New, this.onNewThread); - } - }; - - 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 renderThreadPanelSummary(): JSX.Element | null { - if (!this.state.thread) { - return null; - } - - return ( -
- - {this.state.thread.length} - -
- ); - } - - private renderThreadInfo(): React.ReactNode { - if (this.state.thread && this.state.thread.id === this.props.mxEvent.getId()) { - return ( - - ); - } - - if (this.context.timelineRenderingType === TimelineRenderingType.Search && this.props.mxEvent.threadRootId) { - if (this.props.highlightLink) { - return ( - - - {_t("timeline|thread_info_basic")} - - ); - } - - return ( -

- - {_t("timeline|thread_info_basic")} -

- ); - } - } - - private readonly onViewInRoomClick = (_anchor: HTMLElement | null): void => { - dis.dispatch({ - action: Action.ViewRoom, - event_id: this.props.mxEvent.getId(), - highlighted: true, - room_id: this.props.mxEvent.getRoomId(), - metricsTrigger: undefined, // room doesn't change - }); - }; - - private readonly onCopyLinkToThreadClick = async (_anchor: HTMLElement | null): Promise => { - const { permalinkCreator, mxEvent } = this.props; - if (!permalinkCreator) return; - const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId()!); - await copyPlaintext(matrixToUrl); - }; - - private readonly onRoomReceipt = (ev: MatrixEvent, room: Room): void => { - // ignore events for other rooms - const tileRoom = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()); - if (room !== tileRoom) return; - - if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt && !this.isListeningForReceipts) { - return; - } - - // We force update because we have no state or prop changes to queue up, instead relying on - // 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) { - MatrixClientPeg.safeGet().removeListener(RoomEvent.Receipt, this.onRoomReceipt); - this.isListeningForReceipts = false; - } - }); - }; - - /** called when the event is decrypted after we show it. - */ - private readonly onDecrypted = (): void => { - // we need to re-verify the sending device. - this.verifyEvent(); - this.forceUpdate(); - }; - - private readonly onUserVerificationChanged = (userId: string, _trustStatus: UserVerificationStatus): void => { - if (userId === this.props.mxEvent.getSender()) { - this.verifyEvent(); - } - }; - - /** called when the event is edited after we show it. */ - private readonly onReplaced = (): void => { - // re-verify the event if it is replaced (the edit may not be verified) - this.verifyEvent(); - }; - - private verifyEvent(): void { - this.doVerifyEvent().catch((e) => { - const event = this.props.mxEvent; - logger.error(`Error getting encryption info on event ${event.getId()} in room ${event.getRoomId()}`, e); - }); - } - - private async doVerifyEvent(): Promise { - // if the event was edited, show the verification info for the edit, not - // the original - const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; - - if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) { - this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null }); - return; - } - - const encryptionInfo = - (await MatrixClientPeg.safeGet().getCrypto()?.getEncryptionInfoForEvent(mxEvent)) ?? null; - if (this.unmounted) return; - if (encryptionInfo === null) { - // likely a decryption error - this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null }); - return; - } - - this.setState({ shieldColour: encryptionInfo.shieldColour, shieldReason: encryptionInfo.shieldReason }); - } - - private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean { - const keysA = Object.keys(objA) as Array; - const keysB = Object.keys(objB) as Array; - - if (keysA.length !== keysB.length) { - return false; - } - - for (let i = 0; i < keysA.length; i++) { - const key = keysA[i]; - - if (!objB.hasOwnProperty(key)) { - return false; - } - - // need to deep-compare readReceipts - if (key === "readReceipts") { - const rA = objA[key]; - const rB = objB[key]; - if (rA === rB) { - continue; - } - - if (!rA || !rB) { - return false; - } - - if (rA.length !== rB.length) { - return false; - } - for (let j = 0; j < rA.length; j++) { - if (rA[j].userId !== rB[j].userId) { - return false; - } - // one has a member set and the other doesn't? - if (rA[j].roomMember !== rB[j].roomMember) { - return false; - } - } - } else { - if (objA[key] !== objB[key]) { - return false; - } - } - } - return true; - } - - /** - * Determine whether an event should be highlighted - * For edited events, if a previous version of the event was highlighted - * the event should remain highlighted as the user may have been notified - * (Clearer explanation of why an event is highlighted is planned - - * https://github.com/vector-im/element-web/issues/24927) - * @returns boolean - */ - private shouldHighlight(): boolean { - if (this.props.forExport) return false; - if (this.context.timelineRenderingType === TimelineRenderingType.Notification) return false; - if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) return false; - - if (this.props.isRedacted) return false; - - const cli = MatrixClientPeg.safeGet(); - const actions = cli.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent); - // get the actions for the previous version of the event too if it is an edit - const previousActions = this.props.mxEvent.replacingEvent() - ? cli.getPushActionsForEvent(this.props.mxEvent) - : undefined; - if (!actions?.tweaks && !previousActions?.tweaks) { - return false; - } - - // don't show self-highlights from another of our clients - if (this.props.mxEvent.getSender() === cli.credentials.userId) { - return false; - } - - return !!(actions?.tweaks.highlight || previousActions?.tweaks.highlight); - } - - private readonly onSenderProfileClick = (): void => { - dis.dispatch({ - action: Action.ComposerInsert, - userId: this.props.mxEvent.getSender()!, - timelineRenderingType: this.context.timelineRenderingType, - }); - }; - - private readonly onPermalinkClicked = (e: MouseEvent): void => { - // This allows the permalink to be opened in a new tab/window or copied as - // matrix.to, but also for it to enable routing within Element when clicked. - e.preventDefault(); - dis.dispatch({ - action: Action.ViewRoom, - event_id: this.props.mxEvent.getId(), - highlighted: true, - room_id: this.props.mxEvent.getRoomId(), - metricsTrigger: - this.context.timelineRenderingType === TimelineRenderingType.Search ? "MessageSearch" : undefined, - }); - }; - - private renderE2EPadlock(): ReactNode { - // if the event was edited, show the verification info for the edit, not - // the original - const ev = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; - - // no icon for local rooms - if (isLocalRoom(ev.getRoomId()!)) return null; - - // event could not be decrypted - if (ev.isDecryptionFailure()) { - switch (ev.decryptionFailureReason) { - // These two errors get icons from DecryptionFailureBody, so we hide the padlock icon - case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: - case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE: - return null; - default: - return ; - } - } - - if (this.state.shieldReason === EventShieldReason.AUTHENTICITY_NOT_GUARANTEED) { - // This may happen if the message was forwarded to us by another user, in which case we can show a better message - const forwarder = this.props.mxEvent.getKeyForwardingUser(); - if (forwarder) { - return ; - } - } - - if (this.state.shieldColour !== EventShieldColour.NONE) { - let shieldReasonMessage: string; - switch (this.state.shieldReason) { - case EventShieldReason.UNVERIFIED_IDENTITY: - shieldReasonMessage = _t("encryption|event_shield_reason_unverified_identity"); - break; - - case EventShieldReason.UNSIGNED_DEVICE: - shieldReasonMessage = _t("encryption|event_shield_reason_unsigned_device"); - break; - - case EventShieldReason.UNKNOWN_DEVICE: - shieldReasonMessage = _t("encryption|event_shield_reason_unknown_device"); - break; - - case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED: - shieldReasonMessage = _t("encryption|event_shield_reason_authenticity_not_guaranteed"); - break; - - case EventShieldReason.MISMATCHED_SENDER_KEY: - shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender_key"); - break; - - case EventShieldReason.SENT_IN_CLEAR: - shieldReasonMessage = _t("common|unencrypted"); - break; - - case EventShieldReason.VERIFICATION_VIOLATION: - shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified"); - break; - - case EventShieldReason.MISMATCHED_SENDER: - shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender"); - break; - - default: - shieldReasonMessage = _t("error|unknown"); - break; - } - - if (this.state.shieldColour === EventShieldColour.GREY) { - return ; - } else { - // red, by elimination - return ; - } - } - - if (this.context.isRoomEncrypted) { - // else if room is encrypted - // and event is being encrypted or is not_sent (Unknown Devices/Network Error) - if (ev.status === EventStatus.ENCRYPTING) { - return null; - } - if (ev.status === EventStatus.NOT_SENT) { - return null; - } - if (ev.isState()) { - return null; // we expect this to be unencrypted - } - if (ev.isRedacted()) { - return null; // we expect this to be unencrypted - } - if (!ev.isEncrypted()) { - // if the event is not encrypted, but it's an e2e room, show a warning - return ; - } - } - - // no padlock needed - return null; - } - - private readonly onActionBarFocusChange = (actionBarFocused: boolean): void => { - this.setState((prevState) => ({ - actionBarFocused, - hover: actionBarFocused ? prevState.hover : (this.ref.current?.matches(":hover") ?? false), - })); - }; - - private readonly onFocusWithin = (event: FocusEvent): void => { - // Show the action toolbar for keyboard-visible focus, with what-input as a fallback signal. - const target = event.target as HTMLElement; - const showActionBarFromFocus = - target.matches(":focus-visible") || document.body.dataset["data-whatinput"] === "keyboard"; - this.setState({ focusWithin: true, showActionBarFromFocus }); - }; - - private readonly onBlurWithin = (event: FocusEvent): void => { - if (event.currentTarget.contains(event.relatedTarget)) { - return; - } - - this.setState({ focusWithin: false, showActionBarFromFocus: false }); - }; - - private readonly getTile: () => IEventTileType | null = () => this.tile.current; - - 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; - }; - - private readonly onReactionsCreated = (relationType: string, eventType: string): void => { - if (relationType !== "m.annotation" || eventType !== "m.reaction") { - return; - } - this.setState({ - reactions: this.getReactions(), - }); - }; - - private readonly onContextMenu = (ev: React.MouseEvent): void => { - this.showContextMenu(ev); - }; - - private readonly onTimestampContextMenu = (ev: React.MouseEvent): void => { - this.showContextMenu(ev, this.props.permalinkCreator?.forEvent(this.props.mxEvent.getId()!)); - }; - - private showContextMenu(ev: React.MouseEvent, permalink?: string): void { - const clickTarget = ev.target as HTMLElement; - - // Try to find an anchor element - const anchorElement = clickTarget instanceof HTMLAnchorElement ? clickTarget : clickTarget.closest("a"); - - // There is no way to copy non-PNG images into clipboard, so we can't - // have our own handling for copying images, so we leave it to the - // Electron layer (webcontents-handler.ts) - if (clickTarget instanceof HTMLImageElement) return; - - // Return if we're in a browser and click either an a tag, as in those cases we want to use the native browser menu - if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && anchorElement) return; - - // We don't want to show the menu when editing a message - if (this.props.editState) return; - - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - contextMenu: { - position: { - left: ev.clientX, - top: ev.clientY, - bottom: ev.clientY, - }, - link: anchorElement?.href || permalink, - }, - actionBarFocused: true, - hover: false, - }); - } - - private readonly onCloseMenu = (): void => { - this.setState({ - contextMenu: undefined, - actionBarFocused: false, - hover: false, - }); - }; - - private readonly setQuoteExpanded = (expanded: boolean): void => { - this.setState({ - isQuoteExpanded: expanded, - }); - }; - - /** - * In some cases we can't use shouldHideEvent() since whether or not we hide - * an event depends on other things that the event itself - * @returns {boolean} true if event should be hidden - */ - private shouldHideEvent(): boolean { - // If the call was replaced we don't render anything since we render the other call - if (this.props.callEventGrouper?.hangupReason === CallErrorCode.Replaced) return true; - - return false; - } - - private renderContextMenu(): ReactNode { - if (!this.state.contextMenu) return null; - - const tile = this.getTile(); - const replyChain = this.getReplyChain(); - const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined; - const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined; - - return ( - - ); - } - - public render(): ReactNode { - const msgtype = this.props.mxEvent.getContent().msgtype; - const eventType = this.props.mxEvent.getType(); - - const { - hasRenderer, - isBubbleMessage, - isInfoMessage, - isLeftAlignedBubbleMessage, - noBubbleEvent, - isSeeingThroughMessageHiddenForModeration, - } = getEventDisplayInfo( - MatrixClientPeg.safeGet(), - this.props.mxEvent, - this.context.showHiddenEvents, - this.shouldHideEvent(), - ); - const { isQuoteExpanded } = this.state; - // This shouldn't happen: the caller should check we support this type - // before trying to instantiate us - if (!hasRenderer) { - const { mxEvent } = this.props; - logger.warn(`Event type not supported: type:${eventType} isState:${mxEvent.isState()}`); - return ( -
-
{_t("timeline|error_no_renderer")}
-
- ); - } - - const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent); - - const lineClasses = classNames("mx_EventTile_line", { - mx_EventTile_mediaLine: isProbablyMedia, - mx_EventTile_image: - this.props.mxEvent.getType() === EventType.RoomMessage && - this.props.mxEvent.getContent().msgtype === MsgType.Image, - mx_EventTile_sticker: this.props.mxEvent.getType() === EventType.Sticker, - mx_EventTile_emote: - this.props.mxEvent.getType() === EventType.RoomMessage && - this.props.mxEvent.getContent().msgtype === MsgType.Emote, - }); - - const isSending = ["sending", "queued", "encrypting"].includes(this.props.eventSendStatus!); - const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; - const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); - - let isContinuation = this.props.continuation; - if ( - this.context.timelineRenderingType !== TimelineRenderingType.Room && - this.context.timelineRenderingType !== TimelineRenderingType.Search && - this.context.timelineRenderingType !== TimelineRenderingType.Thread && - this.props.layout !== Layout.Bubble - ) { - isContinuation = false; - } - - const isRenderingNotification = this.context.timelineRenderingType === TimelineRenderingType.Notification; - - const isEditing = !!this.props.editState; - const classes = classNames({ - mx_EventTile_bubbleContainer: isBubbleMessage, - mx_EventTile_leftAlignedBubble: isLeftAlignedBubbleMessage, - mx_EventTile: true, - mx_EventTile_isEditing: isEditing, - mx_EventTile_info: isInfoMessage, - mx_EventTile_12hr: this.props.isTwelveHour, - // Note: we keep the `sending` state class for tests, not for our styles - mx_EventTile_sending: !isEditing && isSending, - mx_EventTile_highlight: this.shouldHighlight(), - mx_EventTile_selected: this.props.isSelectedEvent || this.state.contextMenu, - mx_EventTile_continuation: - isContinuation || eventType === EventType.CallInvite || ElementCallEventType.matches(eventType), - mx_EventTile_last: this.props.last, - mx_EventTile_lastInSection: this.props.lastInSection, - mx_EventTile_contextual: this.props.contextual, - mx_EventTile_actionBarFocused: this.state.actionBarFocused, - mx_EventTile_bad: isEncryptionFailure, - mx_EventTile_emote: msgtype === MsgType.Emote, - mx_EventTile_noSender: this.props.hideSender, - mx_EventTile_clamp: - this.context.timelineRenderingType === TimelineRenderingType.ThreadsList || isRenderingNotification, - mx_EventTile_noBubble: noBubbleEvent, - }); - - // If the tile is in the Sending state, don't speak the message. - const ariaLive = this.props.eventSendStatus !== null ? "off" : undefined; - - let permalink = "#"; - if (this.props.permalinkCreator) { - permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()!); - } - - // we can't use local echoes as scroll tokens, because their event IDs change. - // Local echos have a send "status". - const scrollToken = this.props.mxEvent.status ? undefined : this.props.mxEvent.getId(); - - let avatar: JSX.Element | null = null; - let sender: JSX.Element | null = null; - let avatarSize: string | null; - let needsSenderProfile: boolean; - - if (isRenderingNotification) { - avatarSize = "24px"; - needsSenderProfile = true; - } else if (isInfoMessage) { - // a small avatar, with no sender profile, for - // joins/parts/etc - avatarSize = "14px"; - needsSenderProfile = false; - } else if ( - this.context.timelineRenderingType === TimelineRenderingType.ThreadsList || - (this.context.timelineRenderingType === TimelineRenderingType.Thread && !this.props.continuation) - ) { - avatarSize = "32px"; - needsSenderProfile = true; - } else if (eventType === EventType.RoomCreate || isBubbleMessage) { - avatarSize = null; - needsSenderProfile = false; - } else if (this.props.layout == Layout.IRC) { - avatarSize = "14px"; - needsSenderProfile = true; - } else if ( - (this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) || - eventType === EventType.CallInvite || - ElementCallEventType.matches(eventType) - ) { - // no avatar or sender profile for continuation messages and call tiles - avatarSize = null; - needsSenderProfile = false; - } else if (this.context.timelineRenderingType === TimelineRenderingType.File) { - avatarSize = "20px"; - needsSenderProfile = true; - } else { - avatarSize = "30px"; - needsSenderProfile = true; - } - - if (this.props.mxEvent.sender && avatarSize !== null) { - let member: RoomMember | null = null; - // set member to receiver (target) if it is a 3PID invite - // so that the correct avatar is shown as the text is - // `$target accepted the invitation for $email` - if (this.props.mxEvent.getContent().third_party_invite) { - member = this.props.mxEvent.target; - } else { - member = this.props.mxEvent.sender; - } - // In the ThreadsList view we use the entire EventTile as a click target to open the thread instead - const viewUserOnClick = - !this.props.inhibitInteraction && - ![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes( - this.context.timelineRenderingType, - ); - avatar = ( -
- -
- ); - } - - if (needsSenderProfile && this.props.hideSender !== true) { - if ( - this.context.timelineRenderingType === TimelineRenderingType.Room || - this.context.timelineRenderingType === TimelineRenderingType.Search || - this.context.timelineRenderingType === TimelineRenderingType.Pinned || - this.context.timelineRenderingType === TimelineRenderingType.Thread - ) { - sender = ; - } else if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) { - sender = ; - } else { - sender = ; - } - } - - const showMessageActionBar = - !isEditing && - !this.props.forExport && - (this.state.hover || - this.state.showActionBarFromFocus || - (this.state.actionBarFocused && !this.state.contextMenu)); - const actionBar = showMessageActionBar ? ( - this.setQuoteExpanded(!isQuoteExpanded)} - getRelationsForEvent={this.props.getRelationsForEvent} - /> - ) : undefined; - - const showTimestamp = - this.props.mxEvent.getTs() && - !this.props.hideTimestamp && - (this.props.alwaysShowTimestamps || - this.props.last || - this.state.hover || - this.state.focusWithin || - this.state.actionBarFocused || - Boolean(this.state.contextMenu)); - - // Thread panel shows the timestamp of the last reply in that thread - let ts = - this.context.timelineRenderingType !== TimelineRenderingType.ThreadsList - ? this.props.mxEvent.getTs() - : this.state.thread?.replyToEvent?.getTs(); - if (typeof ts !== "number") { - // Fall back to something we can use - ts = this.props.mxEvent.getTs(); - } - - const messageTimestampProps: MessageTimestampViewModelProps = { - showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList, - showTwelveHour: this.props.isTwelveHour, - ts, - receivedTs: getLateEventInfo(this.props.mxEvent)?.received_ts, - }; - const messageTimestamp = ; - const linkedMessageTimestamp = ( - - ); - - const useIRCLayout = this.props.layout === Layout.IRC; - // Used to simplify the UI layout where necessary by not conditionally rendering an element at the start - const dummyTimestamp = useIRCLayout ? : null; - const timestamp = showTimestamp && ts ? messageTimestamp : dummyTimestamp; - const linkedTimestamp = - timestamp !== dummyTimestamp && !this.props.hideTimestamp ? linkedMessageTimestamp : dummyTimestamp; - - let pinnedMessageBadge: JSX.Element | undefined; - if (PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent)) { - pinnedMessageBadge = ; - } - - let reactionsRow: JSX.Element | undefined; - if (!isRedacted) { - reactionsRow = ( - - ); - } - - // If we have reactions or a pinned message badge, we need a footer - const hasFooter = Boolean((reactionsRow && this.state.reactions) || pinnedMessageBadge); - - const groupTimestamp = !useIRCLayout ? linkedTimestamp : null; - const ircTimestamp = useIRCLayout ? linkedTimestamp : null; - const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); - const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); - - let msgOption: JSX.Element | undefined; - if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { - msgOption = ; - } else if (this.props.showReadReceipts) { - msgOption = ( - - ); - } - - let replyChain: JSX.Element | undefined; - if ( - haveRendererForEvent(this.props.mxEvent, MatrixClientPeg.safeGet(), this.context.showHiddenEvents) && - shouldDisplayReply(this.props.mxEvent) - ) { - replyChain = ( - - ); - } - - // Use `getSender()` because searched events might not have a proper `sender`. - const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId(); - - switch (this.context.timelineRenderingType) { - case TimelineRenderingType.Thread: { - return React.createElement( - this.props.as || "li", - { - "ref": this.ref, - "className": classes, - "aria-live": ariaLive, - "aria-atomic": true, - "data-scroll-tokens": scrollToken, - "data-has-reply": !!replyChain, - "data-layout": this.props.layout, - "data-self": isOwnEvent, - "data-event-id": this.props.mxEvent.getId(), - "onMouseEnter": () => this.setState({ hover: true }), - "onMouseLeave": () => this.setState({ hover: false }), - "onFocus": this.onFocusWithin, - "onBlur": this.onBlurWithin, - }, - [ -
- {avatar} - {sender} -
, -
- {this.renderContextMenu()} - {replyChain} - {renderTile(TimelineRenderingType.Thread, { - ...this.props, - - // overrides - ref: this.tile, - isSeeingThroughMessageHiddenForModeration, - - // appease TS - highlights: this.props.highlights, - highlightLink: this.props.highlightLink, - permalinkCreator: this.props.permalinkCreator!, - showHiddenEvents: this.context.showHiddenEvents, - })} - {actionBar} - {linkedTimestamp} - {msgOption} -
, - hasFooter && ( -
- {(this.props.layout === Layout.Group || !isOwnEvent) && pinnedMessageBadge} - {reactionsRow} - {this.props.layout === Layout.Bubble && isOwnEvent && pinnedMessageBadge} -
- ), - ], - ); - } - case TimelineRenderingType.Notification: - case TimelineRenderingType.ThreadsList: { - const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()); - // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers - return React.createElement( - this.props.as || "li", - { - "ref": this.ref, - "className": classes, - "tabIndex": -1, - "aria-live": ariaLive, - "aria-atomic": "true", - "data-scroll-tokens": scrollToken, - "data-layout": this.props.layout, - "data-shape": this.context.timelineRenderingType, - "data-self": isOwnEvent, - "data-has-reply": !!replyChain, - "onMouseEnter": () => this.setState({ hover: true }), - "onMouseLeave": () => this.setState({ hover: false }), - "onFocus": this.onFocusWithin, - "onBlur": this.onBlurWithin, - "onClick": (ev: MouseEvent) => { - const target = ev.currentTarget as HTMLElement; - let index = -1; - if (target.parentElement) index = Array.from(target.parentElement.children).indexOf(target); - switch (this.context.timelineRenderingType) { - case TimelineRenderingType.Notification: - this.onViewInRoomClick(null); - break; - case TimelineRenderingType.ThreadsList: - dis.dispatch({ - action: Action.ShowThread, - rootEvent: this.props.mxEvent, - push: true, - }); - PosthogTrackers.trackInteraction("WebThreadsPanelThreadItem", ev, index ?? -1); - break; - } - }, - }, - <> -
- {sender} - {isRenderingNotification && room ? ( - - {" "} - {_t( - "timeline|in_room_name", - { room: room.name }, - { strong: (sub) => {sub} }, - )} - - ) : ( - "" - )} - {timestamp} - -
- {isRenderingNotification && room ? ( -
- -
- ) : ( - avatar - )} -
-
- {this.props.mxEvent.isRedacted() ? ( - - ) : this.props.mxEvent.isDecryptionFailure() ? ( - - ) : ( - - )} -
- {this.renderThreadPanelSummary()} -
- {this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && ( - - )} - - {msgOption} - , - ); - } - case TimelineRenderingType.File: { - return React.createElement( - this.props.as || "li", - { - "className": classes, - "aria-live": ariaLive, - "aria-atomic": true, - "data-scroll-tokens": scrollToken, - }, - [ - -
- {avatar} - {sender} - {timestamp} -
-
, -
- {this.renderContextMenu()} - {renderTile(TimelineRenderingType.File, { - ...this.props, - - // overrides - ref: this.tile, - isSeeingThroughMessageHiddenForModeration, - - // appease TS - highlights: this.props.highlights, - highlightLink: this.props.highlightLink, - permalinkCreator: this.props.permalinkCreator, - showHiddenEvents: this.context.showHiddenEvents, - })} -
, - ], - ); - } - - default: { - // Pinned, Room, Search - // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers - return React.createElement( - this.props.as || "li", - { - "ref": this.ref, - "className": classes, - "tabIndex": -1, - "aria-live": ariaLive, - "aria-atomic": "true", - "data-scroll-tokens": scrollToken, - "data-layout": this.props.layout, - "data-self": isOwnEvent, - "data-event-id": this.props.mxEvent.getId(), - "data-has-reply": !!replyChain, - "onMouseEnter": () => this.setState({ hover: true }), - "onMouseLeave": () => this.setState({ hover: false }), - "onFocus": this.onFocusWithin, - "onBlur": this.onBlurWithin, - }, - <> - {ircTimestamp} - {sender} - {ircPadlock} - {avatar} -
- {this.renderContextMenu()} - {groupTimestamp} - {groupPadlock} - {replyChain} - {renderTile(this.context.timelineRenderingType, { - ...this.props, - - // overrides - ref: this.tile, - isSeeingThroughMessageHiddenForModeration, - - // appease TS - highlights: this.props.highlights, - highlightLink: this.props.highlightLink, - permalinkCreator: this.props.permalinkCreator, - showHiddenEvents: this.context.showHiddenEvents, - })} - {actionBar} - {this.props.layout === Layout.IRC && ( - <> - {hasFooter && ( -
- {pinnedMessageBadge} - {reactionsRow} -
- )} - {this.renderThreadInfo()} - - )} -
- {this.props.layout !== Layout.IRC && ( - <> - {hasFooter && ( -
- {(this.props.layout === Layout.Group || !isOwnEvent) && pinnedMessageBadge} - {reactionsRow} - {this.props.layout === Layout.Bubble && isOwnEvent && pinnedMessageBadge} -
- )} - {this.renderThreadInfo()} - - )} - {msgOption} - , - ); - } - } - } -} - -/** - * Props for the event-tile fallback rendered after the tile error boundary catches a render failure. - */ -interface EventTileErrorFallbackProps { - error: Error; - layout: Layout; - mxEvent: MatrixEvent; -} - -function EventTileErrorFallback({ error, layout, mxEvent }: Readonly): JSX.Element { - const developerMode = useSettingValue("developerMode"); - const vm = useCreateAutoDisposedViewModel( - () => new TileErrorViewModel({ error, layout: layout as TileErrorViewLayout, mxEvent, developerMode }), - ); - - useEffect(() => { - vm.setError(error); - }, [error, vm]); - - useEffect(() => { - vm.setLayout(layout as TileErrorViewLayout); - }, [layout, vm]); - - useEffect(() => { - vm.setDeveloperMode(developerMode); - }, [developerMode, vm]); - - return ; -} - -interface EventTileErrorBoundaryProps { - children: ReactNode; - layout: Layout; - mxEvent: MatrixEvent; -} - -interface EventTileErrorBoundaryState { - error?: Error; -} - -class EventTileErrorBoundary extends React.Component { - public constructor(props: EventTileErrorBoundaryProps) { - super(props); - this.state = {}; - } - - public static getDerivedStateFromError(error: Error): Partial { - return { error }; - } - - public render(): ReactNode { - if (this.state.error) { - return ( - - ); - } - - return this.props.children; - } -} - -// Wrap all event tiles with the tile error boundary so that any throws even during construction are captured -const SafeEventTile = (props: EventTileProps): JSX.Element => { - return ( - - - - ); -}; -export default SafeEventTile; - -function E2ePadlockUnencrypted(): JSX.Element { - return ; -} - -function E2ePadlockDecryptionFailure(): JSX.Element { - return ; -} - -interface ISentReceiptProps { - messageState: EventStatus | undefined; -} - -function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { - const isSent = !messageState || messageState === "sent"; - const isFailed = messageState === "not_sent"; - - let icon: JSX.Element | undefined; - let label: string | undefined; - if (messageState === "encrypting") { - icon = ; - label = _t("timeline|send_state_encrypting"); - } else if (isSent) { - icon = ; - label = _t("timeline|send_state_sent"); - } else if (isFailed) { - icon = ; - label = _t("timeline|send_state_failed"); - } else { - icon = ; - label = _t("timeline|send_state_sending"); - } - - return ( -
-
- -
- {icon} -
-
-
-
- ); -} - -/** - * Wraps MessageTimestampView with a view model synced to the provided props. - * This wrapper can be removed after EventTile has been changed to a function component. - */ -function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Element { - const vm = useCreateAutoDisposedViewModel(() => new MessageTimestampViewModel(props)); - useEffect(() => { - vm.setTimestamp(props.ts); - vm.setReceivedTimestamp(props.receivedTs); - vm.setDisplayOptions({ - showTwelveHour: props.showTwelveHour, - showRelative: props.showRelative, - }); - vm.setHref(props.href); - vm.setHandlers({ onClick: props.onClick, onContextMenu: props.onContextMenu }); - }, [vm, props]); - - return ( - <> - {/* Render icon as described in, https://github.com/matrix-org/matrix-react-sdk/pull/11760 */} - {props.receivedTs ? ( - - ) : undefined} - - - ); -} - -interface ReactionsRowButtonItemProps { - mxEvent: MatrixEvent; - content: string; - count: number; - reactionEvents: MatrixEvent[]; - myReactionEvent?: MatrixEvent; - disabled?: boolean; - customReactionImagesEnabled?: boolean; -} - -function ReactionsRowButtonItem(props: Readonly): JSX.Element { - const client = useMatrixClientContext(); - - const vm = useCreateAutoDisposedViewModel( - () => - new ReactionsRowButtonViewModel({ - client, - mxEvent: props.mxEvent, - content: props.content, - count: props.count, - reactionEvents: props.reactionEvents, - myReactionEvent: props.myReactionEvent, - disabled: props.disabled, - customReactionImagesEnabled: props.customReactionImagesEnabled, - }), - ); - - useEffect(() => { - vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled); - }, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]); - - useEffect(() => { - vm.setCount(props.count); - }, [props.count, vm]); - - useEffect(() => { - vm.setMyReactionEvent(props.myReactionEvent); - }, [props.myReactionEvent, vm]); - - useEffect(() => { - vm.setDisabled(props.disabled); - }, [props.disabled, vm]); - - return ; -} - -interface ReactionGroup { - content: string; - events: MatrixEvent[]; -} - -const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] => - reactions - ?.getSortedAnnotationsByKey() - ?.map(([content, events]) => ({ - content, - events: [...events], - })) - .filter(({ events }) => events.length > 0) ?? []; - -const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => { - if (!reactions || !userId) { - return null; - } - - const myReactions = reactions.getAnnotationsBySender()?.[userId]; - if (!myReactions) { - return null; - } - - return [...myReactions.values()]; -}; - -interface ReactionsRowWrapperProps { - mxEvent: MatrixEvent; - reactions?: Relations | null; -} - -function ReactionsRowWrapper({ mxEvent, reactions }: Readonly): JSX.Element | null { - const roomContext = useContext(RoomContext); - const userId = roomContext.room?.client.getUserId() ?? undefined; - const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); - const [myReactions, setMyReactions] = useState(() => getMyReactions(reactions, userId)); - const [menuDisplayed, setMenuDisplayed] = useState(false); - const [menuAnchorRect, setMenuAnchorRect] = useState(null); - - const vm = useCreateAutoDisposedViewModel( - () => - new ReactionsRowViewModel({ - isActionable: isContentActionable(mxEvent), - reactionGroupCount: reactionGroups.length, - canReact: roomContext.canReact, - addReactionButtonActive: false, - }), - ); - - const openReactionMenu = useCallback((event: React.MouseEvent): void => { - setMenuAnchorRect(event.currentTarget.getBoundingClientRect()); - setMenuDisplayed(true); - }, []); - - const closeReactionMenu = useCallback((): void => { - setMenuDisplayed(false); - }, []); - - const updateReactionsState = useCallback((): void => { - const nextReactionGroups = getReactionGroups(reactions); - setReactionGroups(nextReactionGroups); - setMyReactions(getMyReactions(reactions, userId)); - vm.setReactionGroupCount(nextReactionGroups.length); - }, [reactions, userId, vm]); - - useEffect(() => { - vm.setActionable(isContentActionable(mxEvent)); - }, [mxEvent, vm]); - - useEffect(() => { - vm.setCanReact(roomContext.canReact); - if (!roomContext.canReact && menuDisplayed) { - setMenuDisplayed(false); - } - }, [roomContext.canReact, menuDisplayed, vm]); - - useEffect(() => { - vm.setAddReactionHandlers({ - onAddReactionClick: openReactionMenu, - onAddReactionContextMenu: openReactionMenu, - }); - }, [openReactionMenu, vm]); - - useEffect(() => { - vm.setAddReactionButtonActive(menuDisplayed); - }, [menuDisplayed, vm]); - - useEffect(() => { - updateReactionsState(); - }, [updateReactionsState]); - - useEffect(() => { - if (!reactions) return; - - reactions.on(RelationsEvent.Add, updateReactionsState); - reactions.on(RelationsEvent.Remove, updateReactionsState); - reactions.on(RelationsEvent.Redaction, updateReactionsState); - - return () => { - reactions.off(RelationsEvent.Add, updateReactionsState); - reactions.off(RelationsEvent.Remove, updateReactionsState); - reactions.off(RelationsEvent.Redaction, updateReactionsState); - }; - }, [reactions, updateReactionsState]); - - useEffect(() => { - const onDecrypted = (): void => { - vm.setActionable(isContentActionable(mxEvent)); - }; - - if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { - mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted); - } - - return () => { - mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted); - }; - }, [mxEvent, vm]); - - const snapshot = useViewModel(vm); - const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images"); - const items = useMemo((): JSX.Element[] | undefined => { - const mappedItems = reactionGroups.map(({ content, events }) => { - // Deduplicate reaction events by sender per Matrix spec. - const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender()); - const myReactionEvent = myReactions?.find((reactionEvent) => { - if (reactionEvent.isRedacted()) { - return false; - } - return reactionEvent.getRelation()?.key === content; - }); - - return ( - - ); - }); - - if (!mappedItems.length) { - return undefined; - } - - return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems; - }, [ - reactionGroups, - myReactions, - mxEvent, - customReactionImagesEnabled, - roomContext.canReact, - roomContext.canSelfRedact, - snapshot.showAllButtonVisible, - ]); - - if (!snapshot.isVisible || !items?.length) { - return null; - } - - let contextMenu: JSX.Element | undefined; - if (menuDisplayed && menuAnchorRect && reactions && roomContext.canReact) { - contextMenu = ( - - - - ); - } - - return ( - <> - - {items} - - {contextMenu} - - ); -} - -interface ActionBarWrapperProps { - mxEvent: MatrixEvent; - reactions?: Relations | null; - permalinkCreator?: RoomPermalinkCreator; - getTile: () => IEventTileType | null; - getReplyChain: () => ReplyChain | null; - onFocusChange?: (focused: boolean) => void; - isQuoteExpanded?: boolean; - toggleThreadExpanded: () => void; - getRelationsForEvent?: GetRelationsForEvent; -} - -interface ThreadListActionBarWrapperProps { - onViewInRoomClick: (anchor: HTMLElement | null) => void; - onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; -} - -function ThreadListActionBarWrapper({ - onViewInRoomClick, - onCopyLinkClick, -}: Readonly): JSX.Element { - const vm = useCreateAutoDisposedViewModel( - () => - new ThreadListActionBarViewModel({ - onViewInRoomClick, - onCopyLinkClick, - }), - ); - - useEffect(() => { - vm.setProps({ - onViewInRoomClick, - onCopyLinkClick, - }); - }, [vm, onViewInRoomClick, onCopyLinkClick]); - - return ; -} - -function ActionBarWrapper({ - mxEvent, - reactions, - permalinkCreator, - getTile, - getReplyChain, - onFocusChange, - isQuoteExpanded, - toggleThreadExpanded, - getRelationsForEvent, -}: Readonly): JSX.Element { - const roomContext = useContext(RoomContext); - const { isCard } = useContext(CardContext); - const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState(null); - const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState(null); - const isSearch = Boolean(roomContext.search); - const handleOptionsClick = useCallback((anchor: HTMLElement | null): void => { - setOptionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); - }, []); - const handleReactionsClick = useCallback((anchor: HTMLElement | null): void => { - setReactionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); - }, []); - const vm = useCreateAutoDisposedViewModel( - () => - new EventTileActionBarViewModel({ - mxEvent, - timelineRenderingType: roomContext.timelineRenderingType, - canSendMessages: roomContext.canSendMessages, - canReact: roomContext.canReact, - isSearch, - isCard, - isQuoteExpanded, - onToggleThreadExpanded: toggleThreadExpanded, - onOptionsClick: handleOptionsClick, - onReactionsClick: handleReactionsClick, - getRelationsForEvent, - }), - ); - - useEffect(() => { - vm.setProps({ - mxEvent, - timelineRenderingType: roomContext.timelineRenderingType, - canSendMessages: roomContext.canSendMessages, - canReact: roomContext.canReact, - isSearch, - isCard, - isQuoteExpanded, - getRelationsForEvent, - onToggleThreadExpanded: toggleThreadExpanded, - onOptionsClick: handleOptionsClick, - onReactionsClick: handleReactionsClick, - }); - }, [ - vm, - mxEvent, - roomContext.timelineRenderingType, - roomContext.canSendMessages, - roomContext.canReact, - isSearch, - isCard, - isQuoteExpanded, - getRelationsForEvent, - handleOptionsClick, - handleReactionsClick, - toggleThreadExpanded, - ]); - - useEffect(() => { - onFocusChange?.(Boolean(optionsMenuAnchorRect || reactionsMenuAnchorRect)); - }, [onFocusChange, optionsMenuAnchorRect, reactionsMenuAnchorRect]); - - useEffect(() => { - setOptionsMenuAnchorRect(null); - setReactionsMenuAnchorRect(null); - }, [mxEvent]); - - const closeOptionsMenu = useCallback((): void => { - setOptionsMenuAnchorRect(null); - }, []); - - const closeReactionsMenu = useCallback((): void => { - setReactionsMenuAnchorRect(null); - }, []); - - const tile = getTile(); - const replyChain = getReplyChain(); - const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined; - const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined; - - return ( - <> - - {optionsMenuAnchorRect ? ( - - ) : null} - {reactionsMenuAnchorRect ? ( - - - - ) : null} - - ); -} diff --git a/apps/web/src/components/views/rooms/EventTile/Avatar.tsx b/apps/web/src/components/views/rooms/EventTile/Avatar.tsx new file mode 100644 index 00000000000..173e684af02 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/Avatar.tsx @@ -0,0 +1,44 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; + +import type { RoomMember } from "matrix-js-sdk/src/matrix"; +import { AvatarSize } from "../../../../models/rooms/EventTileModel"; +import MemberAvatar from "../../avatars/MemberAvatar"; + +type AvatarProps = Readonly<{ + member?: RoomMember | null; + size: AvatarSize; + viewUserOnClick: boolean; + forceHistorical: boolean; +}>; + +const avatarSizeByMode: Record, string> = { + [AvatarSize.XSmall]: "14px", + [AvatarSize.Small]: "20px", + [AvatarSize.Medium]: "24px", + [AvatarSize.Large]: "30px", + [AvatarSize.XLarge]: "32px", +}; + +export function Avatar({ member, size, viewUserOnClick, forceHistorical }: AvatarProps): JSX.Element | undefined { + if (!member || size === AvatarSize.None) return undefined; + + const pixelSize = avatarSizeByMode[size]; + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/EncryptionIndicator.tsx b/apps/web/src/components/views/rooms/EventTile/EncryptionIndicator.tsx new file mode 100644 index 00000000000..6676c718514 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/EncryptionIndicator.tsx @@ -0,0 +1,41 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; + +import { EncryptionIndicatorMode } from "../../../../models/rooms/EventTileModel"; +import { E2eMessageSharedIcon } from "./E2eMessageSharedIcon"; +import { E2ePadlock, E2ePadlockIcon } from "./E2ePadlock"; + +type EncryptionIndicatorProps = Readonly<{ + icon: EncryptionIndicatorMode; + title?: string; + sharedUserId?: string; + roomId?: string; +}>; + +export function EncryptionIndicator({ + icon, + title, + sharedUserId, + roomId, +}: EncryptionIndicatorProps): JSX.Element | null { + if (sharedUserId && roomId) { + return ; + } + + switch (icon) { + case EncryptionIndicatorMode.DecryptionFailure: + return ; + case EncryptionIndicatorMode.Normal: + return ; + case EncryptionIndicatorMode.Warning: + return ; + default: + return null; + } +} diff --git a/apps/web/src/components/views/rooms/EventTile/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile/EventTile.tsx new file mode 100644 index 00000000000..9e2ab97658d --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/EventTile.tsx @@ -0,0 +1,1199 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { + useCallback, + useContext, + useEffect, + useImperativeHandle, + useId, + useMemo, + useRef, + useState, + type FocusEvent, + type JSX, + type MouseEvent, + type Ref, + type RefObject, +} from "react"; +import { ActionBarView, useCreateAutoDisposedViewModel, useViewModel } from "@element-hq/web-shared-components"; + +import type { EventStatus, Relations, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix"; +import { + EventTileViewModel, + type EventTileViewModelProps, + type EventTileViewSnapshot, +} from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; +import RoomContext from "../../../../contexts/RoomContext"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { Action } from "../../../../dispatcher/actions"; +import dis from "../../../../dispatcher/dispatcher"; +import PosthogTrackers from "../../../../PosthogTrackers"; +import { copyPlaintext } from "../../../../utils/strings"; +import { EventTileView, type EventTileViewProps } from "./EventTileView"; +import { _t } from "../../../../languageHandler"; +import PlatformPeg from "../../../../PlatformPeg"; +import { Layout } from "../../../../settings/enums/Layout"; +import type LegacyCallEventGrouper from "../../../structures/LegacyCallEventGrouper"; +import RoomAvatar from "../../avatars/RoomAvatar"; +import ThreadSummary, { ThreadMessagePreview } from "../ThreadSummary"; +import { UnreadNotificationBadge } from "../NotificationBadge/UnreadNotificationBadge"; +import { AvatarSize, AvatarSubject, ThreadInfoMode } from "../../../../models/rooms/EventTileModel"; +import type ReplyChain from "../../elements/ReplyChain"; +import type { ComposerInsertPayload } from "../../../../dispatcher/payloads/ComposerInsertPayload"; +import type EditorStateTransfer from "../../../../utils/EditorStateTransfer"; +import type { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; +import { type IReadReceiptPosition } from "../ReadReceiptMarker"; +import type { MessageBodyProps, MessageBodyRenderTileProps } from "./MessageBody"; +import type { EventTileContextMenuState, EventTileOps, GetRelationsForEvent, ReadReceiptProps } from "./types"; +import { ThreadInfo } from "./ThreadInfo"; +import MessageContextMenu from "../../context_menus/MessageContextMenu"; +import ContextMenu, { aboveLeftOf, aboveRightOf } from "../../../structures/ContextMenu"; +import ReactionPicker from "../../emojipicker/ReactionPicker"; +import { EventTileActionBarViewModel } from "../../../../viewmodels/room/timeline/event-tile/actions/EventTileActionBarViewModel"; +import type { EventTileActionBarViewModelProps } from "../../../../viewmodels/room/timeline/event-tile/actions/EventTileActionBarViewModel"; +import { ThreadListActionBarViewModel } from "../../../../viewmodels/room/ThreadListActionBarViewModel"; +import type { ThreadListActionBarViewModelProps } from "../../../../viewmodels/room/ThreadListActionBarViewModel"; +import { CardContext } from "../../right_panel/context"; +import { Sender } from "./Sender"; +import { Avatar } from "./Avatar"; +import { ReplyPreview } from "./ReplyPreview"; +import { MessageStatus } from "./MessageStatus"; +import { Footer } from "./Footer"; +import { MessageBody } from "./MessageBody"; +import { type EventTileCommandDeps } from "./EventTileCommands"; +import { EventTileErrorBoundary } from "./EventTileErrorBoundary"; + +/** Ref handle for direct access to tile actions and the root element. */ +export interface EventTileHandle extends EventTileOps { + /** Ref to the tile root DOM element. */ + ref: RefObject; + /** Recomputes derived tile state without changing props. */ + forceUpdate(): void; +} + +/** Core event identity and imperative ref inputs for the tile. */ +interface EventTileCoreProps { + /** The Matrix event represented by this tile. */ + mxEvent: MatrixEvent; + /** Optional send-status override for locally pending events. */ + eventSendStatus?: EventStatus; + /** Optional ref used to expose the tile handle to callers. */ + ref?: Ref; +} + +/** Rendering flags and layout options that shape tile presentation. */ +interface EventTileRenderingProps { + /** Optional root element tag name override. */ + as?: string; + /** The active room layout variant. */ + layout?: Layout; + /** Whether timestamps should use a twelve-hour clock. */ + isTwelveHour?: boolean; + /** Whether the tile is being rendered for export rather than live interaction. */ + forExport?: boolean; + /** Whether timestamps should remain visible even when the tile is idle. */ + alwaysShowTimestamps?: boolean; + /** Whether the event should be treated as redacted for rendering purposes. */ + isRedacted?: boolean; + /** Whether the tile continues the previous sender block visually. */ + continuation?: boolean; + /** Whether this is the last visible tile in the current list. */ + last?: boolean; + /** Whether this is the last tile in its grouped section. */ + lastInSection?: boolean; + /** Whether this event is the most recent successfully sent event. */ + lastSuccessful?: boolean; + /** Whether the tile is shown in a contextual timeline. */ + contextual?: boolean; + /** Whether this tile corresponds to the selected event. */ + isSelectedEvent?: boolean; + /** Whether sender information should be hidden. */ + hideSender?: boolean; + /** Whether timestamp rendering should be hidden. */ + hideTimestamp?: boolean; + /** Whether interactive affordances should be disabled. */ + inhibitInteraction?: boolean; + /** Whether moderation-hidden content is currently being revealed. */ + isSeeingThroughMessageHiddenForModeration?: boolean; + /** Whether URL preview rendering should be enabled for supported events. */ + showUrlPreview?: boolean; + /** Highlight tokens to emphasize within the message body. */ + highlights?: string[]; + /** Link target used to highlight matching content inside the tile. */ + highlightLink?: string; +} + +/** Relation and receipt inputs used to enrich tile rendering. */ +interface EventTileRelationProps { + /** Optional relation lookup function for the current event. */ + getRelationsForEvent?: GetRelationsForEvent; + /** Whether reactions should be shown. */ + showReactions?: boolean; + /** Whether read receipts should be shown when available. */ + showReadReceipts?: boolean; + /** Read receipt entries available for the tile. */ + readReceipts?: ReadReceiptProps[]; + /** Precomputed read receipt positions keyed by user ID. */ + readReceiptMap?: { [userId: string]: IReadReceiptPosition }; +} + +/** Editing-related inputs for tiles that participate in composer state. */ +interface EventTileEditingProps { + /** Current edit-state transfer object associated with the event. */ + editState?: EditorStateTransfer; + /** Event ID of the replacement event currently being composed. */ + replacingEventId?: string; + /** Optional callback used by child components to detect unmounting during async work. */ + checkUnmounting?: () => boolean; +} + +/** Optional environment helpers supplied by the surrounding room view. */ +interface EventTileEnvironmentProps { + /** Optional resize observer used to monitor the rendered tile root. */ + resizeObserver?: ResizeObserver; + /** Optional permalink helper used to generate event links. */ + permalinkCreator?: RoomPermalinkCreator; + /** Optional helper used to group legacy call events. */ + callEventGrouper?: LegacyCallEventGrouper; +} + +/** Props for the tile implementation, excluding the optional error boundary wrapper flag. */ +type EventTileBaseProps = EventTileCoreProps & + EventTileRenderingProps & + EventTileRelationProps & + EventTileEditingProps & + EventTileEnvironmentProps; + +/** Props for {@link EventTile}. */ +export interface EventTileProps extends EventTileBaseProps { + /** Wraps the tile in {@link EventTileErrorBoundary}. Defaults to `true`. */ + withErrorBoundary?: boolean; +} + +/** Event handlers and room data returned from `useEventTileCommands`. */ +type UseEventTileCommandsResult = { + room: Room | null; + openInRoom: (_anchor: HTMLElement | null) => void; + copyLinkToThread: (_anchor: HTMLElement | null) => Promise; + onPermalinkClicked: (ev: MouseEvent) => void; + onListTileClick: (ev: MouseEvent) => void; + onContextMenu: (ev: MouseEvent) => void; + onTimestampContextMenu: (ev: MouseEvent) => void; +}; + +type EventTileContentNodes = { + sender: JSX.Element; + avatar: JSX.Element; + replyChain?: JSX.Element; + messageBody: JSX.Element; + actionBar?: JSX.Element; + messageStatus: JSX.Element; + footer: JSX.Element; +}; + +type EventTileThreadNodes = { + info?: JSX.Element; + replyCount?: number; + preview?: JSX.Element; + toolbar?: JSX.Element; +}; + +type UseEventTileNodesArgs = { + props: EventTileBaseProps; + roomContext: React.ContextType; + snapshot: EventTileViewSnapshot; + tileRef: RefObject; + replyChainRef: RefObject; + suppressReadReceiptAnimation: boolean; + tileContentId: string; + vm: EventTileViewModel; + onActionBarFocusChange: (focused: boolean) => void; + toggleThreadExpanded: () => void; + openInRoom: (_anchor: HTMLElement | null) => void; + copyLinkToThread: (_anchor: HTMLElement | null) => void | Promise; +}; + +type UseEventTileContextMenuNodeArgs = { + props: EventTileBaseProps; + snapshot: EventTileViewSnapshot; + tileRef: RefObject; + replyChainRef: RefObject; + vm: EventTileViewModel; +}; + +type ActionBarHostProps = { + mxEvent: MatrixEvent; + reactions: Relations | null; + permalinkCreator?: RoomPermalinkCreator; + getRelationsForEvent?: GetRelationsForEvent; + isQuoteExpanded?: boolean; + tileRef: React.RefObject; + replyChainRef: React.RefObject; + onFocusChange: (focused: boolean) => void; + toggleThreadExpanded: () => void; +}; + +type ThreadToolbarHostProps = { + onViewInRoomClick: (anchor: HTMLElement | null) => void; + onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; +}; + +type EventTileContextMenuHostProps = { + contextMenu: EventTileContextMenuState; + mxEvent: MatrixEvent; + reactions: Relations | null; + permalinkCreator?: RoomPermalinkCreator; + getRelationsForEvent?: GetRelationsForEvent; + tileRef: React.RefObject; + replyChainRef: React.RefObject; + onFinished: () => void; +}; + +function buildEventTileViewModelProps( + props: EventTileBaseProps, + readReceipts: EventTileViewModelProps["readReceipts"], + cli: ReturnType, + commandDeps: EventTileViewModelProps["commandDeps"], + roomContext: Pick< + React.ContextType, + "timelineRenderingType" | "isRoomEncrypted" | "showHiddenEvents" + >, +): EventTileViewModelProps { + return { + mxEvent: props.mxEvent, + eventSendStatus: props.eventSendStatus, + editState: props.editState, + permalinkCreator: props.permalinkCreator, + callEventGrouper: props.callEventGrouper, + forExport: props.forExport, + layout: props.layout, + isTwelveHour: props.isTwelveHour, + alwaysShowTimestamps: props.alwaysShowTimestamps, + isRedacted: props.isRedacted, + continuation: props.continuation, + last: props.last, + lastInSection: props.lastInSection, + contextual: props.contextual, + isSelectedEvent: props.isSelectedEvent, + hideSender: props.hideSender, + hideTimestamp: props.hideTimestamp, + inhibitInteraction: props.inhibitInteraction, + highlightLink: props.highlightLink, + showReactions: props.showReactions, + getRelationsForEvent: props.getRelationsForEvent, + readReceipts, + showReadReceipts: props.showReadReceipts, + lastSuccessful: props.lastSuccessful, + commandDeps, + cli, + timelineRenderingType: roomContext.timelineRenderingType, + isRoomEncrypted: Boolean(roomContext.isRoomEncrypted), + showHiddenEvents: roomContext.showHiddenEvents, + }; +} + +function getAvatarMember(props: EventTileBaseProps, avatarSubject: AvatarSubject): RoomMember | null { + switch (avatarSubject) { + case AvatarSubject.Target: + return props.mxEvent.target; + case AvatarSubject.Sender: + return props.mxEvent.sender; + case AvatarSubject.None: + default: + return null; + } +} + +function useEventTileCommands( + props: EventTileBaseProps, + cli: ReturnType, + vm: EventTileViewModel, +): UseEventTileCommandsResult { + const roomId = props.mxEvent.getRoomId(); + const room = roomId ? cli.getRoom(roomId) : null; + + const onPermalinkClicked = useCallback( + (ev: MouseEvent): void => { + vm.onPermalinkClicked(ev); + }, + [vm], + ); + const openInRoom = useCallback( + (_anchor: HTMLElement | null): void => { + vm.openInRoom(); + }, + [vm], + ); + const copyLinkToThread = useCallback( + async (_anchor: HTMLElement | null): Promise => { + await vm.copyLinkToThread(); + }, + [vm], + ); + const onContextMenu = useCallback( + (ev: MouseEvent): void => { + vm.openContextMenu(ev); + }, + [vm], + ); + const onTimestampContextMenu = useCallback( + (ev: MouseEvent): void => { + const eventId = props.mxEvent.getId(); + vm.openContextMenu(ev, eventId ? props.permalinkCreator?.forEvent(eventId) : undefined); + }, + [vm, props.permalinkCreator, props.mxEvent], + ); + const onListTileClick = useCallback( + (ev: MouseEvent): void => { + const target = ev.currentTarget; + let index = -1; + if (target.parentElement) index = Array.from(target.parentElement.children).indexOf(target); + + vm.onListTileClick(ev.nativeEvent, index); + }, + [vm], + ); + + return useMemo( + () => ({ + room, + onPermalinkClicked, + openInRoom, + copyLinkToThread, + onContextMenu, + onTimestampContextMenu, + onListTileClick, + }), + [ + room, + onPermalinkClicked, + openInRoom, + copyLinkToThread, + onContextMenu, + onTimestampContextMenu, + onListTileClick, + ], + ); +} + +function useEventTileNodes({ + props, + roomContext, + snapshot, + tileRef, + replyChainRef, + suppressReadReceiptAnimation, + tileContentId, + vm, + onActionBarFocusChange, + toggleThreadExpanded, + openInRoom, + copyLinkToThread, +}: UseEventTileNodesArgs): { content: EventTileContentNodes; thread: EventTileThreadNodes } { + const avatarMember = getAvatarMember(props, snapshot.avatarSubject); + const onSenderProfileClick = useCallback((): void => { + dis.dispatch({ + action: Action.ComposerInsert, + userId: props.mxEvent.getSender()!, + timelineRenderingType: roomContext.timelineRenderingType, + }); + }, [props.mxEvent, roomContext.timelineRenderingType]); + const setQuoteExpanded = useCallback( + (expanded: boolean): void => { + vm.setQuoteExpanded(expanded); + }, + [vm], + ); + const renderTileProps = useMemo( + () => ({ + mxEvent: props.mxEvent, + forExport: props.forExport, + showUrlPreview: props.showUrlPreview, + highlights: props.highlights, + highlightLink: props.highlightLink, + getRelationsForEvent: props.getRelationsForEvent, + editState: props.editState, + replacingEventId: props.replacingEventId, + callEventGrouper: props.callEventGrouper, + inhibitInteraction: props.inhibitInteraction, + }), + [ + props.mxEvent, + props.forExport, + props.showUrlPreview, + props.highlights, + props.highlightLink, + props.getRelationsForEvent, + props.editState, + props.replacingEventId, + props.callEventGrouper, + props.inhibitInteraction, + ], + ); + const replyChain = useMemo( + () => + snapshot.shouldRenderReplyPreview ? ( + + ) : undefined, + [ + snapshot.shouldRenderReplyPreview, + props.mxEvent, + props.forExport, + props.permalinkCreator, + props.layout, + props.alwaysShowTimestamps, + props.getRelationsForEvent, + snapshot.isQuoteExpanded, + replyChainRef, + setQuoteExpanded, + ], + ); + const actionBar = useMemo( + () => + snapshot.shouldRenderActionBar ? ( + + ) : undefined, + [ + snapshot.shouldRenderActionBar, + props.mxEvent, + props.permalinkCreator, + props.getRelationsForEvent, + snapshot.reactions, + snapshot.isQuoteExpanded, + tileRef, + replyChainRef, + onActionBarFocusChange, + toggleThreadExpanded, + ], + ); + const sender = useMemo( + () => , + [snapshot.senderMode, props.mxEvent, onSenderProfileClick], + ); + const avatar = useMemo( + () => ( + + ), + [avatarMember, snapshot.avatarSize, snapshot.avatarMemberUserOnClick, snapshot.avatarForceHistorical], + ); + const messageStatus = useMemo( + () => ( + + ), + [ + props.eventSendStatus, + snapshot.shouldShowSentReceipt, + snapshot.shouldShowSendingReceipt, + snapshot.showReadReceipts, + props.readReceipts, + props.readReceiptMap, + props.checkUnmounting, + props.isTwelveHour, + suppressReadReceiptAnimation, + ], + ); + const footer = useMemo( + () => ( +
+
+
+ ), + [ + props.layout, + props.mxEvent, + props.isRedacted, + snapshot.isPinned, + snapshot.isOwnEvent, + snapshot.reactions, + tileContentId, + ], + ); + const messageBodyProps = useMemo( + () => ({ + mxEvent: props.mxEvent, + isDecryptionFailure: snapshot.isEncryptionFailure, + timelineRenderingType: roomContext.timelineRenderingType, + tileRenderType: snapshot.tileRenderType, + isSeeingThroughMessageHiddenForModeration: snapshot.isSeeingThroughMessageHiddenForModeration, + renderTileProps, + tileRef, + permalinkCreator: props.permalinkCreator, + showHiddenEvents: roomContext.showHiddenEvents, + }), + [ + props.mxEvent, + snapshot.isEncryptionFailure, + roomContext.timelineRenderingType, + snapshot.tileRenderType, + snapshot.isSeeingThroughMessageHiddenForModeration, + renderTileProps, + tileRef, + props.permalinkCreator, + roomContext.showHiddenEvents, + ], + ); + const messageBody = useMemo(() => , [messageBodyProps]); + const info = useMemo( + () => + snapshot.threadInfoMode === ThreadInfoMode.None ? undefined : ( + + ) : undefined + } + href={snapshot.threadInfoHref} + label={snapshot.threadInfoLabel} + /> + ), + [ + snapshot.threadInfoMode, + snapshot.threadUpdateKey, + props.mxEvent, + snapshot.thread, + snapshot.threadInfoHref, + snapshot.threadInfoLabel, + ], + ); + const preview = useMemo( + () => + snapshot.shouldRenderThreadPreview && snapshot.thread ? ( + + ) : undefined, + [snapshot.shouldRenderThreadPreview, snapshot.thread, snapshot.threadUpdateKey], + ); + const toolbar = useMemo( + () => + snapshot.shouldRenderThreadToolbar ? ( + + ) : undefined, + [snapshot.shouldRenderThreadToolbar, openInRoom, copyLinkToThread], + ); + + return useMemo( + () => ({ + content: { + sender, + avatar, + replyChain, + messageBody, + actionBar, + messageStatus, + footer, + }, + thread: { + info, + replyCount: snapshot.threadReplyCount, + preview, + toolbar, + }, + }), + [ + sender, + avatar, + replyChain, + messageBody, + actionBar, + messageStatus, + footer, + info, + snapshot.threadReplyCount, + preview, + toolbar, + ], + ); +} + +function useEventTileContextMenuNode({ + props, + snapshot, + tileRef, + replyChainRef, + vm, +}: UseEventTileContextMenuNodeArgs): JSX.Element | undefined { + const closeContextMenu = useCallback((): void => { + vm.closeContextMenu(); + }, [vm]); + + return snapshot.contextMenuState && snapshot.isContextMenuOpen ? ( + + ) : undefined; +} + +function buildEventTileActionBarViewModelProps( + props: Pick, + roomContext: Pick, "timelineRenderingType" | "canSendMessages" | "canReact">, + isSearch: boolean, + isCard: boolean, + handleOptionsClick: NonNullable, + handleReactionsClick: NonNullable, +): EventTileActionBarViewModelProps { + return { + mxEvent: props.mxEvent, + timelineRenderingType: roomContext.timelineRenderingType, + canSendMessages: roomContext.canSendMessages, + canReact: roomContext.canReact, + isSearch, + isCard, + isQuoteExpanded: props.isQuoteExpanded, + onToggleThreadExpanded: props.toggleThreadExpanded, + onOptionsClick: handleOptionsClick, + onReactionsClick: handleReactionsClick, + getRelationsForEvent: props.getRelationsForEvent, + }; +} + +function EventTileActionBarHost({ + mxEvent, + reactions, + permalinkCreator, + getRelationsForEvent, + isQuoteExpanded, + tileRef, + replyChainRef, + onFocusChange, + toggleThreadExpanded, +}: Readonly): JSX.Element { + const roomContext = useContext(RoomContext); + const { isCard } = useContext(CardContext); + const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState(null); + const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState(null); + const isSearch = Boolean(roomContext.search); + const handleOptionsClick = useCallback((anchor: HTMLElement | null): void => { + setOptionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); + }, []); + const handleReactionsClick = useCallback((anchor: HTMLElement | null): void => { + setReactionsMenuAnchorRect(anchor?.getBoundingClientRect() ?? null); + }, []); + const actionBarViewModelProps = useMemo( + () => + buildEventTileActionBarViewModelProps( + { mxEvent, isQuoteExpanded, getRelationsForEvent, toggleThreadExpanded }, + roomContext, + isSearch, + isCard, + handleOptionsClick, + handleReactionsClick, + ), + [ + mxEvent, + isQuoteExpanded, + getRelationsForEvent, + toggleThreadExpanded, + roomContext, + isSearch, + isCard, + handleOptionsClick, + handleReactionsClick, + ], + ); + const vm = useCreateAutoDisposedViewModel(() => new EventTileActionBarViewModel(actionBarViewModelProps)); + + useEffect(() => { + vm.setProps(actionBarViewModelProps); + }, [vm, actionBarViewModelProps]); + useEffect(() => { + onFocusChange(Boolean(optionsMenuAnchorRect || reactionsMenuAnchorRect)); + }, [onFocusChange, optionsMenuAnchorRect, reactionsMenuAnchorRect]); + useEffect(() => { + setOptionsMenuAnchorRect(null); + setReactionsMenuAnchorRect(null); + }, [mxEvent]); + + const closeOptionsMenu = useCallback((): void => { + setOptionsMenuAnchorRect(null); + }, []); + const closeReactionsMenu = useCallback((): void => { + setReactionsMenuAnchorRect(null); + }, []); + const collapseReplyChain = replyChainRef.current?.canCollapse() ? replyChainRef.current.collapse : undefined; + + return ( + <> + + {optionsMenuAnchorRect ? ( + + ) : null} + {reactionsMenuAnchorRect ? ( + + + + ) : null} + + ); +} + +function buildThreadToolbarViewModelProps({ + onViewInRoomClick, + onCopyLinkClick, +}: ThreadToolbarHostProps): ThreadListActionBarViewModelProps { + return { + onViewInRoomClick, + onCopyLinkClick, + }; +} + +function ThreadToolbarHost({ onViewInRoomClick, onCopyLinkClick }: Readonly): JSX.Element { + const threadToolbarViewModelProps = useMemo( + () => buildThreadToolbarViewModelProps({ onViewInRoomClick, onCopyLinkClick }), + [onViewInRoomClick, onCopyLinkClick], + ); + const vm = useCreateAutoDisposedViewModel(() => new ThreadListActionBarViewModel(threadToolbarViewModelProps)); + + useEffect(() => { + vm.setProps(threadToolbarViewModelProps); + }, [vm, threadToolbarViewModelProps]); + + return ; +} + +function EventTileContextMenuHost({ + contextMenu, + mxEvent, + reactions, + permalinkCreator, + getRelationsForEvent, + tileRef, + replyChainRef, + onFinished, +}: Readonly): JSX.Element { + return ( + + ); +} + +function EventTileBody({ ref: forwardedRef, ...props }: Readonly): JSX.Element { + const roomContext = useContext(RoomContext); + const cli = useMatrixClientContext(); + const commandDeps = useMemo( + () => ({ + dispatch: (payload) => dis.dispatch(payload), + copyPlaintext, + trackInteraction: (name, ev, index) => PosthogTrackers.trackInteraction(name, ev, index), + allowOverridingNativeContextMenus: () => Boolean(PlatformPeg.get()?.allowOverridingNativeContextMenus()), + }), + [], + ); + const tileContentId = useId(); + const rootRef = useRef(null); + const tileRef = useRef(null); + const replyChainRef = useRef(null); + const [suppressReadReceiptAnimation, setSuppressReadReceiptAnimation] = useState(true); + const vmReadReceipts = useMemo( + () => props.readReceipts?.map(({ userId, ts, roomMember }) => ({ userId, ts, roomMember })), + [props.readReceipts], + ); + const { showHiddenEvents, isRoomEncrypted, timelineRenderingType } = roomContext; + const { + mxEvent, + eventSendStatus, + editState, + permalinkCreator, + callEventGrouper, + forExport, + layout, + isTwelveHour, + alwaysShowTimestamps, + isRedacted, + continuation, + last, + lastInSection, + contextual, + isSelectedEvent, + hideSender, + hideTimestamp, + inhibitInteraction, + highlightLink, + showReactions, + getRelationsForEvent, + showReadReceipts, + lastSuccessful, + } = props; + const viewModelProps = useMemo( + () => + buildEventTileViewModelProps( + { + mxEvent, + eventSendStatus, + editState, + permalinkCreator, + callEventGrouper, + forExport, + layout, + isTwelveHour, + alwaysShowTimestamps, + isRedacted, + continuation, + last, + lastInSection, + contextual, + isSelectedEvent, + hideSender, + hideTimestamp, + inhibitInteraction, + highlightLink, + showReactions, + getRelationsForEvent, + readReceipts: props.readReceipts, + showReadReceipts, + lastSuccessful, + }, + vmReadReceipts, + cli, + commandDeps, + { + showHiddenEvents, + isRoomEncrypted, + timelineRenderingType, + }, + ), + [ + mxEvent, + eventSendStatus, + editState, + permalinkCreator, + callEventGrouper, + forExport, + layout, + isTwelveHour, + alwaysShowTimestamps, + isRedacted, + continuation, + last, + lastInSection, + contextual, + isSelectedEvent, + hideSender, + hideTimestamp, + inhibitInteraction, + highlightLink, + showReactions, + getRelationsForEvent, + props.readReceipts, + showReadReceipts, + lastSuccessful, + vmReadReceipts, + cli, + commandDeps, + showHiddenEvents, + isRoomEncrypted, + timelineRenderingType, + ], + ); + const vm = useCreateAutoDisposedViewModel(() => new EventTileViewModel(viewModelProps)); + + useEffect(() => { + vm.refreshVerification(); + }, [vm]); + useImperativeHandle( + forwardedRef, + (): EventTileHandle => ({ + ref: rootRef, + forceUpdate: () => vm.refreshDerivedState(), + isWidgetHidden: () => tileRef.current?.isWidgetHidden?.() ?? false, + unhideWidget: () => tileRef.current?.unhideWidget?.(), + getMediaHelper: () => tileRef.current?.getMediaHelper?.(), + }), + [vm], + ); + useEffect(() => { + vm.updateProps(viewModelProps); + }, [viewModelProps, vm]); + + const snapshot = useViewModel(vm); + + useEffect(() => { + const rootNode = rootRef.current; + if (!props.resizeObserver || !rootNode) return; + + props.resizeObserver.observe(rootNode); + + return () => { + props.resizeObserver?.unobserve(rootNode); + }; + }, [props.resizeObserver, props.as, roomContext.timelineRenderingType, snapshot.hasRenderer]); + useEffect(() => { + setSuppressReadReceiptAnimation(false); + }, []); + + const { + room, + onPermalinkClicked, + openInRoom, + copyLinkToThread, + onContextMenu, + onTimestampContextMenu, + onListTileClick, + } = useEventTileCommands(props, cli, vm); + const onActionBarFocusChange = useCallback( + (focused: boolean): void => { + vm.onActionBarFocusChange(focused, rootRef.current?.matches(":hover") ?? false); + }, + [vm, rootRef], + ); + const toggleThreadExpanded = useCallback((): void => { + vm.toggleQuoteExpanded(); + }, [vm]); + const nodes = useEventTileNodes({ + props, + roomContext, + snapshot, + tileRef, + replyChainRef, + suppressReadReceiptAnimation, + tileContentId, + vm, + onActionBarFocusChange, + toggleThreadExpanded, + openInRoom, + copyLinkToThread, + }); + const contextMenuNode = useEventTileContextMenuNode({ + props, + snapshot, + tileRef, + replyChainRef, + vm, + }); + const notificationRoomLabel = room + ? _t( + "timeline|in_room_name", + { room: snapshot.notificationView.roomName ?? room.name }, + { strong: (sub) => {sub} }, + ) + : undefined; + const onMouseEnter = useCallback((): void => vm.setHover(true), [vm]); + const onMouseLeave = useCallback((): void => vm.setHover(false), [vm]); + const onFocus = useCallback( + (event: FocusEvent): void => { + const target = event.target as HTMLElement; + const showActionBarFromFocus = + target.matches(":focus-visible") || document.body.dataset.whatinput === "keyboard"; + vm.onFocusEnter(showActionBarFromFocus); + }, + [vm], + ); + const onBlur = useCallback( + (event: FocusEvent): void => { + if (event.currentTarget.contains(event.relatedTarget)) { + return; + } + + vm.onFocusLeave(); + }, + [vm], + ); + const eventTileViewProps = useMemo( + () => ({ + as: props.as, + rootRef, + contentId: tileContentId, + eventId: snapshot.eventId, + layout: props.layout, + timelineRenderingType: roomContext.timelineRenderingType, + rootClassName: snapshot.rootClassName, + contentClassName: snapshot.contentClassName, + ariaLive: snapshot.ariaLive, + scrollTokens: snapshot.scrollToken, + isOwnEvent: snapshot.isOwnEvent, + content: { + sender: nodes.content.sender, + avatar: nodes.content.avatar, + replyChain: nodes.content.replyChain, + messageStatus: nodes.content.messageStatus, + messageBody: nodes.content.messageBody, + actionBar: nodes.content.actionBar, + footer: snapshot.hasFooter ? nodes.content.footer : undefined, + contextMenu: contextMenuNode, + }, + threads: { + info: nodes.thread.info, + replyCount: nodes.thread.replyCount, + preview: nodes.thread.preview, + toolbar: nodes.thread.toolbar, + }, + timestamp: { + ...snapshot.timestampView, + isTwelveHour: props.isTwelveHour, + onPermalinkClicked, + onContextMenu: onTimestampContextMenu, + }, + encryption: snapshot.encryptionView, + notification: { + enabled: snapshot.notificationView.enabled, + roomLabel: notificationRoomLabel, + roomAvatar: room ? ( +
+ +
+ ) : undefined, + unreadBadge: room ? ( + + ) : undefined, + }, + handlers: { + onClick: snapshot.isListLikeTile ? onListTileClick : undefined, + onContextMenu, + onMouseEnter, + onMouseLeave, + onFocus, + onBlur, + }, + }), + [ + props.as, + props.layout, + props.isTwelveHour, + props.mxEvent, + rootRef, + tileContentId, + snapshot.eventId, + snapshot.rootClassName, + snapshot.contentClassName, + snapshot.ariaLive, + snapshot.scrollToken, + snapshot.isOwnEvent, + snapshot.hasFooter, + snapshot.timestampView, + snapshot.encryptionView, + snapshot.isListLikeTile, + snapshot.notificationView, + roomContext.timelineRenderingType, + nodes.content.sender, + nodes.content.avatar, + nodes.content.replyChain, + nodes.content.messageStatus, + nodes.content.messageBody, + nodes.content.actionBar, + nodes.content.footer, + nodes.thread.info, + nodes.thread.replyCount, + nodes.thread.preview, + nodes.thread.toolbar, + contextMenuNode, + onPermalinkClicked, + onTimestampContextMenu, + notificationRoomLabel, + room, + onListTileClick, + onContextMenu, + onMouseEnter, + onMouseLeave, + onFocus, + onBlur, + ], + ); + + if (snapshot.shouldRenderMissingRendererFallback) { + return ( +
} className="mx_EventTile mx_EventTile_info mx_MNoticeBody"> +
{_t("timeline|error_no_renderer")}
+
+ ); + } + + return ; +} + +/** Renders a single timeline event tile directly from its view model. */ +export function EventTile(props: Readonly): JSX.Element { + const { withErrorBoundary = true, layout = Layout.Group, forExport = false, ...rest } = props; + const tileProps = { ...rest, layout, forExport }; + const tile = ; + + if (!withErrorBoundary) { + return tile; + } + + return ( + + {tile} + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/EventTileCommands.ts b/apps/web/src/components/views/rooms/EventTile/EventTileCommands.ts new file mode 100644 index 00000000000..7439832d113 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/EventTileCommands.ts @@ -0,0 +1,148 @@ +/* +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 { Action } from "../../../../dispatcher/actions"; +import type { ActionPayload } from "../../../../dispatcher/payloads"; +import type { ShowThreadPayload } from "../../../../dispatcher/payloads/ShowThreadPayload"; +import type { ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload"; +import { ClickMode } from "../../../../models/rooms/EventTileModel"; +import type { InteractionName } from "../../../../PosthogTrackers"; +import type EditorStateTransfer from "../../../../utils/EditorStateTransfer"; +import type { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; +import type { EventTileContextMenuState } from "./types"; + +/** + * Command-side behaviors for {@link EventTile}. + * + * These functions form a small side-effect boundary between the tile's + * React wiring and the application services it needs to call, such as the + * dispatcher, clipboard helpers, analytics, and platform policy checks. + * + * Keeping them separate from the component preserves a clean split: + * the tile adapts DOM events, while this module executes command intent + * in a directly unit-testable form. + */ + +/** Side-effect dependencies used by event tile commands. */ +export interface EventTileCommandDeps { + dispatch: (payload: ActionPayload) => void; + copyPlaintext: (text: string) => Promise; + trackInteraction: (name: InteractionName, ev: Event, index?: number) => void; + allowOverridingNativeContextMenus: () => boolean; +} + +/** Event-specific context used to execute event tile commands. */ +export interface EventTileCommandContext { + mxEvent: MatrixEvent; + permalinkCreator?: RoomPermalinkCreator; + openedFromSearch: boolean; + tileClickMode: ClickMode; + editState?: EditorStateTransfer; +} + +/** Minimal event shape needed for context menu decisions. */ +export interface EventTileContextMenuEvent { + clientX: number; + clientY: number; + target: EventTarget | null; + preventDefault(): void; + stopPropagation(): void; +} + +/** Opens the current event in its room. */ +export function openEventInRoom( + deps: EventTileCommandDeps, + context: EventTileCommandContext, + highlighted = true, +): void { + const payload: ViewRoomPayload = { + action: Action.ViewRoom, + event_id: context.mxEvent.getId(), + highlighted, + room_id: context.mxEvent.getRoomId(), + metricsTrigger: undefined, + }; + + deps.dispatch(payload); +} + +/** Handles timestamp permalink clicks, including search attribution. */ +export function onPermalinkClicked( + deps: EventTileCommandDeps, + context: EventTileCommandContext, + ev: Pick, +): void { + ev.preventDefault(); + const payload: ViewRoomPayload = { + action: Action.ViewRoom, + event_id: context.mxEvent.getId(), + highlighted: true, + room_id: context.mxEvent.getRoomId(), + metricsTrigger: context.openedFromSearch ? "MessageSearch" : undefined, + }; + + deps.dispatch(payload); +} + +/** Copies a permalink for the current event from the thread-list affordance when available. */ +export async function copyLinkToThread(deps: EventTileCommandDeps, context: EventTileCommandContext): Promise { + if (!context.permalinkCreator) return; + const eventId = context.mxEvent.getId(); + if (!eventId) return; + + await deps.copyPlaintext(context.permalinkCreator.forEvent(eventId)); +} + +/** Builds context menu state when the tile should override the native menu. */ +export function buildContextMenuState( + deps: EventTileCommandDeps, + context: EventTileCommandContext, + ev: EventTileContextMenuEvent, + permalink?: string, +): EventTileContextMenuState | undefined { + const clickTarget = ev.target; + if (!(clickTarget instanceof HTMLElement) || clickTarget instanceof HTMLImageElement) return undefined; + + const anchorElement = clickTarget instanceof HTMLAnchorElement ? clickTarget : clickTarget.closest("a"); + if (!deps.allowOverridingNativeContextMenus() && anchorElement) return undefined; + if (context.editState) return undefined; + + ev.preventDefault(); + ev.stopPropagation(); + + return { + position: { + left: ev.clientX, + top: ev.clientY, + bottom: ev.clientY, + }, + link: anchorElement?.href || permalink, + }; +} + +/** Handles click behavior for notification and thread-list tiles. */ +export function onListTileClick( + deps: EventTileCommandDeps, + context: EventTileCommandContext, + ev: Event, + index: number, +): void { + switch (context.tileClickMode) { + case ClickMode.ViewRoom: + openEventInRoom(deps, context); + break; + case ClickMode.ShowThread: + deps.dispatch({ + action: Action.ShowThread, + rootEvent: context.mxEvent, + push: true, + } satisfies ShowThreadPayload); + deps.trackInteraction("WebThreadsPanelThreadItem", ev, index); + break; + } +} diff --git a/apps/web/src/components/views/rooms/EventTile/EventTileErrorBoundary.tsx b/apps/web/src/components/views/rooms/EventTile/EventTileErrorBoundary.tsx new file mode 100644 index 00000000000..57eefc2bcc4 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/EventTileErrorBoundary.tsx @@ -0,0 +1,83 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useEffect, type ReactNode, type JSX } from "react"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { + TileErrorView, + type TileErrorViewLayout, + useCreateAutoDisposedViewModel, +} from "@element-hq/web-shared-components"; + +import { useSettingValue } from "../../../../hooks/useSettings"; +import { type Layout } from "../../../../settings/enums/Layout"; +import { TileErrorViewModel } from "../../../../viewmodels/message-body/TileErrorViewModel"; + +/** + * Props for the event-tile fallback rendered after the tile error boundary catches a render failure. + */ +interface EventTileErrorFallbackProps { + error: Error; + layout: Layout; + mxEvent: MatrixEvent; +} + +function EventTileErrorFallback({ error, layout, mxEvent }: Readonly): JSX.Element { + const developerMode = useSettingValue("developerMode"); + const vm = useCreateAutoDisposedViewModel( + () => new TileErrorViewModel({ error, layout: layout as TileErrorViewLayout, mxEvent, developerMode }), + ); + + useEffect(() => { + vm.setError(error); + }, [error, vm]); + + useEffect(() => { + vm.setLayout(layout as TileErrorViewLayout); + }, [layout, vm]); + + useEffect(() => { + vm.setDeveloperMode(developerMode); + }, [developerMode, vm]); + + return ; +} + +interface EventTileErrorBoundaryProps { + children: ReactNode; + layout: Layout; + mxEvent: MatrixEvent; +} + +interface EventTileErrorBoundaryState { + error?: Error; +} + +export class EventTileErrorBoundary extends React.Component { + public constructor(props: EventTileErrorBoundaryProps) { + super(props); + this.state = {}; + } + + public static getDerivedStateFromError(error: Error): Partial { + return { error }; + } + + public render(): ReactNode { + if (this.state.error) { + return ( + + ); + } + + return this.props.children; + } +} diff --git a/apps/web/src/components/views/rooms/EventTile/EventTileView.tsx b/apps/web/src/components/views/rooms/EventTile/EventTileView.tsx new file mode 100644 index 00000000000..d4118a1136a --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/EventTileView.tsx @@ -0,0 +1,559 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { + memo, + type ElementType, + type FocusEventHandler, + type JSX, + type MouseEventHandler, + type ReactNode, + type Ref, +} from "react"; + +import { TimelineRenderingType } from "../../../../contexts/RoomContext"; +import { + PadlockMode, + type EncryptionIndicatorMode, + TimestampDisplayMode, + TimestampFormatMode, +} from "../../../../models/rooms/EventTileModel"; +import { Layout } from "../../../../settings/enums/Layout"; +import { EncryptionIndicator } from "./EncryptionIndicator"; +import { Timestamp } from "./Timestamp"; +import { ThreadPanelSummary } from "./ThreadPanelSummary"; + +// Our component structure for EventTiles on the timeline is: +// +// .-EventTile------------------------------------------------. +// | MemberAvatar (SenderProfile) TimeStamp | +// | .-{Message,Textual}Event---------------. Read Avatars | +// | | .-MFooBody-------------------. | | +// | | | (only if MessageEvent) | | | +// | | '----------------------------' | | +// | '--------------------------------------' | +// '----------------------------------------------------------' + +/** Structured content regions rendered inside the tile body. */ +type EventTileContentProps = { + /** Sender node, when shown. */ + sender?: ReactNode; + /** Avatar node, when shown. */ + avatar?: ReactNode; + /** Reply preview node, when shown. */ + replyChain?: ReactNode; + /** Message body node for the event content. */ + messageBody: ReactNode; + /** Action bar node for the tile controls. */ + actionBar?: ReactNode; + /** Message status node when shown. */ + messageStatus?: ReactNode; + /** Footer node when shown. */ + footer?: ReactNode; + /** Context menu node when the menu is open. */ + contextMenu?: ReactNode; +}; + +/** Thread summary and toolbar props for the tile. */ +type EventTileThreadsProps = { + /** Inline thread metadata node. */ + info?: ReactNode; + /** Reply count for the thread summary. */ + replyCount?: number; + /** Thread preview node. */ + preview?: ReactNode; + /** Thread toolbar node, when shown. */ + toolbar?: ReactNode; +}; + +/** Timestamp display props for the tile. */ +type EventTileTimestampProps = { + /** Timestamp display mode. */ + displayMode: TimestampDisplayMode; + /** Timestamp formatting mode. */ + formatMode: TimestampFormatMode; + /** Whether timestamps should use a twelve-hour clock. */ + isTwelveHour?: boolean; + /** Event timestamp in milliseconds. */ + ts?: number; + /** Received timestamp in milliseconds. */ + receivedTs?: number; + /** Permalink for the event. */ + permalink: string; + /** Click handler for timestamp permalinks. */ + onPermalinkClicked?: MouseEventHandler; + /** Context menu handler for the timestamp. */ + onContextMenu?: MouseEventHandler; +}; + +/** Encryption indicator props for the tile. */ +type EventTileEncryptionProps = { + /** Padlock presentation mode. */ + padlockMode: PadlockMode; + /** Encryption indicator icon mode. */ + mode: EncryptionIndicatorMode; + /** Optional tooltip title for the indicator. */ + indicatorTitle?: string; + /** User ID that shared keys for the event, when available. */ + sharedKeysUserId?: string; + /** Room ID associated with shared keys, when available. */ + sharedKeysRoomId?: string; +}; + +/** Notification-specific room metadata rendered in list-style timelines. */ +type EventTileNotificationProps = { + /** Whether notification metadata should be shown. */ + enabled: boolean; + /** Optional room label node. */ + roomLabel?: ReactNode; + /** Optional room avatar node. */ + roomAvatar?: ReactNode; + /** Optional unread badge node. */ + unreadBadge?: ReactNode; +}; + +/** DOM event handlers attached to the tile root or content regions. */ +type EventTileHandlersProps = { + /** Optional click handler for the tile root. */ + onClick?: MouseEventHandler; + /** Mouse enter handler for the tile root. */ + onMouseEnter: MouseEventHandler; + /** Mouse leave handler for the tile root. */ + onMouseLeave: MouseEventHandler; + /** Focus handler for the tile root. */ + onFocus: FocusEventHandler; + /** Blur handler for the tile root. */ + onBlur: FocusEventHandler; + /** Context menu handler for the content region. */ + onContextMenu: MouseEventHandler; +}; + +/** Props consumed by {@link EventTileView}. */ +export interface EventTileViewProps { + /** Optional root element tag name override. */ + as?: string; + /** Ref to the tile root element. */ + rootRef?: Ref; + /** DOM ID for the main content region. */ + contentId: string; + /** Optional event ID attached to the root element. */ + eventId?: string; + /** Active room layout variant. */ + layout?: Layout; + /** Timeline rendering mode for the current view. */ + timelineRenderingType: TimelineRenderingType; + /** CSS class name for the tile root. */ + rootClassName: string; + /** CSS class name for the tile content region. */ + contentClassName: string; + /** Optional `aria-live` override. */ + ariaLive?: "off"; + /** DOM scroll tokens used by scroll-state restoration. */ + scrollTokens?: string; + /** Whether the event belongs to the current user. */ + isOwnEvent: boolean; + /** Structured content props for the tile body. */ + content: EventTileContentProps; + /** Thread summary and toolbar props. */ + threads: EventTileThreadsProps; + /** Timestamp props. */ + timestamp: EventTileTimestampProps; + /** Encryption indicator props. */ + encryption: EventTileEncryptionProps; + /** Notification-specific room metadata props. */ + notification: EventTileNotificationProps; + /** DOM handlers for the tile. */ + handlers: EventTileHandlersProps; +} + +/** Shared props for timestamp helper components. */ +type PlainTimestampProps = { + /** Timestamp props to render. */ + timestamp: EventTileTimestampProps; +}; + +const PlainTimestamp = memo(function PlainTimestamp({ timestamp }: PlainTimestampProps): JSX.Element | null { + if (timestamp.displayMode !== TimestampDisplayMode.Plain) return null; + + return ( + + ); +}); + +const LinkedTimestamp = memo(function LinkedTimestamp({ timestamp }: PlainTimestampProps): JSX.Element | null { + if (timestamp.displayMode === TimestampDisplayMode.Hidden || timestamp.displayMode === TimestampDisplayMode.Plain) { + return null; + } + if (timestamp.displayMode === TimestampDisplayMode.Placeholder) { + return ; + } + + return ( + + ); +}); + +/** Props for the event content wrapper region. */ +type EventContentRegionProps = { + /** Optional content region DOM ID. */ + contentId?: string; + /** CSS class name for the content region. */ + contentClassName: string; + /** Context menu handler for the content region. */ + onContextMenu: MouseEventHandler; + /** Child nodes rendered inside the region. */ + children: ReactNode; +}; + +const EventContentRegion = memo(function EventContentRegion({ + contentId, + contentClassName, + onContextMenu, + children, +}: Readonly): JSX.Element { + return ( +
+ {children} +
+ ); +}); + +const FooterThreadMeta = memo(function FooterThreadMeta({ + footer, + info, +}: { + footer?: ReactNode; + info?: ReactNode; +}): JSX.Element | null { + if (!footer && !info) return null; + + return ( + <> + {footer} + {info} + + ); +}); + +const ThreadsPanelRegion = memo(function ThreadsPanelRegion({ + replyCount, + preview, +}: EventTileViewProps["threads"]): JSX.Element | null { + if (replyCount === undefined && preview === undefined) { + return null; + } + + return ( + <> + {replyCount !== undefined && preview !== undefined && ( + + )} + + ); +}); + +type ThreadTimelineContentProps = Pick< + EventTileViewProps, + "contentId" | "content" | "contentClassName" | "timestamp" +> & { + onContextMenu: MouseEventHandler; +}; + +const ThreadTimelineContent = memo(function ThreadTimelineContent({ + contentId, + content, + contentClassName, + timestamp, + onContextMenu, +}: ThreadTimelineContentProps): JSX.Element { + return ( + <> +
+ {content.avatar} + {content.sender} +
+ + {content.contextMenu} + {content.replyChain} + {content.messageBody} + {content.actionBar} + + {content.messageStatus} + + + + ); +}); + +type ListTimelineContentProps = Pick< + EventTileViewProps, + "content" | "contentClassName" | "notification" | "threads" | "timestamp" +> & { + onContextMenu: MouseEventHandler; +}; + +const ListTimelineContent = memo(function ListTimelineContent({ + content, + contentClassName, + notification, + threads, + timestamp, + onContextMenu, +}: ListTimelineContentProps): JSX.Element { + return ( + <> +
+ {content.sender} + {notification.enabled && notification.roomLabel ? ( + {notification.roomLabel} + ) : ( + "" + )} + + {notification.unreadBadge} +
+ {notification.enabled && notification.roomAvatar ? notification.roomAvatar : content.avatar} + +
{content.messageBody}
+ +
+ {threads.toolbar} + {content.messageStatus} + + ); +}); + +type FileTimelineContentProps = Pick & { + onContextMenu: MouseEventHandler; +}; + +const FileTimelineContent = memo(function FileTimelineContent({ + content, + contentClassName, + timestamp, + onContextMenu, +}: FileTimelineContentProps): JSX.Element { + return ( + <> + +
+ {content.avatar} + {content.sender} + +
+
+ + {content.contextMenu} + {content.messageBody} + + + ); +}); + +type DefaultTimelineContentProps = Pick< + EventTileViewProps, + "contentId" | "content" | "contentClassName" | "encryption" | "layout" | "threads" | "timestamp" +> & { + onContextMenu: MouseEventHandler; +}; + +const DefaultTimelineContent = memo(function DefaultTimelineContent({ + contentId, + content, + contentClassName, + encryption, + layout, + threads, + timestamp, + onContextMenu, +}: DefaultTimelineContentProps): JSX.Element { + return ( + <> + {layout === Layout.IRC && } + {content.sender} + {encryption.padlockMode === PadlockMode.Irc && ( + + )} + {content.avatar} + + {content.contextMenu} + {layout !== Layout.IRC && } + {encryption.padlockMode === PadlockMode.Group && ( + + )} + {content.replyChain} + {content.messageBody} + {content.actionBar} + {layout === Layout.IRC && } + + {layout !== Layout.IRC && } + {content.messageStatus} + + ); +}); + +function EventTileViewComponent(props: Readonly): JSX.Element { + const { + as, + rootRef, + contentId, + eventId, + layout, + timelineRenderingType, + rootClassName, + contentClassName, + ariaLive, + scrollTokens, + isOwnEvent, + content, + threads, + timestamp, + encryption, + notification, + handlers, + } = props; + + const Root = (as ?? "li") as ElementType; + const replyChain = content.replyChain; + + switch (timelineRenderingType) { + case TimelineRenderingType.Thread: + return ( + + + + ); + case TimelineRenderingType.Notification: + case TimelineRenderingType.ThreadsList: + return ( + + + + ); + case TimelineRenderingType.File: + return ( + + + + ); + default: + return ( + + + + ); + } +} + +/** Memoized view component for rendering a single event tile. */ +export const EventTileView = memo(EventTileViewComponent); diff --git a/apps/web/src/components/views/rooms/EventTile/Footer.tsx b/apps/web/src/components/views/rooms/EventTile/Footer.tsx new file mode 100644 index 00000000000..bc989952eb7 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/Footer.tsx @@ -0,0 +1,50 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { memo, type JSX } from "react"; +import { PinnedMessageBadge } from "@element-hq/web-shared-components"; + +import type { MatrixEvent, Relations } from "matrix-js-sdk/src/matrix"; +import { Layout } from "../../../../settings/enums/Layout"; +import { ReactionsRow } from "./ReactionsRow"; + +type FooterProps = Readonly<{ + mxEvent: MatrixEvent; + reactions: Relations | null; + isRedacted?: boolean; + isPinned: boolean; + isOwnEvent: boolean; + layout?: Layout; + tileContentId: string; +}>; + +export const Footer = memo(function Footer({ + mxEvent, + reactions, + isRedacted, + isPinned, + isOwnEvent, + layout, + tileContentId, +}: FooterProps): JSX.Element | undefined { + if (!isPinned && !reactions) { + return undefined; + } + + const pinnedMessageBadge = isPinned ? ( + + ) : undefined; + const reactionsRow = isRedacted ? undefined : ; + + return ( + <> + {(layout === Layout.Group || !isOwnEvent) && pinnedMessageBadge} + {reactionsRow} + {layout === Layout.Bubble && isOwnEvent && pinnedMessageBadge} + + ); +}); diff --git a/apps/web/src/components/views/rooms/EventTile/MessageBody.tsx b/apps/web/src/components/views/rooms/EventTile/MessageBody.tsx new file mode 100644 index 00000000000..a0db9f869d0 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/MessageBody.tsx @@ -0,0 +1,73 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { memo, type JSX } from "react"; + +import type { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { TimelineRenderingType } from "../../../../contexts/RoomContext"; +import { renderTile, type EventTileTypeProps } from "../../../../events/EventTileFactory"; +import type { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; +import { EventPreview } from "../EventPreview"; +import type { EventTileOps } from "./types"; +import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../../messages/MBodyFactory"; + +export type MessageBodyRenderTileProps = Omit< + EventTileTypeProps, + "ref" | "permalinkCreator" | "showHiddenEvents" | "isSeeingThroughMessageHiddenForModeration" +>; + +/** + * Props used to render the event body content for a single tile. + */ +export type MessageBodyProps = Readonly<{ + mxEvent: MatrixEvent; + isDecryptionFailure: boolean; + renderTileProps: MessageBodyRenderTileProps; + timelineRenderingType: TimelineRenderingType; + tileRenderType: TimelineRenderingType; + showHiddenEvents: boolean; + isSeeingThroughMessageHiddenForModeration: boolean; + permalinkCreator?: RoomPermalinkCreator; + tileRef: React.RefObject; +}>; + +function MessageBodyComponent({ + mxEvent, + isDecryptionFailure, + renderTileProps, + timelineRenderingType, + tileRenderType, + showHiddenEvents, + isSeeingThroughMessageHiddenForModeration, + permalinkCreator, + tileRef, +}: MessageBodyProps): JSX.Element | null { + if ( + timelineRenderingType === TimelineRenderingType.Notification || + timelineRenderingType === TimelineRenderingType.ThreadsList + ) { + if (mxEvent.isRedacted()) { + return ; + } + + if (isDecryptionFailure) { + return ; + } + + return ; + } + + return renderTile(tileRenderType, { + ...renderTileProps, + ref: tileRef, + permalinkCreator, + showHiddenEvents, + isSeeingThroughMessageHiddenForModeration, + }); +} + +export const MessageBody = memo(MessageBodyComponent); diff --git a/apps/web/src/components/views/rooms/EventTile/MessageStatus.tsx b/apps/web/src/components/views/rooms/EventTile/MessageStatus.tsx new file mode 100644 index 00000000000..1db370cb9bb --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/MessageStatus.tsx @@ -0,0 +1,115 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { memo, useMemo, type JSX, type ReactNode } from "react"; +import { CircleIcon, CheckCircleIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Tooltip } from "@vector-im/compound-web"; +import { EventStatus } from "matrix-js-sdk/src/matrix"; + +import { _t } from "../../../../languageHandler"; +import { StaticNotificationState } from "../../../../stores/notifications/StaticNotificationState"; +import NotificationBadge from "../NotificationBadge"; +import { ReadReceiptGroup } from "../ReadReceiptGroup"; +import type { IReadReceiptPosition } from "../ReadReceiptMarker"; +import type { ReadReceiptProps } from "./types"; + +type MessageStatusProps = Readonly<{ + messageState: EventStatus | undefined; + suppressReadReceiptAnimation: boolean; + shouldShowSentReceipt: boolean; + shouldShowSendingReceipt: boolean; + showReadReceipts: boolean; + readReceipts?: ReadReceiptProps[]; + readReceiptMap?: { [userId: string]: IReadReceiptPosition }; + isTwelveHour?: boolean; + checkUnmounting?: () => boolean; +}>; + +function MessageStatusComponent({ + messageState, + suppressReadReceiptAnimation, + shouldShowSentReceipt, + shouldShowSendingReceipt, + showReadReceipts, + readReceipts, + readReceiptMap, + isTwelveHour, + checkUnmounting, +}: MessageStatusProps): JSX.Element | undefined { + const sentReceipt = useMemo( + () => getSentReceiptDetails(messageState, shouldShowSentReceipt, shouldShowSendingReceipt), + [messageState, shouldShowSentReceipt, shouldShowSendingReceipt], + ); + const sentReceiptIcon = sentReceipt?.icon; + const sentReceiptLabel = sentReceipt?.label; + const receiptGroup = useMemo( + () => + showReadReceipts ? ( + + ) : undefined, + [showReadReceipts, readReceipts, readReceiptMap, checkUnmounting, suppressReadReceiptAnimation, isTwelveHour], + ); + + if (sentReceiptIcon && sentReceiptLabel) { + return ( +
+
+ +
+ {sentReceiptIcon} +
+
+
+
+ ); + } + + if (receiptGroup) { + return <>{receiptGroup}; + } + + return undefined; +} + +export const MessageStatus = memo(MessageStatusComponent); + +function getSentReceiptDetails( + messageState: EventStatus | undefined, + shouldShowSentReceipt: boolean, + shouldShowSendingReceipt: boolean, +): { icon: ReactNode; label: string } | undefined { + if (!shouldShowSentReceipt && !shouldShowSendingReceipt) { + return undefined; + } + + const isSent = !messageState || messageState === EventStatus.SENT; + const isFailed = messageState === EventStatus.NOT_SENT; + + let icon: JSX.Element | undefined; + let label: string | undefined; + if (messageState === EventStatus.ENCRYPTING) { + icon = ; + label = _t("timeline|send_state_encrypting"); + } else if (isSent) { + icon = ; + label = _t("timeline|send_state_sent"); + } else if (isFailed) { + icon = ; + label = _t("timeline|send_state_failed"); + } else { + icon = ; + label = _t("timeline|send_state_sending"); + } + + return icon && label ? { icon, label } : undefined; +} diff --git a/apps/web/src/components/views/rooms/EventTile/ReactionsRow.tsx b/apps/web/src/components/views/rooms/EventTile/ReactionsRow.tsx new file mode 100644 index 00000000000..3fbe684fa0c --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ReactionsRow.tsx @@ -0,0 +1,257 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useCallback, useContext, useEffect, useMemo, useReducer, useState, type JSX } from "react"; +import { + ReactionsRowButtonView, + ReactionsRowView, + useCreateAutoDisposedViewModel, + useViewModel, +} from "@element-hq/web-shared-components"; +import { uniqBy } from "lodash"; +import { MatrixEventEvent, RelationsEvent, type MatrixEvent, type Relations } from "matrix-js-sdk/src/matrix"; + +import RoomContext from "../../../../contexts/RoomContext"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import SettingsStore from "../../../../settings/SettingsStore"; +import ContextMenu, { aboveLeftOf } from "../../../structures/ContextMenu"; +import ReactionPicker from "../../emojipicker/ReactionPicker"; +import { isContentActionable } from "../../../../utils/EventUtils"; +import { ReactionsRowButtonViewModel } from "../../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel"; +import { + MAX_ITEMS_WHEN_LIMITED, + ReactionsRowViewModel, +} from "../../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel"; + +interface ReactionsRowButtonItemProps { + mxEvent: MatrixEvent; + content: string; + count: number; + reactionEvents: MatrixEvent[]; + myReactionEvent?: MatrixEvent; + disabled?: boolean; + customReactionImagesEnabled?: boolean; +} + +function ReactionsRowButtonItem(props: Readonly): JSX.Element { + const client = useMatrixClientContext(); + + const vm = useCreateAutoDisposedViewModel( + () => + new ReactionsRowButtonViewModel({ + client, + mxEvent: props.mxEvent, + content: props.content, + count: props.count, + reactionEvents: props.reactionEvents, + myReactionEvent: props.myReactionEvent, + disabled: props.disabled, + customReactionImagesEnabled: props.customReactionImagesEnabled, + }), + ); + + useEffect(() => { + vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled); + }, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]); + + useEffect(() => { + vm.setCount(props.count); + }, [props.count, vm]); + + useEffect(() => { + vm.setMyReactionEvent(props.myReactionEvent); + }, [props.myReactionEvent, vm]); + + useEffect(() => { + vm.setDisabled(props.disabled); + }, [props.disabled, vm]); + + return ; +} + +interface ReactionGroup { + content: string; + events: MatrixEvent[]; +} + +const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] => + reactions + ?.getSortedAnnotationsByKey() + ?.map(([content, events]) => ({ + content, + events: [...events], + })) + .filter(({ events }) => events.length > 0) ?? []; + +const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => { + if (!reactions || !userId) { + return null; + } + + const myReactions = reactions.getAnnotationsBySender()?.[userId]; + if (!myReactions) { + return null; + } + + return [...myReactions.values()]; +}; + +export interface ReactionsRowProps { + mxEvent: MatrixEvent; + reactions?: Relations | null; +} + +export function ReactionsRow({ mxEvent, reactions }: Readonly): JSX.Element | null { + const roomContext = useContext(RoomContext); + const userId = roomContext.room?.client.getUserId() ?? undefined; + const [menuDisplayed, setMenuDisplayed] = useState(false); + const [menuAnchorRect, setMenuAnchorRect] = useState(null); + const [, bumpRelationsVersion] = useReducer((version: number) => version + 1, 0); + const reactionGroups = getReactionGroups(reactions); + const myReactions = getMyReactions(reactions, userId); + + const vm = useCreateAutoDisposedViewModel( + () => + new ReactionsRowViewModel({ + isActionable: isContentActionable(mxEvent), + reactionGroupCount: reactionGroups.length, + canReact: roomContext.canReact, + addReactionButtonActive: false, + }), + ); + + const openReactionMenu = useCallback((event: React.MouseEvent): void => { + setMenuAnchorRect(event.currentTarget.getBoundingClientRect()); + setMenuDisplayed(true); + }, []); + + const closeReactionMenu = useCallback((): void => { + setMenuDisplayed(false); + }, []); + + useEffect(() => { + vm.setActionable(isContentActionable(mxEvent)); + }, [mxEvent, vm]); + + useEffect(() => { + vm.setCanReact(roomContext.canReact); + if (!roomContext.canReact && menuDisplayed) { + setMenuDisplayed(false); + } + }, [roomContext.canReact, menuDisplayed, vm]); + + useEffect(() => { + vm.setAddReactionHandlers({ + onAddReactionClick: openReactionMenu, + onAddReactionContextMenu: openReactionMenu, + }); + }, [openReactionMenu, vm]); + + useEffect(() => { + vm.setAddReactionButtonActive(menuDisplayed); + }, [menuDisplayed, vm]); + + useEffect(() => { + vm.setReactionGroupCount(reactionGroups.length); + }, [reactionGroups.length, vm]); + + useEffect(() => { + if (!reactions) return; + + const onRelationsChanged = (): void => { + bumpRelationsVersion(); + }; + + reactions.on(RelationsEvent.Add, onRelationsChanged); + reactions.on(RelationsEvent.Remove, onRelationsChanged); + reactions.on(RelationsEvent.Redaction, onRelationsChanged); + + return () => { + reactions.off(RelationsEvent.Add, onRelationsChanged); + reactions.off(RelationsEvent.Remove, onRelationsChanged); + reactions.off(RelationsEvent.Redaction, onRelationsChanged); + }; + }, [reactions]); + + useEffect(() => { + const onDecrypted = (): void => { + vm.setActionable(isContentActionable(mxEvent)); + }; + + if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { + mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted); + } + + return () => { + mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted); + }; + }, [mxEvent, vm]); + + const snapshot = useViewModel(vm); + const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images"); + const items = useMemo((): JSX.Element[] | undefined => { + const mappedItems = reactionGroups.map(({ content, events }) => { + const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender()); + const myReactionEvent = myReactions?.find((reactionEvent) => { + if (reactionEvent.isRedacted()) { + return false; + } + return reactionEvent.getRelation()?.key === content; + }); + + return ( + + ); + }); + + if (!mappedItems.length) { + return undefined; + } + + return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems; + }, [ + reactionGroups, + myReactions, + mxEvent, + customReactionImagesEnabled, + roomContext.canReact, + roomContext.canSelfRedact, + snapshot.showAllButtonVisible, + ]); + + if (!snapshot.isVisible || !items?.length) { + return null; + } + + const contextMenu = + menuDisplayed && menuAnchorRect && reactions && roomContext.canReact ? ( + + + + ) : undefined; + + return ( + <> + + {items} + + {contextMenu} + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/ReplyPreview.tsx b/apps/web/src/components/views/rooms/EventTile/ReplyPreview.tsx new file mode 100644 index 00000000000..a280f47ec42 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ReplyPreview.tsx @@ -0,0 +1,55 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; + +import type { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type Layout } from "../../../../settings/enums/Layout"; +import type { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; +import ReplyChain from "../../elements/ReplyChain"; +import type { GetRelationsForEvent } from "./types"; + +/** + * Props used to render the reply preview shown ahead of the main event body. + */ +export type ReplyPreviewProps = Readonly<{ + mxEvent: MatrixEvent; + forExport?: boolean; + permalinkCreator?: RoomPermalinkCreator; + layout?: Layout; + getRelationsForEvent?: GetRelationsForEvent; + alwaysShowTimestamps?: boolean; + isQuoteExpanded?: boolean; + replyChainRef: React.RefObject; + setQuoteExpanded: (expanded: boolean) => void; +}>; + +export function ReplyPreview({ + mxEvent, + forExport, + permalinkCreator, + layout, + getRelationsForEvent, + alwaysShowTimestamps, + isQuoteExpanded, + replyChainRef, + setQuoteExpanded, +}: ReplyPreviewProps): JSX.Element | undefined { + return ( + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/Sender.tsx b/apps/web/src/components/views/rooms/EventTile/Sender.tsx new file mode 100644 index 00000000000..155d3df5098 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/Sender.tsx @@ -0,0 +1,31 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; + +import type { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { SenderMode } from "../../../../models/rooms/EventTileModel"; +import SenderProfile from "../../messages/SenderProfile"; + +type SenderProps = Readonly<{ + mode: SenderMode; + mxEvent: MatrixEvent; + onClick?: () => void; +}>; + +export function Sender({ mode, mxEvent, onClick }: SenderProps): JSX.Element | undefined { + switch (mode) { + case SenderMode.Hidden: + return undefined; + case SenderMode.ComposerInsert: + return ; + case SenderMode.Tooltip: + return ; + default: + return ; + } +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadInfo.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadInfo.tsx new file mode 100644 index 00000000000..c927ab7a6af --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadInfo.tsx @@ -0,0 +1,36 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX, type ReactNode } from "react"; +import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +type ThreadInfoProps = Readonly<{ + summary?: ReactNode; + href?: string; + label?: string; +}>; + +/** Renders either a full thread summary node or a compact icon-plus-label thread indicator. */ +export function ThreadInfo({ summary, href, label }: ThreadInfoProps): JSX.Element | undefined { + if (summary) return <>{summary}; + if (!label) return undefined; + + const content = ( + <> + + {label} + + ); + + return href ? ( + + {content} + + ) : ( +

{content}

+ ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/ThreadPanelSummary.tsx b/apps/web/src/components/views/rooms/EventTile/ThreadPanelSummary.tsx new file mode 100644 index 00000000000..8c99d0579ac --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/ThreadPanelSummary.tsx @@ -0,0 +1,24 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX, type ReactNode } from "react"; +import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +type ThreadPanelSummaryProps = Readonly<{ + replyCount: number; + preview: ReactNode; +}>; + +export function ThreadPanelSummary({ replyCount, preview }: ThreadPanelSummaryProps): JSX.Element { + return ( +
+ + {replyCount} + {preview} +
+ ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/Timestamp.tsx b/apps/web/src/components/views/rooms/EventTile/Timestamp.tsx new file mode 100644 index 00000000000..cae2501a932 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/Timestamp.tsx @@ -0,0 +1,49 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useEffect, type JSX } from "react"; +import { MessageTimestampView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; + +import type { MessageTimestampViewModelProps } from "../../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel"; +import { MessageTimestampViewModel } from "../../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel"; +import { Icon as LateIcon } from "../../../../../res/img/sensor.svg"; + +export function Timestamp(props: Readonly): JSX.Element { + const viewModel = useCreateAutoDisposedViewModel(() => new MessageTimestampViewModel(props)); + + useEffect(() => { + viewModel.setTimestamp(props.ts); + }, [viewModel, props.ts]); + + useEffect(() => { + viewModel.setReceivedTimestamp(props.receivedTs); + }, [viewModel, props.receivedTs]); + + useEffect(() => { + viewModel.setDisplayOptions({ + showTwelveHour: props.showTwelveHour, + showRelative: props.showRelative, + }); + }, [viewModel, props.showTwelveHour, props.showRelative]); + + useEffect(() => { + viewModel.setHref(props.href); + }, [viewModel, props.href]); + + useEffect(() => { + viewModel.setHandlers({ onClick: props.onClick, onContextMenu: props.onContextMenu }); + }, [viewModel, props.onClick, props.onContextMenu]); + + return ( + <> + {props.receivedTs ? ( + + ) : undefined} + + + ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/index.ts b/apps/web/src/components/views/rooms/EventTile/index.ts new file mode 100644 index 00000000000..23a3d654569 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/index.ts @@ -0,0 +1,10 @@ +/* +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. +*/ + +export { EventTile as default, EventTile } from "./EventTile"; +export type { EventTileHandle, EventTileProps } from "./EventTile"; +export type { EventTileContextMenuState, EventTileOps, GetRelationsForEvent, ReadReceiptProps } from "./types"; diff --git a/apps/web/src/components/views/rooms/EventTile/types.ts b/apps/web/src/components/views/rooms/EventTile/types.ts new file mode 100644 index 00000000000..c75b4234788 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/types.ts @@ -0,0 +1,42 @@ +/* +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 { EventType, Relations, RelationType, RoomMember } from "matrix-js-sdk/src/matrix"; +import type { MediaEventHelper } from "../../../../utils/MediaEventHelper"; + +/** Resolves aggregated relations for an event, such as reactions or edits. */ +export type GetRelationsForEvent = ( + eventId: string, + relationType: RelationType | string, + eventType: EventType | string, +) => Relations | null | undefined; + +/** Read receipt metadata shared across event tile layers. */ +export interface ReadReceiptProps { + /** User ID that emitted the receipt. */ + userId: string; + /** Receipt timestamp in milliseconds. */ + ts: number; + /** Room member associated with the receipt, if known. */ + roomMember: RoomMember | null; +} + +/** Captured pointer position and link metadata for opening an event tile context menu. */ +export interface EventTileContextMenuState { + position: Pick; + link?: string; +} + +/** Shared imperative operations exposed by event tile implementations. */ +export type EventTileOps = { + /** Returns whether an embedded widget preview is currently hidden. */ + isWidgetHidden?(): boolean; + /** Restores a previously hidden embedded widget preview. */ + unhideWidget?(): void; + /** Returns the media helper for eligible media events when one is available. */ + getMediaHelper?(): MediaEventHelper | undefined; +}; diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 06c843f1907..c9a8bad7229 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -599,9 +599,13 @@ export class MessageComposer extends React.Component { ? Math.round(this.state.recordingTimeLeftSeconds) : 0; controls.push( - + boolean; suppressAnimation: boolean; @@ -198,7 +198,7 @@ export function ReadReceiptGroup({ ); } -interface ReadReceiptPersonProps extends IReadReceiptProps { +interface ReadReceiptPersonProps extends ReadReceiptProps { isTwelveHour?: boolean; onAfterClick?: () => void; } diff --git a/apps/web/src/events/EventTileFactory.tsx b/apps/web/src/events/EventTileFactory.tsx index 56faffc26cb..02a1389d206 100644 --- a/apps/web/src/events/EventTileFactory.tsx +++ b/apps/web/src/events/EventTileFactory.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { type JSX, useEffect } from "react"; import { type MatrixEvent, EventType, @@ -25,7 +25,7 @@ import { import SettingsStore from "../settings/SettingsStore"; import type LegacyCallEventGrouper from "../components/structures/LegacyCallEventGrouper"; -import { type IEventTileType, type EventTileProps } from "../components/views/rooms/EventTile"; +import { type EventTileOps, type EventTileProps } from "../components/views/rooms/EventTile"; import { TimelineRenderingType } from "../contexts/RoomContext"; import MessageEvent from "../components/views/messages/MessageEvent"; import LegacyCallEvent from "../components/views/messages/LegacyCallEvent"; @@ -66,7 +66,7 @@ export interface EventTileTypeProps extends Pick< | "isSeeingThroughMessageHiddenForModeration" | "inhibitInteraction" > { - ref?: React.RefObject; + ref?: React.RefObject; maxImageHeight?: number; // pixels overrideBodyTypes?: Record>; overrideEventTypes?: Record>; @@ -81,9 +81,17 @@ const LegacyCallEventFactory: Factory ); const CallEventFactory: Factory = (ref, props) => ; -export const TextualEventFactory: Factory = (ref, props) => { - const vm = new TextualEventViewModel(props); +function TextualEventWrappedView(props: Readonly): JSX.Element { + const vm = useCreateAutoDisposedViewModel(() => new TextualEventViewModel(props)); + + useEffect(() => { + vm.updateProps(props); + }, [props, vm]); + return ; +} +export const TextualEventFactory: Factory = (_ref, props) => { + return ; }; function EncryptionEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { const cli = useMatrixClientContext(); diff --git a/apps/web/src/models/rooms/EventTileModel.ts b/apps/web/src/models/rooms/EventTileModel.ts new file mode 100644 index 00000000000..f143f74deba --- /dev/null +++ b/apps/web/src/models/rooms/EventTileModel.ts @@ -0,0 +1,128 @@ +/* +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. +*/ + +/** Controls how sender information is presented for an event tile. */ +export enum SenderMode { + /** Omits sender information entirely. */ + Hidden = "hidden", + /** Shows sender information using the standard timeline presentation. */ + Default = "default", + /** Shows sender information in the compact composer insertion style. */ + ComposerInsert = "composerInsert", + /** Shows sender information in a tooltip-oriented presentation. */ + Tooltip = "tooltip", +} + +/** Controls which thread metadata is shown alongside an event tile. */ +export enum ThreadInfoMode { + /** Hides thread metadata. */ + None = "none", + /** Shows the standard thread summary UI. */ + Summary = "summary", + /** Shows the compact search-result thread label as a link. */ + SearchLink = "searchLink", + /** Shows the compact search-result thread label without a link. */ + SearchText = "searchText", +} + +/** Controls whether the thread summary and toolbar are rendered below the tile. */ +export enum ThreadPanelMode { + /** Hides all thread panel UI. */ + None = "none", + /** Shows only the thread toolbar. */ + Toolbar = "toolbar", + /** Shows only the thread summary. */ + Summary = "summary", + /** Shows both the thread summary and the thread toolbar. */ + SummaryWithToolbar = "summaryWithToolbar", +} + +/** Defines the primary click action for the tile. */ +export enum ClickMode { + /** Disables tile click behavior. */ + None = "none", + /** Opens the room that contains the event. */ + ViewRoom = "viewRoom", + /** Opens the event thread. */ + ShowThread = "showThread", +} + +/** Describes how the event body was rendered. */ +export enum EventTileRenderMode { + /** Rendered with the event's normal renderer. */ + Rendered = "rendered", + /** Rendered with a generic fallback because no dedicated renderer was available. */ + MissingRendererFallback = "missingRendererFallback", +} + +/** Controls which encryption status indicator is shown. */ +export enum EncryptionIndicatorMode { + /** Hides the encryption indicator. */ + None = "none", + /** Shows the normal encryption indicator. */ + Normal = "normal", + /** Shows a warning indicator for encryption-related issues. */ + Warning = "warning", + /** Shows a decryption failure indicator. */ + DecryptionFailure = "decryptionFailure", +} + +/** Controls how the tile timestamp is presented. */ +export enum TimestampDisplayMode { + /** Hides the timestamp. */ + Hidden = "hidden", + /** Shows the timestamp as plain text. */ + Plain = "plain", + /** Shows the timestamp as a permalink. */ + Linked = "linked", + /** Reserves timestamp space without rendering the actual time, used for IRC alignment. */ + Placeholder = "placeholder", +} + +/** Controls whether timestamps use absolute or relative formatting. */ +export enum TimestampFormatMode { + /** Uses an absolute date/time representation. */ + Absolute = "absolute", + /** Uses a relative time representation. */ + Relative = "relative", +} + +/** Controls which padlock badge is shown for room type or origin. */ +export enum PadlockMode { + /** Hides the padlock badge. */ + None = "none", + /** Shows the group room padlock badge. */ + Group = "group", + /** Shows the IRC padlock badge. */ + Irc = "irc", +} + +/** Defines the avatar size used by the tile. */ +export enum AvatarSize { + /** Hides the avatar. */ + None = "none", + /** Uses the extra-small avatar size. */ + XSmall = "xsmall", + /** Uses the small avatar size. */ + Small = "small", + /** Uses the medium avatar size. */ + Medium = "medium", + /** Uses the large avatar size. */ + Large = "large", + /** Uses the extra-large avatar size. */ + XLarge = "xlarge", +} + +/** Selects which entity the avatar should represent. */ +export enum AvatarSubject { + /** Does not render an avatar subject. */ + None = "none", + /** Uses the event sender as the avatar subject. */ + Sender = "sender", + /** Uses the event target as the avatar subject. */ + Target = "target", +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts new file mode 100644 index 00000000000..0b8ca0ae266 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -0,0 +1,2043 @@ +/* +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 { + CryptoEvent, + DecryptionFailureCode, + EventShieldColour, + EventShieldReason, + type UserVerificationStatus, +} from "matrix-js-sdk/src/crypto-api"; +import { + EventStatus, + EventType, + MsgType, + type MatrixClient, + type MatrixEvent, + MatrixEventEvent, + type NotificationCountType, + type Relations, + type Room, + RoomEvent, + ThreadEvent, + type Thread, +} from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; +import { BaseViewModel } from "@element-hq/web-shared-components"; +import classNames from "classnames"; + +import type LegacyCallEventGrouper from "../../../../components/structures/LegacyCallEventGrouper"; +import { + buildContextMenuState, + copyLinkToThread, + onListTileClick, + onPermalinkClicked, + openEventInRoom, + type EventTileCommandContext, + type EventTileCommandDeps, +} from "../../../../components/views/rooms/EventTile/EventTileCommands"; +import { + AvatarSubject, + AvatarSize, + ClickMode, + EventTileRenderMode, + EncryptionIndicatorMode, + PadlockMode, + SenderMode, + TimestampDisplayMode, + TimestampFormatMode, + ThreadPanelMode, + ThreadInfoMode, +} from "../../../../models/rooms/EventTileModel"; +import { TimelineRenderingType } from "../../../../contexts/RoomContext"; +import { ElementCallEventType } from "../../../../call-types"; +import { DecryptionFailureTracker } from "../../../../DecryptionFailureTracker"; +import { isMessageEvent } from "../../../../events/EventTileFactory"; +import { _t } from "../../../../languageHandler"; +import type { + EventTileContextMenuState, + GetRelationsForEvent, + ReadReceiptProps, +} from "../../../../components/views/rooms/EventTile/types"; +import { Layout } from "../../../../settings/enums/Layout"; +import { getEventDisplayInfo } from "../../../../utils/EventRenderingUtils"; +import { getLateEventInfo } from "../../../../components/structures/grouper/LateEventGrouper"; +import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom"; +import { MediaEventHelper as TileMediaEventHelper } from "../../../../utils/MediaEventHelper"; +import { shouldDisplayReply } from "../../../../utils/Reply"; +import type EditorStateTransfer from "../../../../utils/EditorStateTransfer"; +import type { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; +import PinningUtils from "../../../../utils/PinningUtils"; + +/** Interaction-only state that changes in response to pointer and focus events. */ +interface EventTileInteractionSnapshot { + /** Whether the tile action bar should be presented as focused. */ + actionBarFocused: boolean; + /** Whether the tile is currently hovered. */ + hover: boolean; + /** Whether focus is currently inside the tile subtree. */ + focusWithin: boolean; + /** Whether keyboard-visible focus should force the action bar to show. */ + showActionBarFromFocus: boolean; + /** Whether the tile context menu is currently open. */ + isContextMenuOpen: boolean; + /** Full context-menu state for rendering the current menu instance. */ + contextMenuState?: EventTileContextMenuState; + /** Whether the reply quote preview is expanded. */ + isQuoteExpanded?: boolean; +} + +/** Derived receipt and reaction state for the tile footer. */ +interface EventTileReceiptSnapshot { + /** The reaction aggregation for the event, if reactions are being tracked. */ + reactions: Relations | null; + /** Whether the tile should show the sent receipt state. */ + shouldShowSentReceipt: boolean; + /** Whether the tile should show the sending receipt state. */ + shouldShowSendingReceipt: boolean; + /** Whether read receipts should be rendered for this tile. */ + showReadReceipts: boolean; +} + +/** Rendering decisions that shape the tile body and footer layout. */ +interface EventTileRenderingSnapshot { + /** Whether the event should render with highlighted styling. */ + isHighlighted: boolean; + /** Whether the tile is a continuation of the previous sender block. */ + isContinuation: boolean; + /** Whether the event is still in a local sending state. */ + isSending: boolean; + /** Whether the event is currently being edited. */ + isEditing: boolean; + /** Whether the tile should show a reply preview above the event content. */ + showReplyPreview: boolean; + /** Whether the tile should currently render the reply preview component. */ + shouldRenderReplyPreview: boolean; + /** Whether the tile should currently render the action bar. */ + shouldRenderActionBar: boolean; + /** The event renderer mode chosen for the tile body. */ + renderMode: EventTileRenderMode; + /** Whether a dedicated renderer exists for the event content. */ + hasRenderer: boolean; + /** The high-level timeline rendering type for the tile. */ + tileRenderType: TimelineRenderingType; + /** Whether the event should render as a bubble-style message. */ + isBubbleMessage: boolean; + /** Whether the event should render as an informational message. */ + isInfoMessage: boolean; + /** Whether the bubble should be left-aligned instead of sender-aligned. */ + isLeftAlignedBubbleMessage: boolean; + /** Whether the event should suppress bubble styling entirely. */ + noBubbleEvent: boolean; + /** Whether hidden-for-moderation content is currently being revealed through the tile UI. */ + isSeeingThroughMessageHiddenForModeration: boolean; + /** Whether the event is currently pinned in the room. */ + isPinned: boolean; + /** Whether the tile should render footer content. */ + hasFooter: boolean; +} + +/** Timestamp and permalink data derived for the tile header or footer. */ +interface EventTileTimestampSnapshot { + /** Whether the timestamp should currently be visible. */ + showTimestamp: boolean; + /** The permalink for this event, or an empty string when unavailable. */ + permalink: string; + /** The DOM scroll token used to anchor this tile in scroll state restoration. */ + scrollToken?: string; + /** The visual timestamp presentation mode. */ + timestampDisplayMode: TimestampDisplayMode; + /** The formatting style used when rendering the timestamp. */ + timestampFormatMode: TimestampFormatMode; + /** The timestamp value, in milliseconds since the Unix epoch. */ + timestampTs: number; +} + +/** Plain timestamp view data consumed by the thin React layer. */ +interface EventTileTimestampViewData { + /** Timestamp display mode. */ + displayMode: TimestampDisplayMode; + /** Timestamp formatting mode. */ + formatMode: TimestampFormatMode; + /** Event timestamp in milliseconds. */ + ts: number; + /** Received timestamp for late-event rendering, when available. */ + receivedTs?: number; + /** Event permalink. */ + permalink: string; +} + +/** Thread-related state derived from the event and current room context. */ +interface EventTileThreadSnapshot { + /** The thread associated with the event, if any. */ + thread: Thread | null; + /** A stable change key used to invalidate thread-dependent rendering. */ + threadUpdateKey: string; + /** The thread notification type to surface for this tile. */ + threadNotification?: NotificationCountType; + /** Whether the event is associated with a thread. */ + hasThread: boolean; + /** Whether the event is the root event of a thread. */ + isThreadRoot: boolean; + /** The thread panel layout mode to render below the tile. */ + threadPanelMode: ThreadPanelMode; + /** The thread metadata mode to render inline with the tile. */ + threadInfoMode: ThreadInfoMode; + /** Optional href used by the inline thread info affordance. */ + threadInfoHref?: string; + /** Optional label used by the inline thread info affordance. */ + threadInfoLabel?: string; + /** Reply count to surface below the tile when thread summary UI is shown. */ + threadReplyCount?: number; + /** Whether the thread reply preview should be rendered below the tile. */ + shouldRenderThreadPreview: boolean; + /** Whether the thread action toolbar should be rendered below the tile. */ + shouldRenderThreadToolbar: boolean; + /** The primary click action exposed by the tile. */ + tileClickMode: ClickMode; + /** Whether the tile is currently rendered in search results. */ + openedFromSearch: boolean; +} + +/** Sender and avatar presentation derived for the tile. */ +interface EventTileSenderSnapshot { + /** Whether the event sender is the current user. */ + isOwnEvent: boolean; + /** Whether sender information should be shown. */ + showSender: boolean; + /** Which entity the avatar should represent. */ + avatarSubject: AvatarSubject; + /** The avatar size to render. */ + avatarSize: AvatarSize; + /** Whether clicking the avatar should target the member or user affordance. */ + avatarMemberUserOnClick: boolean; + /** Whether avatar lookup should prefer historical membership data. */ + avatarForceHistorical: boolean; + /** The sender presentation mode to use. */ + senderMode: SenderMode; +} + +/** Encryption and room-security presentation state for the tile. */ +interface EventTileEncryptionSnapshot { + /** The verification shield color derived for the event. */ + shieldColour: EventShieldColour; + /** The verification or warning reason associated with the shield. */ + shieldReason: EventShieldReason | null; + /** Whether the event is currently in a decryption failure state. */ + isEncryptionFailure: boolean; + /** The padlock badge mode to render for room context. */ + padlockMode: PadlockMode; + /** The encryption indicator mode to show in the tile chrome. */ + encryptionIndicatorMode: EncryptionIndicatorMode; + /** The user ID that shared keys for the event, when available. */ + sharedKeysUserId?: string; + /** The room ID associated with the shared keys, when available. */ + sharedKeysRoomId?: string; + /** Optional tooltip title for the encryption indicator. */ + encryptionIndicatorTitle?: string; +} + +/** Plain encryption view data consumed by the thin React layer. */ +interface EventTileEncryptionViewData { + /** Padlock presentation mode. */ + padlockMode: PadlockMode; + /** Encryption indicator icon mode. */ + mode: EncryptionIndicatorMode; + /** Optional tooltip title for the indicator. */ + indicatorTitle?: string; + /** User ID that shared keys for the event, when available. */ + sharedKeysUserId?: string; + /** Room ID associated with shared keys, when available. */ + sharedKeysRoomId?: string; +} + +/** Plain notification-list data consumed by the thin React layer. */ +interface EventTileNotificationViewData { + /** Whether notification metadata should be rendered. */ + enabled: boolean; + /** Room name to surface in notification views, when available. */ + roomName?: string; +} + +/** Additional presentational data currently derived alongside the VM snapshot. */ +interface EventTilePresentationSnapshot { + /** Optional event ID attached to the rendered tile root. */ + eventId?: string; + /** Optional aria-live setting for the rendered tile root. */ + ariaLive?: "off"; + /** CSS class name for the tile root. */ + rootClassName: string; + /** CSS class name for the tile content wrapper. */ + contentClassName: string; + /** Whether the tile is rendered in notifications mode. */ + isNotification: boolean; + /** Whether the tile behaves like a clickable list item. */ + isListLikeTile: boolean; + /** Plain room name for notification list rendering. */ + notificationRoomName?: string; + /** Received timestamp used for late-event timestamp rendering. */ + receivedTs?: number; + /** Whether the thin view should render the missing-renderer fallback path. */ + shouldRenderMissingRendererFallback: boolean; + /** Grouped timestamp data for the thin view. */ + timestampView: EventTileTimestampViewData; + /** Grouped encryption data for the thin view. */ + encryptionView: EventTileEncryptionViewData; + /** Grouped notification data for the thin view. */ + notificationView: EventTileNotificationViewData; +} + +/** Shared intermediate values used while deriving a tile snapshot. */ +interface EventTileDerivationContext { + /** Event display metadata reused across rendering decisions. */ + displayInfo: ReturnType; + /** The verification shield color derived for the event. */ + shieldColour: EventShieldColour; + /** The verification or warning reason associated with the shield. */ + shieldReason: EventShieldReason | null; + /** The reaction aggregation for the event, if reactions are being tracked. */ + reactions: Relations | null; + /** The thread associated with the event, if any. */ + thread: Thread | null; + /** The thread notification type to surface for this tile. */ + threadNotification?: NotificationCountType; + /** Whether the event is associated with a thread. */ + hasThread: boolean; + /** Whether the event is the root event of a thread. */ + isThreadRoot: boolean; + /** Whether sender information should be shown. */ + showSender: boolean; + /** Shared sender/avatar presentation classification for the tile. */ + senderPresentation: EventTileSenderPresentationContext; + /** Whether the event is currently in a decryption failure state. */ + isEncryptionFailure: boolean; + /** Whether the timestamp should currently be visible. */ + showTimestamp: boolean; +} + +/** Shared sender/avatar classification reused across sender derivation helpers. */ +interface EventTileSenderPresentationContext { + /** The avatar size to render. */ + avatarSize: AvatarSize; + /** Whether the sender should be treated as a profile-bearing identity. */ + needsSenderProfile: boolean; +} + +/** Fully derived view state consumed by the `EventTile` rendering layer. */ +export type EventTileViewSnapshot = EventTileInteractionSnapshot & + EventTileReceiptSnapshot & + EventTileRenderingSnapshot & + EventTileTimestampSnapshot & + EventTileThreadSnapshot & + EventTileSenderSnapshot & + EventTileEncryptionSnapshot & + EventTilePresentationSnapshot; + +/** Core event and service dependencies required by the view model. */ +interface EventTileCoreProps { + /** The Matrix client used for decryption, receipts, and room lookups. */ + cli: MatrixClient; + /** The Matrix event represented by this tile. */ + mxEvent: MatrixEvent; + /** The local send status override for the event, when applicable. */ + eventSendStatus?: EventStatus; + /** The current composer edit state associated with the event. */ + editState?: EditorStateTransfer; + /** Optional permalink helper used to generate event links. */ + permalinkCreator?: RoomPermalinkCreator; + /** Optional helper used to group legacy call events. */ + callEventGrouper?: LegacyCallEventGrouper; +} + +/** Rendering flags and layout context that influence tile presentation. */ +interface EventTileRenderingProps { + /** Whether the tile is being rendered for export rather than live interaction. */ + forExport?: boolean; + /** The timeline rendering mode for the current room view. */ + timelineRenderingType: TimelineRenderingType; + /** The active room layout variant. */ + layout?: Layout; + /** Whether timestamps should use a twelve-hour clock. */ + isTwelveHour?: boolean; + /** Whether timestamps should always be visible regardless of hover state. */ + alwaysShowTimestamps?: boolean; + /** Whether the event should be treated as redacted for rendering purposes. */ + isRedacted?: boolean; + /** Whether this tile visually continues the previous event group. */ + continuation?: boolean; + /** Whether this is the last visible tile in the current list. */ + last?: boolean; + /** Whether this is the last tile in its grouped section. */ + lastInSection?: boolean; + /** Whether the tile is being rendered in a contextual timeline. */ + contextual?: boolean; + /** Whether this tile corresponds to the currently selected event. */ + isSelectedEvent?: boolean; + /** Whether hidden events should still be rendered. */ + showHiddenEvents: boolean; + /** Whether the room containing the event is encrypted. */ + isRoomEncrypted: boolean; + /** Whether sender information should be suppressed. */ + hideSender?: boolean; + /** Whether timestamp rendering should be suppressed. */ + hideTimestamp?: boolean; + /** Whether interactive affordances should be disabled. */ + inhibitInteraction?: boolean; + /** A link target used to highlight matching content inside the tile. */ + highlightLink?: string; +} + +/** Optional relation and receipt inputs used to enrich tile footer state. */ +interface EventTileRelationProps { + /** Whether reaction aggregation should be computed and displayed. */ + showReactions?: boolean; + /** Optional relation lookup function for the event. */ + getRelationsForEvent?: GetRelationsForEvent; + /** Read receipt data available for the tile. */ + readReceipts?: ReadReceiptProps[]; + /** Whether read receipts should be rendered when available. */ + showReadReceipts?: boolean; + /** Whether this event is the most recent successfully sent event. */ + lastSuccessful?: boolean; +} + +/** Side-effect dependencies used by command-oriented VM methods. */ +interface EventTileCommandProps { + /** Dispatcher, clipboard, analytics, and platform-policy command services. */ + commandDeps: EventTileCommandDeps; +} + +/** Inputs required to derive the `EventTile` view snapshot. */ +export type EventTileViewModelProps = EventTileCoreProps & + EventTileRenderingProps & + EventTileRelationProps & + EventTileCommandProps; + +/** Derives and maintains render state for a single timeline event tile. */ +export class EventTileViewModel extends BaseViewModel { + private static readonly cliTrustListenerRegistry = new WeakMap< + MatrixClient, + { + listeners: Set<(userId: string, trustStatus: UserVerificationStatus) => void>; + handler: (userId: string, trustStatus: UserVerificationStatus) => void; + } + >(); + + private static readonly roomThreadListenerRegistry = new WeakMap< + Room, + { + listenersByEventId: Map void>>; + handler: (thread: Thread) => void; + } + >(); + + private isListeningForReceipts = false; + private verifyGeneration = 0; + private currentCli: MatrixClient | null = null; + private currentEvent: MatrixEvent | null = null; + private currentRoom: Room | null = null; + private currentRoomThreadEventId: string | null = null; + private isListeningForUserTrust = false; + private isListeningForReactions = false; + + /** Creates a view model for a single event tile. */ + public constructor(props: EventTileViewModelProps) { + super(props, EventTileViewModel.deriveSnapshot(props)); + + this.rebindListeners(null, props); + this.updateReceiptListener(); + this.decryptEventIfNeeded(); + } + + /** Releases all Matrix listeners owned by this view model. */ + public override dispose(): void { + this.unbindAllListeners(); + super.dispose(); + } + + /** Updates whether the tile is currently hovered. */ + public setHover(hover: boolean): void { + this.updateInteractionSnapshot({ hover }); + } + + /** Updates whether the quoted reply preview is expanded. */ + public setQuoteExpanded(isQuoteExpanded: boolean): void { + this.updateInteractionSnapshot({ isQuoteExpanded }); + } + + /** Applies root focus entry state and whether keyboard focus should reveal the action bar. */ + public onFocusEnter(showActionBarFromFocus: boolean): void { + this.updateInteractionSnapshot({ + focusWithin: true, + showActionBarFromFocus, + }); + } + + /** Applies root focus exit state. */ + public onFocusLeave(): void { + this.updateInteractionSnapshot({ + focusWithin: false, + showActionBarFromFocus: false, + }); + } + + /** Applies action-bar focus state and syncs hover state with the current tile hover status. */ + public onActionBarFocusChange(focused: boolean, isTileHovered: boolean): void { + this.updateInteractionSnapshot({ + actionBarFocused: focused, + hover: focused ? this.snapshot.current.hover : isTileHovered, + }); + } + + /** Applies the interaction state changes required when opening the context menu. */ + public onContextMenuOpen(): void { + this.updateInteractionSnapshot({ + isContextMenuOpen: true, + actionBarFocused: true, + hover: false, + }); + } + + /** Applies the interaction state changes required when closing the context menu. */ + public onContextMenuClose(): void { + this.updateInteractionSnapshot({ + isContextMenuOpen: false, + actionBarFocused: false, + contextMenuState: undefined, + hover: false, + }); + } + + /** Opens the event in room through the command boundary. */ + public openInRoom(): void { + openEventInRoom(this.props.commandDeps, this.getCommandContext()); + } + + /** Handles timestamp permalink clicks through the command boundary. */ + public onPermalinkClicked(ev: Pick): void { + onPermalinkClicked(this.props.commandDeps, this.getCommandContext(), ev); + } + + /** Copies the thread permalink through the command boundary when available. */ + public async copyLinkToThread(): Promise { + await copyLinkToThread(this.props.commandDeps, this.getCommandContext()); + } + + /** Opens the tile context menu when the click target should override the native menu. */ + public openContextMenu( + ev: { + clientX: number; + clientY: number; + target: EventTarget | null; + preventDefault(): void; + stopPropagation(): void; + }, + permalink?: string, + ): void { + const contextMenuState = buildContextMenuState(this.props.commandDeps, this.getCommandContext(), ev, permalink); + if (!contextMenuState) return; + + this.updateInteractionSnapshot({ + isContextMenuOpen: true, + actionBarFocused: true, + contextMenuState, + hover: false, + }); + } + + /** Closes the tile context menu and clears the stored menu state. */ + public closeContextMenu(): void { + this.onContextMenuClose(); + } + + /** Handles list-style tile click behavior through the command boundary. */ + public onListTileClick(ev: Event, index: number): void { + onListTileClick(this.props.commandDeps, this.getCommandContext(), ev, index); + } + + /** Toggles the reply quote expansion flag. */ + public toggleQuoteExpanded(): void { + this.setQuoteExpanded(!this.snapshot.current.isQuoteExpanded); + } + + /** Replaces the model props and refreshes affected listeners and derived state. */ + public updateProps(props: EventTileViewModelProps): void { + const previousProps = this.props; + const previousEvent = this.props.mxEvent; + const previousEventSendStatus = this.props.eventSendStatus; + const previousShowReactions = this.props.showReactions; + + this.props = props; + this.rebindListeners(previousProps, props); + this.updateSnapshot({ + reactions: EventTileViewModel.getReactions(props), + thread: EventTileViewModel.getThread(props), + }); + + if ( + previousEvent !== props.mxEvent || + previousEventSendStatus !== props.eventSendStatus || + previousShowReactions !== props.showReactions + ) { + if (previousEvent !== props.mxEvent) { + this.decryptEventIfNeeded(); + } + this.refreshVerification(); + } + } + + /** Recomputes the full derived snapshot from the current props and live event state. */ + public refreshDerivedState(): void { + this.updateSnapshot(); + } + + /** Re-runs event verification and updates the encryption shield state when the async check completes. */ + public refreshVerification(): void { + void this.verifyEvent(); + } + + private rebindListeners(previousProps: EventTileViewModelProps | null, nextProps: EventTileViewModelProps): void { + if (previousProps?.cli !== nextProps.cli || previousProps?.forExport !== nextProps.forExport) { + // Client-scoped listeners must move when we swap MatrixClient instances or stop/start live rendering. + this.unbindCliListeners(); + this.bindCliListeners(nextProps); + } + + if ( + previousProps?.mxEvent !== nextProps.mxEvent || + previousProps?.showReactions !== nextProps.showReactions || + previousProps?.forExport !== nextProps.forExport + ) { + // Event-scoped listeners depend on the current event, whether reactions are shown, and export mode. + this.unbindEventListeners(); + this.bindEventListeners(nextProps); + } + + const nextRoom = EventTileViewModel.getRoom(nextProps); + const nextRoomThreadEventId = EventTileViewModel.getRoomThreadListenerEventId(nextProps); + if (this.currentRoom !== nextRoom || this.currentRoomThreadEventId !== nextRoomThreadEventId) { + // Room-scoped listeners follow the room that owns the tile's event. + this.unbindRoomListeners(); + this.bindRoomListeners(nextRoom, nextRoomThreadEventId); + } + } + + private bindCliListeners(props: EventTileViewModelProps): void { + this.currentCli = props.cli; + if (props.forExport) return; + + // Re-verify the encryption shield when the sender's trust state changes. + this.trackCliTrust(props.cli, this.onUserVerificationChanged); + if (this.isListeningForReceipts) { + // Refresh sent/sending receipt state for tiles that currently display delivery receipts. + props.cli.on(RoomEvent.Receipt, this.onRoomReceipt); + } + this.isListeningForUserTrust = true; + } + + private unbindCliListeners(): void { + if (this.currentCli && this.isListeningForUserTrust) { + const entry = EventTileViewModel.cliTrustListenerRegistry.get(this.currentCli); + entry?.listeners.delete(this.onUserVerificationChanged); + if (entry?.listeners.size === 0) { + this.currentCli.off(CryptoEvent.UserTrustStatusChanged, entry.handler); + EventTileViewModel.cliTrustListenerRegistry.delete(this.currentCli); + } + } + if (this.currentCli && this.isListeningForReceipts) { + // Receipt listeners are opt-in and should only stay attached while this tile needs them. + this.currentCli.off(RoomEvent.Receipt, this.onRoomReceipt); + } + + this.currentCli = null; + this.isListeningForReceipts = false; + this.isListeningForUserTrust = false; + } + + private bindEventListeners(props: EventTileViewModelProps): void { + this.currentEvent = props.mxEvent; + // Keep thread summary data current as replies are added or thread metadata changes. + props.mxEvent.on(ThreadEvent.Update, this.onThreadUpdate); + + if (props.forExport) return; + + // Recompute rendering and encryption state once an encrypted event finishes decrypting. + props.mxEvent.on(MatrixEventEvent.Decrypted, this.onDecrypted); + // Update the tile if this event gets edited and its replacement changes what should be rendered. + props.mxEvent.on(MatrixEventEvent.Replaced, this.onReplaced); + DecryptionFailureTracker.instance.addVisibleEvent(props.mxEvent); + + if (props.showReactions) { + // Refresh the reaction summary when new reaction relations are attached to the event. + props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); + this.isListeningForReactions = true; + } + } + + private unbindEventListeners(): void { + if (!this.currentEvent) return; + + // Remove all listeners tied to the previous event before following a new event instance. + this.currentEvent.off(ThreadEvent.Update, this.onThreadUpdate); + this.currentEvent.off(MatrixEventEvent.Decrypted, this.onDecrypted); + this.currentEvent.off(MatrixEventEvent.Replaced, this.onReplaced); + const eventId = this.currentEvent.getId(); + if (eventId) { + DecryptionFailureTracker.instance.visibleEvents.delete(eventId); + } + if (this.isListeningForReactions) { + this.currentEvent.off(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); + } + + this.currentEvent = null; + this.isListeningForReactions = false; + } + + private bindRoomListeners(room: Room | null, eventId: string | null): void { + this.currentRoom = room; + this.currentRoomThreadEventId = eventId; + // Pick up the thread object later if this event becomes recognized as a thread root after initial render. + if (room && eventId) { + this.trackRoomThread(room, eventId, this.onNewThread); + } + } + + private unbindRoomListeners(): void { + if (!this.currentRoom) return; + + const entry = EventTileViewModel.roomThreadListenerRegistry.get(this.currentRoom); + const eventId = this.currentRoomThreadEventId; + const listeners = eventId ? entry?.listenersByEventId.get(eventId) : undefined; + listeners?.delete(this.onNewThread); + if (eventId && listeners?.size === 0) { + entry?.listenersByEventId.delete(eventId); + } + if (entry?.listenersByEventId.size === 0) { + this.currentRoom.off(ThreadEvent.New, entry.handler); + EventTileViewModel.roomThreadListenerRegistry.delete(this.currentRoom); + } + + this.currentRoom = null; + this.currentRoomThreadEventId = null; + } + + private unbindAllListeners(): void { + this.unbindRoomListeners(); + this.unbindEventListeners(); + this.unbindCliListeners(); + } + + private trackRoomThread(room: Room, eventId: string, callback: (thread: Thread) => void): void { + let entry = EventTileViewModel.roomThreadListenerRegistry.get(room); + + if (!entry) { + const listenersByEventId = new Map void>>(); + const handler = (thread: Thread): void => { + const listeners = listenersByEventId.get(thread.id); + if (!listeners) return; + + for (const listener of listeners) { + listener(thread); + } + }; + + entry = { listenersByEventId, handler }; + EventTileViewModel.roomThreadListenerRegistry.set(room, entry); + room.on(ThreadEvent.New, handler); + } + + let listeners = entry.listenersByEventId.get(eventId); + if (!listeners) { + listeners = new Set(); + entry.listenersByEventId.set(eventId, listeners); + } + + listeners.add(callback); + } + + private trackCliTrust( + cli: MatrixClient, + callback: (userId: string, trustStatus: UserVerificationStatus) => void, + ): void { + let entry = EventTileViewModel.cliTrustListenerRegistry.get(cli); + + if (!entry) { + const listeners = new Set<(userId: string, trustStatus: UserVerificationStatus) => void>(); + const handler = (userId: string, trustStatus: UserVerificationStatus): void => { + for (const listener of listeners) { + listener(userId, trustStatus); + } + }; + + entry = { listeners, handler }; + EventTileViewModel.cliTrustListenerRegistry.set(cli, entry); + cli.on(CryptoEvent.UserTrustStatusChanged, handler); + } + + entry.listeners.add(callback); + } + + private updateSnapshot(partial?: Partial): void { + const nextSnapshot = EventTileViewModel.deriveSnapshot(this.props, this.snapshot.current, partial); + + this.snapshot.merge(nextSnapshot); + + this.updateReceiptListener(nextSnapshot); + } + + private mergeSnapshot(partial: Partial): void { + const nextSnapshot: EventTileViewSnapshot = { + ...this.snapshot.current, + ...partial, + }; + + this.snapshot.merge(partial); + this.updateReceiptListener(nextSnapshot); + } + + private updateReceiptSnapshot(partial: Partial = {}): void { + const reactions = partial.reactions ?? this.snapshot.current.reactions ?? this.getReactions(); + const receiptSnapshot = EventTileViewModel.deriveReceiptSnapshot(this.props, reactions); + + this.mergeSnapshot({ + ...partial, + ...receiptSnapshot, + hasFooter: EventTileViewModel.getHasFooter( + this.props.isRedacted, + this.snapshot.current.isPinned, + receiptSnapshot.reactions, + ), + }); + } + + private updateThreadSnapshot(thread: Thread | null): void { + const partial: Partial = { thread }; + const baseSnapshot = EventTileViewModel.createBaseSnapshot(this.snapshot.current, partial, this.props); + const context = EventTileViewModel.createDerivationContext(this.props, baseSnapshot); + + this.mergeSnapshot({ + ...partial, + ...EventTileViewModel.deriveThreadSnapshot(this.props, context), + ...EventTileViewModel.deriveTimestampSnapshot(this.props, baseSnapshot, context), + }); + } + + private updateVerificationSnapshot(shieldColour: EventShieldColour, shieldReason: EventShieldReason | null): void { + const partial: Partial = { shieldColour, shieldReason }; + const baseSnapshot = EventTileViewModel.createBaseSnapshot(this.snapshot.current, partial, this.props); + const context = EventTileViewModel.createDerivationContext(this.props, baseSnapshot); + const encryptionSnapshot = EventTileViewModel.deriveEncryptionSnapshot(this.props, context); + const encryptionIndicatorTitle = EventTileViewModel.getEncryptionIndicatorTitle( + this.props, + { + ...baseSnapshot, + ...partial, + ...encryptionSnapshot, + } as EventTileViewSnapshot, + encryptionSnapshot.isEncryptionFailure, + ); + + this.mergeSnapshot({ + ...partial, + ...encryptionSnapshot, + encryptionIndicatorTitle, + encryptionView: EventTileViewModel.getEncryptionViewData({ + ...baseSnapshot, + ...partial, + ...encryptionSnapshot, + encryptionIndicatorTitle, + } as EventTileViewSnapshot), + }); + } + + private updateInteractionSnapshot(partial: Partial): void { + const currentSnapshot = this.snapshot.current; + const nextSnapshot: EventTileViewSnapshot = { + ...currentSnapshot, + ...partial, + }; + + Object.assign(nextSnapshot, EventTileViewModel.deriveInteractionDependentSnapshot(this.props, nextSnapshot)); + + this.snapshot.merge(nextSnapshot); + } + + private updateReceiptListener(snapshot: EventTileViewSnapshot = this.snapshot.current): void { + const shouldListen = snapshot.shouldShowSentReceipt || snapshot.shouldShowSendingReceipt; + if (shouldListen && !this.isListeningForReceipts) { + // Only subscribe to room receipts while this tile renders sent/sending receipt affordances. + this.currentCli?.on(RoomEvent.Receipt, this.onRoomReceipt); + this.isListeningForReceipts = true; + } else if (!shouldListen && this.isListeningForReceipts) { + // Drop the receipt listener once receipt state is no longer visible for this tile. + this.currentCli?.off(RoomEvent.Receipt, this.onRoomReceipt); + this.isListeningForReceipts = false; + } + } + + private getReactions(): Relations | null { + return EventTileViewModel.getReactions(this.props); + } + + private readonly onRoomReceipt = (_event: MatrixEvent, room: Room): void => { + const roomId = this.props.mxEvent.getRoomId(); + const tileRoom = roomId ? this.props.cli.getRoom(roomId) : null; + if (room !== tileRoom) return; + + this.updateReceiptSnapshot(); + }; + + private readonly onDecrypted = (): void => { + this.refreshVerification(); + this.updateSnapshot(); + }; + + private readonly onUserVerificationChanged = (userId: string, _trustStatus: UserVerificationStatus): void => { + if (userId === this.props.mxEvent.getSender()) { + this.refreshVerification(); + } + }; + + private readonly onReplaced = (): void => { + this.refreshVerification(); + this.updateSnapshot(); + }; + + private readonly onReactionsCreated = (relationType: string, eventType: string): void => { + if (relationType !== "m.annotation" || eventType !== "m.reaction") { + return; + } + + this.updateReceiptSnapshot({ + reactions: this.getReactions(), + }); + }; + + private readonly updateThread = (thread: Thread): void => { + this.updateThreadSnapshot(thread); + }; + + private readonly onThreadUpdate = (thread: Thread): void => { + this.updateThread(thread); + }; + + private readonly onNewThread = (thread: Thread): void => { + this.updateThread(thread); + this.unbindRoomListeners(); + }; + + private decryptEventIfNeeded(): void { + this.props.cli.decryptEventIfNeeded(this.props.mxEvent)?.catch(() => { + // Match the previous fire-and-forget behaviour without assuming a Promise is always returned. + }); + } + + private async verifyEvent(): Promise { + try { + const verifyGeneration = ++this.verifyGeneration; + const event = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; + + if (!event.isEncrypted() || event.isRedacted()) { + this.updateVerificationSnapshot(EventShieldColour.NONE, null); + return; + } + + const encryptionInfo = (await this.props.cli.getCrypto()?.getEncryptionInfoForEvent(event)) ?? null; + if (this.isDisposed || verifyGeneration !== this.verifyGeneration) { + return; + } + if (encryptionInfo === null) { + this.updateVerificationSnapshot(EventShieldColour.NONE, null); + return; + } + + this.updateVerificationSnapshot(encryptionInfo.shieldColour, encryptionInfo.shieldReason); + } catch (error) { + logger.error( + `Error getting encryption info on event ${this.props.mxEvent.getId()} in room ${this.props.mxEvent.getRoomId()}`, + error, + ); + } + } + + private getCommandContext(): EventTileCommandContext { + return { + mxEvent: this.props.mxEvent, + permalinkCreator: this.props.permalinkCreator, + openedFromSearch: this.snapshot.current.openedFromSearch, + tileClickMode: this.snapshot.current.tileClickMode, + editState: this.props.editState, + }; + } + + /** + * Builds the full tile view state from current props plus any listener-driven partial updates. + * Merge order matters here: defaults are seeded first, then previous/partial state is preserved, + * and finally the derived fields are recomputed from the latest props. + */ + private static deriveSnapshot( + props: EventTileViewModelProps, + previousSnapshot?: EventTileViewSnapshot, + partial: Partial = {}, + ): EventTileViewSnapshot { + const baseSnapshot = EventTileViewModel.createBaseSnapshot(previousSnapshot, partial, props); + const context = EventTileViewModel.createDerivationContext(props, baseSnapshot); + const receiptSnapshot = EventTileViewModel.deriveReceiptSnapshot(props, context.reactions); + const renderingSnapshot = EventTileViewModel.deriveRenderingSnapshot(props, context); + const threadSnapshot = EventTileViewModel.deriveThreadSnapshot(props, context); + const senderSnapshot = EventTileViewModel.deriveSenderSnapshot(props, context); + const encryptionSnapshot = EventTileViewModel.deriveEncryptionSnapshot(props, context); + const timestampSnapshot = EventTileViewModel.deriveTimestampSnapshot(props, baseSnapshot, context); + const hasFooter = EventTileViewModel.getHasFooter( + props.isRedacted, + renderingSnapshot.isPinned, + receiptSnapshot.reactions, + ); + + const snapshot: EventTileViewSnapshot = { + ...baseSnapshot, + ...receiptSnapshot, + ...renderingSnapshot, + ...timestampSnapshot, + ...threadSnapshot, + ...senderSnapshot, + ...encryptionSnapshot, + hasFooter, + eventId: EventTileViewModel.getEventId(props), + ariaLive: EventTileViewModel.getAriaLive(props), + rootClassName: EventTileViewModel.getRootClassName(props, { + ...baseSnapshot, + ...receiptSnapshot, + ...renderingSnapshot, + ...timestampSnapshot, + ...threadSnapshot, + ...senderSnapshot, + ...encryptionSnapshot, + hasFooter, + }), + contentClassName: EventTileViewModel.getContentClassName(props.mxEvent), + isNotification: EventTileViewModel.getIsNotification(props), + isListLikeTile: EventTileViewModel.getIsListLikeTile(props), + notificationRoomName: EventTileViewModel.getNotificationRoomName(props), + receivedTs: EventTileViewModel.getReceivedTs(props), + shouldRenderMissingRendererFallback: + renderingSnapshot.renderMode === EventTileRenderMode.MissingRendererFallback, + timestampView: EventTileViewModel.getTimestampViewData({ + ...baseSnapshot, + ...timestampSnapshot, + receivedTs: EventTileViewModel.getReceivedTs(props), + } as EventTileViewSnapshot), + encryptionView: EventTileViewModel.getEncryptionViewData({ + ...baseSnapshot, + ...encryptionSnapshot, + } as EventTileViewSnapshot), + notificationView: EventTileViewModel.getNotificationViewData(props), + }; + + snapshot.shouldRenderActionBar = EventTileViewModel.getShouldRenderActionBar(props, snapshot); + snapshot.encryptionIndicatorTitle = EventTileViewModel.getEncryptionIndicatorTitle( + props, + snapshot, + snapshot.isEncryptionFailure, + ); + snapshot.encryptionView = EventTileViewModel.getEncryptionViewData(snapshot); + snapshot.rootClassName = EventTileViewModel.getRootClassName(props, snapshot); + return snapshot; + } + + private static createBaseSnapshot( + previousSnapshot: EventTileViewSnapshot | undefined, + partial: Partial, + props: EventTileViewModelProps, + ): EventTileViewSnapshot { + return { + actionBarFocused: false, + shieldColour: EventShieldColour.NONE, + shieldReason: null, + reactions: null, + hover: false, + focusWithin: false, + showActionBarFromFocus: false, + isContextMenuOpen: false, + contextMenuState: undefined, + isQuoteExpanded: undefined, + thread: null, + threadUpdateKey: "", + threadNotification: undefined, + shouldShowSentReceipt: false, + shouldShowSendingReceipt: false, + isHighlighted: false, + showTimestamp: false, + isContinuation: false, + isSending: false, + isEditing: false, + showReplyPreview: false, + shouldRenderReplyPreview: false, + shouldRenderActionBar: false, + renderMode: EventTileRenderMode.Rendered, + isEncryptionFailure: false, + isOwnEvent: false, + permalink: "#", + scrollToken: undefined, + hasThread: false, + isThreadRoot: false, + hasRenderer: false, + isBubbleMessage: false, + isInfoMessage: false, + isLeftAlignedBubbleMessage: false, + noBubbleEvent: false, + isSeeingThroughMessageHiddenForModeration: false, + showSender: false, + avatarSubject: AvatarSubject.None, + threadPanelMode: ThreadPanelMode.None, + showReadReceipts: false, + padlockMode: PadlockMode.None, + timestampDisplayMode: TimestampDisplayMode.Hidden, + timestampFormatMode: TimestampFormatMode.Absolute, + timestampTs: props.mxEvent.getTs(), + tileRenderType: props.timelineRenderingType, + avatarSize: AvatarSize.None, + avatarMemberUserOnClick: false, + avatarForceHistorical: false, + senderMode: SenderMode.Hidden, + isPinned: false, + hasFooter: false, + encryptionIndicatorMode: EncryptionIndicatorMode.None, + sharedKeysUserId: undefined, + sharedKeysRoomId: undefined, + encryptionIndicatorTitle: undefined, + threadInfoMode: ThreadInfoMode.None, + threadInfoHref: undefined, + threadInfoLabel: undefined, + threadReplyCount: undefined, + shouldRenderThreadPreview: false, + shouldRenderThreadToolbar: false, + tileClickMode: ClickMode.None, + openedFromSearch: false, + eventId: undefined, + ariaLive: "off", + rootClassName: "mx_EventTile", + contentClassName: "mx_EventTile_line", + isNotification: false, + isListLikeTile: false, + notificationRoomName: undefined, + receivedTs: undefined, + shouldRenderMissingRendererFallback: false, + timestampView: { + displayMode: TimestampDisplayMode.Hidden, + formatMode: TimestampFormatMode.Absolute, + ts: props.mxEvent.getTs(), + receivedTs: undefined, + permalink: "#", + }, + encryptionView: { + padlockMode: PadlockMode.None, + mode: EncryptionIndicatorMode.None, + indicatorTitle: undefined, + sharedKeysUserId: undefined, + sharedKeysRoomId: undefined, + }, + notificationView: { + enabled: false, + roomName: undefined, + }, + ...previousSnapshot, + ...partial, + }; + } + + private static deriveReceiptSnapshot( + props: EventTileViewModelProps, + reactions: Relations | null, + ): EventTileReceiptSnapshot { + const shouldShowSentReceipt = EventTileViewModel.getShouldShowSentReceipt(props); + const shouldShowSendingReceipt = EventTileViewModel.getShouldShowSendingReceipt(props); + + return { + reactions, + shouldShowSentReceipt, + shouldShowSendingReceipt, + showReadReceipts: EventTileViewModel.getShowReadReceipts(props, { + shouldShowSentReceipt, + shouldShowSendingReceipt, + }), + }; + } + + private static deriveRenderingSnapshot( + props: EventTileViewModelProps, + context: EventTileDerivationContext, + ): EventTileRenderingSnapshot { + const showReplyPreview = EventTileViewModel.getShowReplyPreview(props); + const hasRenderer = context.displayInfo.hasRenderer; + + return { + isHighlighted: EventTileViewModel.getShouldHighlight(props), + isContinuation: EventTileViewModel.getIsContinuation(props), + isSending: EventTileViewModel.getIsSending(props), + isEditing: EventTileViewModel.getIsEditing(props), + showReplyPreview, + shouldRenderReplyPreview: EventTileViewModel.getShouldRenderReplyPreview(showReplyPreview, hasRenderer), + shouldRenderActionBar: false, + renderMode: EventTileViewModel.getRenderMode(props, hasRenderer), + hasRenderer, + tileRenderType: EventTileViewModel.getTileRenderType(props), + isBubbleMessage: context.displayInfo.isBubbleMessage, + isInfoMessage: context.displayInfo.isInfoMessage, + isLeftAlignedBubbleMessage: context.displayInfo.isLeftAlignedBubbleMessage, + noBubbleEvent: context.displayInfo.noBubbleEvent, + isSeeingThroughMessageHiddenForModeration: context.displayInfo.isSeeingThroughMessageHiddenForModeration, + isPinned: EventTileViewModel.getIsPinned(props), + hasFooter: false, + }; + } + + private static deriveTimestampSnapshot( + props: EventTileViewModelProps, + baseSnapshot: EventTileViewSnapshot, + context: EventTileDerivationContext, + ): EventTileTimestampSnapshot { + const timestampSnapshot: EventTileTimestampSnapshot = { + showTimestamp: context.showTimestamp, + permalink: EventTileViewModel.getPermalink(props), + scrollToken: EventTileViewModel.getScrollToken(props), + timestampDisplayMode: EventTileViewModel.getTimestampDisplayMode(props, context.showTimestamp), + timestampFormatMode: EventTileViewModel.getTimestampFormatMode(props), + timestampTs: props.mxEvent.getTs(), + }; + + timestampSnapshot.timestampTs = EventTileViewModel.getTimestampTs(props, { + ...baseSnapshot, + ...timestampSnapshot, + thread: context.thread, + }); + + return timestampSnapshot; + } + + private static deriveThreadSnapshot( + props: EventTileViewModelProps, + context: EventTileDerivationContext, + ): EventTileThreadSnapshot { + return { + thread: context.thread, + threadUpdateKey: EventTileViewModel.getThreadUpdateKey(context.thread), + threadNotification: context.threadNotification, + hasThread: context.hasThread, + isThreadRoot: context.isThreadRoot, + threadPanelMode: EventTileViewModel.getThreadPanelMode(props, context.thread), + threadInfoMode: EventTileViewModel.getThreadInfoMode(props, context.isThreadRoot, context.thread), + threadInfoHref: EventTileViewModel.getThreadInfoHref(props), + threadInfoLabel: EventTileViewModel.getThreadInfoLabel(props), + threadReplyCount: EventTileViewModel.getThreadReplyCount(props, context.thread), + shouldRenderThreadPreview: EventTileViewModel.getShouldRenderThreadPreview(props, context.thread), + shouldRenderThreadToolbar: EventTileViewModel.getShouldRenderThreadToolbar(props, context.thread), + tileClickMode: EventTileViewModel.getTileClickMode(props), + openedFromSearch: EventTileViewModel.getOpenedFromSearch(props), + }; + } + + private static deriveSenderSnapshot( + props: EventTileViewModelProps, + context: EventTileDerivationContext, + ): EventTileSenderSnapshot { + return { + isOwnEvent: EventTileViewModel.getIsOwnEvent(props), + showSender: context.showSender, + avatarSubject: EventTileViewModel.getAvatarSubject(props, context.senderPresentation.avatarSize), + avatarSize: context.senderPresentation.avatarSize, + avatarMemberUserOnClick: EventTileViewModel.getAvatarMemberUserOnClick( + props, + context.senderPresentation.avatarSize, + ), + avatarForceHistorical: EventTileViewModel.getAvatarForceHistorical(props), + senderMode: EventTileViewModel.getSenderMode(props, context.showSender, context.senderPresentation), + }; + } + + private static deriveEncryptionSnapshot( + props: EventTileViewModelProps, + context: EventTileDerivationContext, + ): EventTileEncryptionSnapshot { + const event = props.mxEvent.replacingEvent() ?? props.mxEvent; + const encryptionIndicatorMode = EventTileViewModel.getEncryptionIndicatorMode( + props, + context.isEncryptionFailure, + context.shieldColour, + context.shieldReason, + ); + + return { + shieldColour: context.shieldColour, + shieldReason: context.shieldReason, + isEncryptionFailure: context.isEncryptionFailure, + padlockMode: EventTileViewModel.getPadlockMode(props, context.displayInfo.isBubbleMessage), + encryptionIndicatorMode, + sharedKeysUserId: EventTileViewModel.getSharedKeysUserId( + props, + event, + context.isEncryptionFailure, + context.shieldReason, + ), + sharedKeysRoomId: EventTileViewModel.getSharedKeysRoomId( + event, + context.isEncryptionFailure, + context.shieldReason, + ), + }; + } + + private static createDerivationContext( + props: EventTileViewModelProps, + baseSnapshot: EventTileViewSnapshot, + ): EventTileDerivationContext { + const displayInfo = EventTileViewModel.getDisplayInfo(props); + const thread = baseSnapshot.thread ?? EventTileViewModel.getThread(props); + const showSender = EventTileViewModel.getShowSender(props); + const senderPresentation = EventTileViewModel.getSenderPresentationContext( + props, + displayInfo.isInfoMessage, + displayInfo.isBubbleMessage, + ); + + return { + displayInfo, + shieldColour: baseSnapshot.shieldColour, + shieldReason: baseSnapshot.shieldReason, + reactions: baseSnapshot.reactions ?? EventTileViewModel.getReactions(props), + thread, + threadNotification: baseSnapshot.threadNotification, + hasThread: Boolean(thread), + isThreadRoot: thread?.id === props.mxEvent.getId(), + showSender, + senderPresentation, + isEncryptionFailure: EventTileViewModel.getIsEncryptionFailure(props), + showTimestamp: EventTileViewModel.getShowTimestamp(props, baseSnapshot), + }; + } + + private static deriveInteractionDependentSnapshot( + props: EventTileViewModelProps, + snapshot: EventTileViewSnapshot, + ): Pick & + Pick & + Pick { + const showTimestamp = EventTileViewModel.getShowTimestamp(props, snapshot); + const timestampDisplayMode = EventTileViewModel.getTimestampDisplayMode(props, showTimestamp); + const nextSnapshot: EventTileViewSnapshot = { + ...snapshot, + showTimestamp, + timestampDisplayMode, + }; + + return { + showTimestamp, + timestampDisplayMode, + timestampView: EventTileViewModel.getTimestampViewData(nextSnapshot), + shouldRenderActionBar: EventTileViewModel.getShouldRenderActionBar(props, snapshot), + }; + } + + private static getDisplayInfo(props: EventTileViewModelProps): ReturnType { + return getEventDisplayInfo(props.cli, props.mxEvent, props.showHiddenEvents, this.shouldHideEvent(props)); + } + + private static getRoom(props: EventTileViewModelProps): Room | null { + const roomId = props.mxEvent.getRoomId(); + return roomId ? props.cli.getRoom(roomId) : null; + } + + private static shouldHideEvent(props: EventTileViewModelProps): boolean { + return props.callEventGrouper?.hangupReason === CallErrorCode.Replaced; + } + + private static getReactions(props: EventTileViewModelProps): Relations | null { + if (!props.showReactions || !props.getRelationsForEvent) { + return null; + } + + const eventId = props.mxEvent.getId(); + if (!eventId) { + return null; + } + + return props.getRelationsForEvent(eventId, "m.annotation", "m.reaction") ?? null; + } + + private static isEligibleForSpecialReceipt(props: EventTileViewModelProps): boolean { + // "Special" receipts are the custom sent/sending indicators for the current user's own message, + // used when there are no explicit read receipts to show instead. + if (props.readReceipts && props.readReceipts.length > 0) return false; + + const roomId = props.mxEvent.getRoomId(); + const room = roomId ? props.cli.getRoom(roomId) : null; + if (!room) return false; + + const myUserId = props.cli.getUserId(); + if (!myUserId || props.mxEvent.getSender() !== myUserId) return false; + + if (!isMessageEvent(props.mxEvent) && props.mxEvent.getType() !== EventType.RoomMessageEncrypted) return false; + + return true; + } + + private static getShouldShowSentReceipt(props: EventTileViewModelProps): boolean { + // Show the custom "sent" receipt only for the current user's most recent eligible message. + if (!this.isEligibleForSpecialReceipt(props)) return false; + if (!props.lastSuccessful) return false; + if (props.timelineRenderingType === TimelineRenderingType.ThreadsList) return false; + if (props.eventSendStatus && props.eventSendStatus !== EventStatus.SENT) return false; + + const receipts = props.readReceipts || []; + const myUserId = props.cli.getUserId(); + if (receipts.some((receipt) => receipt.userId !== myUserId)) return false; + + return true; + } + + private static getShouldShowSendingReceipt(props: EventTileViewModelProps): boolean { + // Show the custom "sending" receipt while that same eligible message is still pending send. + if (!this.isEligibleForSpecialReceipt(props)) return false; + if (!props.eventSendStatus || props.eventSendStatus === EventStatus.SENT) return false; + return true; + } + + private static getShowReadReceipts( + props: EventTileViewModelProps, + receiptState: Pick, + ): boolean { + return ( + !receiptState.shouldShowSentReceipt && + !receiptState.shouldShowSendingReceipt && + Boolean(props.showReadReceipts) + ); + } + + private static getShouldHighlight(props: EventTileViewModelProps): boolean { + if (props.forExport) return false; + if (props.timelineRenderingType === TimelineRenderingType.Notification) return false; + if (props.timelineRenderingType === TimelineRenderingType.ThreadsList) return false; + if (props.isRedacted) return false; + + const actions = props.cli.getPushActionsForEvent(props.mxEvent.replacingEvent() || props.mxEvent); + const previousActions = props.mxEvent.replacingEvent() + ? props.cli.getPushActionsForEvent(props.mxEvent) + : undefined; + + if (!actions?.tweaks && !previousActions?.tweaks) { + return false; + } + + if (props.mxEvent.getSender() === props.cli.credentials.userId) { + return false; + } + + return !!(actions?.tweaks.highlight || previousActions?.tweaks.highlight); + } + + private static getIsContinuation(props: EventTileViewModelProps): boolean { + // Continuation layout is only meaningful in views that visually group adjacent events. + if ( + props.timelineRenderingType !== TimelineRenderingType.Room && + props.timelineRenderingType !== TimelineRenderingType.Search && + props.timelineRenderingType !== TimelineRenderingType.Thread && + props.layout !== Layout.Bubble + ) { + return false; + } + + return Boolean(props.continuation); + } + + private static getShowReplyPreview(props: EventTileViewModelProps): boolean { + return !this.shouldHideEvent(props) && shouldDisplayReply(props.mxEvent); + } + + private static getShouldRenderReplyPreview(showReplyPreview: boolean, hasRenderer: boolean): boolean { + return showReplyPreview && hasRenderer; + } + + private static getRenderMode(props: EventTileViewModelProps, hasRenderer: boolean): EventTileRenderMode { + if (!hasRenderer && props.timelineRenderingType !== TimelineRenderingType.Notification) { + return EventTileRenderMode.MissingRendererFallback; + } + + return EventTileRenderMode.Rendered; + } + + private static getTileRenderType(props: EventTileViewModelProps): TimelineRenderingType { + if (props.timelineRenderingType === TimelineRenderingType.Thread) { + return TimelineRenderingType.Thread; + } + + if (props.timelineRenderingType === TimelineRenderingType.File) { + return TimelineRenderingType.File; + } + + return props.timelineRenderingType; + } + + private static getIsSending(props: EventTileViewModelProps): boolean { + return ( + !!props.eventSendStatus && + [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.ENCRYPTING].includes(props.eventSendStatus) + ); + } + + private static getIsEditing(props: EventTileViewModelProps): boolean { + return Boolean(props.editState); + } + + private static getIsPinned(props: EventTileViewModelProps): boolean { + return PinningUtils.isPinned(props.cli, props.mxEvent); + } + + private static getHasFooter( + isRedacted: boolean | undefined, + isPinned: boolean, + reactions: Relations | null, + ): boolean { + return isPinned || (!isRedacted && !!reactions); + } + + private static getShouldRenderActionBar(props: EventTileViewModelProps, snapshot: EventTileViewSnapshot): boolean { + return ( + !snapshot.isEditing && + !props.forExport && + (snapshot.hover || + snapshot.showActionBarFromFocus || + (snapshot.actionBarFocused && !snapshot.isContextMenuOpen)) + ); + } + + private static getPermalink(props: EventTileViewModelProps): string { + const eventId = props.mxEvent.getId(); + if (!props.permalinkCreator || !eventId) return "#"; + return props.permalinkCreator.forEvent(eventId); + } + + private static getEventId(props: EventTileViewModelProps): string | undefined { + return props.mxEvent.getId() ?? undefined; + } + + private static getAriaLive(props: EventTileViewModelProps): "off" | undefined { + return props.eventSendStatus === null ? undefined : "off"; + } + + private static getScrollToken(props: EventTileViewModelProps): string | undefined { + return props.mxEvent.status ? undefined : (props.mxEvent.getId() ?? undefined); + } + + private static getReceivedTs(props: EventTileViewModelProps): number | undefined { + return getLateEventInfo(props.mxEvent)?.received_ts; + } + + private static getShowTimestamp(props: EventTileViewModelProps, snapshot: EventTileViewSnapshot): boolean { + return Boolean( + props.mxEvent.getTs() && + !props.hideTimestamp && + (props.alwaysShowTimestamps || + props.last || + snapshot.hover || + snapshot.focusWithin || + snapshot.actionBarFocused || + snapshot.isContextMenuOpen), + ); + } + + private static getTimestampDisplayMode( + props: EventTileViewModelProps, + showTimestamp: boolean, + ): TimestampDisplayMode { + if (!showTimestamp) { + return props.layout === Layout.IRC ? TimestampDisplayMode.Placeholder : TimestampDisplayMode.Hidden; + } + + return props.timelineRenderingType === TimelineRenderingType.Notification || + props.timelineRenderingType === TimelineRenderingType.File || + props.timelineRenderingType === TimelineRenderingType.ThreadsList + ? TimestampDisplayMode.Plain + : TimestampDisplayMode.Linked; + } + + private static getTimestampFormatMode(props: EventTileViewModelProps): TimestampFormatMode { + return props.timelineRenderingType === TimelineRenderingType.ThreadsList + ? TimestampFormatMode.Relative + : TimestampFormatMode.Absolute; + } + + private static getTimestampTs(props: EventTileViewModelProps, snapshot: EventTileViewSnapshot): number { + if (props.timelineRenderingType !== TimelineRenderingType.ThreadsList) { + return props.mxEvent.getTs(); + } + + return snapshot.thread?.replyToEvent?.getTs() ?? props.mxEvent.getTs(); + } + + private static getThread(props: EventTileViewModelProps): Thread | null { + // Thread lookup can lag behind event creation, so try both the event-attached thread and the room index. + let thread = props.mxEvent.getThread() ?? undefined; + if (!thread) { + const roomId = props.mxEvent.getRoomId(); + const room = roomId ? props.cli.getRoom(roomId) : null; + thread = room?.findThreadForEvent(props.mxEvent) ?? undefined; + } + return thread ?? null; + } + + private static getRoomThreadListenerEventId(props: EventTileViewModelProps): string | null { + if (EventTileViewModel.getThread(props)) { + return null; + } + + return props.mxEvent.getId() ?? null; + } + + private static getThreadUpdateKey(thread: Thread | null): string { + if (!thread) return ""; + + return `${thread.id}:${thread.length}:${thread.replyToEvent?.getId() ?? ""}`; + } + + private static getThreadPanelMode(props: EventTileViewModelProps, thread: Thread | null): ThreadPanelMode { + const showsToolbar = props.timelineRenderingType === TimelineRenderingType.ThreadsList; + const showsSummary = + (props.timelineRenderingType === TimelineRenderingType.Notification || + props.timelineRenderingType === TimelineRenderingType.ThreadsList) && + Boolean(thread); + + if (showsToolbar && showsSummary) { + return ThreadPanelMode.SummaryWithToolbar; + } + if (showsToolbar) { + return ThreadPanelMode.Toolbar; + } + if (showsSummary) { + return ThreadPanelMode.Summary; + } + + return ThreadPanelMode.None; + } + + private static getThreadInfoMode( + props: EventTileViewModelProps, + isThreadRoot: boolean, + thread: Thread | null, + ): ThreadInfoMode { + if (isThreadRoot && thread) { + return ThreadInfoMode.Summary; + } + + if (props.timelineRenderingType === TimelineRenderingType.Search && props.mxEvent.threadRootId) { + return props.highlightLink ? ThreadInfoMode.SearchLink : ThreadInfoMode.SearchText; + } + + return ThreadInfoMode.None; + } + + private static getThreadInfoHref(props: EventTileViewModelProps): string | undefined { + return props.timelineRenderingType === TimelineRenderingType.Search ? props.highlightLink : undefined; + } + + private static getThreadInfoLabel(props: EventTileViewModelProps): string | undefined { + return props.timelineRenderingType === TimelineRenderingType.Search + ? _t("timeline|thread_info_basic") + : undefined; + } + + private static getThreadReplyCount(props: EventTileViewModelProps, thread: Thread | null): number | undefined { + const threadPanelMode = EventTileViewModel.getThreadPanelMode(props, thread); + if ( + (threadPanelMode === ThreadPanelMode.Summary || threadPanelMode === ThreadPanelMode.SummaryWithToolbar) && + thread + ) { + return thread.length; + } + + return undefined; + } + + private static getShouldRenderThreadPreview(props: EventTileViewModelProps, thread: Thread | null): boolean { + const threadPanelMode = EventTileViewModel.getThreadPanelMode(props, thread); + return ( + (threadPanelMode === ThreadPanelMode.Summary || threadPanelMode === ThreadPanelMode.SummaryWithToolbar) && + Boolean(thread) + ); + } + + private static getShouldRenderThreadToolbar(props: EventTileViewModelProps, thread: Thread | null): boolean { + const threadPanelMode = EventTileViewModel.getThreadPanelMode(props, thread); + return threadPanelMode === ThreadPanelMode.Toolbar || threadPanelMode === ThreadPanelMode.SummaryWithToolbar; + } + + private static getTileClickMode(props: EventTileViewModelProps): ClickMode { + switch (props.timelineRenderingType) { + case TimelineRenderingType.Notification: + return ClickMode.ViewRoom; + case TimelineRenderingType.ThreadsList: + return ClickMode.ShowThread; + default: + return ClickMode.None; + } + } + + private static getOpenedFromSearch(props: EventTileViewModelProps): boolean { + return props.timelineRenderingType === TimelineRenderingType.Search; + } + + private static getIsNotification(props: EventTileViewModelProps): boolean { + return props.timelineRenderingType === TimelineRenderingType.Notification; + } + + private static getIsListLikeTile(props: EventTileViewModelProps): boolean { + return ( + props.timelineRenderingType === TimelineRenderingType.Notification || + props.timelineRenderingType === TimelineRenderingType.ThreadsList + ); + } + + private static getNotificationRoomName(props: EventTileViewModelProps): string | undefined { + return EventTileViewModel.getRoom(props)?.name; + } + + private static getShowSender(props: EventTileViewModelProps): boolean { + return !props.hideSender; + } + + private static getSenderPresentationContext( + props: EventTileViewModelProps, + isInfoMessage: boolean, + isBubbleMessage: boolean, + ): EventTileSenderPresentationContext { + // Sender presentation is classified once here so avatar size and sender-profile rules stay aligned. + const eventType = props.mxEvent.getType(); + + if (props.timelineRenderingType === TimelineRenderingType.Notification) { + return { + avatarSize: AvatarSize.Medium, + needsSenderProfile: true, + }; + } + + if (isInfoMessage) { + return { + avatarSize: AvatarSize.XSmall, + needsSenderProfile: false, + }; + } + + if ( + props.timelineRenderingType === TimelineRenderingType.ThreadsList || + (props.timelineRenderingType === TimelineRenderingType.Thread && !props.continuation) + ) { + return { + avatarSize: AvatarSize.XLarge, + needsSenderProfile: true, + }; + } + + if (eventType === EventType.RoomCreate || isBubbleMessage) { + return { + avatarSize: AvatarSize.None, + needsSenderProfile: false, + }; + } + + if (props.layout === Layout.IRC) { + return { + avatarSize: AvatarSize.XSmall, + needsSenderProfile: true, + }; + } + + if ( + (props.continuation && props.timelineRenderingType !== TimelineRenderingType.File) || + eventType === EventType.CallInvite || + ElementCallEventType.matches(eventType) + ) { + return { + avatarSize: AvatarSize.None, + needsSenderProfile: false, + }; + } + + if (props.timelineRenderingType === TimelineRenderingType.File) { + return { + avatarSize: AvatarSize.Small, + needsSenderProfile: true, + }; + } + + return { + avatarSize: AvatarSize.Large, + needsSenderProfile: true, + }; + } + + private static getAvatarSubject(props: EventTileViewModelProps, avatarSize: AvatarSize): AvatarSubject { + if (avatarSize === AvatarSize.None) { + return AvatarSubject.None; + } + + return props.mxEvent.getContent().third_party_invite ? AvatarSubject.Target : AvatarSubject.Sender; + } + + private static getAvatarMemberUserOnClick(props: EventTileViewModelProps, avatarSize: AvatarSize): boolean { + if (avatarSize === AvatarSize.None) return false; + if (props.inhibitInteraction) return false; + + return ![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes( + props.timelineRenderingType, + ); + } + + private static getAvatarForceHistorical(props: EventTileViewModelProps): boolean { + return props.mxEvent.getType() === EventType.RoomMember; + } + + private static getSenderMode( + props: EventTileViewModelProps, + showSender: boolean, + senderPresentation: EventTileSenderPresentationContext, + ): SenderMode { + if (!showSender || !senderPresentation.needsSenderProfile) { + return SenderMode.Hidden; + } + + switch (props.timelineRenderingType) { + case TimelineRenderingType.Room: + case TimelineRenderingType.Search: + case TimelineRenderingType.Pinned: + case TimelineRenderingType.Thread: + return SenderMode.ComposerInsert; + case TimelineRenderingType.ThreadsList: + return SenderMode.Tooltip; + default: + return SenderMode.Default; + } + } + + private static getIsOwnEvent(props: EventTileViewModelProps): boolean { + return props.mxEvent.getSender() === props.cli.getUserId(); + } + + private static getIsEncryptionFailure(props: EventTileViewModelProps): boolean { + return props.mxEvent.isDecryptionFailure(); + } + + private static getPadlockMode(props: EventTileViewModelProps, isBubbleMessage: boolean): PadlockMode { + if (isBubbleMessage) return PadlockMode.None; + return props.layout === Layout.IRC ? PadlockMode.Irc : PadlockMode.Group; + } + + private static getEncryptionIndicatorMode( + props: EventTileViewModelProps, + isEncryptionFailure: boolean, + shieldColour: EventShieldColour, + shieldReason: EventShieldReason | null, + ): EncryptionIndicatorMode { + // Collapse crypto and shield state into the UI-level indicator the tile should render. + const event = props.mxEvent.replacingEvent() ?? props.mxEvent; + + if (isLocalRoom(event.getRoomId())) return EncryptionIndicatorMode.None; + + if (isEncryptionFailure) { + return EventTileViewModel.getDecryptionFailureIndicatorMode(event.decryptionFailureReason); + } + + if (shieldReason === EventShieldReason.AUTHENTICITY_NOT_GUARANTEED && props.mxEvent.getKeyForwardingUser()) { + return EncryptionIndicatorMode.None; + } + + if (shieldColour !== EventShieldColour.NONE) { + return shieldColour === EventShieldColour.GREY + ? EncryptionIndicatorMode.Normal + : EncryptionIndicatorMode.Warning; + } + + if (!props.isRoomEncrypted || EventTileViewModel.shouldSuppressRoomEncryptionIndicator(event)) { + return EncryptionIndicatorMode.None; + } + + return event.isEncrypted() ? EncryptionIndicatorMode.None : EncryptionIndicatorMode.Warning; + } + + private static getEncryptionIndicatorTitle( + props: EventTileViewModelProps, + snapshot: EventTileViewSnapshot, + isEncryptionFailure: boolean, + ): string | undefined { + const event = props.mxEvent.replacingEvent() ?? props.mxEvent; + + if (isEncryptionFailure) { + switch (event.decryptionFailureReason) { + case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: + case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE: + return undefined; + default: + return _t("timeline|undecryptable_tooltip"); + } + } + + if (snapshot.shieldColour !== EventShieldColour.NONE) { + switch (snapshot.shieldReason) { + case EventShieldReason.UNVERIFIED_IDENTITY: + return _t("encryption|event_shield_reason_unverified_identity"); + case EventShieldReason.UNSIGNED_DEVICE: + return _t("encryption|event_shield_reason_unsigned_device"); + case EventShieldReason.UNKNOWN_DEVICE: + return _t("encryption|event_shield_reason_unknown_device"); + case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED: + return _t("encryption|event_shield_reason_authenticity_not_guaranteed"); + case EventShieldReason.MISMATCHED_SENDER_KEY: + return _t("encryption|event_shield_reason_mismatched_sender_key"); + case EventShieldReason.SENT_IN_CLEAR: + return _t("common|unencrypted"); + case EventShieldReason.VERIFICATION_VIOLATION: + return _t("timeline|decryption_failure|sender_identity_previously_verified"); + case EventShieldReason.MISMATCHED_SENDER: + return _t("encryption|event_shield_reason_mismatched_sender"); + default: + return _t("error|unknown"); + } + } + + if (props.isRoomEncrypted && !event.isEncrypted() && !event.isState() && !event.isRedacted()) { + if (event.status === EventStatus.ENCRYPTING) return undefined; + if (event.status === EventStatus.NOT_SENT) return undefined; + return _t("common|unencrypted"); + } + + return undefined; + } + + private static getTimestampViewData(snapshot: EventTileViewSnapshot): EventTileTimestampViewData { + return { + displayMode: snapshot.timestampDisplayMode, + formatMode: snapshot.timestampFormatMode, + ts: snapshot.timestampTs, + receivedTs: snapshot.receivedTs, + permalink: snapshot.permalink, + }; + } + + private static getEncryptionViewData(snapshot: EventTileViewSnapshot): EventTileEncryptionViewData { + return { + padlockMode: snapshot.padlockMode, + mode: snapshot.encryptionIndicatorMode, + indicatorTitle: snapshot.encryptionIndicatorTitle, + sharedKeysUserId: snapshot.sharedKeysUserId, + sharedKeysRoomId: snapshot.sharedKeysRoomId, + }; + } + + private static getNotificationViewData(props: EventTileViewModelProps): EventTileNotificationViewData { + return { + enabled: EventTileViewModel.getIsNotification(props), + roomName: EventTileViewModel.getNotificationRoomName(props), + }; + } + + private static getContentClassName(mxEvent: MatrixEvent): string { + const isProbablyMedia = TileMediaEventHelper.isEligible(mxEvent); + + return classNames("mx_EventTile_line", { + mx_EventTile_mediaLine: isProbablyMedia, + mx_EventTile_image: + mxEvent.getType() === EventType.RoomMessage && mxEvent.getContent().msgtype === MsgType.Image, + mx_EventTile_sticker: mxEvent.getType() === EventType.Sticker, + mx_EventTile_emote: + mxEvent.getType() === EventType.RoomMessage && mxEvent.getContent().msgtype === MsgType.Emote, + }); + } + + private static getRootClassName(props: EventTileViewModelProps, snapshot: EventTileViewSnapshot): string { + const eventType = props.mxEvent.getType(); + const msgtype = props.mxEvent.getContent().msgtype; + const isNotification = EventTileViewModel.getIsNotification(props); + + return classNames({ + mx_EventTile_bubbleContainer: snapshot.isBubbleMessage, + mx_EventTile_leftAlignedBubble: snapshot.isLeftAlignedBubbleMessage, + mx_EventTile: true, + mx_EventTile_isEditing: snapshot.isEditing, + mx_EventTile_info: snapshot.isInfoMessage, + mx_EventTile_12hr: props.isTwelveHour, + mx_EventTile_sending: !snapshot.isEditing && snapshot.isSending, + mx_EventTile_highlight: snapshot.isHighlighted, + mx_EventTile_selected: props.isSelectedEvent || snapshot.isContextMenuOpen, + mx_EventTile_continuation: + snapshot.isContinuation || + eventType === EventType.CallInvite || + ElementCallEventType.matches(eventType), + mx_EventTile_last: props.last, + mx_EventTile_lastInSection: props.lastInSection, + mx_EventTile_contextual: props.contextual, + mx_EventTile_actionBarFocused: snapshot.actionBarFocused, + mx_EventTile_bad: snapshot.isEncryptionFailure, + mx_EventTile_emote: msgtype === MsgType.Emote, + mx_EventTile_noSender: !snapshot.showSender, + mx_EventTile_clamp: props.timelineRenderingType === TimelineRenderingType.ThreadsList || isNotification, + mx_EventTile_noBubble: snapshot.noBubbleEvent, + }); + } + + private static getDecryptionFailureIndicatorMode( + reason: DecryptionFailureCode | null | undefined, + ): EncryptionIndicatorMode { + switch (reason) { + case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: + case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE: + return EncryptionIndicatorMode.None; + default: + return EncryptionIndicatorMode.DecryptionFailure; + } + } + + private static shouldSuppressRoomEncryptionIndicator(event: MatrixEvent): boolean { + return ( + event.status === EventStatus.ENCRYPTING || + event.status === EventStatus.NOT_SENT || + event.isState() || + event.isRedacted() + ); + } + + private static getSharedKeysUserId( + props: EventTileViewModelProps, + event: MatrixEvent, + isEncryptionFailure: boolean, + shieldReason: EventShieldReason | null, + ): string | undefined { + if ( + isLocalRoom(event.getRoomId()) || + isEncryptionFailure || + shieldReason !== EventShieldReason.AUTHENTICITY_NOT_GUARANTEED + ) { + return undefined; + } + + return props.mxEvent.getKeyForwardingUser() ?? undefined; + } + + private static getSharedKeysRoomId( + event: MatrixEvent, + isEncryptionFailure: boolean, + shieldReason: EventShieldReason | null, + ): string | undefined { + if ( + isLocalRoom(event.getRoomId()) || + isEncryptionFailure || + shieldReason !== EventShieldReason.AUTHENTICITY_NOT_GUARANTEED + ) { + return undefined; + } + + return event.getRoomId() ?? undefined; + } +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/TextualEventViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/TextualEventViewModel.ts index fce936f05a7..92a6aa64731 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/TextualEventViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/TextualEventViewModel.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { MatrixEventEvent } from "matrix-js-sdk/src/matrix"; +import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; import { type TextualEventViewSnapshot, BaseViewModel } from "@element-hq/web-shared-components"; import { type EventTileTypeProps } from "../../../../events/EventTileFactory"; @@ -13,14 +13,44 @@ import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { textForEvent } from "../../../../TextForEvent"; export class TextualEventViewModel extends BaseViewModel { + private listenedEvent?: EventTileTypeProps["mxEvent"]; + public constructor(props: EventTileTypeProps) { super(props, { content: "" }); + this.rebindListener(props.mxEvent); + this.setTextFromEvent(); + } + + public override dispose(): void { + this.rebindListener(undefined); + super.dispose(); + } + + public updateProps(props: EventTileTypeProps): void { + const previousEvent = this.props.mxEvent; + this.props = props; + + if (previousEvent !== props.mxEvent) { + this.rebindListener(props.mxEvent); + } + this.setTextFromEvent(); - this.disposables.trackListener(this.props.mxEvent, MatrixEventEvent.SentinelUpdated, this.setTextFromEvent); } private setTextFromEvent = (): void => { const content = textForEvent(this.props.mxEvent, MatrixClientPeg.safeGet(), true, this.props.showHiddenEvents); this.snapshot.set({ content }); }; + + private rebindListener(mxEvent: MatrixEvent | undefined): void { + if (this.listenedEvent) { + this.listenedEvent.off(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent); + } + + this.listenedEvent = mxEvent; + + if (mxEvent) { + mxEvent.on(MatrixEventEvent.SentinelUpdated, this.setTextFromEvent); + } + } } diff --git a/apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/actions/EventTileActionBarViewModel.ts similarity index 82% rename from apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts rename to apps/web/src/viewmodels/room/timeline/event-tile/actions/EventTileActionBarViewModel.ts index ab6dacde494..d75b60930ff 100644 --- a/apps/web/src/viewmodels/room/EventTileActionBarViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/actions/EventTileActionBarViewModel.ts @@ -14,6 +14,7 @@ import { MsgType, RelationType, RoomStateEvent, + type RoomState, type MatrixEvent, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -24,26 +25,26 @@ import { type ActionBarViewSnapshot, } from "@element-hq/web-shared-components"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import defaultDispatcher from "../../dispatcher/dispatcher"; -import { Action } from "../../dispatcher/actions"; -import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; -import { type GetRelationsForEvent } from "../../components/views/rooms/EventTile"; -import { canCancel, canEditContent, editEvent, isContentActionable } from "../../utils/EventUtils"; -import { TimelineRenderingType } from "../../contexts/RoomContext"; -import Resend from "../../Resend"; -import PinningUtils from "../../utils/PinningUtils"; -import PosthogTrackers from "../../PosthogTrackers"; -import { shouldDisplayReply } from "../../utils/Reply"; -import { MediaEventHelper } from "../../utils/MediaEventHelper"; -import SettingsStore from "../../settings/SettingsStore"; -import { type SettingKey } from "../../settings/Settings"; -import { getMediaVisibility, setMediaVisibility } from "../../utils/media/mediaVisibility"; -import { FileDownloader } from "../../utils/FileDownloader"; -import { _t } from "../../languageHandler"; -import Modal from "../../Modal"; -import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; -import { ModuleApi } from "../../modules/Api"; +import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; +import defaultDispatcher from "../../../../../dispatcher/dispatcher"; +import { Action } from "../../../../../dispatcher/actions"; +import { type ShowThreadPayload } from "../../../../../dispatcher/payloads/ShowThreadPayload"; +import { type GetRelationsForEvent } from "../../../../../components/views/rooms/EventTile"; +import { canCancel, canEditContent, editEvent, isContentActionable } from "../../../../../utils/EventUtils"; +import { TimelineRenderingType } from "../../../../../contexts/RoomContext"; +import Resend from "../../../../../Resend"; +import PinningUtils from "../../../../../utils/PinningUtils"; +import PosthogTrackers from "../../../../../PosthogTrackers"; +import { shouldDisplayReply } from "../../../../../utils/Reply"; +import { MediaEventHelper } from "../../../../../utils/MediaEventHelper"; +import SettingsStore from "../../../../../settings/SettingsStore"; +import { type SettingKey } from "../../../../../settings/Settings"; +import { getMediaVisibility, setMediaVisibility } from "../../../../../utils/media/mediaVisibility"; +import { FileDownloader } from "../../../../../utils/FileDownloader"; +import { _t } from "../../../../../languageHandler"; +import Modal from "../../../../../Modal"; +import ErrorDialog from "../../../../../components/views/dialogs/ErrorDialog"; +import { ModuleApi } from "../../../../../modules/Api"; /** Props for the event-tile action bar view model. */ export interface EventTileActionBarViewModelProps { @@ -103,6 +104,14 @@ export class EventTileActionBarViewModel extends BaseViewModel implements ActionBarViewActions { + private static readonly roomStateListenerRegistry = new WeakMap< + RoomState, + { + listenersByType: Map void>>; + handler: (event?: MatrixEvent) => void; + } + >(); + private listenerCleanups: Array<() => void> = []; private downloadPermissionRequestId = 0; private downloadRequestId = 0; @@ -252,8 +261,10 @@ export class EventTileActionBarViewModel const { mxEvent } = this.props; const roomId = mxEvent.getRoomId(); this.trackEvent(mxEvent, MatrixEventEvent.Status, this.refreshSnapshot); - this.trackEvent(mxEvent, MatrixEventEvent.Decrypted, this.refreshSnapshot); this.trackEvent(mxEvent, MatrixEventEvent.BeforeRedaction, this.refreshSnapshot); + if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { + this.trackEventOnce(mxEvent, MatrixEventEvent.Decrypted, this.refreshSnapshot); + } this.watchSetting("mediaPreviewConfig", roomId ?? null); this.watchSetting("showMediaEventIds", null); @@ -261,8 +272,8 @@ export class EventTileActionBarViewModel ? MatrixClientPeg.safeGet().getRoom(roomId)?.getLiveTimeline().getState(EventTimeline.FORWARDS) : undefined; if (roomState) { - roomState.on(RoomStateEvent.Events, this.onRoomEvent); - this.addListenerCleanup(() => roomState.off(RoomStateEvent.Events, this.onRoomEvent)); + this.trackRoomStateEvent(roomState, EventType.RoomPinnedEvents, this.refreshSnapshot); + this.trackRoomStateEvent(roomState, EventType.RoomJoinRules, this.refreshSnapshot); } MatrixClientPeg.safeGet().decryptEventIfNeeded(mxEvent); @@ -285,6 +296,62 @@ export class EventTileActionBarViewModel this.addListenerCleanup(() => event.off(eventName, callback)); } + private trackEventOnce( + event: MatrixEvent, + eventName: MatrixEventEvent, + callback: (...args: unknown[]) => void, + ): void { + event.once(eventName, callback); + this.addListenerCleanup(() => event.off(eventName, callback)); + } + + private trackRoomStateEvent(roomState: RoomState, eventType: string, callback: () => void): void { + let entry = EventTileActionBarViewModel.roomStateListenerRegistry.get(roomState); + + if (!entry) { + const listenersByType = new Map void>>(); + const handler = (event?: MatrixEvent): void => { + if (!event) return; + + const listeners = listenersByType.get(event.getType()); + if (!listeners) return; + + for (const listener of listeners) { + listener(); + } + }; + + entry = { listenersByType, handler }; + EventTileActionBarViewModel.roomStateListenerRegistry.set(roomState, entry); + roomState.on(RoomStateEvent.Events, handler); + } + + let listeners = entry.listenersByType.get(eventType); + if (!listeners) { + listeners = new Set(); + entry.listenersByType.set(eventType, listeners); + } + + listeners.add(callback); + this.addListenerCleanup(() => { + const currentEntry = EventTileActionBarViewModel.roomStateListenerRegistry.get(roomState); + if (!currentEntry) return; + + const currentListeners = currentEntry.listenersByType.get(eventType); + if (!currentListeners) return; + + currentListeners.delete(callback); + if (currentListeners.size === 0) { + currentEntry.listenersByType.delete(eventType); + } + + if (currentEntry.listenersByType.size === 0) { + roomState.off(RoomStateEvent.Events, currentEntry.handler); + EventTileActionBarViewModel.roomStateListenerRegistry.delete(roomState); + } + }); + } + private watchSetting(settingName: SettingKey, roomId: string | null): void { const watcherRef = SettingsStore.watchSetting(settingName, roomId, this.refreshSnapshot); this.addListenerCleanup(() => SettingsStore.unwatchSetting(watcherRef)); @@ -342,12 +409,6 @@ export class EventTileActionBarViewModel return true; } - private readonly onRoomEvent = (event?: MatrixEvent): void => { - if (!event) return; - if (event.getType() !== EventType.RoomPinnedEvents && event.getType() !== EventType.RoomJoinRules) return; - this.refreshSnapshot(); - }; - /** * Runs an action against the failed event variant that is still actionable. */ diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx deleted file mode 100644 index 42e43771dea..00000000000 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ /dev/null @@ -1,689 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react"; -import { mocked } from "jest-mock"; -import { - EventStatus, - EventType, - type IEventDecryptionResult, - type MatrixClient, - MatrixEvent, - NotificationCountType, - PendingEventOrdering, - Room, - TweakName, -} from "matrix-js-sdk/src/matrix"; -import { - type CryptoApi, - DecryptionFailureCode, - type EventEncryptionInfo, - EventShieldColour, - EventShieldReason, -} from "matrix-js-sdk/src/crypto-api"; -import { mkEncryptedMatrixEvent } from "matrix-js-sdk/src/testing"; -import { getByTestId } from "@testing-library/dom"; - -import EventTile, { type EventTileProps } from "../../../../../src/components/views/rooms/EventTile"; -import * as EventTileFactory from "../../../../../src/events/EventTileFactory"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import { filterConsole, flushPromises, getRoomContext, mkEvent, mkMessage, stubClient } from "../../../../test-utils"; -import { mkThread } from "../../../../test-utils/threads"; -import DMRoomMap from "../../../../../src/utils/DMRoomMap"; -import dis from "../../../../../src/dispatcher/dispatcher"; -import { Action } from "../../../../../src/dispatcher/actions"; -import PinningUtils from "../../../../../src/utils/PinningUtils"; -import { Layout } from "../../../../../src/settings/enums/Layout"; -import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; - -describe("EventTile", () => { - const ROOM_ID = "!roomId:example.org"; - let mxEvent: MatrixEvent; - let room: Room; - let client: MatrixClient; - - // let changeEvent: (event: MatrixEvent) => void; - - /** wrap the EventTile up in context providers, and with basic properties, as it would be by MessagePanel normally. */ - function WrappedEventTile(props: { - roomContext: RoomContextType; - eventTilePropertyOverrides?: Partial; - }) { - return ( - - - - - - ); - } - - function getComponent( - overrides: Partial = {}, - renderingType: TimelineRenderingType = TimelineRenderingType.Room, - roomContext: Partial = {}, - ) { - const context = getRoomContext(room, { - timelineRenderingType: renderingType, - ...roomContext, - }); - return render(); - } - - beforeEach(() => { - jest.clearAllMocks(); - - stubClient(); - client = MatrixClientPeg.safeGet(); - - room = new Room(ROOM_ID, client, client.getSafeUserId(), { - pendingEventOrdering: PendingEventOrdering.Detached, - timelineSupport: true, - }); - - jest.spyOn(client, "getRoom").mockReturnValue(room); - jest.spyOn(client, "decryptEventIfNeeded").mockResolvedValue(); - jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); - - mxEvent = mkMessage({ - room: room.roomId, - user: "@alice:example.org", - msg: "Hello world!", - event: true, - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe("EventTile thread summary", () => { - beforeEach(() => { - jest.spyOn(client, "supportsThreads").mockReturnValue(true); - }); - - it("removes the thread summary when thread is deleted", async () => { - const { - rootEvent, - events: [, reply], - } = mkThread({ - room, - client, - authorId: "@alice:example.org", - participantUserIds: ["@alice:example.org"], - length: 2, // root + 1 answer - }); - getComponent( - { - mxEvent: rootEvent, - }, - TimelineRenderingType.Room, - ); - - await waitFor(() => expect(screen.queryByTestId("thread-summary")).not.toBeNull()); - - const redaction = mkEvent({ - event: true, - type: EventType.RoomRedaction, - user: "@alice:example.org", - room: room.roomId, - redacts: reply.getId(), - content: {}, - }); - - act(() => room.processThreadedEvents([redaction], false)); - - await waitFor(() => expect(screen.queryByTestId("thread-summary")).toBeNull()); - }); - }); - - describe("EventTile renderingType: ThreadsList", () => { - it("shows an unread notification badge", () => { - const { container } = getComponent({}, TimelineRenderingType.ThreadsList); - - // By default, the thread will assume it is read. - expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0); - - act(() => { - room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Total, 3); - }); - - expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); - expect(container.getElementsByClassName("mx_NotificationBadge_level_highlight")).toHaveLength(0); - - act(() => { - room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Highlight, 1); - }); - - expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); - expect(container.getElementsByClassName("mx_NotificationBadge_level_highlight")).toHaveLength(1); - }); - }); - - describe("EventTile renderingType: Threads", () => { - it("should display the pinned message badge", async () => { - jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true); - getComponent({}, TimelineRenderingType.Thread); - - expect(screen.getByText("Pinned message")).toBeInTheDocument(); - }); - }); - - describe("EventTile renderingType: File", () => { - it("should not display the pinned message badge", async () => { - jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true); - getComponent({}, TimelineRenderingType.File); - - expect(screen.queryByText("Pinned message")).not.toBeInTheDocument(); - }); - }); - - describe("EventTile renderingType: default", () => { - it.each([[Layout.Group], [Layout.Bubble], [Layout.IRC]])( - "should display the pinned message badge", - async (layout) => { - jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true); - getComponent({ layout }); - - expect(screen.getByText("Pinned message")).toBeInTheDocument(); - }, - ); - - it("renders the tile error fallback when tile rendering throws", async () => { - jest.spyOn(console, "error").mockImplementation(() => {}); - jest.spyOn(EventTileFactory, "renderTile").mockImplementation(() => { - throw new Error("Boom"); - }); - - getComponent(); - - await waitFor(() => { - expect(screen.getByText("Can't load this message (m.room.message)")).toBeInTheDocument(); - }); - }); - }); - - describe("EventTile in the right panel", () => { - beforeAll(() => { - const dmRoomMap: DMRoomMap = { - getUserIdForRoomId: jest.fn(), - } as unknown as DMRoomMap; - DMRoomMap.setShared(dmRoomMap); - }); - - it("renders the room name for notifications", () => { - const { container } = getComponent({}, TimelineRenderingType.Notification); - expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent( - "@alice:example.org in !roomId:example.org", - ); - }); - - it("renders the sender for the thread list", () => { - const { container } = getComponent({}, TimelineRenderingType.ThreadsList); - expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent("@alice:example.org"); - }); - - it("renders the shared redacted body for thread previews", () => { - jest.spyOn(mxEvent, "isRedacted").mockReturnValue(true); - jest.spyOn(mxEvent, "getUnsigned").mockReturnValue({ - redacted_because: { - sender: "@moderator:example.org", - origin_server_ts: Date.UTC(2022, 10, 17, 15, 58, 32), - }, - } as any); - - const { container } = getComponent({}, TimelineRenderingType.ThreadsList); - const redactedBody = container.querySelector(".mx_RedactedBody"); - - expect(redactedBody).not.toBeNull(); - expect(redactedBody).toHaveTextContent("Message deleted by @moderator:example.org"); - }); - - it.each([ - [TimelineRenderingType.Notification, Action.ViewRoom], - [TimelineRenderingType.ThreadsList, Action.ShowThread], - ])("type %s dispatches %s", (renderingType, action) => { - jest.spyOn(dis, "dispatch"); - - const { container } = getComponent({}, renderingType); - - fireEvent.click(container.querySelector("li")!); - - expect(dis.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - action, - }), - ); - }); - }); - describe("Event verification", () => { - // data for our stubbed getEncryptionInfoForEvent: a map from event id to result - const eventToEncryptionInfoMap = new Map(); - - beforeEach(() => { - eventToEncryptionInfoMap.clear(); - - const mockCrypto = { - // a mocked version of getEncryptionInfoForEvent which will pick its result from `eventToEncryptionInfoMap` - getEncryptionInfoForEvent: async (event: MatrixEvent) => eventToEncryptionInfoMap.get(event.getId()!)!, - } as unknown as CryptoApi; - client.getCrypto = () => mockCrypto; - }); - - it("shows a warning for an event from an unverified device", async () => { - mxEvent = await mkEncryptedMatrixEvent({ - plainContent: { msgtype: "m.text", body: "msg1" }, - plainType: "m.room.message", - sender: "@alice:example.org", - roomId: room.roomId, - }); - eventToEncryptionInfoMap.set(mxEvent.getId()!, { - shieldColour: EventShieldColour.RED, - shieldReason: EventShieldReason.UNSIGNED_DEVICE, - } as EventEncryptionInfo); - - const { container } = getComponent(); - await flushPromises(); - - const eventTiles = container.getElementsByClassName("mx_EventTile"); - expect(eventTiles).toHaveLength(1); - - // there should be a warning shield - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName( - "Encrypted by a device not verified by its owner.", - ); - }); - - it("shows no shield for a verified event", async () => { - mxEvent = await mkEncryptedMatrixEvent({ - plainContent: { msgtype: "m.text", body: "msg1" }, - plainType: "m.room.message", - sender: "@alice:example.org", - roomId: room.roomId, - }); - eventToEncryptionInfoMap.set(mxEvent.getId()!, { - shieldColour: EventShieldColour.NONE, - shieldReason: null, - } as EventEncryptionInfo); - - const { container } = getComponent(); - await flushPromises(); - - const eventTiles = container.getElementsByClassName("mx_EventTile"); - expect(eventTiles).toHaveLength(1); - - // there should be no warning - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); - }); - - it.each([ - [EventShieldReason.UNKNOWN, "Unknown error"], - [EventShieldReason.UNVERIFIED_IDENTITY, "Encrypted by an unverified user."], - [EventShieldReason.UNSIGNED_DEVICE, "Encrypted by a device not verified by its owner."], - [EventShieldReason.UNKNOWN_DEVICE, "Encrypted by an unknown or deleted device."], - [ - EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, - "The authenticity of this encrypted message can't be guaranteed on this device.", - ], - [EventShieldReason.MISMATCHED_SENDER_KEY, "Encrypted by an unverified session"], - [EventShieldReason.SENT_IN_CLEAR, "Not encrypted"], - [EventShieldReason.VERIFICATION_VIOLATION, "Sender's verified digital identity was reset"], - [ - EventShieldReason.MISMATCHED_SENDER, - "The sender of the event does not match the owner of the device that sent it.", - ], - ])("shows the correct reason code for %i (%s)", async (reasonCode: EventShieldReason, expectedText: string) => { - mxEvent = await mkEncryptedMatrixEvent({ - plainContent: { msgtype: "m.text", body: "msg1" }, - plainType: "m.room.message", - sender: "@alice:example.org", - roomId: room.roomId, - }); - eventToEncryptionInfoMap.set(mxEvent.getId()!, { - shieldColour: EventShieldColour.GREY, - shieldReason: reasonCode, - } as EventEncryptionInfo); - - const { container } = getComponent(); - await flushPromises(); - - const e2eIcons = container.getElementsByClassName("mx_EventTile_e2eIcon"); - expect(e2eIcons).toHaveLength(1); - expect(e2eIcons[0]).toHaveAccessibleName(expectedText); - }); - - it("shows the correct reason code for a forwarded message", async () => { - mxEvent = await mkEncryptedMatrixEvent({ - plainContent: { msgtype: "m.text", body: "msg1" }, - plainType: "m.room.message", - sender: "@alice:example.org", - roomId: room.roomId, - }); - // @ts-ignore assignment to private member - mxEvent.keyForwardedBy = "@bob:example.org"; - eventToEncryptionInfoMap.set(mxEvent.getId()!, { - shieldColour: EventShieldColour.GREY, - shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, - } as EventEncryptionInfo); - - const { container } = getComponent(); - - const e2eIcon = await waitFor(() => getByTestId(container, "e2e-padlock")); - expect(e2eIcon).toHaveAccessibleName( - "@bob:example.org (@bob:example.org) shared this message since you were not in the room when it was sent.", - ); - }); - - describe("undecryptable event", () => { - filterConsole("Error decrypting event"); - - it("shows an undecryptable warning", async () => { - mxEvent = mkEvent({ - type: "m.room.encrypted", - room: room.roomId, - user: "@alice:example.org", - event: true, - content: {}, - }); - - const mockCrypto = { - decryptEvent: async (_ev): Promise => { - throw new Error("can't decrypt"); - }, - } as Parameters[0]; - await mxEvent.attemptDecryption(mockCrypto); - - const { container } = getComponent(); - await flushPromises(); - - const eventTiles = container.getElementsByClassName("mx_EventTile"); - expect(eventTiles).toHaveLength(1); - - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName( - "This message could not be decrypted", - ); - }); - - it("should not show a shield for previously-verified users", async () => { - mxEvent = mkEvent({ - type: "m.room.encrypted", - room: room.roomId, - user: "@alice:example.org", - event: true, - content: {}, - }); - - const mockCrypto = { - decryptEvent: async (_ev): Promise => { - throw new Error("can't decrypt"); - }, - } as Parameters[0]; - await mxEvent.attemptDecryption(mockCrypto); - mxEvent["_decryptionFailureReason"] = DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED; - - const { container } = getComponent(); - await act(flushPromises); - - const eventTiles = container.getElementsByClassName("mx_EventTile"); - expect(eventTiles).toHaveLength(1); - - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); - }); - }); - - it("should update the warning when the event is edited", async () => { - // we start out with an event from the trusted device - mxEvent = await mkEncryptedMatrixEvent({ - plainContent: { msgtype: "m.text", body: "msg1" }, - plainType: "m.room.message", - sender: "@alice:example.org", - roomId: room.roomId, - }); - eventToEncryptionInfoMap.set(mxEvent.getId()!, { - shieldColour: EventShieldColour.NONE, - shieldReason: null, - } as EventEncryptionInfo); - - const roomContext = getRoomContext(room, {}); - const { container, rerender } = render(); - - await flushPromises(); - - const eventTiles = container.getElementsByClassName("mx_EventTile"); - expect(eventTiles).toHaveLength(1); - - // there should be no warning - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); - - // then we replace the event with one from the unverified device - const replacementEvent = await mkEncryptedMatrixEvent({ - plainContent: { msgtype: "m.text", body: "msg1" }, - plainType: "m.room.message", - sender: "@alice:example.org", - roomId: room.roomId, - }); - eventToEncryptionInfoMap.set(replacementEvent.getId()!, { - shieldColour: EventShieldColour.RED, - shieldReason: EventShieldReason.UNSIGNED_DEVICE, - } as EventEncryptionInfo); - - await act(async () => { - mxEvent.makeReplaced(replacementEvent); - rerender(); - await flushPromises; - }); - - // check it was updated - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName( - "Encrypted by a device not verified by its owner.", - ); - }); - - it("should update the warning when the event is replaced with an unencrypted one", async () => { - // we start out with an event from the trusted device - mxEvent = await mkEncryptedMatrixEvent({ - plainContent: { msgtype: "m.text", body: "msg1" }, - plainType: "m.room.message", - sender: "@alice:example.org", - roomId: room.roomId, - }); - - eventToEncryptionInfoMap.set(mxEvent.getId()!, { - shieldColour: EventShieldColour.NONE, - shieldReason: null, - } as EventEncryptionInfo); - - const roomContext = getRoomContext(room, { isRoomEncrypted: true }); - const { container, rerender } = render(); - await flushPromises(); - - const eventTiles = container.getElementsByClassName("mx_EventTile"); - expect(eventTiles).toHaveLength(1); - - // there should be no warning - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); - - // then we replace the event with an unencrypted one - const replacementEvent = await mkMessage({ - msg: "msg2", - user: "@alice:example.org", - room: room.roomId, - event: true, - }); - - await act(async () => { - mxEvent.makeReplaced(replacementEvent); - rerender(); - await flushPromises; - }); - - // check it was updated - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); - expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName("Not encrypted"); - }); - }); - - describe("event highlighting", () => { - const isHighlighted = (container: HTMLElement): boolean => - !!container.getElementsByClassName("mx_EventTile_highlight").length; - - beforeEach(() => { - mocked(client.getPushActionsForEvent).mockReturnValue(null); - }); - - it("does not highlight message where message matches no push actions", () => { - const { container } = getComponent(); - - expect(client.getPushActionsForEvent).toHaveBeenCalledWith(mxEvent); - expect(isHighlighted(container)).toBeFalsy(); - }); - - it("does not highlight when message's push actions does not have a highlight tweak", () => { - mocked(client.getPushActionsForEvent).mockReturnValue({ notify: true, tweaks: {} }); - const { container } = getComponent(); - - expect(isHighlighted(container)).toBeFalsy(); - }); - - it("does not highlight when message's push actions have a highlight tweak but message has been redacted", () => { - mocked(client.getPushActionsForEvent).mockReturnValue({ - notify: true, - tweaks: { [TweakName.Highlight]: true }, - }); - const { container } = getComponent({ isRedacted: true }); - - expect(isHighlighted(container)).toBeFalsy(); - }); - - it("highlights when message's push actions have a highlight tweak", () => { - mocked(client.getPushActionsForEvent).mockReturnValue({ - notify: true, - tweaks: { [TweakName.Highlight]: true }, - }); - const { container } = getComponent(); - - expect(isHighlighted(container)).toBeTruthy(); - }); - - describe("when a message has been edited", () => { - let editingEvent: MatrixEvent; - - beforeEach(() => { - editingEvent = new MatrixEvent({ - type: "m.room.message", - room_id: ROOM_ID, - sender: "@alice:example.org", - content: { - "msgtype": "m.text", - "body": "* edited body", - "m.new_content": { - msgtype: "m.text", - body: "edited body", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: mxEvent.getId(), - }, - }, - }); - mxEvent.makeReplaced(editingEvent); - }); - - it("does not highlight message where no version of message matches any push actions", () => { - const { container } = getComponent(); - - // get push actions for both events - expect(client.getPushActionsForEvent).toHaveBeenCalledWith(mxEvent); - expect(client.getPushActionsForEvent).toHaveBeenCalledWith(editingEvent); - expect(isHighlighted(container)).toBeFalsy(); - }); - - it(`does not highlight when no version of message's push actions have a highlight tweak`, () => { - mocked(client.getPushActionsForEvent).mockReturnValue({ notify: true, tweaks: {} }); - const { container } = getComponent(); - - expect(isHighlighted(container)).toBeFalsy(); - }); - - it(`highlights when previous version of message's push actions have a highlight tweak`, () => { - mocked(client.getPushActionsForEvent).mockImplementation((event: MatrixEvent) => { - if (event === mxEvent) { - return { notify: true, tweaks: { [TweakName.Highlight]: true } }; - } - return { notify: false, tweaks: {} }; - }); - const { container } = getComponent(); - - expect(isHighlighted(container)).toBeTruthy(); - }); - - it(`highlights when new version of message's push actions have a highlight tweak`, () => { - mocked(client.getPushActionsForEvent).mockImplementation((event: MatrixEvent) => { - if (event === editingEvent) { - return { notify: true, tweaks: { [TweakName.Highlight]: true } }; - } - return { notify: false, tweaks: {} }; - }); - const { container } = getComponent(); - - expect(isHighlighted(container)).toBeTruthy(); - }); - }); - }); - - it("should display the not encrypted status for an unencrypted event when the room becomes encrypted", async () => { - jest.spyOn(client.getCrypto()!, "getEncryptionInfoForEvent").mockResolvedValue({ - shieldColour: EventShieldColour.NONE, - shieldReason: null, - }); - - const { rerender } = getComponent(); - await flushPromises(); - // The room and the event are unencrypted, the tile should not show the not encrypted status - expect(screen.queryByText("Not encrypted")).toBeNull(); - - // The room is now encrypted - rerender( - , - ); - - // The event tile should now show the not encrypted status - await waitFor(() => expect(screen.getByText("Not encrypted")).toBeInTheDocument()); - }); - - it.each([ - [EventStatus.NOT_SENT, "Failed to send"], - [EventStatus.SENDING, "Sending your message…"], - [EventStatus.ENCRYPTING, "Encrypting your message…"], - ])("should display %s status icon", (eventSendStatus, text) => { - const ownEvent = mkMessage({ - room: room.roomId, - user: client.getSafeUserId(), - msg: "Hello world!", - event: true, - }); - const { getByRole } = getComponent({ mxEvent: ownEvent, eventSendStatus }); - - expect(getByRole("status")).toHaveAccessibleName(text); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTile-test.tsx new file mode 100644 index 00000000000..299f2934b5c --- /dev/null +++ b/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTile-test.tsx @@ -0,0 +1,1089 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022, 2023 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react"; +import { + EventStatus, + EventType, + type IEventDecryptionResult, + type MatrixClient, + type MatrixEvent, + MatrixEventEvent, + NotificationCountType, + PendingEventOrdering, + Room, + RoomEvent, + type Thread, + ThreadEvent, +} from "matrix-js-sdk/src/matrix"; +import { + type CryptoApi, + DecryptionFailureCode, + type EventEncryptionInfo, + EventShieldColour, + EventShieldReason, +} from "matrix-js-sdk/src/crypto-api"; +import { mkEncryptedMatrixEvent } from "matrix-js-sdk/src/testing"; +import { getByTestId } from "@testing-library/dom"; + +import { + EventTile, + type EventTileHandle, + type EventTileProps, +} from "../../../../../../src/components/views/rooms/EventTile"; +import * as EventTileFactory from "../../../../../../src/events/EventTileFactory"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; +import { type RoomContextType, TimelineRenderingType } from "../../../../../../src/contexts/RoomContext"; +import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import { LOCAL_ROOM_ID_PREFIX, LocalRoom } from "../../../../../../src/models/LocalRoom"; +import { + createTestClient, + filterConsole, + flushPromises, + getRoomContext, + mkEvent, + mkMessage, + stubClient, +} from "../../../../../test-utils"; +import { makeThreadEvent, mkThread } from "../../../../../test-utils/threads"; +import DMRoomMap from "../../../../../../src/utils/DMRoomMap"; +import dis from "../../../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../../../src/dispatcher/actions"; +import PinningUtils from "../../../../../../src/utils/PinningUtils"; +import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx"; +import { DecryptionFailureTracker } from "../../../../../../src/DecryptionFailureTracker"; +import SettingsStore from "../../../../../../src/settings/SettingsStore.ts"; + +jest.mock("../../../../../../src/utils/EventRenderingUtils", () => ({ + ...jest.requireActual("../../../../../../src/utils/EventRenderingUtils"), + getEventDisplayInfo: jest.fn(), +})); + +jest.mock("../../../../../../src/components/views/rooms/EventTile/ReplyPreview", () => ({ + ReplyPreview: ({ mxEvent }: { mxEvent: MatrixEvent }) =>
{mxEvent.getId()}
, +})); + +jest.mock("../../../../../../src/components/views/rooms/EventTile/Avatar", () => ({ + Avatar: ({ member }: { member?: { userId?: string } | null }) => + member ?
{member.userId}
: null, +})); + +jest.mock("../../../../../../src/components/views/context_menus/MessageContextMenu", () => ({ + __esModule: true, + default: ({ onFinished }: { onFinished?: () => void }) => ( +
+ +
+ ), + aboveRightOf: jest.fn(), + aboveLeftOf: jest.fn(), +})); + +const mockGetEventDisplayInfo = jest.requireMock("../../../../../../src/utils/EventRenderingUtils") + .getEventDisplayInfo as jest.Mock; + +function defer(): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("EventTile", () => { + const ROOM_ID = "!roomId:example.org"; + let mxEvent: MatrixEvent; + let room: Room; + let client: MatrixClient; + + // let changeEvent: (event: MatrixEvent) => void; + + /** wrap the EventTile up in context providers, and with basic properties, as it would be by MessagePanel normally. */ + function WrappedEventTile(props: { + roomContext: RoomContextType; + eventTilePropertyOverrides?: Partial; + }) { + return ( + + + + + + ); + } + + function getComponent( + overrides: Partial = {}, + renderingType: TimelineRenderingType = TimelineRenderingType.Room, + roomContext: Partial = {}, + ) { + const context = getRoomContext(room, { + timelineRenderingType: renderingType, + ...roomContext, + }); + return render( + , + ); + } + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + client = MatrixClientPeg.safeGet(); + + room = new Room(ROOM_ID, client, client.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + timelineSupport: true, + }); + + jest.spyOn(client, "getRoom").mockReturnValue(room); + jest.spyOn(client, "decryptEventIfNeeded").mockResolvedValue(); + jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); + + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Hello world!", + event: true, + }); + + mockGetEventDisplayInfo.mockReturnValue({ + hasRenderer: true, + isBubbleMessage: false, + isInfoMessage: false, + isLeftAlignedBubbleMessage: false, + noBubbleEvent: false, + isSeeingThroughMessageHiddenForModeration: false, + }); + }); + + afterEach(() => { + jest.spyOn(PinningUtils, "isPinned").mockReturnValue(false); + delete document.body.dataset.whatinput; + }); + + describe("EventTile thread summary", () => { + beforeEach(() => { + jest.spyOn(client, "supportsThreads").mockReturnValue(true); + }); + + it("removes the thread summary when thread is deleted", async () => { + const { + rootEvent, + events: [, reply], + } = mkThread({ + room, + client, + authorId: "@alice:example.org", + participantUserIds: ["@alice:example.org"], + length: 2, // root + 1 answer + }); + getComponent( + { + mxEvent: rootEvent, + }, + TimelineRenderingType.Room, + ); + + await waitFor(() => expect(screen.queryByTestId("thread-summary")).not.toBeNull()); + + const redaction = mkEvent({ + event: true, + type: EventType.RoomRedaction, + user: "@alice:example.org", + room: room.roomId, + redacts: reply.getId(), + content: {}, + }); + + act(() => room.processThreadedEvents([redaction], false)); + + await waitFor(() => expect(screen.queryByTestId("thread-summary")).toBeNull()); + }); + + it("updates the thread preview when a new reply is added", async () => { + const { + thread, + rootEvent, + events: [, reply1], + } = mkThread({ + room, + client, + authorId: "@alice:example.org", + participantUserIds: ["@alice:example.org"], + length: 2, + }); + thread.initialEventsFetched = true; + + reply1.getContent().body = "ReplyEvent1"; + + getComponent({ mxEvent: rootEvent }, TimelineRenderingType.Room); + + await screen.findByText("ReplyEvent1"); + + const reply2 = makeThreadEvent({ + user: "@alice:example.org", + room: room.roomId, + event: true, + msg: "ReplyEvent2", + rootEventId: rootEvent.getId()!, + replyToEventId: reply1.getId()!, + }); + + await act(async () => { + await thread.addEvent(reply2, false, true); + }); + + await screen.findByText("ReplyEvent2"); + }); + }); + + describe("EventTile renderingType: ThreadsList", () => { + it("renders the sender in the thread list details", async () => { + const { container } = getComponent({}, TimelineRenderingType.ThreadsList); + + await waitFor(() => { + const sender = container.querySelector(".mx_EventTile_details .mx_DisambiguatedProfile"); + expect(sender).not.toBeNull(); + expect(sender).toHaveTextContent("@alice:example.org"); + }); + }); + + it("shows an unread notification badge", () => { + const { container } = getComponent({}, TimelineRenderingType.ThreadsList); + + // By default, the thread will assume it is read. + expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0); + + act(() => { + room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Total, 3); + }); + + expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); + expect(container.getElementsByClassName("mx_NotificationBadge_level_highlight")).toHaveLength(0); + + act(() => { + room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Highlight, 1); + }); + + expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); + expect(container.getElementsByClassName("mx_NotificationBadge_level_highlight")).toHaveLength(1); + }); + }); + + describe("EventTile renderingType: Notification", () => { + it("renders the room name in the notification details", async () => { + const dmRoomMap: DMRoomMap = { + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap; + DMRoomMap.setShared(dmRoomMap); + room.name = "Test room"; + + const { container } = getComponent({}, TimelineRenderingType.Notification); + + await waitFor(() => { + const details = container.getElementsByClassName("mx_EventTile_details")[0]; + expect(details).toHaveTextContent("@alice:example.org"); + expect(details).toHaveTextContent("in Test room"); + }); + }); + + it("does not render the missing renderer fallback for notifications", () => { + mockGetEventDisplayInfo.mockReturnValue({ + hasRenderer: false, + isBubbleMessage: false, + isInfoMessage: false, + isLeftAlignedBubbleMessage: false, + noBubbleEvent: false, + isSeeingThroughMessageHiddenForModeration: false, + }); + + const { container } = getComponent({}, TimelineRenderingType.Notification); + + expect(container).not.toHaveTextContent("This event could not be displayed"); + }); + }); + + describe("EventTile renderingType: File", () => { + it("renders the timestamp in the sender details when enabled", async () => { + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Hello world!", + event: true, + ts: 123, + }); + + const { container } = getComponent({ mxEvent, alwaysShowTimestamps: true }, TimelineRenderingType.File); + + await waitFor(() => { + expect(container.getElementsByClassName("mx_MessageTimestamp")).toHaveLength(1); + }); + }); + }); + + describe("EventTile presenter wiring", () => { + it("renders the missing renderer fallback when the VM selects it", () => { + mockGetEventDisplayInfo.mockReturnValue({ + hasRenderer: false, + isBubbleMessage: false, + isInfoMessage: false, + isLeftAlignedBubbleMessage: false, + noBubbleEvent: false, + isSeeingThroughMessageHiddenForModeration: false, + }); + + const { container } = getComponent(); + + expect(container).toHaveTextContent("This event could not be displayed"); + }); + + it("renders a reply preview when the VM says the event is a reply", async () => { + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Reply", + event: true, + relatesTo: { + "m.in_reply_to": { + event_id: "$parent", + }, + }, + }); + + const { container } = getComponent({ mxEvent }); + const eventId = mxEvent.getId(); + expect(eventId).toBeDefined(); + await waitFor(() => expect(getByTestId(container, "reply-preview")).toHaveTextContent(eventId ?? "")); + }); + + it("resolves the avatar subject from the VM for third-party invites", async () => { + mxEvent = mkEvent({ + event: true, + type: "m.room.member", + user: "@alice:example.org", + room: room.roomId, + content: { + membership: "invite", + third_party_invite: { + display_name: "Bob", + }, + }, + }); + mxEvent.sender = { + userId: "@alice:example.org", + membership: "join", + name: "@alice:example.org", + rawDisplayName: "@alice:example.org", + roomId: room.roomId, + } as never; + mxEvent.target = { + userId: "@bob:example.org", + membership: "invite", + name: "@bob:example.org", + rawDisplayName: "@bob:example.org", + roomId: room.roomId, + } as never; + + const { container } = getComponent({ mxEvent }); + + await waitFor(() => expect(getByTestId(container, "avatar-subject")).toHaveTextContent("@bob:example.org")); + }); + + it("shows the action bar for keyboard focus but not generic focus, and keeps it while focus moves within the tile", async () => { + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Keyboard focus", + event: true, + ts: 123, + }); + + const { container } = getComponent({ mxEvent }); + const tile = container.querySelector(".mx_EventTile") as HTMLElement; + + fireEvent.focus(tile, { target: tile }); + expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); + + document.body.dataset.whatinput = "keyboard"; + fireEvent.focus(tile, { target: tile }); + + await waitFor(() => expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull()); + + const child = document.createElement("button"); + tile.append(child); + + fireEvent.blur(tile, { relatedTarget: child }); + + expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull(); + }); + + it("clears hover-driven action bar state when the context menu opens and hides the timestamp again when it closes", async () => { + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Context menu", + event: true, + ts: 123, + }); + + const { container } = getComponent({ mxEvent }); + const tile = container.querySelector(".mx_EventTile") as HTMLElement; + const line = container.querySelector(".mx_EventTile_line") as HTMLElement; + + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + + fireEvent.mouseEnter(tile); + await waitFor(() => expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull()); + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + + fireEvent.contextMenu(line); + await waitFor(() => expect(screen.getByTestId("message-context-menu")).toBeInTheDocument()); + + expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + + fireEvent.click(screen.getByTestId("close-message-context-menu")); + await waitFor(() => expect(screen.queryByTestId("message-context-menu")).toBeNull()); + + expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + }); + }); + + describe("EventTile renderingType: Pinned", () => { + it("does not render a DisambiguatedProfile for continuation messages", () => { + const { container } = getComponent({ continuation: true }, TimelineRenderingType.Pinned); + + expect(container.getElementsByClassName("mx_DisambiguatedProfile")).toHaveLength(0); + }); + }); + + it("renders the tile error fallback when tile rendering throws", async () => { + jest.spyOn(console, "error").mockImplementation(() => {}); + const renderTileSpy = jest.spyOn(EventTileFactory, "renderTile").mockImplementation(() => { + throw new Error("Boom"); + }); + + try { + getComponent({ withErrorBoundary: true }); + + await waitFor(() => { + expect(screen.getByText("Can't load this message (m.room.message)")).toBeInTheDocument(); + }); + } finally { + renderTileSpy.mockRestore(); + } + }); + + describe("EventTile in the right panel", () => { + beforeAll(() => { + const dmRoomMap: DMRoomMap = { + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap; + DMRoomMap.setShared(dmRoomMap); + }); + + it("renders the room name for notifications", () => { + const { container } = getComponent({}, TimelineRenderingType.Notification); + expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent( + "@alice:example.org in !roomId:example.org", + ); + }); + + it("renders the sender for the thread list", () => { + const { container } = getComponent({}, TimelineRenderingType.ThreadsList); + expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent("@alice:example.org"); + }); + + it("renders the shared redacted body for thread previews", () => { + jest.spyOn(mxEvent, "isRedacted").mockReturnValue(true); + jest.spyOn(mxEvent, "getUnsigned").mockReturnValue({ + redacted_because: { + sender: "@moderator:example.org", + origin_server_ts: Date.UTC(2022, 10, 17, 15, 58, 32), + }, + } as any); + + const { container } = getComponent({}, TimelineRenderingType.ThreadsList); + const redactedBody = container.querySelector(".mx_RedactedBody"); + + expect(redactedBody).not.toBeNull(); + expect(redactedBody).toHaveTextContent("Message deleted by @moderator:example.org"); + }); + + it.each([ + [TimelineRenderingType.Notification, Action.ViewRoom], + [TimelineRenderingType.ThreadsList, Action.ShowThread], + ])("type %s dispatches %s", (renderingType, action) => { + jest.spyOn(dis, "dispatch"); + + const { container } = getComponent({}, renderingType); + + fireEvent.click(container.querySelector("li")!); + + expect(dis.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + action, + }), + ); + }); + }); + describe("Event verification", () => { + // data for our stubbed getEncryptionInfoForEvent: a map from event id to result + const eventToEncryptionInfoMap = new Map(); + + beforeEach(() => { + eventToEncryptionInfoMap.clear(); + + const mockCrypto = { + // a mocked version of getEncryptionInfoForEvent which will pick its result from `eventToEncryptionInfoMap` + getEncryptionInfoForEvent: async (event: MatrixEvent) => eventToEncryptionInfoMap.get(event.getId()!)!, + } as unknown as CryptoApi; + client.getCrypto = () => mockCrypto; + }); + + it("shows the correct reason code for a forwarded message", async () => { + mxEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + // @ts-ignore assignment to private member + mxEvent.keyForwardedBy = "@bob:example.org"; + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.GREY, + shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, + } as EventEncryptionInfo); + + const { container } = getComponent(); + + const e2eIcon = await waitFor(() => getByTestId(container, "e2e-padlock")); + expect(e2eIcon).toHaveAccessibleName( + "@bob:example.org (@bob:example.org) shared this message since you were not in the room when it was sent.", + ); + }); + + it("does not show a forwarded-message icon for local rooms", async () => { + room = new LocalRoom(LOCAL_ROOM_ID_PREFIX + "event-tile-test", client, client.getSafeUserId()); + jest.spyOn(client, "getRoom").mockImplementation((roomId) => (roomId === room.roomId ? room : null)); + + mxEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + // @ts-ignore assignment to private member + mxEvent.keyForwardedBy = "@bob:example.org"; + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.GREY, + shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, + } as EventEncryptionInfo); + + const { container } = getComponent(); + + await flushPromises(); + + expect(container.querySelector('[data-testid="e2e-padlock"]')).toBeNull(); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + }); + + describe("undecryptable event", () => { + filterConsole("Error decrypting event"); + + it("shows an undecryptable warning", async () => { + mxEvent = mkEvent({ + type: "m.room.encrypted", + room: room.roomId, + user: "@alice:example.org", + event: true, + content: {}, + }); + + const mockCrypto = { + decryptEvent: async (_ev): Promise => { + throw new Error("can't decrypt"); + }, + } as Parameters[0]; + await mxEvent.attemptDecryption(mockCrypto); + + const { container } = getComponent(); + await flushPromises(); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName( + "This message could not be decrypted", + ); + }); + + it("should not show a shield for previously-verified users", async () => { + mxEvent = mkEvent({ + type: "m.room.encrypted", + room: room.roomId, + user: "@alice:example.org", + event: true, + content: {}, + }); + + const mockCrypto = { + decryptEvent: async (_ev): Promise => { + throw new Error("can't decrypt"); + }, + } as Parameters[0]; + await mxEvent.attemptDecryption(mockCrypto); + mxEvent["_decryptionFailureReason"] = DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED; + + const { container } = getComponent(); + await act(flushPromises); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + }); + }); + + it("should update the warning when the event is edited", async () => { + // we start out with an event from the trusted device + mxEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.NONE, + shieldReason: null, + } as EventEncryptionInfo); + + const roomContext = getRoomContext(room, {}); + const { container, rerender } = render(); + + await flushPromises(); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + + // there should be no warning + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + + // then we replace the event with one from the unverified device + const replacementEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + eventToEncryptionInfoMap.set(replacementEvent.getId()!, { + shieldColour: EventShieldColour.RED, + shieldReason: EventShieldReason.UNSIGNED_DEVICE, + } as EventEncryptionInfo); + + await act(async () => { + mxEvent.makeReplaced(replacementEvent); + rerender(); + await flushPromises; + }); + + // check it was updated + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName( + "Encrypted by a device not verified by its owner.", + ); + }); + + it("should update the warning when the event is replaced with an unencrypted one", async () => { + // we start out with an event from the trusted device + mxEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.NONE, + shieldReason: null, + } as EventEncryptionInfo); + + const roomContext = getRoomContext(room, { isRoomEncrypted: true }); + const { container, rerender } = render(); + await flushPromises(); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + + // there should be no warning + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + + // then we replace the event with an unencrypted one + const replacementEvent = await mkMessage({ + msg: "msg2", + user: "@alice:example.org", + room: room.roomId, + event: true, + }); + + await act(async () => { + mxEvent.makeReplaced(replacementEvent); + rerender(); + await flushPromises; + }); + + // check it was updated + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName("Not encrypted"); + }); + + it("ignores stale verification results after the event changes", async () => { + const firstEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + const secondEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg2" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + + const firstResult = defer(); + const secondResult = defer(); + const getEncryptionInfoForEvent = jest.fn((event: MatrixEvent) => { + if (event.getId() === firstEvent.getId()) { + return firstResult.promise; + } + if (event.getId() === secondEvent.getId()) { + return secondResult.promise; + } + return Promise.resolve(null); + }); + client.getCrypto = () => + ({ + getEncryptionInfoForEvent, + }) as unknown as CryptoApi; + + const roomContext = getRoomContext(room, {}); + const { container, rerender } = render( + , + ); + + rerender( + , + ); + + await act(async () => { + secondResult.resolve({ + shieldColour: EventShieldColour.RED, + shieldReason: EventShieldReason.UNSIGNED_DEVICE, + } as EventEncryptionInfo); + await flushPromises(); + }); + + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName( + "Encrypted by a device not verified by its owner.", + ); + + await act(async () => { + firstResult.resolve({ + shieldColour: EventShieldColour.NONE, + shieldReason: null, + } as EventEncryptionInfo); + await flushPromises(); + }); + + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName( + "Encrypted by a device not verified by its owner.", + ); + }); + }); + + it("decrypts the event on mount and when the event prop changes", async () => { + const firstEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "First", + event: true, + }); + const secondEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Second", + event: true, + }); + + const roomContext = getRoomContext(room, {}); + const { rerender } = render( + , + ); + + await waitFor(() => expect(client.decryptEventIfNeeded).toHaveBeenCalledWith(firstEvent)); + + jest.mocked(client.decryptEventIfNeeded).mockClear(); + + rerender( + , + ); + + await waitFor(() => expect(client.decryptEventIfNeeded).toHaveBeenCalledWith(secondEvent)); + }); + + it("rerenders the message body when the event decrypts in place", async () => { + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Hello world!", + event: true, + }); + + let isDecryptionFailure = true; + jest.spyOn(mxEvent, "isDecryptionFailure").mockImplementation(() => isDecryptionFailure); + + const { container } = getComponent(); + + await waitFor(() => expect(container.querySelector(".mx_DecryptionFailureBody")).not.toBeNull()); + expect(screen.queryByText("Hello world!")).toBeNull(); + + await act(async () => { + isDecryptionFailure = false; + mxEvent.emit(MatrixEventEvent.Decrypted, mxEvent, undefined); + await flushPromises(); + }); + + await waitFor(() => expect(container.querySelector(".mx_DecryptionFailureBody")).toBeNull()); + expect(screen.getByText("Hello world!")).toBeInTheDocument(); + }); + + it("marks the event as visible to the decryption failure tracker on mount", () => { + const addVisibleEventSpy = jest.spyOn(DecryptionFailureTracker.instance, "addVisibleEvent"); + + getComponent(); + + expect(addVisibleEventSpy).toHaveBeenCalledWith(mxEvent); + }); + + it("marks a new event as visible to the decryption failure tracker when the event prop changes", () => { + const addVisibleEventSpy = jest.spyOn(DecryptionFailureTracker.instance, "addVisibleEvent"); + const firstEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "First", + event: true, + }); + const secondEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Second", + event: true, + }); + + const roomContext = getRoomContext(room, {}); + const { rerender } = render( + , + ); + + expect(addVisibleEventSpy).toHaveBeenCalledWith(firstEvent); + + addVisibleEventSpy.mockClear(); + + rerender( + , + ); + + expect(addVisibleEventSpy).toHaveBeenCalledWith(secondEvent); + }); + + it("does not mark exported events as visible to the decryption failure tracker", () => { + const addVisibleEventSpy = jest.spyOn(DecryptionFailureTracker.instance, "addVisibleEvent"); + + getComponent({ forExport: true }); + + expect(addVisibleEventSpy).not.toHaveBeenCalled(); + }); + + it("removes the ThreadEvent.New listener once the matching thread is found", () => { + const offSpy = jest.spyOn(room, "off"); + getComponent(); + + const thread = { + id: mxEvent.getId(), + length: 0, + on: jest.fn(), + off: jest.fn(), + } as unknown as Thread; + + act(() => { + room.emit(ThreadEvent.New, thread, false); + }); + + expect(offSpy).toHaveBeenCalledWith(ThreadEvent.New, expect.any(Function)); + }); + + it("subscribes to room receipts only once when the client context changes", () => { + const ownEvent = mkMessage({ + room: room.roomId, + user: client.getSafeUserId(), + msg: "Hello world!", + event: true, + }); + const roomContext = getRoomContext(room, {}); + const { rerender } = render( + , + ); + + const secondClient = createTestClient(); + jest.spyOn(secondClient, "getRoom").mockReturnValue(room); + jest.spyOn(secondClient, "decryptEventIfNeeded").mockResolvedValue(); + const secondClientOnSpy = jest.spyOn(secondClient, "on"); + + client = secondClient; + + rerender( + , + ); + + expect(secondClientOnSpy.mock.calls.filter(([eventName]) => eventName === RoomEvent.Receipt)).toHaveLength(1); + }); + + it("should display the not encrypted status for an unencrypted event when the room becomes encrypted", async () => { + jest.spyOn(client.getCrypto()!, "getEncryptionInfoForEvent").mockResolvedValue({ + shieldColour: EventShieldColour.NONE, + shieldReason: null, + }); + + const { rerender } = getComponent(); + await flushPromises(); + // The room and the event are unencrypted, the tile should not show the not encrypted status + expect(screen.queryByText("Not encrypted")).toBeNull(); + + // The room is now encrypted + rerender( + , + ); + + // The event tile should now show the not encrypted status + await waitFor(() => expect(screen.getByText("Not encrypted")).toBeInTheDocument()); + }); + + it("refreshes derived state when forceUpdate is called through the imperative ref", () => { + const ref = React.createRef(); + const isPinnedSpy = jest.spyOn(PinningUtils, "isPinned").mockReturnValue(false); + + getComponent({ ref }); + + expect(screen.queryByText("Pinned message")).toBeNull(); + + isPinnedSpy.mockReturnValue(true); + act(() => ref.current?.forceUpdate()); + + expect(screen.getByText("Pinned message")).toBeInTheDocument(); + }); + + it("exposes the expected forwarded tile ref shape", () => { + const ref = React.createRef(); + + getComponent({ ref }); + + expect(ref.current).toMatchObject({ + ref: expect.any(Object), + forceUpdate: expect.any(Function), + isWidgetHidden: expect.any(Function), + unhideWidget: expect.any(Function), + getMediaHelper: expect.any(Function), + }); + expect(ref.current?.ref.current).toBeInstanceOf(HTMLElement); + expect(ref.current?.isWidgetHidden?.()).toBe(false); + expect(ref.current?.getMediaHelper?.()).toBeUndefined(); + }); + + it.each([ + [EventStatus.NOT_SENT, "Failed to send"], + [EventStatus.SENDING, "Sending your message…"], + [EventStatus.ENCRYPTING, "Encrypting your message…"], + ])("should display %s status icon", (eventSendStatus, text) => { + const ownEvent = mkMessage({ + room: room.roomId, + user: client.getSafeUserId(), + msg: "Hello world!", + event: true, + }); + const { getByRole } = getComponent({ mxEvent: ownEvent, eventSendStatus }); + + expect(getByRole("status")).toHaveAccessibleName(text); + }); +}); diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTileCommands-test.ts b/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTileCommands-test.ts new file mode 100644 index 00000000000..f7f250087b0 --- /dev/null +++ b/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTileCommands-test.ts @@ -0,0 +1,185 @@ +/* +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 { PendingEventOrdering, Room, type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import { Action } from "../../../../../../src/dispatcher/actions"; +import { ClickMode } from "../../../../../../src/models/rooms/EventTileModel"; +import { + buildContextMenuState, + copyLinkToThread, + onListTileClick, + onPermalinkClicked, + openEventInRoom, + type EventTileCommandContext, + type EventTileCommandDeps, +} from "../../../../../../src/components/views/rooms/EventTile/EventTileCommands"; +import { mkMessage, stubClient } from "../../../../../test-utils"; + +describe("EventTileCommands", () => { + const ROOM_ID = "!roomId:example.org"; + let mxEvent: MatrixEvent; + let deps: jest.Mocked; + + function makeContext(overrides: Partial = {}): EventTileCommandContext { + return { + mxEvent, + openedFromSearch: false, + tileClickMode: ClickMode.None, + ...overrides, + }; + } + + beforeEach(() => { + stubClient(); + const client = MatrixClientPeg.safeGet(); + const room = new Room(ROOM_ID, client, client.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + timelineSupport: true, + }); + + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Hello world!", + event: true, + }); + + deps = { + dispatch: jest.fn(), + copyPlaintext: jest.fn().mockResolvedValue(undefined), + trackInteraction: jest.fn(), + allowOverridingNativeContextMenus: jest.fn().mockReturnValue(true), + }; + }); + + it("opens an event in room", () => { + openEventInRoom(deps, makeContext()); + + expect(deps.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + event_id: mxEvent.getId(), + highlighted: true, + room_id: mxEvent.getRoomId(), + metricsTrigger: undefined, + }); + }); + + it("adds search metrics when opening from permalink in search", () => { + const preventDefault = jest.fn(); + + onPermalinkClicked(deps, makeContext({ openedFromSearch: true }), { preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(deps.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + event_id: mxEvent.getId(), + highlighted: true, + room_id: mxEvent.getRoomId(), + metricsTrigger: "MessageSearch", + }); + }); + + it("copies the thread permalink when available", async () => { + const permalinkCreator = { + forEvent: jest.fn().mockReturnValue("https://example.org/#/room/$event"), + } as any; + + await copyLinkToThread(deps, makeContext({ permalinkCreator })); + + expect(permalinkCreator.forEvent).toHaveBeenCalledWith(mxEvent.getId()); + expect(deps.copyPlaintext).toHaveBeenCalledWith("https://example.org/#/room/$event"); + }); + + it("does nothing when copying a thread link without a permalink creator", async () => { + await copyLinkToThread(deps, makeContext()); + + expect(deps.copyPlaintext).not.toHaveBeenCalled(); + }); + + it("builds context menu state for non-anchor targets", () => { + const target = document.createElement("div"); + const preventDefault = jest.fn(); + const stopPropagation = jest.fn(); + + const state = buildContextMenuState(deps, makeContext(), { + clientX: 10, + clientY: 20, + target, + preventDefault, + stopPropagation, + }); + + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + expect(state).toEqual({ + position: { + left: 10, + top: 20, + bottom: 20, + }, + link: undefined, + }); + }); + + it("suppresses the custom context menu for anchor targets when native menus are preserved", () => { + deps.allowOverridingNativeContextMenus.mockReturnValue(false); + const anchor = document.createElement("a"); + anchor.href = "https://example.org"; + + const state = buildContextMenuState(deps, makeContext(), { + clientX: 10, + clientY: 20, + target: anchor, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + + expect(state).toBeUndefined(); + }); + + it("suppresses the custom context menu while editing", () => { + const target = document.createElement("div"); + + const state = buildContextMenuState(deps, makeContext({ editState: {} as any }), { + clientX: 10, + clientY: 20, + target, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + + expect(state).toBeUndefined(); + }); + + it("opens the room on notification list click", () => { + onListTileClick(deps, makeContext({ tileClickMode: ClickMode.ViewRoom }), new Event("click"), 2); + + expect(deps.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + event_id: mxEvent.getId(), + highlighted: true, + room_id: mxEvent.getRoomId(), + metricsTrigger: undefined, + }); + expect(deps.trackInteraction).not.toHaveBeenCalled(); + }); + + it("opens the thread and tracks interaction on thread list click", () => { + const event = new Event("click"); + + onListTileClick(deps, makeContext({ tileClickMode: ClickMode.ShowThread }), event, 3); + + expect(deps.dispatch).toHaveBeenCalledWith({ + action: Action.ShowThread, + rootEvent: mxEvent, + push: true, + }); + expect(deps.trackInteraction).toHaveBeenCalledWith("WebThreadsPanelThreadItem", event, 3); + }); +}); diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTileView-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTileView-test.tsx new file mode 100644 index 00000000000..12f899dbf2e --- /dev/null +++ b/apps/web/test/unit-tests/components/views/rooms/EventTile/EventTileView-test.tsx @@ -0,0 +1,498 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { getByLabelText, render, screen } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { TimelineRenderingType } from "../../../../../../src/contexts/RoomContext"; +import { + EncryptionIndicatorMode, + PadlockMode, + TimestampDisplayMode, + TimestampFormatMode, +} from "../../../../../../src/models/rooms/EventTileModel"; +import { Layout } from "../../../../../../src/settings/enums/Layout"; +import { + EventTileView, + type EventTileViewProps, +} from "../../../../../../src/components/views/rooms/EventTile/EventTileView"; + +type EventTileViewOverrides = Omit< + Partial, + "content" | "threads" | "timestamp" | "encryption" | "notification" | "handlers" +> & { + content?: Partial; + threads?: Partial; + timestamp?: Partial; + encryption?: Partial; + notification?: Partial; + handlers?: Partial; +}; + +jest.mock("../../../../../../src/components/views/rooms/EventTile/EncryptionIndicator", () => ({ + EncryptionIndicator: ({ + icon, + title, + sharedUserId, + roomId, + }: { + icon: string; + title?: string; + sharedUserId?: string; + roomId?: string; + }) => ( +
+ {icon}:{title}:{sharedUserId}:{roomId} +
+ ), +})); + +jest.mock("../../../../../../src/components/views/rooms/EventTile/Timestamp", () => ({ + Timestamp: ({ ts, href }: { ts: number; href?: string }) => ( + {href ? `${href}:${ts}` : ts} + ), +})); + +jest.mock("../../../../../../src/components/views/rooms/EventTile/ThreadInfo", () => ({ + ThreadInfo: ({ summary, href, label }: { summary?: React.ReactNode; href?: string; label?: string }) => { + if (summary) { + return
{summary}
; + } + + if (href) { + return ( + + {label} + + ); + } + + return
{label}
; + }, +})); + +jest.mock("../../../../../../src/components/views/rooms/EventTile/ThreadPanelSummary", () => ({ + ThreadPanelSummary: ({ replyCount }: { replyCount: number }) => ( +
{replyCount}
+ ), +})); + +describe("EventTileView", () => { + function makeProps(overrides: EventTileViewOverrides = {}): EventTileViewProps { + const baseProps: EventTileViewProps = { + contentId: "event", + eventId: "$event:example.org", + timelineRenderingType: TimelineRenderingType.Room, + rootClassName: "mx_EventTile", + contentClassName: "mx_EventTile_line", + isOwnEvent: false, + content: { + sender: , + messageBody:
Message body
, + }, + threads: { + toolbar: undefined, + }, + timestamp: { + displayMode: TimestampDisplayMode.Linked, + formatMode: TimestampFormatMode.Absolute, + permalink: "#", + }, + encryption: { + padlockMode: PadlockMode.None, + mode: EncryptionIndicatorMode.None, + }, + notification: { + enabled: false, + }, + handlers: { + onMouseEnter: jest.fn(), + onMouseLeave: jest.fn(), + onFocus: jest.fn(), + onBlur: jest.fn(), + onContextMenu: jest.fn(), + }, + }; + + return { + ...baseProps, + ...overrides, + content: { + ...baseProps.content, + ...overrides.content, + }, + threads: { + ...baseProps.threads, + ...overrides.threads, + }, + timestamp: { + ...baseProps.timestamp, + ...overrides.timestamp, + }, + encryption: { + ...baseProps.encryption, + ...overrides.encryption, + }, + notification: { + ...baseProps.notification, + ...overrides.notification, + }, + handlers: { + ...baseProps.handlers, + ...overrides.handlers, + }, + }; + } + + it("renders the notification room label slot", () => { + const { container } = render( + + {" in "} + !roomId:example.org + + ), + }, + })} + />, + ); + + expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent( + "default:@alice:example.org in !roomId:example.org", + ); + }); + + it("renders the sender slot for the thread list", () => { + const { container } = render( + tooltip:@alice:example.org, + }, + })} + />, + ); + + expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent( + "tooltip:@alice:example.org", + ); + }); + + it("does not render a sender when the sender mode is hidden", () => { + render( + , + ); + + expect(screen.queryByTestId("sender")).toBeNull(); + }); + + it("renders thread info summary content", () => { + render( + Thread summary, + }, + })} + />, + ); + + expect(screen.getByTestId("thread-info-summary")).toHaveTextContent("Thread summary"); + }); + + it("renders thread info links", () => { + render( + + In thread + + ), + }, + })} + />, + ); + + expect(screen.getByTestId("thread-info-link")).toHaveAttribute("href", "#thread"); + expect(screen.getByTestId("thread-info-link")).toHaveTextContent("In thread"); + }); + + it("renders thread info text when no link is provided", () => { + render( + In thread, + }, + })} + />, + ); + + expect(screen.getByTestId("thread-info-text")).toHaveTextContent("In thread"); + }); + + it("renders the pinned message badge for thread tiles", () => { + render( + Pinned message, + }, + layout: Layout.Group, + })} + />, + ); + + expect(screen.getByText("Pinned message")).toBeInTheDocument(); + }); + + it("renders reactions in the footer when provided", () => { + render( + +
Reactions
+ + ), + }, + })} + />, + ); + + expect(screen.getByTestId("reactions-row")).toBeInTheDocument(); + }); + + it("does not render the pinned message badge for file tiles", () => { + render( + Pinned message, + }, + })} + />, + ); + + expect(screen.queryByText("Pinned message")).not.toBeInTheDocument(); + }); + + it.each([[Layout.Group], [Layout.Bubble], [Layout.IRC]])( + "renders the pinned message badge for default timeline layout %s", + (layout) => { + render( + Pinned message, + }, + })} + />, + ); + + expect(screen.getByText("Pinned message")).toBeInTheDocument(); + }, + ); + + it("renders a timestamp when showTimestamp is true in the thread list", () => { + render( + , + ); + + expect(screen.getByTestId("timestamp")).toHaveTextContent("123"); + }); + + it("renders a linked timestamp when enabled", () => { + render( + , + ); + + expect(screen.getByTestId("linked-timestamp")).toHaveTextContent("#event:123"); + }); + + it("renders an IRC dummy timestamp placeholder", () => { + const { container } = render( + , + ); + + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + }); + + it("renders the encryption indicator for group layouts", () => { + render( + , + ); + + expect(screen.getByTestId("encryption-indicator")).toHaveTextContent( + "warning:Warning:@bob:example.org:!room:example.org", + ); + }); + + it("renders the encryption indicator for IRC layouts", () => { + render( + , + ); + + expect(screen.getByTestId("encryption-indicator")).toHaveTextContent("normal:Info"); + }); + + it("renders the thread toolbar for thread list tiles", () => { + const { container } = render( + +
00:00
Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • +
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • diff --git a/apps/web/test/viewmodels/event-tiles/TextualEventViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/TextualEventViewModel-test.ts index c53dac955ae..de8347d2df7 100644 --- a/apps/web/test/viewmodels/event-tiles/TextualEventViewModel-test.ts +++ b/apps/web/test/viewmodels/event-tiles/TextualEventViewModel-test.ts @@ -32,4 +32,31 @@ describe("TextualEventViewModel", () => { expect(cb).toHaveBeenCalledTimes(1); }); + + it("should rebind sentinel listeners when props change", () => { + const firstEvent = new MatrixEvent({}); + const secondEvent = new MatrixEvent({}); + stubClient(); + + const vm = new TextualEventViewModel({ + showHiddenEvents: false, + mxEvent: firstEvent, + }); + + const cb = jest.fn(); + + vm.subscribe(cb); + vm.updateProps({ + showHiddenEvents: false, + mxEvent: secondEvent, + }); + + cb.mockClear(); + + firstEvent.emit(MatrixEventEvent.SentinelUpdated); + expect(cb).not.toHaveBeenCalled(); + + secondEvent.emit(MatrixEventEvent.SentinelUpdated); + expect(cb).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/web/test/viewmodels/room/timeline/event-tile/EventTileViewModel-test.ts b/apps/web/test/viewmodels/room/timeline/event-tile/EventTileViewModel-test.ts new file mode 100644 index 00000000000..26b2b6238b4 --- /dev/null +++ b/apps/web/test/viewmodels/room/timeline/event-tile/EventTileViewModel-test.ts @@ -0,0 +1,1240 @@ +/* +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 { mocked } from "jest-mock"; +import { + EventStatus, + type IEventDecryptionResult, + MatrixEvent, + PendingEventOrdering, + Room, + RoomEvent, + type Thread, + ThreadEvent, + TweakName, +} from "matrix-js-sdk/src/matrix"; +import { + type CryptoApi, + DecryptionFailureCode, + type EventEncryptionInfo, + EventShieldColour, + EventShieldReason, +} from "matrix-js-sdk/src/crypto-api"; +import { mkEncryptedMatrixEvent } from "matrix-js-sdk/src/testing"; + +import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; +import { Action } from "../../../../../src/dispatcher/actions"; +import { + AvatarSubject, + ClickMode, + EventTileRenderMode, + EncryptionIndicatorMode, + PadlockMode, + SenderMode, + ThreadPanelMode, + TimestampDisplayMode, + ThreadInfoMode, +} from "../../../../../src/models/rooms/EventTileModel"; +import { + EventTileViewModel, + type EventTileViewModelProps, +} from "../../../../../src/viewmodels/room/timeline/event-tile/EventTileViewModel"; +import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; +import { Layout } from "../../../../../src/settings/enums/Layout"; +import { _t } from "../../../../../src/languageHandler"; +import { filterConsole, flushPromises, mkEvent, mkMessage, stubClient } from "../../../../test-utils"; +import { mkThread } from "../../../../test-utils/threads"; + +jest.mock("../../../../../src/utils/EventRenderingUtils", () => ({ + ...jest.requireActual("../../../../../src/utils/EventRenderingUtils"), + getEventDisplayInfo: jest.fn(), +})); + +const mockGetEventDisplayInfo = jest.requireMock("../../../../../src/utils/EventRenderingUtils") + .getEventDisplayInfo as jest.Mock; + +function createTimestampedViewModel( + room: Room, + createViewModel: (overrides?: Partial) => EventTileViewModel, +): EventTileViewModel { + const timestampedEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "timestamped", + event: true, + ts: 123, + }); + + return createViewModel({ mxEvent: timestampedEvent }); +} + +describe("EventTileViewModel", () => { + const ROOM_ID = "!roomId:example.org"; + let mxEvent: MatrixEvent; + let room: Room; + let client: ReturnType; + let commandDeps: EventTileViewModelProps["commandDeps"]; + + const createdViewModels: EventTileViewModel[] = []; + + function makeProps(overrides: Partial = {}): EventTileViewModelProps { + return { + cli: client, + mxEvent, + timelineRenderingType: TimelineRenderingType.Room, + isRoomEncrypted: false, + showHiddenEvents: false, + commandDeps, + ...overrides, + }; + } + + function createViewModel(overrides: Partial = {}): EventTileViewModel { + const vm = new EventTileViewModel(makeProps(overrides)); + vm.refreshVerification(); + createdViewModels.push(vm); + return vm; + } + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + client = MatrixClientPeg.safeGet(); + + room = new Room(ROOM_ID, client, client.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + timelineSupport: true, + }); + + jest.spyOn(client, "getRoom").mockReturnValue(room); + jest.spyOn(client, "decryptEventIfNeeded").mockResolvedValue(); + commandDeps = { + dispatch: jest.fn(), + copyPlaintext: jest.fn().mockResolvedValue(true), + trackInteraction: jest.fn(), + allowOverridingNativeContextMenus: jest.fn().mockReturnValue(true), + }; + + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Hello world!", + event: true, + }); + + mockGetEventDisplayInfo.mockReturnValue({ + hasRenderer: true, + isBubbleMessage: false, + isInfoMessage: false, + isLeftAlignedBubbleMessage: false, + noBubbleEvent: false, + isSeeingThroughMessageHiddenForModeration: false, + }); + }); + + afterEach(() => { + for (const vm of createdViewModels.splice(0)) { + vm.dispose(); + } + }); + + describe("click and sender modes", () => { + it.each([ + [TimelineRenderingType.Notification, ClickMode.ViewRoom], + [TimelineRenderingType.ThreadsList, ClickMode.ShowThread], + [TimelineRenderingType.Room, ClickMode.None], + ])("sets tile click mode for %s", (timelineRenderingType, tileClickMode) => { + const vm = createViewModel({ timelineRenderingType }); + + expect(vm.getSnapshot().tileClickMode).toBe(tileClickMode); + }); + + it.each([ + [TimelineRenderingType.Room, SenderMode.ComposerInsert], + [TimelineRenderingType.Search, SenderMode.ComposerInsert], + [TimelineRenderingType.ThreadsList, SenderMode.Tooltip], + [TimelineRenderingType.Notification, SenderMode.Default], + ])("sets sender mode for %s", (timelineRenderingType, senderMode) => { + const vm = createViewModel({ timelineRenderingType }); + + expect(vm.getSnapshot().senderMode).toBe(senderMode); + }); + + it("hides the sender when hideSender is set", () => { + const vm = createViewModel({ hideSender: true }); + + expect(vm.getSnapshot().senderMode).toBe(SenderMode.Hidden); + }); + + it("marks tiles as opened from search in search view", () => { + const vm = createViewModel({ timelineRenderingType: TimelineRenderingType.Search }); + + expect(vm.getSnapshot().openedFromSearch).toBe(true); + }); + + it("does not show a reply preview for non-reply events", () => { + const vm = createViewModel(); + + expect(vm.getSnapshot().showReplyPreview).toBe(false); + }); + + it("shows a reply preview for reply events", () => { + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Reply", + event: true, + relatesTo: { + "m.in_reply_to": { + event_id: "$parent", + }, + }, + }); + + const vm = createViewModel({ mxEvent }); + + expect(vm.getSnapshot().showReplyPreview).toBe(true); + expect(vm.getSnapshot().shouldRenderReplyPreview).toBe(true); + }); + + it("does not render the reply preview when no renderer exists", () => { + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Reply", + event: true, + relatesTo: { + "m.in_reply_to": { + event_id: "$parent", + }, + }, + }); + + mockGetEventDisplayInfo.mockReturnValue({ + hasRenderer: false, + isBubbleMessage: false, + isInfoMessage: false, + isLeftAlignedBubbleMessage: false, + noBubbleEvent: false, + isSeeingThroughMessageHiddenForModeration: false, + }); + + const vm = createViewModel({ mxEvent }); + + expect(vm.getSnapshot().showReplyPreview).toBe(true); + expect(vm.getSnapshot().shouldRenderReplyPreview).toBe(false); + }); + + it("does not recompute display info for hover-only updates", () => { + const vm = createViewModel(); + const initialDisplayInfoCalls = mockGetEventDisplayInfo.mock.calls.length; + + vm.setHover(true); + vm.setHover(false); + + expect(mockGetEventDisplayInfo).toHaveBeenCalledTimes(initialDisplayInfoCalls); + }); + + it("uses the target avatar subject for third-party invite events", () => { + mxEvent = mkEvent({ + event: true, + type: "m.room.member", + user: "@alice:example.org", + room: room.roomId, + content: { + third_party_invite: { + display_name: "Bob", + }, + }, + }); + + const vm = createViewModel({ mxEvent }); + + expect(vm.getSnapshot().avatarSubject).toBe(AvatarSubject.Target); + }); + + it("keeps avatar clicks enabled for info-message avatars in room timelines", () => { + mxEvent = mkEvent({ + event: true, + type: "m.room.member", + user: "@alice:example.org", + room: room.roomId, + content: { + membership: "join", + }, + }); + + mockGetEventDisplayInfo.mockReturnValue({ + hasRenderer: true, + isBubbleMessage: false, + isInfoMessage: true, + isLeftAlignedBubbleMessage: false, + noBubbleEvent: false, + isSeeingThroughMessageHiddenForModeration: false, + }); + + const vm = createViewModel({ mxEvent }); + + expect(vm.getSnapshot().avatarMemberUserOnClick).toBe(true); + }); + + it("uses the missing renderer fallback mode for non-notification tiles without a renderer", () => { + mockGetEventDisplayInfo.mockReturnValue({ + hasRenderer: false, + isBubbleMessage: false, + isInfoMessage: false, + isLeftAlignedBubbleMessage: false, + noBubbleEvent: false, + isSeeingThroughMessageHiddenForModeration: false, + }); + + const vm = createViewModel(); + + expect(vm.getSnapshot().renderMode).toBe(EventTileRenderMode.MissingRendererFallback); + }); + + it("keeps notification tiles in rendered mode even without a renderer", () => { + mockGetEventDisplayInfo.mockReturnValue({ + hasRenderer: false, + isBubbleMessage: false, + isInfoMessage: false, + isLeftAlignedBubbleMessage: false, + noBubbleEvent: false, + isSeeingThroughMessageHiddenForModeration: false, + }); + + const vm = createViewModel({ timelineRenderingType: TimelineRenderingType.Notification }); + + expect(vm.getSnapshot().renderMode).toBe(EventTileRenderMode.Rendered); + }); + }); + + describe("thread and timestamp modes", () => { + it("shows thread summary mode for a thread root", () => { + const { rootEvent } = mkThread({ + room, + client, + authorId: "@alice:example.org", + participantUserIds: ["@bob:example.org"], + length: 2, + }); + + const vm = createViewModel({ mxEvent: rootEvent }); + + expect(vm.getSnapshot().threadInfoMode).toBe(ThreadInfoMode.Summary); + }); + + it("shows search link thread info mode for threaded search results with a highlight link", () => { + const searchResult = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "search result", + event: true, + }); + Object.defineProperty(searchResult, "threadRootId", { value: "$thread-root" }); + + const vm = createViewModel({ + mxEvent: searchResult, + timelineRenderingType: TimelineRenderingType.Search, + highlightLink: "#event", + }); + + expect(vm.getSnapshot().threadInfoMode).toBe(ThreadInfoMode.SearchLink); + expect(vm.getSnapshot().threadInfoHref).toBe("#event"); + expect(vm.getSnapshot().threadInfoLabel).toBe(_t("timeline|thread_info_basic")); + }); + + it("shows search text thread info mode for threaded search results without a highlight link", () => { + const searchResult = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "search result", + event: true, + }); + Object.defineProperty(searchResult, "threadRootId", { value: "$thread-root" }); + + const vm = createViewModel({ + mxEvent: searchResult, + timelineRenderingType: TimelineRenderingType.Search, + }); + + expect(vm.getSnapshot().threadInfoMode).toBe(ThreadInfoMode.SearchText); + expect(vm.getSnapshot().threadInfoHref).toBeUndefined(); + expect(vm.getSnapshot().threadInfoLabel).toBe(_t("timeline|thread_info_basic")); + }); + + it("shows timestamps when alwaysShowTimestamps is set", () => { + const timestampedEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "timestamped", + event: true, + ts: 123, + }); + const vm = createViewModel({ mxEvent: timestampedEvent, alwaysShowTimestamps: true }); + + expect(vm.getSnapshot().showTimestamp).toBe(true); + expect(vm.getSnapshot().timestampDisplayMode).toBe(TimestampDisplayMode.Linked); + }); + + it("suppresses timestamps when hideTimestamp is set", () => { + const vm = createViewModel({ alwaysShowTimestamps: true, hideTimestamp: true }); + + expect(vm.getSnapshot().showTimestamp).toBe(false); + expect(vm.getSnapshot().timestampDisplayMode).toBe(TimestampDisplayMode.Hidden); + }); + + it("uses the latest reply timestamp for thread list tiles", () => { + const rootEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "root", + event: true, + ts: 100, + }); + jest.spyOn(rootEvent, "getThread").mockReturnValue({ + id: "$thread", + length: 2, + replyToEvent: { + getTs: () => 101, + getId: () => "$reply", + }, + } as never); + + const vm = createViewModel({ + mxEvent: rootEvent, + timelineRenderingType: TimelineRenderingType.ThreadsList, + }); + + expect(vm.getSnapshot().timestampTs).toBe(101); + }); + + it("does not show timestamps by default for thread list tiles", () => { + const timestampedEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "timestamped", + event: true, + ts: 123, + }); + const vm = createViewModel({ + mxEvent: timestampedEvent, + timelineRenderingType: TimelineRenderingType.ThreadsList, + }); + + expect(vm.getSnapshot().showTimestamp).toBe(false); + expect(vm.getSnapshot().timestampDisplayMode).toBe(TimestampDisplayMode.Hidden); + }); + + it("uses plain timestamp mode for thread list tiles when timestamps are shown", () => { + const timestampedEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "timestamped", + event: true, + ts: 123, + }); + const vm = createViewModel({ + mxEvent: timestampedEvent, + timelineRenderingType: TimelineRenderingType.ThreadsList, + }); + + vm.setHover(true); + + expect(vm.getSnapshot().showTimestamp).toBe(true); + expect(vm.getSnapshot().timestampDisplayMode).toBe(TimestampDisplayMode.Plain); + }); + + it("uses plain timestamp mode for file tiles when timestamps are shown", () => { + const timestampedEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "timestamped", + event: true, + ts: 123, + }); + const vm = createViewModel({ + mxEvent: timestampedEvent, + timelineRenderingType: TimelineRenderingType.File, + alwaysShowTimestamps: true, + }); + + expect(vm.getSnapshot().showTimestamp).toBe(true); + expect(vm.getSnapshot().timestampDisplayMode).toBe(TimestampDisplayMode.Plain); + }); + }); + + describe("interaction state", () => { + it("shows and hides timestamps when hover changes", () => { + const vm = createTimestampedViewModel(room, createViewModel); + + expect(vm.getSnapshot().showTimestamp).toBe(false); + expect(vm.getSnapshot().shouldRenderActionBar).toBe(false); + + vm.setHover(true); + expect(vm.getSnapshot().hover).toBe(true); + expect(vm.getSnapshot().showTimestamp).toBe(true); + expect(vm.getSnapshot().shouldRenderActionBar).toBe(true); + + vm.setHover(false); + expect(vm.getSnapshot().hover).toBe(false); + expect(vm.getSnapshot().showTimestamp).toBe(false); + expect(vm.getSnapshot().shouldRenderActionBar).toBe(false); + }); + + it("shows timestamps when focus enters the tile", () => { + const vm = createTimestampedViewModel(room, createViewModel); + + vm.onFocusEnter(false); + + expect(vm.getSnapshot().focusWithin).toBe(true); + expect(vm.getSnapshot().showTimestamp).toBe(true); + }); + + it("shows the action bar when focus enters via keyboard", () => { + const vm = createTimestampedViewModel(room, createViewModel); + + vm.onFocusEnter(true); + + expect(vm.getSnapshot().showActionBarFromFocus).toBe(true); + expect(vm.getSnapshot().focusWithin).toBe(true); + expect(vm.getSnapshot().showTimestamp).toBe(true); + expect(vm.getSnapshot().shouldRenderActionBar).toBe(true); + }); + + it("shows timestamps when the action bar is focused", () => { + const vm = createTimestampedViewModel(room, createViewModel); + + vm.onActionBarFocusChange(true, false); + + expect(vm.getSnapshot().actionBarFocused).toBe(true); + expect(vm.getSnapshot().showTimestamp).toBe(true); + expect(vm.getSnapshot().shouldRenderActionBar).toBe(true); + }); + + it("keeps action bar focus in sync with the context menu state", () => { + const vm = createTimestampedViewModel(room, createViewModel); + + vm.onContextMenuOpen(); + + expect(vm.getSnapshot().isContextMenuOpen).toBe(true); + expect(vm.getSnapshot().actionBarFocused).toBe(true); + expect(vm.getSnapshot().showTimestamp).toBe(true); + expect(vm.getSnapshot().shouldRenderActionBar).toBe(false); + + vm.onContextMenuClose(); + + expect(vm.getSnapshot().isContextMenuOpen).toBe(false); + expect(vm.getSnapshot().actionBarFocused).toBe(false); + expect(vm.getSnapshot().showTimestamp).toBe(false); + expect(vm.getSnapshot().shouldRenderActionBar).toBe(false); + }); + + it("preserves keyboard-triggered action bar visibility when the context menu closes", () => { + const vm = createTimestampedViewModel(room, createViewModel); + + vm.onFocusEnter(true); + vm.onContextMenuOpen(); + vm.onContextMenuClose(); + + expect(vm.getSnapshot().showActionBarFromFocus).toBe(true); + expect(vm.getSnapshot().actionBarFocused).toBe(false); + expect(vm.getSnapshot().isContextMenuOpen).toBe(false); + expect(vm.getSnapshot().shouldRenderActionBar).toBe(true); + }); + + it("applies focus enter and leave as a single VM transition", () => { + const vm = createTimestampedViewModel(room, createViewModel); + + vm.onFocusEnter(true); + + expect(vm.getSnapshot().focusWithin).toBe(true); + expect(vm.getSnapshot().showActionBarFromFocus).toBe(true); + expect(vm.getSnapshot().shouldRenderActionBar).toBe(true); + + vm.onFocusLeave(); + + expect(vm.getSnapshot().focusWithin).toBe(false); + expect(vm.getSnapshot().showActionBarFromFocus).toBe(false); + expect(vm.getSnapshot().shouldRenderActionBar).toBe(false); + }); + + it("resets hover when action bar focus is lost through the VM helper", () => { + const vm = createTimestampedViewModel(room, createViewModel); + + vm.setHover(true); + vm.onActionBarFocusChange(true, true); + + expect(vm.getSnapshot().actionBarFocused).toBe(true); + expect(vm.getSnapshot().hover).toBe(true); + + vm.onActionBarFocusChange(false, false); + + expect(vm.getSnapshot().actionBarFocused).toBe(false); + expect(vm.getSnapshot().hover).toBe(false); + expect(vm.getSnapshot().shouldRenderActionBar).toBe(false); + }); + + it("opens and closes the context menu through VM helpers", () => { + const vm = createTimestampedViewModel(room, createViewModel); + + vm.onContextMenuOpen(); + + expect(vm.getSnapshot().isContextMenuOpen).toBe(true); + expect(vm.getSnapshot().actionBarFocused).toBe(true); + expect(vm.getSnapshot().hover).toBe(false); + + vm.onContextMenuClose(); + + expect(vm.getSnapshot().isContextMenuOpen).toBe(false); + expect(vm.getSnapshot().actionBarFocused).toBe(false); + expect(vm.getSnapshot().hover).toBe(false); + }); + + it("stores and clears context menu state through VM command helpers", () => { + const vm = createTimestampedViewModel(room, createViewModel); + const target = document.createElement("div"); + + vm.openContextMenu({ + clientX: 10, + clientY: 20, + target, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + + expect(vm.getSnapshot().contextMenuState).toEqual({ + position: { left: 10, top: 20, bottom: 20 }, + link: undefined, + }); + expect(vm.getSnapshot().isContextMenuOpen).toBe(true); + + vm.closeContextMenu(); + + expect(vm.getSnapshot().contextMenuState).toBeUndefined(); + expect(vm.getSnapshot().isContextMenuOpen).toBe(false); + }); + + it("tracks quote expansion state", () => { + const vm = createViewModel(); + + vm.setQuoteExpanded(true); + expect(vm.getSnapshot().isQuoteExpanded).toBe(true); + + vm.setQuoteExpanded(false); + expect(vm.getSnapshot().isQuoteExpanded).toBe(false); + }); + + it("toggles quote expansion through the dedicated VM helper", () => { + const vm = createViewModel(); + + vm.toggleQuoteExpanded(); + expect(vm.getSnapshot().isQuoteExpanded).toBe(true); + + vm.toggleQuoteExpanded(); + expect(vm.getSnapshot().isQuoteExpanded).toBe(false); + }); + }); + + describe("command methods", () => { + it("dispatches room navigation from permalink clicks", () => { + const vm = createViewModel({ timelineRenderingType: TimelineRenderingType.Search }); + const preventDefault = jest.fn(); + + vm.onPermalinkClicked({ preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(commandDeps.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + event_id: mxEvent.getId(), + highlighted: true, + room_id: mxEvent.getRoomId(), + metricsTrigger: "MessageSearch", + }); + }); + + it("copies the thread permalink through the VM", async () => { + const permalinkCreator = { + forEvent: jest.fn().mockReturnValue("https://example.org/#/room/$event"), + } as any; + const vm = createViewModel({ permalinkCreator }); + + await vm.copyLinkToThread(); + + expect(permalinkCreator.forEvent).toHaveBeenCalledWith(mxEvent.getId()); + expect(commandDeps.copyPlaintext).toHaveBeenCalledWith("https://example.org/#/room/$event"); + }); + + it("dispatches thread opening and tracking for thread list clicks", () => { + const vm = createViewModel({ timelineRenderingType: TimelineRenderingType.ThreadsList }); + const event = new Event("click"); + + vm.onListTileClick(event, 3); + + expect(commandDeps.dispatch).toHaveBeenCalledWith({ + action: Action.ShowThread, + rootEvent: mxEvent, + push: true, + }); + expect(commandDeps.trackInteraction).toHaveBeenCalledWith("WebThreadsPanelThreadItem", event, 3); + }); + }); + + describe("presentational snapshot fields", () => { + it("derives root and content class names in the snapshot", () => { + const vm = createViewModel(); + + expect(vm.getSnapshot().eventId).toBe(mxEvent.getId()); + expect(vm.getSnapshot().ariaLive).toBe("off"); + expect(vm.getSnapshot().rootClassName).toContain("mx_EventTile"); + expect(vm.getSnapshot().contentClassName).toBe("mx_EventTile_line"); + expect(vm.getSnapshot().timestampView.permalink).toBe(vm.getSnapshot().permalink); + expect(vm.getSnapshot().timestampView.ts).toBe(vm.getSnapshot().timestampTs); + expect(vm.getSnapshot().encryptionView.mode).toBe(vm.getSnapshot().encryptionIndicatorMode); + }); + + it("marks missing-renderer fallback directly in the snapshot", () => { + mockGetEventDisplayInfo.mockReturnValue({ + hasRenderer: false, + isBubbleMessage: false, + isInfoMessage: false, + isLeftAlignedBubbleMessage: false, + noBubbleEvent: false, + isSeeingThroughMessageHiddenForModeration: false, + }); + + const vm = createViewModel(); + + expect(vm.getSnapshot().shouldRenderMissingRendererFallback).toBe(true); + }); + + it("derives the encryption indicator title for unencrypted events in encrypted rooms", () => { + const vm = createViewModel({ isRoomEncrypted: true }); + + expect(vm.getSnapshot().encryptionIndicatorTitle).toBe(_t("common|unencrypted")); + }); + + it("derives notification list flags and room name", () => { + const vm = createViewModel({ timelineRenderingType: TimelineRenderingType.Notification }); + + expect(vm.getSnapshot().isNotification).toBe(true); + expect(vm.getSnapshot().isListLikeTile).toBe(true); + expect(vm.getSnapshot().notificationRoomName).toBe(room.name); + expect(vm.getSnapshot().notificationView).toEqual({ + enabled: true, + roomName: room.name, + }); + }); + }); + + describe("padlock and receipts", () => { + it("shows the group padlock for non-IRC layouts", () => { + const vm = createViewModel({ layout: Layout.Group }); + + expect(vm.getSnapshot().padlockMode).toBe(PadlockMode.Group); + }); + + it("shows the IRC padlock for IRC layout", () => { + const vm = createViewModel({ layout: Layout.IRC }); + + expect(vm.getSnapshot().padlockMode).toBe(PadlockMode.Irc); + }); + + it("shows the thread toolbar in the thread list", () => { + const vm = createViewModel({ timelineRenderingType: TimelineRenderingType.ThreadsList }); + + expect(vm.getSnapshot().threadPanelMode).toBe(ThreadPanelMode.Toolbar); + expect(vm.getSnapshot().shouldRenderThreadToolbar).toBe(true); + expect(vm.getSnapshot().shouldRenderThreadPreview).toBe(false); + expect(vm.getSnapshot().threadReplyCount).toBeUndefined(); + }); + + it("shows read receipts when enabled and no sending state takes priority", () => { + const vm = createViewModel({ + showReadReceipts: true, + readReceipts: [{ userId: "@bob:example.org", ts: 1, roomMember: null }], + }); + + expect(vm.getSnapshot().showReadReceipts).toBe(true); + }); + + it("does not recompute display info for receipt-only updates", () => { + mxEvent = mkMessage({ + room: room.roomId, + user: client.getSafeUserId(), + msg: "Hello world!", + event: true, + }); + + createViewModel({ + mxEvent, + lastSuccessful: true, + }); + const initialDisplayInfoCalls = mockGetEventDisplayInfo.mock.calls.length; + + client.emit(RoomEvent.Receipt, mxEvent, room); + + expect(mockGetEventDisplayInfo).toHaveBeenCalledTimes(initialDisplayInfoCalls); + }); + + it("shows the thread panel summary for notifications with a thread", () => { + const { rootEvent } = mkThread({ + room, + client, + authorId: "@alice:example.org", + participantUserIds: ["@bob:example.org"], + length: 2, + }); + + const vm = createViewModel({ + mxEvent: rootEvent, + timelineRenderingType: TimelineRenderingType.Notification, + }); + + expect(vm.getSnapshot().threadPanelMode).toBe(ThreadPanelMode.Summary); + expect(vm.getSnapshot().shouldRenderThreadPreview).toBe(true); + expect(vm.getSnapshot().shouldRenderThreadToolbar).toBe(false); + expect(vm.getSnapshot().threadReplyCount).toBe(vm.getSnapshot().thread?.length); + }); + + it("shows the thread summary and toolbar in the thread list when a thread is present", () => { + const { rootEvent } = mkThread({ + room, + client, + authorId: "@alice:example.org", + participantUserIds: ["@bob:example.org"], + length: 2, + }); + + const vm = createViewModel({ + mxEvent: rootEvent, + timelineRenderingType: TimelineRenderingType.ThreadsList, + }); + + expect(vm.getSnapshot().threadPanelMode).toBe(ThreadPanelMode.SummaryWithToolbar); + expect(vm.getSnapshot().shouldRenderThreadPreview).toBe(true); + expect(vm.getSnapshot().shouldRenderThreadToolbar).toBe(true); + expect(vm.getSnapshot().threadReplyCount).toBe(vm.getSnapshot().thread?.length); + }); + + it("does not show a footer for redacted events with reactions", () => { + const vm = createViewModel({ + isRedacted: true, + showReactions: true, + getRelationsForEvent: jest.fn().mockReturnValue({} as never), + }); + + expect(vm.getSnapshot().hasFooter).toBe(false); + }); + + it("shares a single room thread listener across many tile view models", () => { + const roomOnSpy = jest.spyOn(room, "on"); + const roomOffSpy = jest.spyOn(room, "off"); + const viewModels = Array.from({ length: 101 }, (_, index) => + createViewModel({ + mxEvent: mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: `Message ${index}`, + event: true, + }), + }), + ); + + expect(roomOnSpy).toHaveBeenCalledTimes(1); + expect(roomOnSpy).toHaveBeenCalledWith(ThreadEvent.New, expect.any(Function)); + + viewModels.slice(0, -1).forEach((vm) => vm.dispose()); + expect(roomOffSpy).not.toHaveBeenCalledWith(ThreadEvent.New, expect.any(Function)); + + viewModels.at(-1)?.dispose(); + expect(roomOffSpy).toHaveBeenCalledTimes(1); + expect(roomOffSpy).toHaveBeenCalledWith(ThreadEvent.New, expect.any(Function)); + }); + + it("only updates the tile waiting on the matching new thread root", () => { + const matchingEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Matching root", + event: true, + }); + const otherEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Other root", + event: true, + }); + const matchingVm = createViewModel({ mxEvent: matchingEvent }); + const otherVm = createViewModel({ mxEvent: otherEvent }); + const thread = { + id: matchingEvent.getId(), + length: 3, + replyToEvent: null, + } as unknown as Thread; + + room.emit(ThreadEvent.New, thread, false); + + expect(matchingVm.getSnapshot().thread).toBe(thread); + expect(otherVm.getSnapshot().thread).toBeNull(); + }); + + it("shares a single trust listener across many tile view models", () => { + const cliOnSpy = jest.spyOn(client, "on"); + const cliOffSpy = jest.spyOn(client, "off"); + const viewModels = Array.from({ length: 11 }, (_, index) => + createViewModel({ + mxEvent: mkMessage({ + room: room.roomId, + user: `@user${index}:example.org`, + msg: `Message ${index}`, + event: true, + }), + }), + ); + + expect(cliOnSpy).toHaveBeenCalledTimes(1); + expect(cliOnSpy).toHaveBeenCalledWith("userTrustStatusChanged", expect.any(Function)); + + viewModels.slice(0, -1).forEach((vm) => vm.dispose()); + expect(cliOffSpy).not.toHaveBeenCalledWith("userTrustStatusChanged", expect.any(Function)); + + viewModels.at(-1)?.dispose(); + expect(cliOffSpy).toHaveBeenCalledTimes(1); + expect(cliOffSpy).toHaveBeenCalledWith("userTrustStatusChanged", expect.any(Function)); + }); + }); + + describe("event verification", () => { + const eventToEncryptionInfoMap = new Map(); + + beforeEach(() => { + eventToEncryptionInfoMap.clear(); + + const mockCrypto = { + getEncryptionInfoForEvent: async (event: MatrixEvent) => eventToEncryptionInfoMap.get(event.getId()!)!, + } as unknown as CryptoApi; + client.getCrypto = () => mockCrypto; + }); + + it("shows a warning for an event from an unverified device", async () => { + mxEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.RED, + shieldReason: EventShieldReason.UNSIGNED_DEVICE, + } as EventEncryptionInfo); + + const vm = createViewModel(); + await flushPromises(); + + expect(vm.getSnapshot()).toMatchObject({ + encryptionIndicatorMode: EncryptionIndicatorMode.Warning, + shieldReason: EventShieldReason.UNSIGNED_DEVICE, + }); + }); + + it("shows no shield for a verified event", async () => { + mxEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.NONE, + shieldReason: null, + } as EventEncryptionInfo); + + const vm = createViewModel(); + await flushPromises(); + + expect(vm.getSnapshot()).toMatchObject({ + encryptionIndicatorMode: EncryptionIndicatorMode.None, + }); + }); + + it.each([ + [EventShieldReason.UNKNOWN, "Unknown error"], + [EventShieldReason.UNVERIFIED_IDENTITY, "Encrypted by an unverified user."], + [EventShieldReason.UNSIGNED_DEVICE, "Encrypted by a device not verified by its owner."], + [EventShieldReason.UNKNOWN_DEVICE, "Encrypted by an unknown or deleted device."], + [ + EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, + "The authenticity of this encrypted message can't be guaranteed on this device.", + ], + [EventShieldReason.MISMATCHED_SENDER_KEY, "Encrypted by an unverified session"], + [EventShieldReason.SENT_IN_CLEAR, "Not encrypted"], + [EventShieldReason.VERIFICATION_VIOLATION, "Sender's verified digital identity was reset"], + [ + EventShieldReason.MISMATCHED_SENDER, + "The sender of the event does not match the owner of the device that sent it.", + ], + ])( + "shows the correct reason code for %i (%s)", + async (reasonCode: EventShieldReason, _expectedText: string) => { + mxEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.GREY, + shieldReason: reasonCode, + } as EventEncryptionInfo); + + const vm = createViewModel(); + await flushPromises(); + + expect(vm.getSnapshot()).toMatchObject({ + encryptionIndicatorMode: EncryptionIndicatorMode.Normal, + shieldReason: reasonCode, + }); + }, + ); + + it("exposes shared key metadata for forwarded messages", async () => { + mxEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + // @ts-expect-error private test setup + mxEvent.keyForwardedBy = "@bob:example.org"; + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.GREY, + shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, + } as EventEncryptionInfo); + + const vm = createViewModel(); + await flushPromises(); + + expect(vm.getSnapshot()).toMatchObject({ + encryptionIndicatorMode: EncryptionIndicatorMode.None, + sharedKeysUserId: "@bob:example.org", + sharedKeysRoomId: room.roomId, + }); + }); + + describe("undecryptable event", () => { + filterConsole("Error decrypting event"); + + it("shows an undecryptable warning", async () => { + mxEvent = mkEvent({ + type: "m.room.encrypted", + room: room.roomId, + user: "@alice:example.org", + event: true, + content: {}, + }); + + const mockCrypto = { + decryptEvent: async (): Promise => { + throw new Error("can't decrypt"); + }, + } as unknown as Parameters[0]; + await mxEvent.attemptDecryption(mockCrypto); + + const vm = createViewModel(); + await flushPromises(); + + expect(vm.getSnapshot()).toMatchObject({ + encryptionIndicatorMode: EncryptionIndicatorMode.DecryptionFailure, + }); + }); + + it("should not show a shield for previously-verified users", async () => { + mxEvent = mkEvent({ + type: "m.room.encrypted", + room: room.roomId, + user: "@alice:example.org", + event: true, + content: {}, + }); + + const mockCrypto = { + decryptEvent: async (): Promise => { + throw new Error("can't decrypt"); + }, + } as unknown as Parameters[0]; + await mxEvent.attemptDecryption(mockCrypto); + // @ts-expect-error internal test setup + mxEvent._decryptionFailureReason = DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED; + + const vm = createViewModel(); + await flushPromises(); + + expect(vm.getSnapshot().encryptionIndicatorMode).toBe(EncryptionIndicatorMode.None); + }); + }); + + it("flags unencrypted replacement events when the room is encrypted", async () => { + mxEvent = await mkEncryptedMatrixEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + sender: "@alice:example.org", + roomId: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + shieldColour: EventShieldColour.NONE, + shieldReason: null, + } as EventEncryptionInfo); + + const replacementEvent = mkMessage({ + msg: "msg2", + user: "@alice:example.org", + room: room.roomId, + event: true, + }); + mxEvent.makeReplaced(replacementEvent); + + const vm = createViewModel({ isRoomEncrypted: true }); + await flushPromises(); + + expect(vm.getSnapshot()).toMatchObject({ + encryptionIndicatorMode: EncryptionIndicatorMode.Warning, + }); + }); + }); + + describe("event highlighting", () => { + beforeEach(() => { + mocked(client.getPushActionsForEvent).mockReturnValue(null); + }); + + it("does not highlight message where message matches no push actions", () => { + const vm = createViewModel(); + + expect(client.getPushActionsForEvent).toHaveBeenCalledWith(mxEvent); + expect(vm.getSnapshot().isHighlighted).toBeFalsy(); + }); + + it("does not highlight when message's push actions does not have a highlight tweak", () => { + mocked(client.getPushActionsForEvent).mockReturnValue({ notify: true, tweaks: {} }); + + const vm = createViewModel(); + + expect(vm.getSnapshot().isHighlighted).toBeFalsy(); + }); + + it("does not highlight when message's push actions have a highlight tweak but message has been redacted", () => { + mocked(client.getPushActionsForEvent).mockReturnValue({ + notify: true, + tweaks: { [TweakName.Highlight]: true }, + }); + + const vm = createViewModel({ isRedacted: true }); + + expect(vm.getSnapshot().isHighlighted).toBeFalsy(); + }); + + it("highlights when message's push actions have a highlight tweak", () => { + mocked(client.getPushActionsForEvent).mockReturnValue({ + notify: true, + tweaks: { [TweakName.Highlight]: true }, + }); + + const vm = createViewModel(); + + expect(vm.getSnapshot().isHighlighted).toBeTruthy(); + }); + + describe("when a message has been edited", () => { + let editingEvent: MatrixEvent; + + beforeEach(() => { + editingEvent = new MatrixEvent({ + type: "m.room.message", + room_id: ROOM_ID, + sender: "@alice:example.org", + content: { + "msgtype": "m.text", + "body": "* edited body", + "m.new_content": { + msgtype: "m.text", + body: "edited body", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: mxEvent.getId(), + }, + }, + }); + mxEvent.makeReplaced(editingEvent); + }); + + it("does not highlight message where no version of message matches any push actions", () => { + const vm = createViewModel(); + + expect(client.getPushActionsForEvent).toHaveBeenCalledWith(mxEvent); + expect(client.getPushActionsForEvent).toHaveBeenCalledWith(editingEvent); + expect(vm.getSnapshot().isHighlighted).toBeFalsy(); + }); + + it("does not highlight when no version of message's push actions have a highlight tweak", () => { + mocked(client.getPushActionsForEvent).mockReturnValue({ notify: true, tweaks: {} }); + + const vm = createViewModel(); + + expect(vm.getSnapshot().isHighlighted).toBeFalsy(); + }); + + it("highlights when previous version of message's push actions have a highlight tweak", () => { + mocked(client.getPushActionsForEvent).mockImplementation((event: MatrixEvent) => { + if (event === mxEvent) { + return { notify: true, tweaks: { [TweakName.Highlight]: true } }; + } + return { notify: false, tweaks: {} }; + }); + + const vm = createViewModel(); + + expect(vm.getSnapshot().isHighlighted).toBeTruthy(); + }); + + it("highlights when new version of message's push actions have a highlight tweak", () => { + mocked(client.getPushActionsForEvent).mockImplementation((event: MatrixEvent) => { + if (event === editingEvent) { + return { notify: true, tweaks: { [TweakName.Highlight]: true } }; + } + return { notify: false, tweaks: {} }; + }); + + const vm = createViewModel(); + + expect(vm.getSnapshot().isHighlighted).toBeTruthy(); + }); + }); + }); + + it.each([ + [EventStatus.NOT_SENT, false, true], + [EventStatus.SENDING, false, true], + [EventStatus.ENCRYPTING, false, true], + ])("derives sending receipt flags for %s", (eventSendStatus, shouldShowSentReceipt, shouldShowSendingReceipt) => { + const ownEvent = mkMessage({ + room: room.roomId, + user: client.getSafeUserId(), + msg: "Hello world!", + event: true, + }); + + const vm = createViewModel({ mxEvent: ownEvent, eventSendStatus }); + + expect(vm.getSnapshot()).toMatchObject({ + shouldShowSentReceipt, + shouldShowSendingReceipt, + }); + }); +}); diff --git a/apps/web/test/viewmodels/room/EventTileActionBarViewModel-test.ts b/apps/web/test/viewmodels/room/timeline/event-tile/actions/EventTileActionBarViewModel-test.ts similarity index 84% rename from apps/web/test/viewmodels/room/EventTileActionBarViewModel-test.ts rename to apps/web/test/viewmodels/room/timeline/event-tile/actions/EventTileActionBarViewModel-test.ts index 1c7125beb5f..56b536683e3 100644 --- a/apps/web/test/viewmodels/room/EventTileActionBarViewModel-test.ts +++ b/apps/web/test/viewmodels/room/timeline/event-tile/actions/EventTileActionBarViewModel-test.ts @@ -24,25 +24,25 @@ import { ActionBarAction } from "@element-hq/web-shared-components"; import { EventTileActionBarViewModel, type EventTileActionBarViewModelProps, -} from "../../../src/viewmodels/room/EventTileActionBarViewModel"; -import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; -import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; -import defaultDispatcher from "../../../src/dispatcher/dispatcher"; -import { Action } from "../../../src/dispatcher/actions"; -import Resend from "../../../src/Resend"; -import PinningUtils from "../../../src/utils/PinningUtils"; -import PosthogTrackers from "../../../src/PosthogTrackers"; -import Modal from "../../../src/Modal"; -import ErrorDialog from "../../../src/components/views/dialogs/ErrorDialog"; -import SettingsStore from "../../../src/settings/SettingsStore"; -import { ModuleApi } from "../../../src/modules/Api"; -import { canCancel, canEditContent, editEvent, isContentActionable } from "../../../src/utils/EventUtils"; -import { shouldDisplayReply } from "../../../src/utils/Reply"; -import { MediaEventHelper } from "../../../src/utils/MediaEventHelper"; -import { getMediaVisibility, setMediaVisibility } from "../../../src/utils/media/mediaVisibility"; -import { createTestClient } from "../../test-utils"; - -jest.mock("../../../src/dispatcher/dispatcher", () => ({ +} from "../../../../../../src/viewmodels/room/timeline/event-tile/actions/EventTileActionBarViewModel"; +import { TimelineRenderingType } from "../../../../../../src/contexts/RoomContext"; +import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../../../src/dispatcher/actions"; +import Resend from "../../../../../../src/Resend"; +import PinningUtils from "../../../../../../src/utils/PinningUtils"; +import PosthogTrackers from "../../../../../../src/PosthogTrackers"; +import Modal from "../../../../../../src/Modal"; +import ErrorDialog from "../../../../../../src/components/views/dialogs/ErrorDialog"; +import SettingsStore from "../../../../../../src/settings/SettingsStore"; +import { ModuleApi } from "../../../../../../src/modules/Api"; +import { canCancel, canEditContent, editEvent, isContentActionable } from "../../../../../../src/utils/EventUtils"; +import { shouldDisplayReply } from "../../../../../../src/utils/Reply"; +import { MediaEventHelper } from "../../../../../../src/utils/MediaEventHelper"; +import { getMediaVisibility, setMediaVisibility } from "../../../../../../src/utils/media/mediaVisibility"; +import { createTestClient } from "../../../../../test-utils"; + +jest.mock("../../../../../../src/dispatcher/dispatcher", () => ({ __esModule: true, default: { dispatch: jest.fn(), @@ -51,7 +51,7 @@ jest.mock("../../../src/dispatcher/dispatcher", () => ({ }, })); -jest.mock("../../../src/Resend", () => ({ +jest.mock("../../../../../../src/Resend", () => ({ __esModule: true, default: { resend: jest.fn(), @@ -59,21 +59,21 @@ jest.mock("../../../src/Resend", () => ({ }, })); -jest.mock("../../../src/PosthogTrackers", () => ({ +jest.mock("../../../../../../src/PosthogTrackers", () => ({ __esModule: true, default: { trackPinUnpinMessage: jest.fn(), }, })); -jest.mock("../../../src/Modal", () => ({ +jest.mock("../../../../../../src/Modal", () => ({ __esModule: true, default: { createDialog: jest.fn(), }, })); -jest.mock("../../../src/languageHandler", () => ({ +jest.mock("../../../../../../src/languageHandler", () => ({ _t: (key: string) => { switch (key) { case "timeline|download_failed": @@ -89,14 +89,14 @@ jest.mock("../../../src/languageHandler", () => ({ _td: (key: string) => key, })); -jest.mock("../../../src/utils/EventUtils", () => ({ +jest.mock("../../../../../../src/utils/EventUtils", () => ({ canCancel: jest.fn(), canEditContent: jest.fn(), editEvent: jest.fn(), isContentActionable: jest.fn(), })); -jest.mock("../../../src/utils/PinningUtils", () => ({ +jest.mock("../../../../../../src/utils/PinningUtils", () => ({ __esModule: true, default: { canPin: jest.fn(), @@ -106,17 +106,17 @@ jest.mock("../../../src/utils/PinningUtils", () => ({ }, })); -jest.mock("../../../src/utils/Reply", () => ({ +jest.mock("../../../../../../src/utils/Reply", () => ({ shouldDisplayReply: jest.fn(), })); -jest.mock("../../../src/utils/media/mediaVisibility", () => ({ +jest.mock("../../../../../../src/utils/media/mediaVisibility", () => ({ getMediaVisibility: jest.fn(), setMediaVisibility: jest.fn(), })); const mockDownload = jest.fn(); -jest.mock("../../../src/utils/FileDownloader", () => ({ +jest.mock("../../../../../../src/utils/FileDownloader", () => ({ FileDownloader: jest.fn().mockImplementation(() => ({ download: mockDownload, })), @@ -285,6 +285,32 @@ describe("EventTileActionBarViewModel", () => { expect(vm.getSnapshot().actions).not.toContain(ActionBarAction.Hide); }); + it("ignores unrelated room state events", () => { + const vm = createVm(); + + mocked(PinningUtils.isPinned).mockClear(); + mocked(PinningUtils.canPin).mockClear(); + mocked(PinningUtils.canUnpin).mockClear(); + mocked(canEditContent).mockClear(); + + roomState.emit( + RoomStateEvent.Events, + new MatrixEvent({ + type: EventType.RoomName, + room_id: roomId, + sender: userId, + content: { name: "Renamed room" }, + }), + ); + + expect(PinningUtils.isPinned).not.toHaveBeenCalled(); + expect(PinningUtils.canPin).not.toHaveBeenCalled(); + expect(PinningUtils.canUnpin).not.toHaveBeenCalled(); + expect(canEditContent).not.toHaveBeenCalled(); + + vm.dispose(); + }); + it("ignores stale download permission results after setProps changes the event", async () => { jest.spyOn(MediaEventHelper, "isEligible").mockReturnValue(true); const permissionA = createPendingPromise(); @@ -345,13 +371,60 @@ describe("EventTileActionBarViewModel", () => { vm.dispose(); expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Status, expect.any(Function)); - expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function)); expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.BeforeRedaction, expect.any(Function)); expect(roomStateOffSpy).toHaveBeenCalledWith(RoomStateEvent.Events, expect.any(Function)); expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("mediaPreviewConfig:!room:example.org"); expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("showMediaEventIds:global"); }); + it("shares a single room state listener across multiple action bars", () => { + const roomStateOnSpy = jest.spyOn(roomState, "on"); + const roomStateOffSpy = jest.spyOn(roomState, "off"); + const vms = Array.from({ length: 11 }, (_, index) => + createVm({ + mxEvent: createMessageEvent({ event_id: `$event-${index}` }), + }), + ); + + expect(roomStateOnSpy).toHaveBeenCalledTimes(1); + expect(roomStateOnSpy).toHaveBeenCalledWith(RoomStateEvent.Events, expect.any(Function)); + + vms.slice(0, -1).forEach((vm) => vm.dispose()); + expect(roomStateOffSpy).not.toHaveBeenCalledWith(RoomStateEvent.Events, expect.any(Function)); + + vms.at(-1)?.dispose(); + expect(roomStateOffSpy).toHaveBeenCalledTimes(1); + expect(roomStateOffSpy).toHaveBeenCalledWith(RoomStateEvent.Events, expect.any(Function)); + }); + + it("only subscribes to decrypted while the event still needs decryption", () => { + const mxEvent = createMessageEvent(); + jest.spyOn(mxEvent, "isBeingDecrypted").mockReturnValue(false); + jest.spyOn(mxEvent, "shouldAttemptDecryption").mockReturnValue(false); + const onSpy = jest.spyOn(mxEvent, "on"); + const onceSpy = jest.spyOn(mxEvent, "once"); + + createVm({ mxEvent }); + + expect(onSpy).not.toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function)); + expect(onceSpy).not.toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function)); + }); + + it("subscribes once to decrypted when the event is still decrypting", () => { + const mxEvent = createMessageEvent(); + jest.spyOn(mxEvent, "isBeingDecrypted").mockReturnValue(true); + jest.spyOn(mxEvent, "shouldAttemptDecryption").mockReturnValue(false); + const onceSpy = jest.spyOn(mxEvent, "once"); + const offSpy = jest.spyOn(mxEvent, "off"); + const vm = createVm({ mxEvent }); + + expect(onceSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function)); + + vm.dispose(); + + expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function)); + }); + it("routes resend and cancel actions to the actionable failed event variant", () => { const mxEvent = createMessageEvent(); const localRedactionEvent = createMessageEvent({ event_id: "$redaction" });