/* 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 Please see LICENSE files in the repository root for full details. */ import React, { createRef, forwardRef, JSX, MouseEvent, ReactNode } from "react"; import classNames from "classnames"; import { EventStatus, EventType, MatrixEvent, MatrixEventEvent, MsgType, NotificationCountType, Relations, RelationType, Room, RoomEvent, RoomMember, 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, EventShieldColour, EventShieldReason, UserVerificationStatus, } from "matrix-js-sdk/src/crypto-api"; import { Tooltip } from "@vector-im/compound-web"; import ReplyChain from "../elements/ReplyChain"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; import { Layout } from "../../../settings/enums/Layout"; import { formatTime } from "../../../DateUtils"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { DecryptionFailureBody } from "../messages/DecryptionFailureBody"; import RoomAvatar from "../avatars/RoomAvatar"; import MessageContextMenu from "../context_menus/MessageContextMenu"; import { aboveRightOf } from "../../structures/ContextMenu"; import { objectHasDiff } from "../../../utils/objects"; import EditorStateTransfer from "../../../utils/EditorStateTransfer"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import NotificationBadge from "./NotificationBadge"; import LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper"; import { 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 MessageTimestamp from "../messages/MessageTimestamp"; import { IReadReceiptPosition } from "./ReadReceiptMarker"; import MessageActionBar from "../messages/MessageActionBar"; import ReactionsRow from "../messages/ReactionsRow"; import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { ButtonEvent } from "../elements/AccessibleButton"; import { copyPlaintext, getSelectedText } from "../../../utils/strings"; import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import RedactedBody from "../messages/RedactedBody"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { shouldDisplayReply } from "../../../utils/Reply"; import PosthogTrackers from "../../../PosthogTrackers"; import TileErrorBoundary from "../messages/TileErrorBoundary"; import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory"; import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; import { ReadReceiptGroup } from "./ReadReceiptGroup"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { ElementCall } from "../../../models/Call"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar"; import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; import PinningUtils from "../../../utils/PinningUtils"; import { PinnedMessageBadge } from "../messages/PinnedMessageBadge"; 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; // callback called when dynamic content in events are loaded onHeightChanged?: () => void; // 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?: string; 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; } interface IState { // Whether the action bar is focused. actionBarFocused: 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; // 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 = { // no-op function because onHeightChanged is optional yet some sub-components assume its existence onHeightChanged: function () {}, forExport: false, layout: Layout.Group, }; public static contextType = RoomContext; public declare context: React.ContextType; private unmounted = false; public constructor(props: EventTileProps, context: React.ContextType) { super(props, context); const thread = this.thread; this.state = { // Whether the action bar is focused. actionBarFocused: false, shieldColour: EventShieldColour.NONE, shieldReason: null, // The Relations model from the JS SDK for reactions to `mxEvent` reactions: this.getReactions(), hover: 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; // 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.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 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; } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { // If the shield state changed, the height might have changed. // XXX: does the shield *actually* cause a change in height? Not sure. if (prevState.shieldColour !== this.state.shieldColour && this.props.onHeightChanged) { this.props.onHeightChanged(); } // 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(); } } private 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 viewInRoom = (evt: ButtonEvent): void => { evt.preventDefault(); evt.stopPropagation(); 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 copyLinkToThread = async (evt: ButtonEvent): Promise => { evt.preventDefault(); evt.stopPropagation(); const { permalinkCreator, mxEvent } = this.props; if (!permalinkCreator) return; const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId()!); await copyPlaintext(matrixToUrl); }; private 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 onDecrypted = (): void => { // we need to re-verify the sending device. this.verifyEvent(); // decryption might, of course, trigger a height change, so call onHeightChanged after the re-render this.forceUpdate(this.props.onHeightChanged); }; private 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 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; 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 onSenderProfileClick = (): void => { dis.dispatch({ action: Action.ComposerInsert, userId: this.props.mxEvent.getSender()!, timelineRenderingType: this.context.timelineRenderingType, }); }; private 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()) { return ; } if (this.state.shieldColour !== EventShieldColour.NONE) { let shieldReasonMessage: string; switch (this.state.shieldReason) { case null: case EventShieldReason.UNKNOWN: shieldReasonMessage = _t("error|unknown"); break; 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; } if (this.state.shieldColour === EventShieldColour.GREY) { return ; } else { // red, by elimination return ; } } if (MatrixClientPeg.safeGet().isRoomEncrypted(ev.getRoomId()!)) { // 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 onActionBarFocusChange = (actionBarFocused: boolean): void => { this.setState({ actionBarFocused }); }; private getTile: () => IEventTileType | null = () => this.tile.current; private getReplyChain = (): ReplyChain | null => this.replyChain.current; private 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 onReactionsCreated = (relationType: string, eventType: string): void => { if (relationType !== "m.annotation" || eventType !== "m.reaction") { return; } this.setState({ reactions: this.getReactions(), }); }; private onContextMenu = (ev: React.MouseEvent): void => { this.showContextMenu(ev); }; private 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 or we have // selected text, as in those cases we want to use the native browser // menu if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && (getSelectedText() || 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, }); } private onCloseMenu = (): void => { this.setState({ contextMenu: undefined, actionBarFocused: false, }); }; private 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 || ElementCall.CALL_EVENT_TYPE.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 || ElementCall.CALL_EVENT_TYPE.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; 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.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 messageTimestamp = ( ); const timestamp = showTimestamp && ts ? messageTimestamp : null; 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 linkedTimestamp = !this.props.hideTimestamp ? ( {timestamp} ) : null; const useIRCLayout = this.props.layout === Layout.IRC; const groupTimestamp = !useIRCLayout ? linkedTimestamp : null; const ircTimestamp = useIRCLayout ? linkedTimestamp : null; const bubbleTimestamp = this.props.layout === Layout.Bubble ? messageTimestamp : undefined; 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 }), }, [
{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, onHeightChanged: () => this.props.onHeightChanged, permalinkCreator: this.props.permalinkCreator!, }, this.context.showHiddenEvents, )} {actionBar} {timestamp} {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 }), "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.viewInRoom(ev); 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() ? ( ) : ( MessagePreviewStore.instance.generatePreviewForEvent(this.props.mxEvent) )}
{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, onHeightChanged: this.props.onHeightChanged, permalinkCreator: this.props.permalinkCreator, }, 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 }), }, <> {ircTimestamp} {sender} {ircPadlock} {avatar}
{this.renderContextMenu()} {groupTimestamp} {groupPadlock} {replyChain} {renderTile( this.context.timelineRenderingType, { ...this.props, // overrides ref: this.tile, isSeeingThroughMessageHiddenForModeration, timestamp: bubbleTimestamp, // appease TS highlights: this.props.highlights, highlightLink: this.props.highlightLink, onHeightChanged: this.props.onHeightChanged, permalinkCreator: this.props.permalinkCreator, }, 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} , ); } } } } // Wrap all event tiles with the tile error boundary so that any throws even during construction are captured const SafeEventTile = forwardRef((props, ref) => { return ( <> ); }); export default SafeEventTile; function E2ePadlockUnencrypted(props: Omit): JSX.Element { return ; } function E2ePadlockDecryptionFailure(props: Omit): JSX.Element { return ( ); } enum E2ePadlockIcon { /** grey shield */ Normal = "normal", /** red shield with (!) */ Warning = "warning", /** key in grey circle */ DecryptionFailure = "decryption_failure", } interface IE2ePadlockProps { icon: E2ePadlockIcon; title: string; } class E2ePadlock extends React.Component { public constructor(props: IE2ePadlockProps) { super(props); this.state = { hover: false, }; } public render(): ReactNode { const classes = `mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${this.props.icon}`; // We specify isTriggerInteractive=true and make the div interactive manually as a workaround for // https://github.com/element-hq/compound/issues/294 return (
); } } interface ISentReceiptProps { messageState: EventStatus | null; } function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { const isSent = !messageState || messageState === "sent"; const isFailed = messageState === "not_sent"; const receiptClasses = classNames({ mx_EventTile_receiptSent: isSent, mx_EventTile_receiptSending: !isSent && !isFailed, }); let nonCssBadge: JSX.Element | undefined; if (isFailed) { nonCssBadge = ; } let label = _t("timeline|send_state_sending"); if (messageState === "encrypting") { label = _t("timeline|send_state_encrypting"); } else if (isSent) { label = _t("timeline|send_state_sent"); } else if (isFailed) { label = _t("timeline|send_state_failed"); } return (
{nonCssBadge}
); }