/*
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<HTMLDivElement> {
    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<UnwrappedEventTile>;
}

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<DOMRect, "top" | "left" | "bottom">;
        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<EventTileProps, IState> {
    private suppressReadReceiptAnimation: boolean;
    private isListeningForReceipts: boolean;
    private tile = createRef<IEventTileType>();
    private replyChain = createRef<ReplyChain>();

    public readonly ref = createRef<HTMLElement>();

    public static defaultProps = {
        forExport: false,
        layout: Layout.Group,
    };

    public static contextType = RoomContext;
    declare public context: React.ContextType<typeof RoomContext>;

    private unmounted = false;
    private readonly id = uniqueId();

    public constructor(props: EventTileProps, context: React.ContextType<typeof RoomContext>) {
        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<EventTileProps>, prevState: Readonly<IState>): 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 (
            <div className="mx_ThreadPanel_replies">
                <ThreadsIcon />
                <span className="mx_ThreadPanel_replies_amount">{this.state.thread.length}</span>
                <ThreadMessagePreview thread={this.state.thread} />
            </div>
        );
    }

    private renderThreadInfo(): React.ReactNode {
        if (this.state.thread && this.state.thread.id === this.props.mxEvent.getId()) {
            return (
                <ThreadSummary mxEvent={this.props.mxEvent} thread={this.state.thread} data-testid="thread-summary" />
            );
        }

        if (this.context.timelineRenderingType === TimelineRenderingType.Search && this.props.mxEvent.threadRootId) {
            if (this.props.highlightLink) {
                return (
                    <a className="mx_ThreadSummary_icon" href={this.props.highlightLink}>
                        <ThreadsIcon />
                        {_t("timeline|thread_info_basic")}
                    </a>
                );
            }

            return (
                <p className="mx_ThreadSummary_icon">
                    <ThreadsIcon />
                    {_t("timeline|thread_info_basic")}
                </p>
            );
        }
    }

    private readonly onViewInRoomClick = (_anchor: HTMLElement | null): void => {
        dis.dispatch<ViewRoomPayload>({
            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<void> => {
        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<void> {
        // 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<keyof EventTileProps>;
        const keysB = Object.keys(objB) as Array<keyof EventTileProps>;

        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<ComposerInsertPayload>({
            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<ViewRoomPayload>({
            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 <E2ePadlockDecryptionFailure />;
            }
        }

        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 <E2eMessageSharedIcon keyForwardingUserId={forwarder} roomId={ev.getRoomId()!} />;
            }
        }

        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 <E2ePadlock icon={E2ePadlockIcon.Normal} title={shieldReasonMessage} />;
            } else {
                // red, by elimination
                return <E2ePadlock icon={E2ePadlockIcon.Warning} title={shieldReasonMessage} />;
            }
        }

        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 <E2ePadlockUnencrypted />;
            }
        }

        // 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<HTMLElement>): 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<HTMLElement>): 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 (
            <MessageContextMenu
                {...aboveRightOf(this.state.contextMenu.position)}
                mxEvent={this.props.mxEvent}
                permalinkCreator={this.props.permalinkCreator}
                eventTileOps={eventTileOps}
                collapseReplyChain={collapseReplyChain}
                onFinished={this.onCloseMenu}
                rightClick={true}
                reactions={this.state.reactions}
                link={this.state.contextMenu.link}
                getRelationsForEvent={this.props.getRelationsForEvent}
            />
        );
    }

    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 (
                <div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
                    <div className="mx_EventTile_line">{_t("timeline|error_no_renderer")}</div>
                </div>
            );
        }

        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 = (
                <div className="mx_EventTile_avatar">
                    <MemberAvatar
                        member={member}
                        size={avatarSize}
                        viewUserOnClick={viewUserOnClick}
                        forceHistorical={this.props.mxEvent.getType() === EventType.RoomMember}
                    />
                </div>
            );
        }

        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 = <SenderProfile onClick={this.onSenderProfileClick} mxEvent={this.props.mxEvent} />;
            } else if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) {
                sender = <SenderProfile mxEvent={this.props.mxEvent} withTooltip />;
            } else {
                sender = <SenderProfile mxEvent={this.props.mxEvent} />;
            }
        }

        const showMessageActionBar =
            !isEditing &&
            !this.props.forExport &&
            (this.state.hover ||
                this.state.showActionBarFromFocus ||
                (this.state.actionBarFocused && !this.state.contextMenu));
        const actionBar = showMessageActionBar ? (
            <ActionBarWrapper
                mxEvent={this.props.mxEvent}
                reactions={this.state.reactions}
                permalinkCreator={this.props.permalinkCreator}
                getTile={this.getTile}
                getReplyChain={this.getReplyChain}
                onFocusChange={this.onActionBarFocusChange}
                isQuoteExpanded={isQuoteExpanded}
                toggleThreadExpanded={() => 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 = <MessageTimestampWrapper {...messageTimestampProps} />;
        const linkedMessageTimestamp = (
            <MessageTimestampWrapper
                {...messageTimestampProps}
                href={permalink}
                onClick={this.onPermalinkClicked}
                onContextMenu={this.onTimestampContextMenu}
            />
        );

        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 ? <span className="mx_MessageTimestamp" /> : 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 = <PinnedMessageBadge aria-describedby={this.id} tabIndex={0} />;
        }

        let reactionsRow: JSX.Element | undefined;
        if (!isRedacted) {
            reactionsRow = (
                <ReactionsRowWrapper
                    mxEvent={this.props.mxEvent}
                    reactions={this.state.reactions}
                    key="mx_EventTile_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 = <SentReceipt messageState={this.props.eventSendStatus} />;
        } else if (this.props.showReadReceipts) {
            msgOption = (
                <ReadReceiptGroup
                    readReceipts={this.props.readReceipts ?? []}
                    readReceiptMap={this.props.readReceiptMap ?? {}}
                    checkUnmounting={this.props.checkUnmounting}
                    suppressAnimation={this.suppressReadReceiptAnimation}
                    isTwelveHour={this.props.isTwelveHour}
                />
            );
        }

        let replyChain: JSX.Element | undefined;
        if (
            haveRendererForEvent(this.props.mxEvent, MatrixClientPeg.safeGet(), this.context.showHiddenEvents) &&
            shouldDisplayReply(this.props.mxEvent)
        ) {
            replyChain = (
                <ReplyChain
                    parentEv={this.props.mxEvent}
                    ref={this.replyChain}
                    forExport={this.props.forExport}
                    permalinkCreator={this.props.permalinkCreator}
                    layout={this.props.layout}
                    alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover || this.state.focusWithin}
                    isQuoteExpanded={isQuoteExpanded}
                    setQuoteExpanded={this.setQuoteExpanded}
                    getRelationsForEvent={this.props.getRelationsForEvent}
                />
            );
        }

        // 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,
                    },
                    [
                        <div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
                            {avatar}
                            {sender}
                        </div>,
                        <div
                            id={this.id}
                            className={lineClasses}
                            key="mx_EventTile_line"
                            onContextMenu={this.onContextMenu}
                        >
                            {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}
                        </div>,
                        hasFooter && (
                            <div className="mx_EventTile_footer" key="mx_EventTile_footer">
                                {(this.props.layout === Layout.Group || !isOwnEvent) && pinnedMessageBadge}
                                {reactionsRow}
                                {this.props.layout === Layout.Bubble && isOwnEvent && pinnedMessageBadge}
                            </div>
                        ),
                    ],
                );
            }
            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<ShowThreadPayload>({
                                        action: Action.ShowThread,
                                        rootEvent: this.props.mxEvent,
                                        push: true,
                                    });
                                    PosthogTrackers.trackInteraction("WebThreadsPanelThreadItem", ev, index ?? -1);
                                    break;
                            }
                        },
                    },
                    <>
                        <div className="mx_EventTile_details">
                            {sender}
                            {isRenderingNotification && room ? (
                                <span className="mx_EventTile_truncated">
                                    {" "}
                                    {_t(
                                        "timeline|in_room_name",
                                        { room: room.name },
                                        { strong: (sub) => <strong>{sub}</strong> },
                                    )}
                                </span>
                            ) : (
                                ""
                            )}
                            {timestamp}
                            <UnreadNotificationBadge
                                room={room || undefined}
                                threadId={this.props.mxEvent.getId()}
                                forceDot={true}
                            />
                        </div>
                        {isRenderingNotification && room ? (
                            <div className="mx_EventTile_avatar">
                                <RoomAvatar room={room} size="28px" />
                            </div>
                        ) : (
                            avatar
                        )}
                        <div className={lineClasses} key="mx_EventTile_line">
                            <div className="mx_EventTile_body">
                                {this.props.mxEvent.isRedacted() ? (
                                    <RedactedBodyFactory mxEvent={this.props.mxEvent} />
                                ) : this.props.mxEvent.isDecryptionFailure() ? (
                                    <DecryptionFailureBodyFactory mxEvent={this.props.mxEvent} />
                                ) : (
                                    <EventPreview mxEvent={this.props.mxEvent} />
                                )}
                            </div>
                            {this.renderThreadPanelSummary()}
                        </div>
                        {this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
                            <ThreadListActionBarWrapper
                                onViewInRoomClick={this.onViewInRoomClick}
                                onCopyLinkClick={this.onCopyLinkToThreadClick}
                            />
                        )}

                        {msgOption}
                    </>,
                );
            }
            case TimelineRenderingType.File: {
                return React.createElement(
                    this.props.as || "li",
                    {
                        "className": classes,
                        "aria-live": ariaLive,
                        "aria-atomic": true,
                        "data-scroll-tokens": scrollToken,
                    },
                    [
                        <a
                            className="mx_EventTile_senderDetailsLink"
                            key="mx_EventTile_senderDetailsLink"
                            href={permalink}
                            onClick={this.onPermalinkClicked}
                        >
                            <div className="mx_EventTile_senderDetails" onContextMenu={this.onTimestampContextMenu}>
                                {avatar}
                                {sender}
                                {timestamp}
                            </div>
                        </a>,
                        <div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
                            {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,
                            })}
                        </div>,
                    ],
                );
            }

            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}
                        <div
                            id={this.id}
                            className={lineClasses}
                            key="mx_EventTile_line"
                            onContextMenu={this.onContextMenu}
                        >
                            {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 && (
                                        <div className="mx_EventTile_footer">
                                            {pinnedMessageBadge}
                                            {reactionsRow}
                                        </div>
                                    )}
                                    {this.renderThreadInfo()}
                                </>
                            )}
                        </div>
                        {this.props.layout !== Layout.IRC && (
                            <>
                                {hasFooter && (
                                    <div className="mx_EventTile_footer">
                                        {(this.props.layout === Layout.Group || !isOwnEvent) && pinnedMessageBadge}
                                        {reactionsRow}
                                        {this.props.layout === Layout.Bubble && isOwnEvent && pinnedMessageBadge}
                                    </div>
                                )}
                                {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<EventTileErrorFallbackProps>): 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 <TileErrorView vm={vm} className="mx_EventTile mx_EventTile_info mx_EventTile_content" />;
}

interface EventTileErrorBoundaryProps {
    children: ReactNode;
    layout: Layout;
    mxEvent: MatrixEvent;
}

interface EventTileErrorBoundaryState {
    error?: Error;
}

class EventTileErrorBoundary extends React.Component<EventTileErrorBoundaryProps, EventTileErrorBoundaryState> {
    public constructor(props: EventTileErrorBoundaryProps) {
        super(props);
        this.state = {};
    }

    public static getDerivedStateFromError(error: Error): Partial<EventTileErrorBoundaryState> {
        return { error };
    }

    public render(): ReactNode {
        if (this.state.error) {
            return (
                <EventTileErrorFallback
                    error={this.state.error}
                    layout={this.props.layout}
                    mxEvent={this.props.mxEvent}
                />
            );
        }

        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 (
        <EventTileErrorBoundary mxEvent={props.mxEvent} layout={props.layout ?? Layout.Group}>
            <UnwrappedEventTile {...props} />
        </EventTileErrorBoundary>
    );
};
export default SafeEventTile;

function E2ePadlockUnencrypted(): JSX.Element {
    return <E2ePadlock title={_t("common|unencrypted")} icon={E2ePadlockIcon.Warning} />;
}

function E2ePadlockDecryptionFailure(): JSX.Element {
    return <E2ePadlock title={_t("timeline|undecryptable_tooltip")} icon={E2ePadlockIcon.DecryptionFailure} />;
}

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 = <CircleIcon />;
        label = _t("timeline|send_state_encrypting");
    } else if (isSent) {
        icon = <CheckCircleIcon />;
        label = _t("timeline|send_state_sent");
    } else if (isFailed) {
        icon = <NotificationBadge notification={StaticNotificationState.RED_EXCLAMATION} />;
        label = _t("timeline|send_state_failed");
    } else {
        icon = <CircleIcon />;
        label = _t("timeline|send_state_sending");
    }

    return (
        <div className="mx_EventTile_msgOption">
            <div className="mx_ReadReceiptGroup">
                <Tooltip label={label} placement="top-end">
                    <div className="mx_ReadReceiptGroup_button" role="status">
                        <span className="mx_ReadReceiptGroup_container">{icon}</span>
                    </div>
                </Tooltip>
            </div>
        </div>
    );
}

/**
 * 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 ? (
                <LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" />
            ) : undefined}
            <MessageTimestampView vm={vm} className="mx_MessageTimestamp" />
        </>
    );
}

interface ReactionsRowButtonItemProps {
    mxEvent: MatrixEvent;
    content: string;
    count: number;
    reactionEvents: MatrixEvent[];
    myReactionEvent?: MatrixEvent;
    disabled?: boolean;
    customReactionImagesEnabled?: boolean;
}

function ReactionsRowButtonItem(props: Readonly<ReactionsRowButtonItemProps>): 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 <ReactionsRowButtonView vm={vm} />;
}

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<ReactionsRowWrapperProps>): JSX.Element | null {
    const roomContext = useContext(RoomContext);
    const userId = roomContext.room?.client.getUserId() ?? undefined;
    const [reactionGroups, setReactionGroups] = useState<ReactionGroup[]>(() => getReactionGroups(reactions));
    const [myReactions, setMyReactions] = useState<MatrixEvent[] | null>(() => getMyReactions(reactions, userId));
    const [menuDisplayed, setMenuDisplayed] = useState(false);
    const [menuAnchorRect, setMenuAnchorRect] = useState<DOMRect | null>(null);

    const vm = useCreateAutoDisposedViewModel(
        () =>
            new ReactionsRowViewModel({
                isActionable: isContentActionable(mxEvent),
                reactionGroupCount: reactionGroups.length,
                canReact: roomContext.canReact,
                addReactionButtonActive: false,
            }),
    );

    const openReactionMenu = useCallback((event: React.MouseEvent<HTMLButtonElement>): 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 (
                <ReactionsRowButtonItem
                    key={content}
                    content={content}
                    count={deduplicatedEvents.length}
                    mxEvent={mxEvent}
                    reactionEvents={deduplicatedEvents}
                    myReactionEvent={myReactionEvent}
                    customReactionImagesEnabled={customReactionImagesEnabled}
                    disabled={
                        !roomContext.canReact ||
                        (myReactionEvent && !myReactionEvent.isRedacted() && !roomContext.canSelfRedact)
                    }
                />
            );
        });

        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 = (
            <ContextMenu {...aboveLeftOf(menuAnchorRect)} onFinished={closeReactionMenu} managed={false} focusLock>
                <ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeReactionMenu} />
            </ContextMenu>
        );
    }

    return (
        <>
            <ReactionsRowView vm={vm} className="mx_ReactionsRow">
                {items}
            </ReactionsRowView>
            {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<void>;
}

function ThreadListActionBarWrapper({
    onViewInRoomClick,
    onCopyLinkClick,
}: Readonly<ThreadListActionBarWrapperProps>): JSX.Element {
    const vm = useCreateAutoDisposedViewModel(
        () =>
            new ThreadListActionBarViewModel({
                onViewInRoomClick,
                onCopyLinkClick,
            }),
    );

    useEffect(() => {
        vm.setProps({
            onViewInRoomClick,
            onCopyLinkClick,
        });
    }, [vm, onViewInRoomClick, onCopyLinkClick]);

    return <ActionBarView vm={vm} className="mx_ThreadActionBar" />;
}

function ActionBarWrapper({
    mxEvent,
    reactions,
    permalinkCreator,
    getTile,
    getReplyChain,
    onFocusChange,
    isQuoteExpanded,
    toggleThreadExpanded,
    getRelationsForEvent,
}: Readonly<ActionBarWrapperProps>): JSX.Element {
    const roomContext = useContext(RoomContext);
    const { isCard } = useContext(CardContext);
    const [optionsMenuAnchorRect, setOptionsMenuAnchorRect] = useState<DOMRect | null>(null);
    const [reactionsMenuAnchorRect, setReactionsMenuAnchorRect] = useState<DOMRect | null>(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 (
        <>
            <ActionBarView vm={vm} className="mx_MessageActionBar" />
            {optionsMenuAnchorRect ? (
                <MessageContextMenu
                    {...aboveLeftOf(optionsMenuAnchorRect)}
                    mxEvent={mxEvent}
                    permalinkCreator={permalinkCreator}
                    eventTileOps={eventTileOps}
                    collapseReplyChain={collapseReplyChain}
                    onFinished={closeOptionsMenu}
                    getRelationsForEvent={getRelationsForEvent}
                />
            ) : null}
            {reactionsMenuAnchorRect ? (
                <ContextMenu
                    {...aboveLeftOf(reactionsMenuAnchorRect)}
                    onFinished={closeReactionsMenu}
                    managed={false}
                    focusLock
                >
                    <ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeReactionsMenu} />
                </ContextMenu>
            ) : null}
        </>
    );
}
