Add ability to expand and collapse long quoted messages (#6701)

In case where we had a very long message the experience of going between 
messages to see the full quote isn't very nice on desktop, therefore this commit
adds a button with additional hotkey to normalize the experience a bit.

Fixes https://github.com/vector-im/element-web/issues/18884
pull/21833/head
Dariusz Niemczyk 2021-09-27 12:20:37 +02:00 committed by GitHub
parent e5f2a06102
commit 0cfa2a58c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 153 additions and 64 deletions

View File

@ -59,3 +59,14 @@ limitations under the License.
border-left-color: $username-variant8-color; border-left-color: $username-variant8-color;
} }
} }
.mx_ReplyThread--expanded {
.mx_EventTile_body {
display: block;
overflow-y: scroll !important;
}
.mx_EventTile_collapsedCodeBlock {
// !important needed due to .mx_ReplyTile .mx_EventTile_content .mx_EventTile_pre_container > pre
display: block !important;
}
}

View File

@ -117,6 +117,16 @@ limitations under the License.
mask-image: url('$(res)/img/download.svg'); mask-image: url('$(res)/img/download.svg');
} }
.mx_MessageActionBar_expandMessageButton::after {
mask-size: 12px;
mask-image: url('$(res)/img/element-icons/expand-message.svg');
}
.mx_MessageActionBar_collapseMessageButton::after {
mask-size: 12px;
mask-image: url('$(res)/img/element-icons/collapse-message.svg');
}
.mx_MessageActionBar_downloadButton.mx_MessageActionBar_downloadSpinnerButton::after { .mx_MessageActionBar_downloadButton.mx_MessageActionBar_downloadSpinnerButton::after {
background-color: transparent; // hide the download icon mask background-color: transparent; // hide the download icon mask
} }

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 11 14"><defs/><path fill="#737D8C" fill-rule="evenodd" d="M.2192.234A.753.753 0 011.2815.2321l3.7243 3.7003L8.7181.2202A.753.753 0 019.7805.2185a.747.747 0 01.0017 1.0589L5.5396 5.52a.753.753 0 01-1.0624.0018L.221 1.2928A.747.747 0 01.2192.234zM9.7822 13.7663a.7529.7529 0 01-1.0623.0017l-3.7243-3.7003L1.2833 13.78a.753.753 0 01-1.0624.0018.7471.7471 0 01-.0017-1.059l4.2426-4.2426a.753.753 0 011.0624-.0017l4.2563 4.2289a.747.747 0 01.0017 1.0589z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 543 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 11 14"><defs/><path fill="#17191C" fill-rule="evenodd" d="M.2192 8.494a.753.753 0 011.0623-.0018l3.7243 3.7003 3.7123-3.7123a.753.753 0 011.0624-.0017.747.747 0 01.0017 1.059L5.5396 13.78a.753.753 0 01-1.0624.0018L.221 9.5528A.747.747 0 01.2192 8.494zM9.7822 5.5063A.753.753 0 018.72 5.508L4.9956 1.8077 1.2833 5.52a.753.753 0 01-1.0624.0018.747.747 0 01-.0017-1.059L4.4618.2202A.753.753 0 015.5242.2185l4.2563 4.2289a.747.747 0 01.0017 1.0589z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 538 B

View File

