2017-12-10 12:50:41 +00:00
|
|
|
/*
|
2024-09-09 14:57:16 +01:00
|
|
|
Copyright 2024 New Vector Ltd.
|
|
|
|
|
Copyright 2017-2023 The Matrix.org Foundation C.I.C.
|
2019-08-13 18:17:42 +01:00
|
|
|
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
2017-12-10 12:50:41 +00:00
|
|
|
|
2025-01-06 11:18:54 +00:00
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
2024-09-09 14:57:16 +01:00
|
|
|
Please see LICENSE files in the repository root for full details.
|
2017-12-10 12:50:41 +00:00
|
|
|
*/
|
2021-07-17 08:35:50 +02:00
|
|
|
|
2025-03-27 10:43:58 +00:00
|
|
|
import React, { type JSX } from "react";
|
2021-09-27 12:20:37 +02:00
|
|
|
import classNames from "classnames";
|
2025-02-05 13:25:06 +00:00
|
|
|
import { type MatrixEvent, type Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
2021-09-27 12:20:37 +02:00
|
|
|
|
2021-06-29 13:11:58 +01:00
|
|
|
import { _t } from "../../../languageHandler";
|
2020-05-13 20:41:41 -06:00
|
|
|
import dis from "../../../dispatcher/dispatcher";
|
2025-02-05 13:25:06 +00:00
|
|
|
import { makeUserPermalink, type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
2018-02-10 11:19:43 +00:00
|
|
|
import SettingsStore from "../../../settings/SettingsStore";
|
2025-02-05 13:25:06 +00:00
|
|
|
import { type Layout } from "../../../settings/enums/Layout";
|
2020-04-10 14:52:24 +03:00
|
|
|
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
|
2021-06-29 13:11:58 +01:00
|
|
|
import { Action } from "../../../dispatcher/actions";
|
2021-07-13 08:23:01 +02:00
|
|
|
import Spinner from "./Spinner";
|
|
|
|
|
import ReplyTile from "../rooms/ReplyTile";
|
2023-03-08 13:06:50 +01:00
|
|
|
import { Pill, PillType } from "./Pill";
|
2023-03-29 08:23:54 +01:00
|
|
|
import AccessibleButton from "./AccessibleButton";
|
2022-03-10 15:32:00 +00:00
|
|
|
import { getParentEventId, shouldDisplayReply } from "../../../utils/Reply";
|
2022-03-15 13:52:37 +00:00
|
|
|
import RoomContext from "../../../contexts/RoomContext";
|
2022-03-10 15:32:00 +00:00
|
|
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
2025-02-05 13:25:06 +00:00
|
|
|
import { type GetRelationsForEvent } from "../rooms/EventTile";
|
2021-07-17 08:35:50 +02:00
|
|
|
|
2021-09-27 12:20:37 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
2021-07-17 08:35:50 +02:00
|
|
|
interface IProps {
|
|
|
|
|
// the latest event in this chain of replies
|
2023-03-29 08:23:54 +01:00
|
|
|
parentEv: MatrixEvent;
|
2023-03-13 15:07:20 +00:00
|
|
|
permalinkCreator?: RoomPermalinkCreator;
|
2021-07-17 08:35:50 +02:00
|
|
|
// Specifies which layout to use.
|
2021-07-17 15:29:18 +02:00
|
|
|
layout?: Layout;
|
2021-07-17 08:35:50 +02:00
|
|
|
// Whether to always show a timestamp
|
2021-07-17 15:29:18 +02:00
|
|
|
alwaysShowTimestamps?: boolean;
|
2021-07-19 13:23:55 +05:30
|
|
|
forExport?: boolean;
|
2021-09-27 12:20:37 +02:00
|
|
|
isQuoteExpanded?: boolean;
|
|
|
|
|
setQuoteExpanded: (isExpanded: boolean) => void;
|
2022-11-21 21:54:24 -06:00
|
|
|
getRelationsForEvent?: GetRelationsForEvent;
|
2021-07-17 08:35:50 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface IState {
|
|
|
|
|
// The loaded events to be rendered as linear-replies
|
2021-07-17 15:29:18 +02:00
|
|
|
events: MatrixEvent[];
|
2021-07-17 08:35:50 +02:00
|
|
|
// The latest loaded event which has not yet been shown
|
2023-03-10 14:55:06 +00:00
|
|
|
loadedEv: MatrixEvent | null;
|
2021-07-17 08:35:50 +02:00
|
|
|
// Whether the component is still loading more events
|
2021-07-17 15:29:18 +02:00
|
|
|
loading: boolean;
|
2021-07-17 08:35:50 +02:00
|
|
|
// Whether as error was encountered fetching a replied to event.
|
2021-07-17 15:29:18 +02:00
|
|
|
err: boolean;
|
2021-07-17 08:35:50 +02:00
|
|
|
}
|
2017-12-10 12:50:41 +00:00
|
|
|
|
2018-02-19 14:27:10 +00:00
|
|
|
// 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.
|
2021-10-15 17:42:44 +01:00
|
|
|
export default class ReplyChain extends React.Component<IProps, IState> {
|
2022-12-16 12:29:59 +00:00
|
|
|
public static contextType = RoomContext;
|
2024-12-02 09:39:36 +00:00
|
|
|
declare public context: React.ContextType<typeof RoomContext>;
|
2022-03-10 15:32:00 +00:00
|
|
|
|
2021-07-17 08:35:50 +02:00
|
|
|
private unmounted = false;
|
|
|
|
|
private room: Room;
|
2022-09-12 11:58:05 +01:00
|
|
|
private blockquoteRef = React.createRef<HTMLQuoteElement>();
|
2018-04-27 11:30:47 +01:00
|
|
|
|
2025-03-19 10:40:06 +00:00
|
|
|
public constructor(props: IProps) {
|
|
|
|
|
super(props);
|
2017-12-10 12:50:41 +00:00
|
|
|
|
2018-02-10 11:19:43 +00:00
|
|
|
this.state = {
|
|
|
|
|
events: [],
|
|
|
|
|
loadedEv: null,
|
|
|
|
|
loading: true,
|
2018-02-10 15:45:42 +00:00
|
|
|
err: false,
|
2017-12-10 12:50:41 +00:00
|
|
|
};
|
2017-12-18 19:28:01 +00:00
|
|
|
|
2023-03-29 08:23:54 +01:00
|
|
|
this.room = this.matrixClient.getRoom(this.props.parentEv.getRoomId())!;
|
2022-03-10 15:32:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private get matrixClient(): MatrixClient {
|
2023-06-15 08:46:19 +01:00
|
|
|
return MatrixClientPeg.safeGet();
|
2017-12-17 20:20:45 +00:00
|
|
|
}
|
2017-12-10 12:50:41 +00:00
|
|
|
|
2023-01-12 13:25:14 +00:00
|
|
|
public componentDidMount(): void {
|
2024-11-01 17:39:08 +00:00
|
|
|
this.unmounted = false;
|
2018-04-29 00:54:54 +01:00
|
|
|
this.initialize();
|
2021-09-27 12:20:37 +02:00
|
|
|
this.trySetExpandableQuotes();
|
2018-04-29 00:54:54 +01:00
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:25:14 +00:00
|
|
|
public componentDidUpdate(): void {
|
2021-09-27 12:20:37 +02:00
|
|
|
this.trySetExpandableQuotes();
|
2018-04-29 00:54:54 +01:00
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:25:14 +00:00
|
|
|
public componentWillUnmount(): void {
|
2018-04-29 00:54:54 +01:00
|
|
|
this.unmounted = true;
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:25:14 +00:00
|
|
|
private trySetExpandableQuotes(): void {
|
2021-09-27 12:20:37 +02:00
|
|
|
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;
|
2026-01-06 11:57:23 +00:00
|
|
|
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);
|
2021-09-27 12:20:37 +02:00
|
|
|
if (isElipsisShown) {
|
|
|
|
|
this.props.setQuoteExpanded(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-07-17 08:35:50 +02:00
|
|
|
private async initialize(): Promise<void> {
|
2021-06-29 13:11:58 +01:00
|
|
|
const { parentEv } = this.props;
|
2018-05-01 17:42:58 +01:00
|
|
|
// at time of making this component we checked that props.parentEv has a parentEventId
|
2022-02-22 11:14:56 +00:00
|
|
|
const ev = await this.getEvent(getParentEventId(parentEv));
|
2021-05-21 10:20:24 +01:00
|
|
|
|
2018-05-01 17:42:58 +01:00
|
|
|
if (this.unmounted) return;
|
|
|
|
|
|
|
|
|
|
if (ev) {
|
2021-05-21 10:20:24 +01:00
|
|
|
const loadedEv = await this.getNextEvent(ev);
|
2018-05-01 17:42:58 +01:00
|
|
|
this.setState({
|
|
|
|
|
events: [ev],
|
2021-05-21 10:20:24 +01:00
|
|
|
loadedEv,
|
|
|
|
|
loading: false,
|
|
|
|
|
});
|
2018-05-01 17:42:58 +01:00
|
|
|
} else {
|
2021-06-29 13:11:58 +01:00
|
|
|
this.setState({ err: true });
|
2018-05-01 17:42:58 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-10 14:55:06 +00:00
|
|
|
private async getNextEvent(ev: MatrixEvent): Promise<MatrixEvent | null> {
|
2021-05-21 10:20:24 +01:00
|
|
|
try {
|
2022-02-22 11:14:56 +00:00
|
|
|
const inReplyToEventId = getParentEventId(ev);
|
2023-03-27 08:01:09 +01:00
|
|
|
if (!inReplyToEventId) return null;
|
2021-05-21 10:20:24 +01:00
|
|
|
return await this.getEvent(inReplyToEventId);
|
2024-10-16 16:43:07 +01:00
|
|
|
} catch {
|
2021-05-21 10:20:24 +01:00
|
|
|
return null;
|
2018-05-01 17:42:58 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-29 08:23:54 +01:00
|
|
|
private async getEvent(eventId?: string): Promise<MatrixEvent | null> {
|
2021-06-16 18:00:06 +01:00
|
|
|
if (!eventId) return null;
|
2018-05-03 14:32:13 +01:00
|
|
|
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
|
2022-03-10 15:32:00 +00:00
|
|
|
await this.matrixClient.getEventTimeline(this.room.getUnfilteredTimelineSet(), eventId);
|
2024-10-16 16:43:07 +01:00
|
|
|
} catch {
|
2018-05-03 14:32:13 +01:00
|
|
|
// if it fails catch the error and return early, there's no point trying to find the event in this case.
|
2022-05-09 16:52:05 -06:00
|
|
|
// Return null as it is falsy and thus should be treated as an error (as the event cannot be resolved).
|
2018-05-03 14:32:13 +01:00
|
|
|
return null;
|
|
|
|
|
}
|
2023-03-10 14:55:06 +00:00
|
|
|
return this.room.findEventById(eventId) ?? null;
|
2018-05-03 14:32:13 +01:00
|
|
|
}
|
|
|
|
|
|
2021-07-17 08:35:50 +02:00
|
|
|
public canCollapse = (): boolean => {
|
2018-04-29 00:54:54 +01:00
|
|
|
return this.state.events.length > 1;
|
2021-07-17 08:35:50 +02:00
|
|
|
};
|
2018-04-29 00:54:54 +01:00
|
|
|
|
2021-07-17 08:35:50 +02:00
|
|
|
public collapse = (): void => {
|
2018-04-29 00:54:54 +01:00
|
|
|
this.initialize();
|
2021-07-17 08:35:50 +02:00
|
|
|
};
|
2018-04-29 00:54:54 +01:00
|
|
|
|
2023-03-29 08:23:54 +01:00
|
|
|
private onQuoteClick = async (): Promise<void> => {
|
|
|
|
|
if (!this.state.loadedEv) return;
|
2018-04-29 00:54:54 +01:00
|
|
|
const events = [this.state.loadedEv, ...this.state.events];
|
|
|
|
|
|
2023-03-10 14:55:06 +00:00
|
|
|
let loadedEv: MatrixEvent | null = null;
|
2021-05-21 10:20:24 +01:00
|
|
|
if (events.length > 0) {
|
|
|
|
|
loadedEv = await this.getNextEvent(events[0]);
|
|
|
|
|
}
|
|
|
|
|
|
2018-04-29 00:54:54 +01:00
|
|
|
this.setState({
|
2021-05-21 10:20:24 +01:00
|
|
|
loadedEv,
|
2018-04-29 00:54:54 +01:00
|
|
|
events,
|
2021-05-21 10:20:24 +01:00
|
|
|
});
|
2018-04-29 00:54:54 +01:00
|
|
|
|
2021-07-08 17:36:31 +02:00
|
|
|
dis.fire(Action.FocusSendMessageComposer);
|
2021-07-17 08:35:50 +02:00
|
|
|
};
|
2018-04-29 00:54:54 +01:00
|
|
|
|
2021-10-15 17:42:44 +01:00
|
|
|
private getReplyChainColorClass(ev: MatrixEvent): string {
|
2023-03-27 08:01:09 +01:00
|
|
|
return getUserNameColorClass(ev.getSender()!).replace("Username", "ReplyChain");
|
2018-04-29 00:54:54 +01:00
|
|
|
}
|
|
|
|
|
|
2023-02-13 17:01:43 +00:00
|
|
|
public render(): React.ReactNode {
|
2023-03-10 14:55:06 +00:00
|
|
|
let header: JSX.Element | undefined;
|
2018-02-10 15:45:42 +00:00
|
|
|
if (this.state.err) {
|
2021-10-15 17:42:44 +01:00
|
|
|
header = (
|
|
|
|
|
<blockquote className="mx_ReplyChain mx_ReplyChain_error">
|
2023-10-03 19:17:26 +01:00
|
|
|
{_t("timeline|reply|error_loading")}
|
2018-02-10 15:45:42 +00:00
|
|
|
</blockquote>
|
|
|
|
|
);
|
2022-03-15 13:52:37 +00:00
|
|
|
} else if (this.state.loadedEv && shouldDisplayReply(this.state.events[0])) {
|
2018-02-10 11:19:43 +00:00
|
|
|
const ev = this.state.loadedEv;
|
2022-03-10 15:32:00 +00:00
|
|
|
const room = this.matrixClient.getRoom(ev.getRoomId());
|
2021-10-15 17:42:44 +01:00
|
|
|
header = (
|
|
|
|
|
<blockquote className={`mx_ReplyChain ${this.getReplyChainColorClass(ev)}`}>
|
2018-02-10 11:19:43 +00:00
|
|
|
{_t(
|
2023-10-03 19:17:26 +01:00
|
|
|
"timeline|reply|in_reply_to",
|
2018-02-10 11:19:43 +00:00
|
|
|
{},
|
|
|
|
|
{
|
2022-01-07 10:40:53 +01:00
|
|
|
a: (sub) => (
|
2022-05-31 21:52:12 +00:00
|
|
|
<AccessibleButton
|
|
|
|
|
kind="link_inline"
|
|
|
|
|
className="mx_ReplyChain_show"
|
|
|
|
|
onClick={this.onQuoteClick}
|
|
|
|
|
>
|
2022-01-07 10:40:53 +01:00
|
|
|
{sub}
|
2022-05-31 21:52:12 +00:00
|
|
|
</AccessibleButton>
|
2022-01-07 10:40:53 +01:00
|
|
|
),
|
2020-09-02 17:26:23 +01:00
|
|
|
pill: (
|
|
|
|
|
<Pill
|
2022-05-05 11:13:09 +02:00
|
|
|
type={PillType.UserMention}
|
2023-03-27 08:01:09 +01:00
|
|
|
room={room ?? undefined}
|
|
|
|
|
url={makeUserPermalink(ev.getSender()!)}
|
2020-09-02 17:26:23 +01:00
|
|
|
shouldShowPillAvatar={SettingsStore.getValue("Pill.shouldShowPillAvatar")}
|
|
|
|
|
/>
|
|
|
|
|
),
|
2018-02-10 11:19:43 +00:00
|
|
|
},
|
2018-01-22 16:34:47 +00:00
|
|
|
)}
|
2018-02-10 11:19:43 +00:00
|
|
|
</blockquote>
|
|
|
|
|
);
|
2021-06-08 18:35:45 +05:30
|
|
|
} else if (this.props.forExport) {
|
2022-02-22 11:14:56 +00:00
|
|
|
const eventId = getParentEventId(this.props.parentEv);
|
2021-10-15 17:42:44 +01:00
|
|
|
header = (
|
|
|
|
|
<p className="mx_ReplyChain_Export">
|
2021-08-13 23:44:07 +05:30
|
|
|
{_t(
|
2023-10-03 19:17:26 +01:00
|
|
|
"timeline|reply|in_reply_to_for_export",
|
2021-07-26 23:40:27 +05:30
|
|
|
{},
|
2021-08-13 23:44:07 +05:30
|
|
|
{
|
|
|
|
|
a: (sub) => (
|
2022-11-30 15:18:10 +00:00
|
|
|
<a className="mx_reply_anchor" href={`#${eventId}`} data-scroll-to={eventId}>
|
2022-12-12 12:24:14 +01:00
|
|
|
{" "}
|
2022-11-30 15:18:10 +00:00
|
|
|
{sub}{" "}
|
|
|
|
|
</a>
|
2021-07-26 23:40:27 +05:30
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
)}
|
2021-05-31 19:01:32 +05:30
|
|
|
</p>
|
|
|
|
|
);
|
2018-02-10 11:19:43 +00:00
|
|
|
} else if (this.state.loading) {
|
2025-12-17 11:17:11 +00:00
|
|
|
header = <Spinner size={16} />;
|
2017-12-10 12:50:41 +00:00
|
|
|
}
|
2017-12-10 12:54:19 +00:00
|
|
|
|
2021-09-27 12:20:37 +02:00
|
|
|
const { isQuoteExpanded } = this.props;
|
2018-02-10 11:19:43 +00:00
|
|
|
const evTiles = this.state.events.map((ev) => {
|
2021-09-27 12:20:37 +02:00
|
|
|
const classname = classNames({
|
2021-10-15 17:42:44 +01:00
|
|
|
"mx_ReplyChain": true,
|
|
|
|
|
[this.getReplyChainColorClass(ev)]: true,
|
2021-09-27 12:20:37 +02:00
|
|
|
// We don't want to add the class if it's undefined, it should only be expanded/collapsed when it's true/false
|
2021-10-15 17:42:44 +01:00
|
|
|
"mx_ReplyChain--expanded": isQuoteExpanded === true,
|
2021-09-27 12:20:37 +02:00
|
|
|
// We don't want to add the class if it's undefined, it should only be expanded/collapsed when it's true/false
|
2021-10-15 17:42:44 +01:00
|
|
|
"mx_ReplyChain--collapsed": isQuoteExpanded === false,
|
2021-09-27 12:20:37 +02:00
|
|
|
});
|
|
|
|
|
return (
|
|
|
|
|
<blockquote ref={this.blockquoteRef} className={classname} key={ev.getId()}>
|
|
|
|
|
<ReplyTile
|
|
|
|
|
mxEvent={ev}
|
|
|
|
|
permalinkCreator={this.props.permalinkCreator}
|
|
|
|
|
toggleExpandedQuote={() => this.props.setQuoteExpanded(!this.props.isQuoteExpanded)}
|
2021-12-13 13:12:22 +00:00
|
|
|
getRelationsForEvent={this.props.getRelationsForEvent}
|
2021-09-27 12:20:37 +02:00
|
|
|
/>
|
|
|
|
|
</blockquote>
|
|
|
|
|
);
|
2018-02-10 11:19:43 +00:00
|
|
|
});
|
|
|
|
|
|
2021-10-15 17:42:44 +01:00
|
|
|
return (
|
|
|
|
|
<div className="mx_ReplyChain_wrapper">
|
2018-02-10 11:19:43 +00:00
|
|
|
<div>{header}</div>
|
|
|
|
|
<div>{evTiles}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2017-12-17 20:20:45 +00:00
|
|
|
}
|
|
|
|
|
}
|