/* Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; import classNames from 'classnames'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { Room } from 'matrix-js-sdk/src/models/room'; import { Relations } from 'matrix-js-sdk/src/models/relations'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import SettingsStore from "../../../settings/SettingsStore"; import { 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 { ButtonEvent } from './AccessibleButton'; import { getParentEventId, shouldDisplayReply } from '../../../utils/Reply'; import RoomContext from "../../../contexts/RoomContext"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; /** * 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; // called when the ReplyChain contents has changed, including EventTiles thereof onHeightChanged: () => void; 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?: ( (eventId: string, relationType: string, eventType: string) => Relations ); } 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; // 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 { static contextType = RoomContext; public context!: React.ContextType; private unmounted = false; private room: Room; private blockquoteRef = React.createRef(); constructor(props: IProps, context: React.ContextType) { super(props, context); this.state = { events: [], loadedEv: null, loading: true, err: false, }; this.room = this.matrixClient.getRoom(this.props.parentEv.getRoomId()); } private get matrixClient(): MatrixClient { return MatrixClientPeg.get(); } componentDidMount() { this.initialize(); this.trySetExpandableQuotes(); } componentDidUpdate() { this.props.onHeightChanged(); this.trySetExpandableQuotes(); } componentWillUnmount() { this.unmounted = true; } private trySetExpandableQuotes() { 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 = el.offsetHeight >= SHOW_EXPAND_QUOTE_PIXELS || isCodeEllipsisShown; 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); return await this.getEvent(inReplyToEventId); } catch (e) { 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 (e) { // 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); } public canCollapse = (): boolean => { return this.state.events.length > 1; }; public collapse = (): void => { this.initialize(); }; private onQuoteClick = async (event: ButtonEvent): Promise => { const events = [this.state.loadedEv, ...this.state.events]; let loadedEv = 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"); } render() { let header = null; if (this.state.err) { header =
{ _t('Unable to load event that was replied to, ' + 'it either does not exist or you do not have permission to view it.') }
; } else if (this.state.loadedEv && shouldDisplayReply(this.state.events[0])) { const ev = this.state.loadedEv; const room = this.matrixClient.getRoom(ev.getRoomId()); header =
{ _t('In reply to ', {}, { 'a': (sub) => ( ), 'pill': ( ), }) }
; } else if (this.props.forExport) { const eventId = getParentEventId(this.props.parentEv); header =

{ _t("In reply to this message", {}, { 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 }
; } }