@ -45,7 +45,7 @@ function getOrCreateContainer(): HTMLDivElement {
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
interface IPosition { export interface IPosition {
top?: number; top?: number;
bottom?: number; bottom?: number;
left?: number; left?: number;
@ -430,7 +430,11 @@ export type AboveLeftOf = IPosition & {
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect, // Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?) // and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0): AboveLeftOf => { export const aboveLeftOf = (
elementRect: DOMRect,
chevronFace = ChevronFace.None,
vPadding = 0,
): AboveLeftOf => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset; const buttonRight = elementRect.right + window.pageXOffset;

View File

@ -38,6 +38,7 @@ import ConfirmRedactDialog from '../dialogs/ConfirmRedactDialog';
import ErrorDialog from '../dialogs/ErrorDialog'; import ErrorDialog from '../dialogs/ErrorDialog';
import ShareDialog from '../dialogs/ShareDialog'; import ShareDialog from '../dialogs/ShareDialog';
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { IPosition, ChevronFace } from '../../structures/ContextMenu';
export function canCancel(eventStatus: EventStatus): boolean { export function canCancel(eventStatus: EventStatus): boolean {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
@ -52,7 +53,8 @@ export interface IOperableEventTile {
getEventTileOps(): IEventTileOps; getEventTileOps(): IEventTileOps;
} }
interface IProps { interface IProps extends IPosition {
chevronFace: ChevronFace;
/* the MatrixEvent associated with the context menu */ /* the MatrixEvent associated with the context menu */
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
/* an optional EventTileOps implementation that can be used to unhide preview widgets */ /* an optional EventTileOps implementation that can be used to unhide preview widgets */

View File

@ -16,6 +16,8 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import classNames from 'classnames';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
@ -35,6 +37,12 @@ import ReplyTile from "../rooms/ReplyTile";
import Pill from './Pill'; import Pill from './Pill';
import { Room } from 'matrix-js-sdk/src/models/room'; import { Room } from 'matrix-js-sdk/src/models/room';
/**
* 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 { interface IProps {
// the latest event in this chain of replies // the latest event in this chain of replies
parentEv?: MatrixEvent; parentEv?: MatrixEvent;
@ -45,6 +53,8 @@ interface IProps {
layout?: Layout; layout?: Layout;
// Whether to always show a timestamp // Whether to always show a timestamp
alwaysShowTimestamps?: boolean; alwaysShowTimestamps?: boolean;
isQuoteExpanded?: boolean;
setQuoteExpanded: (isExpanded: boolean) => void;
} }
interface IState { interface IState {
@ -66,6 +76,7 @@ export default class ReplyThread extends React.Component<IProps, IState> {
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private unmounted = false; private unmounted = false;
private room: Room; private room: Room;
private blockquoteRef = React.createRef<HTMLElement>();
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@ -80,7 +91,7 @@ export default class ReplyThread extends React.Component<IProps, IState> {
this.room = this.context.getRoom(this.props.parentEv.getRoomId()); this.room = this.context.getRoom(this.props.parentEv.getRoomId());
} }
public static getParentEventId(ev: MatrixEvent): string { public static getParentEventId(ev: MatrixEvent): string | undefined {
if (!ev || ev.isRedacted()) return; if (!ev || ev.isRedacted()) return;
// XXX: For newer relations (annotations, replacements, etc.), we now // XXX: For newer relations (annotations, replacements, etc.), we now
@ -137,7 +148,7 @@ export default class ReplyThread extends React.Component<IProps, IState> {
public static getNestedReplyText( public static getNestedReplyText(
ev: MatrixEvent, ev: MatrixEvent,
permalinkCreator: RoomPermalinkCreator, permalinkCreator: RoomPermalinkCreator,
): { body: string, html: string } { ): { body: string, html: string } | null {
if (!ev) return null; if (!ev) return null;
let { body, formatted_body: html } = ev.getContent(); let { body, formatted_body: html } = ev.getContent();
@ -237,37 +248,38 @@ export default class ReplyThread extends React.Component<IProps, IState> {
return replyMixin; return replyMixin;
} }
public static makeThread( public static hasThreadReply(event: MatrixEvent) {
parentEv: MatrixEvent, return Boolean(ReplyThread.getParentEventId(event));
onHeightChanged: () => void,
permalinkCreator: RoomPermalinkCreator,
ref: React.RefObject<ReplyThread>,
layout: Layout,
alwaysShowTimestamps: boolean,
): JSX.Element {
if (!ReplyThread.getParentEventId(parentEv)) return null;
return <ReplyThread
parentEv={parentEv}
onHeightChanged={onHeightChanged}
ref={ref}
permalinkCreator={permalinkCreator}
layout={layout}
alwaysShowTimestamps={alwaysShowTimestamps}
/>;
} }
componentDidMount() { componentDidMount() {
this.initialize(); this.initialize();
this.trySetExpandableQuotes();
} }
componentDidUpdate() { componentDidUpdate() {
this.props.onHeightChanged(); this.props.onHeightChanged();
this.trySetExpandableQuotes();
} }
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; 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<void> { private async initialize(): Promise<void> {
const { parentEv } = this.props; const { parentEv } = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId // at time of making this component we checked that props.parentEv has a parentEventId
@ -321,7 +333,7 @@ export default class ReplyThread extends React.Component<IProps, IState> {
this.initialize(); this.initialize();
}; };
private onQuoteClick = async (): Promise<void> => { private onQuoteClick = async (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>): Promise<void> => {
const events = [this.state.loadedEv, ...this.state.events]; const events = [this.state.loadedEv, ...this.state.events];
let loadedEv = null; let loadedEv = null;
@ -373,14 +385,26 @@ export default class ReplyThread extends React.Component<IProps, IState> {
header = <Spinner w={16} h={16} />; header = <Spinner w={16} h={16} />;
} }
const { isQuoteExpanded } = this.props;
const evTiles = this.state.events.map((ev) => { const evTiles = this.state.events.map((ev) => {
return <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`} key={ev.getId()}> const classname = classNames({
'mx_ReplyThread': true,
[this.getReplyThreadColorClass(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_ReplyThread--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_ReplyThread--collapsed': isQuoteExpanded === false,
});
return (
<blockquote ref={this.blockquoteRef} className={classname} key={ev.getId()}>
<ReplyTile <ReplyTile
mxEvent={ev} mxEvent={ev}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
toggleExpandedQuote={() => this.props.setQuoteExpanded(!this.props.isQuoteExpanded)}
/> />
</blockquote>; </blockquote>
);
}); });
return <div className="mx_ReplyThread_wrapper"> return <div className="mx_ReplyThread_wrapper">

View File

@ -17,7 +17,8 @@ limitations under the License.
*/ */
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event'; import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import type { Relations } from 'matrix-js-sdk/src/models/relations';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
@ -35,13 +36,17 @@ import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import DownloadActionButton from "./DownloadActionButton"; import DownloadActionButton from "./DownloadActionButton";
import MessageContextMenu from "../context_menus/MessageContextMenu";
import classNames from 'classnames';
import SettingsStore from '../../../settings/SettingsStore'; import SettingsStore from '../../../settings/SettingsStore';
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import ReplyThread from '../elements/ReplyThread'; import ReplyThread from '../elements/ReplyThread';
interface IOptionsButtonProps { interface IOptionsButtonProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
getTile: () => any; // TODO: FIXME, haven't figured out what the return type is here // TODO: Types
getTile: () => any | null;
getReplyThread: () => ReplyThread; getReplyThread: () => ReplyThread;
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
onFocusChange: (menuDisplayed: boolean) => void; onFocusChange: (menuDisplayed: boolean) => void;
@ -57,8 +62,6 @@ const OptionsButton: React.FC<IOptionsButtonProps> =
let contextMenu; let contextMenu;
if (menuDisplayed) { if (menuDisplayed) {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const tile = getTile && getTile(); const tile = getTile && getTile();
const replyThread = getReplyThread && getReplyThread(); const replyThread = getReplyThread && getReplyThread();
@ -90,7 +93,7 @@ const OptionsButton: React.FC<IOptionsButtonProps> =
interface IReactButtonProps { interface IReactButtonProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
reactions: any; // TODO: types reactions: Relations;
onFocusChange: (menuDisplayed: boolean) => void; onFocusChange: (menuDisplayed: boolean) => void;
} }
@ -127,12 +130,14 @@ const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusC
interface IMessageActionBarProps { interface IMessageActionBarProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
// The Relations model from the JS SDK for reactions to `mxEvent` reactions?: Relations;
reactions?: any; // TODO: types // TODO: Types
getTile: () => any | null;
getReplyThread: () => ReplyThread | undefined;
permalinkCreator?: RoomPermalinkCreator; permalinkCreator?: RoomPermalinkCreator;
getTile: () => any; // TODO: FIXME, haven't figured out what the return type is here onFocusChange: (menuDisplayed: boolean) => void;
getReplyThread?: () => ReplyThread; isQuoteExpanded?: boolean;
onFocusChange?: (menuDisplayed: boolean) => void; toggleThreadExpanded: () => void;
} }
@replaceableComponent("views.messages.MessageActionBar") @replaceableComponent("views.messages.MessageActionBar")
@ -324,6 +329,20 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
toolbarOpts.push(cancelSendingButton); toolbarOpts.push(cancelSendingButton);
} }
if (this.props.isQuoteExpanded !== undefined && ReplyThread.hasThreadReply(this.props.mxEvent)) {
const expandClassName = classNames({
'mx_MessageActionBar_maskButton': true,
'mx_MessageActionBar_expandMessageButton': !this.props.isQuoteExpanded,
'mx_MessageActionBar_collapseMessageButton': this.props.isQuoteExpanded,
});
toolbarOpts.push(<RovingAccessibleTooltipButton
className={expandClassName}
title={this.props.isQuoteExpanded ? _t("Collapse quotes │ ⇧+click") : _t("Expand quotes │ ⇧+click")}
onClick={this.props.toggleThreadExpanded}
key="expand"
/>);
}
// The menu button should be last, so dump it there. // The menu button should be last, so dump it there.
toolbarOpts.push(<OptionsButton toolbarOpts.push(<OptionsButton
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}

View File

@ -138,6 +138,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
// If it's less than 30% we don't add the expansion button. // If it's less than 30% we don't add the expansion button.
// We also round the number as it sometimes can be 29.99... // We also round the number as it sometimes can be 29.99...
const percentageOfViewport = Math.round(pre.offsetHeight / UIStore.instance.windowHeight * 100); const percentageOfViewport = Math.round(pre.offsetHeight / UIStore.instance.windowHeight * 100);
// TODO: additionally show the button if it's an expanded quoted message
if (percentageOfViewport < 30) return; if (percentageOfViewport < 30) return;
const button = document.createElement("span"); const button = document.createElement("span");

View File

@ -322,7 +322,7 @@ interface IState {
reactions: Relations; reactions: Relations;
hover: boolean; hover: boolean;
isQuoteExpanded?: boolean;
thread?: Thread; thread?: Thread;
} }
@ -330,7 +330,8 @@ interface IState {
export default class EventTile extends React.Component<IProps, IState> { export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean; private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean; private isListeningForReceipts: boolean;
private tile = React.createRef(); // TODO: Types
private tile = React.createRef<unknown>();
private replyThread = React.createRef<ReplyThread>(); private replyThread = React.createRef<ReplyThread>();
public readonly ref = createRef<HTMLElement>(); public readonly ref = createRef<HTMLElement>();
@ -888,8 +889,8 @@ export default class EventTile extends React.Component<IProps, IState> {
actionBarFocused: focused, actionBarFocused: focused,
}); });
}; };
// TODO: Types
getTile = () => this.tile.current; getTile: () => any | null = () => this.tile.current;
getReplyThread = () => this.replyThread.current; getReplyThread = () => this.replyThread.current;
@ -914,6 +915,11 @@ export default class EventTile extends React.Component<IProps, IState> {
}); });
}; };
private setQuoteExpanded = (expanded: boolean) => {
this.setState({
isQuoteExpanded: expanded,
});
};
render() { render() {
const msgtype = this.props.mxEvent.getContent().msgtype; const msgtype = this.props.mxEvent.getContent().msgtype;
const eventType = this.props.mxEvent.getType() as EventType; const eventType = this.props.mxEvent.getType() as EventType;
@ -923,6 +929,7 @@ export default class EventTile extends React.Component<IProps, IState> {
isInfoMessage, isInfoMessage,
isLeftAlignedBubbleMessage, isLeftAlignedBubbleMessage,
} = getEventDisplayInfo(this.props.mxEvent); } = getEventDisplayInfo(this.props.mxEvent);
const { isQuoteExpanded } = this.state;
// This shouldn't happen: the caller should check we support this type // This shouldn't happen: the caller should check we support this type
// before trying to instantiate us // before trying to instantiate us
@ -935,6 +942,7 @@ export default class EventTile extends React.Component<IProps, IState> {
</div> </div>
</div>; </div>;
} }
const EventTileType = sdk.getComponent(tileHandler); const EventTileType = sdk.getComponent(tileHandler);
const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1); const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
@ -1054,6 +1062,8 @@ export default class EventTile extends React.Component<IProps, IState> {
getTile={this.getTile} getTile={this.getTile}
getReplyThread={this.getReplyThread} getReplyThread={this.getReplyThread}
onFocusChange={this.onActionBarFocusChange} onFocusChange={this.onActionBarFocusChange}
isQuoteExpanded={isQuoteExpanded}
toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
/> : undefined; /> : undefined;
const showTimestamp = this.props.mxEvent.getTs() const showTimestamp = this.props.mxEvent.getTs()
@ -1192,20 +1202,18 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
default: { default: {
let thread; const thread = haveTileForEvent(this.props.mxEvent) &&
// When the "showHiddenEventsInTimeline" lab is enabled, ReplyThread.hasThreadReply(this.props.mxEvent) ? (
// avoid showing replies for hidden events (events without tiles) <ReplyThread
if (haveTileForEvent(this.props.mxEvent)) { parentEv={this.props.mxEvent}
thread = ReplyThread.makeThread( onHeightChanged={this.props.onHeightChanged}
this.props.mxEvent, ref={this.replyThread}
this.props.onHeightChanged, permalinkCreator={this.props.permalinkCreator}
this.props.permalinkCreator, layout={this.props.layout}
this.replyThread, alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover}
this.props.layout, isQuoteExpanded={isQuoteExpanded}
this.props.alwaysShowTimestamps || this.state.hover, setQuoteExpanded={this.setQuoteExpanded}
); />) : null;
}
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId(); const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers

View File

@ -35,6 +35,7 @@ interface IProps {
highlights?: string[]; highlights?: string[];
highlightLink?: string; highlightLink?: string;
onHeightChanged?(): void; onHeightChanged?(): void;
toggleExpandedQuote?: () => void;
} }
@replaceableComponent("views.rooms.ReplyTile") @replaceableComponent("views.rooms.ReplyTile")
@ -82,6 +83,10 @@ export default class ReplyTile extends React.PureComponent<IProps> {
// This allows the permalink to be opened in a new tab/window or copied as // 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 Riot when clicked. // matrix.to, but also for it to enable routing within Riot when clicked.
e.preventDefault(); e.preventDefault();
// Expand thread on shift key
if (this.props.toggleExpandedQuote && e.shiftKey) {
this.props.toggleExpandedQuote();
} else {
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
event_id: this.props.mxEvent.getId(), event_id: this.props.mxEvent.getId(),
@ -89,6 +94,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
room_id: this.props.mxEvent.getRoomId(), room_id: this.props.mxEvent.getRoomId(),
}); });
} }
}
}; };
render() { render() {

View File

@ -1944,6 +1944,8 @@
"Edit": "Edit", "Edit": "Edit",
"Reply": "Reply", "Reply": "Reply",
"Thread": "Thread", "Thread": "Thread",
"Collapse quotes │ ⇧+click": "Collapse quotes │ ⇧+click",
"Expand quotes │ ⇧+click": "Expand quotes │ ⇧+click",
"Message Actions": "Message Actions", "Message Actions": "Message Actions",
"Download %(text)s": "Download %(text)s", "Download %(text)s": "Download %(text)s",
"Error decrypting attachment": "Error decrypting attachment", "Error decrypting attachment": "Error decrypting attachment",