diff --git a/res/css/views/elements/_ReplyThread.scss b/res/css/views/elements/_ReplyThread.scss index 032cb49359..e19be82e25 100644 --- a/res/css/views/elements/_ReplyThread.scss +++ b/res/css/views/elements/_ReplyThread.scss @@ -59,3 +59,14 @@ limitations under the License. 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; + } +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 6805036e3d..46fc11956f 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -117,6 +117,16 @@ limitations under the License. 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 { background-color: transparent; // hide the download icon mask } diff --git a/res/img/element-icons/collapse-message.svg b/res/img/element-icons/collapse-message.svg new file mode 100644 index 0000000000..91b0713f43 --- /dev/null +++ b/res/img/element-icons/collapse-message.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/element-icons/expand-message.svg b/res/img/element-icons/expand-message.svg new file mode 100644 index 0000000000..a1c5149718 --- /dev/null +++ b/res/img/element-icons/expand-message.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index d65f8e3a10..2173230627 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -45,7 +45,7 @@ function getOrCreateContainer(): HTMLDivElement { const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); -interface IPosition { +export interface IPosition { top?: number; bottom?: number; left?: number; @@ -430,7 +430,11 @@ export type AboveLeftOf = IPosition & { // Placement method for 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?) -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 buttonRight = elementRect.right + window.pageXOffset; diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 8f5d3baa17..c7fcf32260 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -38,6 +38,7 @@ import ConfirmRedactDialog from '../dialogs/ConfirmRedactDialog'; import ErrorDialog from '../dialogs/ErrorDialog'; import ShareDialog from '../dialogs/ShareDialog'; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { IPosition, ChevronFace } from '../../structures/ContextMenu'; export function canCancel(eventStatus: EventStatus): boolean { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; @@ -52,7 +53,8 @@ export interface IOperableEventTile { getEventTileOps(): IEventTileOps; } -interface IProps { +interface IProps extends IPosition { + chevronFace: ChevronFace; /* the MatrixEvent associated with the context menu */ mxEvent: MatrixEvent; /* an optional EventTileOps implementation that can be used to unhide preview widgets */ diff --git a/src/components/views/elements/ReplyThread.tsx b/src/components/views/elements/ReplyThread.tsx index 59c827d5d8..bd81218623 100644 --- a/src/components/views/elements/ReplyThread.tsx +++ b/src/components/views/elements/ReplyThread.tsx @@ -16,6 +16,8 @@ limitations under the License. */ import React from 'react'; +import classNames from 'classnames'; + import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; @@ -35,6 +37,12 @@ import ReplyTile from "../rooms/ReplyTile"; import Pill from './Pill'; 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 { // the latest event in this chain of replies parentEv?: MatrixEvent; @@ -45,6 +53,8 @@ interface IProps { layout?: Layout; // Whether to always show a timestamp alwaysShowTimestamps?: boolean; + isQuoteExpanded?: boolean; + setQuoteExpanded: (isExpanded: boolean) => void; } interface IState { @@ -66,6 +76,7 @@ export default class ReplyThread extends React.Component { static contextType = MatrixClientContext; private unmounted = false; private room: Room; + private blockquoteRef = React.createRef(); constructor(props, context) { super(props, context); @@ -80,7 +91,7 @@ export default class ReplyThread extends React.Component { 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; // XXX: For newer relations (annotations, replacements, etc.), we now @@ -137,7 +148,7 @@ export default class ReplyThread extends React.Component { public static getNestedReplyText( ev: MatrixEvent, permalinkCreator: RoomPermalinkCreator, - ): { body: string, html: string } { + ): { body: string, html: string } | null { if (!ev) return null; let { body, formatted_body: html } = ev.getContent(); @@ -237,37 +248,38 @@ export default class ReplyThread extends React.Component { return replyMixin; } - public static makeThread( - parentEv: MatrixEvent, - onHeightChanged: () => void, - permalinkCreator: RoomPermalinkCreator, - ref: React.RefObject, - layout: Layout, - alwaysShowTimestamps: boolean, - ): JSX.Element { - if (!ReplyThread.getParentEventId(parentEv)) return null; - return ; + public static hasThreadReply(event: MatrixEvent) { + return Boolean(ReplyThread.getParentEventId(event)); } 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 @@ -321,7 +333,7 @@ export default class ReplyThread extends React.Component { this.initialize(); }; - private onQuoteClick = async (): Promise => { + private onQuoteClick = async (event: React.MouseEvent): Promise => { const events = [this.state.loadedEv, ...this.state.events]; let loadedEv = null; @@ -373,14 +385,26 @@ export default class ReplyThread extends React.Component { header = ; } + const { isQuoteExpanded } = this.props; const evTiles = this.state.events.map((ev) => { - return
- -
; + 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 ( +
+ this.props.setQuoteExpanded(!this.props.isQuoteExpanded)} + /> +
+ ); }); return
diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index f76fa32ddc..e835584387 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -17,7 +17,8 @@ limitations under the License. */ 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 * as sdk from '../../../index'; @@ -35,13 +36,17 @@ import Resend from "../../../Resend"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import DownloadActionButton from "./DownloadActionButton"; +import MessageContextMenu from "../context_menus/MessageContextMenu"; +import classNames from 'classnames'; + import SettingsStore from '../../../settings/SettingsStore'; import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import ReplyThread from '../elements/ReplyThread'; interface IOptionsButtonProps { mxEvent: MatrixEvent; - getTile: () => any; // TODO: FIXME, haven't figured out what the return type is here + // TODO: Types + getTile: () => any | null; getReplyThread: () => ReplyThread; permalinkCreator: RoomPermalinkCreator; onFocusChange: (menuDisplayed: boolean) => void; @@ -57,8 +62,6 @@ const OptionsButton: React.FC = let contextMenu; if (menuDisplayed) { - const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu'); - const tile = getTile && getTile(); const replyThread = getReplyThread && getReplyThread(); @@ -90,7 +93,7 @@ const OptionsButton: React.FC = interface IReactButtonProps { mxEvent: MatrixEvent; - reactions: any; // TODO: types + reactions: Relations; onFocusChange: (menuDisplayed: boolean) => void; } @@ -127,12 +130,14 @@ const ReactButton: React.FC = ({ mxEvent, reactions, onFocusC interface IMessageActionBarProps { mxEvent: MatrixEvent; - // The Relations model from the JS SDK for reactions to `mxEvent` - reactions?: any; // TODO: types + reactions?: Relations; + // TODO: Types + getTile: () => any | null; + getReplyThread: () => ReplyThread | undefined; permalinkCreator?: RoomPermalinkCreator; - getTile: () => any; // TODO: FIXME, haven't figured out what the return type is here - getReplyThread?: () => ReplyThread; - onFocusChange?: (menuDisplayed: boolean) => void; + onFocusChange: (menuDisplayed: boolean) => void; + isQuoteExpanded?: boolean; + toggleThreadExpanded: () => void; } @replaceableComponent("views.messages.MessageActionBar") @@ -324,6 +329,20 @@ export default class MessageActionBar extends React.PureComponent); + } + // The menu button should be last, so dump it there. toolbarOpts.push( { // 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... 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; const button = document.createElement("span"); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index d1ac06b199..592827eaf5 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -322,7 +322,7 @@ interface IState { reactions: Relations; hover: boolean; - + isQuoteExpanded?: boolean; thread?: Thread; } @@ -330,7 +330,8 @@ interface IState { export default class EventTile extends React.Component { private suppressReadReceiptAnimation: boolean; private isListeningForReceipts: boolean; - private tile = React.createRef(); + // TODO: Types + private tile = React.createRef(); private replyThread = React.createRef(); public readonly ref = createRef(); @@ -888,8 +889,8 @@ export default class EventTile extends React.Component { actionBarFocused: focused, }); }; - - getTile = () => this.tile.current; + // TODO: Types + getTile: () => any | null = () => this.tile.current; getReplyThread = () => this.replyThread.current; @@ -914,6 +915,11 @@ export default class EventTile extends React.Component { }); }; + private setQuoteExpanded = (expanded: boolean) => { + this.setState({ + isQuoteExpanded: expanded, + }); + }; render() { const msgtype = this.props.mxEvent.getContent().msgtype; const eventType = this.props.mxEvent.getType() as EventType; @@ -923,6 +929,7 @@ export default class EventTile extends React.Component { isInfoMessage, isLeftAlignedBubbleMessage, } = getEventDisplayInfo(this.props.mxEvent); + const { isQuoteExpanded } = this.state; // This shouldn't happen: the caller should check we support this type // before trying to instantiate us @@ -935,6 +942,7 @@ export default class EventTile extends React.Component {
; } + const EventTileType = sdk.getComponent(tileHandler); const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1); @@ -1054,6 +1062,8 @@ export default class EventTile extends React.Component { getTile={this.getTile} getReplyThread={this.getReplyThread} onFocusChange={this.onActionBarFocusChange} + isQuoteExpanded={isQuoteExpanded} + toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)} /> : undefined; const showTimestamp = this.props.mxEvent.getTs() @@ -1192,20 +1202,18 @@ export default class EventTile extends React.Component { } default: { - let thread; - // When the "showHiddenEventsInTimeline" lab is enabled, - // avoid showing replies for hidden events (events without tiles) - if (haveTileForEvent(this.props.mxEvent)) { - thread = ReplyThread.makeThread( - this.props.mxEvent, - this.props.onHeightChanged, - this.props.permalinkCreator, - this.replyThread, - this.props.layout, - this.props.alwaysShowTimestamps || this.state.hover, - ); - } - + const thread = haveTileForEvent(this.props.mxEvent) && + ReplyThread.hasThreadReply(this.props.mxEvent) ? ( + ) : null; 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 diff --git a/src/components/views/rooms/ReplyTile.tsx b/src/components/views/rooms/ReplyTile.tsx index cf7d1ce945..01a9e2f18b 100644 --- a/src/components/views/rooms/ReplyTile.tsx +++ b/src/components/views/rooms/ReplyTile.tsx @@ -35,6 +35,7 @@ interface IProps { highlights?: string[]; highlightLink?: string; onHeightChanged?(): void; + toggleExpandedQuote?: () => void; } @replaceableComponent("views.rooms.ReplyTile") @@ -82,12 +83,17 @@ export default class ReplyTile extends React.PureComponent { // 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. e.preventDefault(); - dis.dispatch({ - action: 'view_room', - event_id: this.props.mxEvent.getId(), - highlighted: true, - room_id: this.props.mxEvent.getRoomId(), - }); + // Expand thread on shift key + if (this.props.toggleExpandedQuote && e.shiftKey) { + this.props.toggleExpandedQuote(); + } else { + dis.dispatch({ + action: 'view_room', + event_id: this.props.mxEvent.getId(), + highlighted: true, + room_id: this.props.mxEvent.getRoomId(), + }); + } } }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b5d90f6671..bc45caedb5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1944,6 +1944,8 @@ "Edit": "Edit", "Reply": "Reply", "Thread": "Thread", + "Collapse quotes │ ⇧+click": "Collapse quotes │ ⇧+click", + "Expand quotes │ ⇧+click": "Expand quotes │ ⇧+click", "Message Actions": "Message Actions", "Download %(text)s": "Download %(text)s", "Error decrypting attachment": "Error decrypting attachment",