From 0bae79d3c335764d96e1119fabfa6245573fc217 Mon Sep 17 00:00:00 2001 From: Germain Date: Tue, 2 Nov 2021 13:18:51 +0000 Subject: [PATCH] Improve Thread View UI (#7063) --- res/css/structures/_ContextualMenu.scss | 8 ++ res/css/views/right_panel/_ThreadPanel.scss | 84 ++++++++---- res/css/views/rooms/_EventTile.scss | 23 ++-- res/css/views/rooms/_MessageComposer.scss | 6 + src/components/structures/ContextMenu.tsx | 4 + src/components/structures/ThreadView.tsx | 127 +++++++++++++++++- .../views/right_panel/RoomHeaderButtons.tsx | 5 +- src/components/views/rooms/EventTile.tsx | 4 +- .../views/rooms/MessageComposer.tsx | 1 + src/i18n/strings/en_EN.json | 2 + 10 files changed, 222 insertions(+), 42 deletions(-) diff --git a/res/css/structures/_ContextualMenu.scss b/res/css/structures/_ContextualMenu.scss index 9f2b9e24b8..88a01f220f 100644 --- a/res/css/structures/_ContextualMenu.scss +++ b/res/css/structures/_ContextualMenu.scss @@ -116,3 +116,11 @@ limitations under the License. border-top: 8px solid $menu-bg-color; border-right: 8px solid transparent; } + +.mx_ContextualMenu_rightAligned { + transform: translateX(-100%); +} + +.mx_ContextualMenu_bottomAligned { + transform: translateY(-100%); +} diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss index 06137196a3..7a3a7d3d60 100644 --- a/res/css/views/right_panel/_ThreadPanel.scss +++ b/res/css/views/right_panel/_ThreadPanel.scss @@ -18,20 +18,24 @@ limitations under the License. display: flex; flex-direction: column; + padding-right: 0; + .mx_BaseCard_header { - padding: 6px 0; + padding: 6px 8px 6px 0; + + .mx_BaseCard_close, + .mx_BaseCard_back { + margin-top: 15px; + } .mx_BaseCard_close { - margin-top: 15px; + right: -8px; } } - .mx_AccessibleButton.mx_BaseCard_back { - display: none; - } - - &__header { - width: calc(100% - 40px); + .mx_ThreadPanel__header { + width: calc(100% - 60px); + margin-left: 30px; display: flex; flex: 1; justify-content: space-between; @@ -99,11 +103,39 @@ limitations under the License. } } + .mx_ThreadPanel_button { + width: 20px; + height: 20px; + margin-top: -3px; + margin-bottom: auto; + position: relative; + + &::before { + top: 2px; + left: 2px; + content: ''; + width: 16px; + height: 16px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-content; + } + + &.mx_ThreadPanel_OptionsButton::before { + mask-image: url('$(res)/img/element-icons/context-menu.svg'); + } + } + + .mx_AutoHideScrollbar { + border-radius: 8px; + } + .mx_RoomView_messageListWrapper { background-color: $background; - border-radius: 8px; - padding-top: 8px; - padding-bottom: 12px; + padding: 8px; + border-radius: inherit; } .mx_ScrollPanel { @@ -116,18 +148,7 @@ limitations under the License. // Account for scrollbar when hovering width: calc(100% - 3px); margin: 0 2px; - - .mx_MessageTimestamp { - // We need to add !important here due to some enormous selectors overriding it anyways - // See: _EventTile.scss:241 - left: unset !important; - right: 0 !important; - top: 16px; - } - - .mx_EventTile_line.mx_EventTile_line { - position: unset; - } + padding-top: 0; .mx_ThreadInfo { position: relative; @@ -148,4 +169,21 @@ limitations under the License. display: none; } } + + .mx_MessageComposer { + background-color: $background; + border-radius: 8px; + margin-top: 8px; + width: calc(100% - 8px); + padding: 0 8px; + box-sizing: border-box; + } +} + +.mx_ThreadPanel_viewInRoom::before { + mask-image: url('$(res)/img/element-icons/view-in-room.svg'); +} + +.mx_ThreadPanel_copyLinkToThread::before { + mask-image: url('$(res)/img/element-icons/link.svg'); } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 592f98590c..900de216a2 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -716,19 +716,10 @@ $left-gutter: 64px; display: flex; flex-direction: column; - .mx_ScrollPanel { - margin-top: 20px; - - .mx_RoomView_MessageList { - padding: 0; - } - } - .mx_EventTile_senderDetails { display: flex; align-items: center; gap: 6px; - margin-bottom: 6px; a { flex: 1; @@ -761,22 +752,28 @@ $left-gutter: 64px; width: 100%; display: flex; flex-direction: column; - margin-top: 0; - padding-bottom: 5px; - margin-bottom: 5px; + padding-top: 0; .mx_MessageTimestamp { left: auto; - right: 0; + right: 2px !important; + top: 1px !important; } .mx_ReactionsRow { order: 999; padding-left: 0; padding-right: 0; + margin-left: 36px; + margin-right: 50px; } } + .mx_EventTile_content { + margin-left: 36px; + margin-right: 50px; + } + .mx_MessageComposer_sendMessage { margin-right: 0; } diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index d824e8105e..3db4c7bce2 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -390,6 +390,12 @@ limitations under the License. padding: 0 0 0 25px; } + &:not(.mx_MessageComposer_e2eStatus) { + .mx_MessageComposer_wrapper { + padding: 0; + } + } + .mx_MessageComposer_button:last-child { margin-right: 0; } diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index aec1cf9dbf..94b4b46fd4 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -49,6 +49,8 @@ export interface IPosition { bottom?: number; left?: number; right?: number; + rightAligned?: boolean; + bottomAligned?: boolean; } export enum ChevronFace { @@ -346,6 +348,8 @@ export class ContextMenu extends React.PureComponent { 'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right, 'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top, 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom, + 'mx_ContextualMenu_rightAligned': this.props.rightAligned === true, + 'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true, }); const menuStyle: CSSProperties = {}; diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index c7a4342449..17895be9d1 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -37,6 +37,15 @@ import { MatrixClientPeg } from '../../MatrixClientPeg'; import { E2EStatus } from '../../utils/ShieldUtils'; import EditorStateTransfer from '../../utils/EditorStateTransfer'; import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext'; +import { ChevronFace, ContextMenuTooltipButton } from './ContextMenu'; +import { _t } from '../../languageHandler'; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from '../views/context_menus/IconizedContextMenu'; +import { ButtonEvent } from '../views/elements/AccessibleButton'; +import { copyPlaintext } from '../../utils/strings'; +import { sleep } from 'matrix-js-sdk/src/utils'; interface IProps { room: Room; @@ -48,13 +57,28 @@ interface IProps { initialEvent?: MatrixEvent; initialEventHighlighted?: boolean; } - interface IState { thread?: Thread; editState?: EditorStateTransfer; replyToEvent?: MatrixEvent; + threadOptionsPosition: DOMRect | null; + copyingPhase: CopyingPhase; } +enum CopyingPhase { + Idle, + Copying, + Failed, +} + +const contextMenuBelow = (elementRect: DOMRect) => { + // align the context menu's icons with the icon which opened the context menu + const left = elementRect.left + window.pageXOffset + elementRect.width; + const top = elementRect.bottom + window.pageYOffset + 17; + const chevronFace = ChevronFace.None; + return { left, top, chevronFace }; +}; + @replaceableComponent("structures.ThreadView") export default class ThreadView extends React.Component { static contextType = RoomContext; @@ -64,7 +88,10 @@ export default class ThreadView extends React.Component { constructor(props: IProps) { super(props); - this.state = {}; + this.state = { + threadOptionsPosition: null, + copyingPhase: CopyingPhase.Idle, + }; } public componentDidMount(): void { @@ -181,6 +208,98 @@ export default class ThreadView extends React.Component { } }; + private onThreadOptionsClick = (ev: ButtonEvent): void => { + if (this.isThreadOptionsVisible) { + this.closeThreadOptions(); + } else { + const position = ev.currentTarget.getBoundingClientRect(); + this.setState({ + threadOptionsPosition: position, + }); + } + }; + + private closeThreadOptions = (): void => { + this.setState({ + threadOptionsPosition: null, + }); + }; + + private get isThreadOptionsVisible(): boolean { + return !!this.state.threadOptionsPosition; + } + + private viewInRoom = (evt: ButtonEvent): void => { + evt.preventDefault(); + evt.stopPropagation(); + dis.dispatch({ + action: 'view_room', + event_id: this.props.mxEvent.getId(), + highlighted: true, + room_id: this.props.mxEvent.getRoomId(), + }); + this.closeThreadOptions(); + }; + + private copyLinkToThread = async (evt: ButtonEvent): Promise => { + evt.preventDefault(); + evt.stopPropagation(); + + const matrixToUrl = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + + this.setState({ + copyingPhase: CopyingPhase.Copying, + }); + + const hasSuccessfullyCopied = await copyPlaintext(matrixToUrl); + + if (hasSuccessfullyCopied) { + await sleep(500); + } else { + this.setState({ copyingPhase: CopyingPhase.Failed }); + await sleep(2500); + } + + this.setState({ copyingPhase: CopyingPhase.Idle }); + + if (hasSuccessfullyCopied) { + this.closeThreadOptions(); + } + }; + + private renderThreadViewHeader = (): JSX.Element => { + return
+ { _t("Thread") } + + { this.isThreadOptionsVisible && ( + + this.viewInRoom(e)} + label={_t("View in room")} + iconClassName="mx_ThreadPanel_viewInRoom" + /> + this.copyLinkToThread(e)} + label={_t("Copy link to thread")} + iconClassName="mx_ThreadPanel_copyLinkToThread" + /> + + ) } + +
; + }; + public render(): JSX.Element { const highlightedEventId = this.props.initialEventHighlighted ? this.props.initialEvent?.getId() @@ -193,10 +312,11 @@ export default class ThreadView extends React.Component { }}> { this.state.thread && ( { showUrlPreview={true} tileShape={TileShape.Thread} empty={
empty
} - alwaysShowTimestamps={true} layout={Layout.Group} hideThreadedMessages={false} hidden={false} diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index 9aac2361f0..fabe46c115 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -128,7 +128,10 @@ export default class RoomHeaderButtons extends HeaderButtons { name="threadsButton" title={_t("Threads")} onClick={dispatchShowThreadsPanelEvent} - isHighlighted={this.isPhase(RightPanelPhases.ThreadPanel)} + isHighlighted={this.isPhase([ + RightPanelPhases.ThreadPanel, + RightPanelPhases.ThreadView, + ])} analytics={['Right Panel', 'Threads List Button', 'click']} /> } { "aria-atomic": true, "data-scroll-tokens": scrollToken, "data-has-reply": !!replyChain, + "onMouseEnter": () => this.setState({ hover: true }), + "onMouseLeave": () => this.setState({ hover: false }), }, [
@@ -1262,7 +1264,6 @@ export default class EventTile extends React.Component { { avatar } { sender } - { timestamp }
,
@@ -1278,6 +1279,7 @@ export default class EventTile extends React.Component { replacingEventId={this.props.replacingEventId} /> { actionBar } + { timestamp }
, reactionsRow, ]); diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 5dcf7c0f5c..8075082dbf 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -640,6 +640,7 @@ export default class MessageComposer extends React.Component { "mx_MessageComposer": true, "mx_GroupLayout": true, "mx_MessageComposer--compact": this.props.compact, + "mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined, }); return ( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b0c02e53c8..41f12b12b0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3006,6 +3006,8 @@ "All threads": "All threads", "Shows all threads from current room": "Shows all threads from current room", "Show:": "Show:", + "Thread options": "Thread options", + "Copy link to thread": "Copy link to thread", "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Failed to load timeline position": "Failed to load timeline position",