/* Copyright 2024 New Vector Ltd. Copyright 2017-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, { type JSX } from "react"; import classNames from "classnames"; import { type MatrixEvent, type Room, type MatrixClient } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; import { makeUserPermalink, type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import SettingsStore from "../../../settings/SettingsStore"; import { type Layout } from "../../../settings/enums/Layout"; import { getUserNameColorClass } from "../../../utils/FormattingUtils"; import { Action } from "../../../dispatcher/actions"; import Spinner from "./Spinner"; import ReplyTile from "../rooms/ReplyTile"; import { Pill, PillType } from "./Pill"; import AccessibleButton from "./AccessibleButton"; import { getParentEventId, shouldDisplayReply } from "../../../utils/Reply"; import RoomContext from "../../../contexts/RoomContext"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { type GetRelationsForEvent } from "../rooms/EventTile"; /** * This number is based on the previous behavior - if we have message of height * over 60px then we want to show button that will allow to expand it. */ const SHOW_EXPAND_QUOTE_PIXELS = 60; interface IProps { // the latest event in this chain of replies parentEv: MatrixEvent; permalinkCreator?: RoomPermalinkCreator; // Specifies which layout to use. layout?: Layout; // Whether to always show a timestamp alwaysShowTimestamps?: boolean; forExport?: boolean; isQuoteExpanded?: boolean; setQuoteExpanded: (isExpanded: boolean) => void; getRelationsForEvent?: GetRelationsForEvent; } interface IState { // The loaded events to be rendered as linear-replies events: MatrixEvent[]; // The latest loaded event which has not yet been shown loadedEv: MatrixEvent | null; // Whether the component is still loading more events loading: boolean; // Whether as error was encountered fetching a replied to event. err: boolean; } // This component does no cycle detection, simply because the only way to make such a cycle would be to // craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would // be low as each event being loaded (after the first) is triggered by an explicit user action. export default class ReplyChain extends React.Component { public static contextType = RoomContext; declare public context: React.ContextType; private unmounted = false; private room: Room; private blockquoteRef = React.createRef(); public constructor(props: IProps) { super(props); this.state = { events: [], loadedEv: null, loading: true, err: false, }; this.room = this.matrixClient.getRoom(this.props.parentEv.getRoomId())!; } private get matrixClient(): MatrixClient { return MatrixClientPeg.safeGet(); } public componentDidMount(): void { this.unmounted = false; this.initialize(); this.trySetExpandableQuotes(); } public componentDidUpdate(): void { this.trySetExpandableQuotes(); } public componentWillUnmount(): void { this.unmounted = true; } private trySetExpandableQuotes(): void { if (this.props.isQuoteExpanded === undefined && this.blockquoteRef.current) { const el: HTMLElement | null = this.blockquoteRef.current.querySelector(".mx_EventTile_body"); if (el) { const code: HTMLElement | null = el.querySelector("code"); const isCodeEllipsisShown = code ? code.offsetHeight >= SHOW_EXPAND_QUOTE_PIXELS : false; const isElipsisShown = isCodeEllipsisShown || el.offsetHeight >= SHOW_EXPAND_QUOTE_PIXELS || // Check whether the body fits into it's scroll container el.clientHeight !== el.scrollHeight || // Do the same for its children as the scroll container may be on them instead [...el.children].some((child) => child.clientHeight !== child.scrollHeight); if (isElipsisShown) { this.props.setQuoteExpanded(false); } } } } private async initialize(): Promise { const { parentEv } = this.props; // at time of making this component we checked that props.parentEv has a parentEventId const ev = await this.getEvent(getParentEventId(parentEv)); if (this.unmounted) return; if (ev) { const loadedEv = await this.getNextEvent(ev); this.setState({ events: [ev], loadedEv, loading: false, }); } else { this.setState({ err: true }); } } private async getNextEvent(ev: MatrixEvent): Promise { try { const inReplyToEventId = getParentEventId(ev); if (!inReplyToEventId) return null; return await this.getEvent(inReplyToEventId); } catch { return null; } } private async getEvent(eventId?: string): Promise { if (!eventId) return null; const event = this.room.findEventById(eventId); if (event) return event; try { // ask the client to fetch the event we want using the context API, only interface to do so is to ask // for a timeline with that event, but once it is loaded we can use findEventById to look up the ev map await this.matrixClient.getEventTimeline(this.room.getUnfilteredTimelineSet(), eventId); } catch { // if it fails catch the error and return early, there's no point trying to find the event in this case. // Return null as it is falsy and thus should be treated as an error (as the event cannot be resolved). return null; } return this.room.findEventById(eventId) ?? null; } public canCollapse = (): boolean => { return this.state.events.length > 1; }; public collapse = (): void => { this.initialize(); }; private onQuoteClick = async (): Promise => { if (!this.state.loadedEv) return; const events = [this.state.loadedEv, ...this.state.events]; let loadedEv: MatrixEvent | null = null; if (events.length > 0) { loadedEv = await this.getNextEvent(events[0]); } this.setState({ loadedEv, events, }); dis.fire(Action.FocusSendMessageComposer); }; private getReplyChainColorClass(ev: MatrixEvent): string { return getUserNameColorClass(ev.getSender()!).replace("Username", "ReplyChain"); } public render(): React.ReactNode { let header: JSX.Element | undefined; if (this.state.err) { header = (
{_t("timeline|reply|error_loading")}
); } else if (this.state.loadedEv && shouldDisplayReply(this.state.events[0])) { const ev = this.state.loadedEv; const room = this.matrixClient.getRoom(ev.getRoomId()); header = (
{_t( "timeline|reply|in_reply_to", {}, { a: (sub) => ( {sub} ), pill: ( ), }, )}
); } else if (this.props.forExport) { const eventId = getParentEventId(this.props.parentEv); header = (

{_t( "timeline|reply|in_reply_to_for_export", {}, { a: (sub) => ( {" "} {sub}{" "} ), }, )}

); } else if (this.state.loading) { header = ; } const { isQuoteExpanded } = this.props; const evTiles = this.state.events.map((ev) => { const classname = classNames({ "mx_ReplyChain": true, [this.getReplyChainColorClass(ev)]: true, // We don't want to add the class if it's undefined, it should only be expanded/collapsed when it's true/false "mx_ReplyChain--expanded": isQuoteExpanded === true, // We don't want to add the class if it's undefined, it should only be expanded/collapsed when it's true/false "mx_ReplyChain--collapsed": isQuoteExpanded === false, }); return (
this.props.setQuoteExpanded(!this.props.isQuoteExpanded)} getRelationsForEvent={this.props.getRelationsForEvent} />
); }); return (
{header}
{evTiles}
); } }