From 38750202ee0effd5bf6a965a4609a283b587e526 Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 3 Nov 2021 18:05:01 +0000 Subject: [PATCH] Design thread list tiles according to mockups (#7078) --- res/css/views/right_panel/_ThreadPanel.scss | 37 ++++- res/css/views/rooms/_EventTile.scss | 90 ++++++++--- src/components/structures/ThreadPanel.tsx | 2 +- src/components/structures/ThreadView.tsx | 120 +------------- .../context_menus/ThreadListContextMenu.tsx | 103 ++++++++++++ src/components/views/rooms/EventTile.tsx | 151 +++++++++++++----- src/i18n/strings/en_EN.json | 8 +- .../__snapshots__/ThreadPanel-test.tsx.snap | 2 + 8 files changed, 332 insertions(+), 181 deletions(-) create mode 100644 src/components/views/context_menus/ThreadListContextMenu.tsx diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss index 7a3a7d3d60..9fd3cb3635 100644 --- a/res/css/views/right_panel/_ThreadPanel.scss +++ b/res/css/views/right_panel/_ThreadPanel.scss @@ -21,13 +21,10 @@ limitations under the License. padding-right: 0; .mx_BaseCard_header { - padding: 6px 8px 6px 0; - .mx_BaseCard_close, .mx_BaseCard_back { margin-top: 15px; } - .mx_BaseCard_close { right: -8px; } @@ -39,6 +36,7 @@ limitations under the License. display: flex; flex: 1; justify-content: space-between; + align-items: center; span:first-of-type { font-weight: 600; @@ -49,7 +47,11 @@ limitations under the License. .mx_AccessibleButton { font-size: 12px; - color: $secondary-content; + color: $primary-content; + } + + .mx_MessageActionBar_optionsButton { + position: relative; } .mx_ContextualMenu_wrapper { @@ -178,6 +180,33 @@ limitations under the License. padding: 0 8px; box-sizing: border-box; } + + .mx_ThreadPanel_dropdown { + padding: 4px 8px; + border-radius: 4px; + line-height: 1.5; + user-select: none; + } + + .mx_ThreadPanel_dropdown:hover, + .mx_ThreadPanel_dropdown[aria-expanded=true] { + background: $quinary-content; + } + + .mx_ThreadPanel_dropdown::before { + content: ""; + width: 18px; + height: 18px; + background: currentColor; + mask-image: url("$(res)/img/feather-customised/chevron-down.svg"); + mask-size: 100%; + mask-repeat: no-repeat; + float: right; + } + + .mx_ThreadPanel_dropdown[aria-expanded=true]::before { + transform: rotate(180deg); + } } .mx_ThreadPanel_viewInRoom::before { diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 900de216a2..c6f0bf1cf0 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -687,28 +687,82 @@ $left-gutter: 64px; padding-left: 11px; padding-right: 15px; } +} - .mx_ThreadInfo_content { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - padding-left: 8px; +.mx_ThreadInfo_content { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + padding-left: 8px; +} + +.mx_ThreadInfo_thread-icon { + mask-image: url('$(res)/img/element-icons/thread-summary.svg'); + mask-position: center; + height: 16px; + min-width: 16px; + background-color: $secondary-content; + mask-repeat: no-repeat; + mask-size: contain; +} +.mx_ThreadInfo_threads-amount { + font-weight: 600; + position: relative; + padding: 0 8px; + white-space: nowrap; +} + +.mx_EventTile[data-shape=thread_list] { + --topOffset: 24px; + --leftOffset: 46px; + + margin: var(--topOffset) 0; + border-radius: 8px; + + &:hover { + background-color: $system; } - .mx_ThreadInfo_thread-icon { - mask-image: url('$(res)/img/element-icons/thread-summary.svg'); - mask-position: center; - height: 16px; - min-width: 16px; - background-color: $secondary-content; - mask-repeat: no-repeat; - mask-size: contain; + &::after { + content: ""; + position: absolute; + left: var(--leftOffset); + right: 0; + height: 1px; + bottom: calc(-1 * var(--topOffset)); + background-color: $quinary-content; } - .mx_ThreadInfo_threads-amount { - font-weight: 600; - position: relative; - padding: 0 8px; - white-space: nowrap; + + &:last-child { + &::after { + content: unset; + } + margin-bottom: 0; + } + + &:first-child { + margin-top: 0; + } + + padding-top: 0; + + .mx_EventTile_avatar { + top: -4px; + left: 0; + } + + .mx_SenderProfile { + margin-left: var(--leftOffset) !important; + } + + .mx_EventTile_line { + padding-left: var(--leftOffset) !important; + padding-bottom: 0; + } + .mx_MessageTimestamp { + right: 0; + left: auto; + top: -23px; } } diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index c7a87945dd..f55a479c3f 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -167,7 +167,7 @@ export const ThreadPanelHeader = ({ filterOption, setFilterOption }: { : null; return
{ _t("Threads") } - menuDisplayed ? closeMenu() : openMenu()}> + menuDisplayed ? closeMenu() : openMenu()}> { `${_t('Show:')} ${value.label}` } { contextMenu } diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 8cdc560df8..af2554273a 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -39,15 +39,8 @@ import EditorStateTransfer from '../../utils/EditorStateTransfer'; import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext'; import ContentMessages from '../../ContentMessages'; import UploadBar from './UploadBar'; -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'; +import { ThreadListContextMenu } from '../views/context_menus/ThreadListContextMenu'; interface IProps { room: Room; @@ -63,24 +56,8 @@ 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; @@ -90,12 +67,8 @@ export default class ThreadView extends React.Component { constructor(props: IProps) { super(props); - this.state = { - threadOptionsPosition: null, - copyingPhase: CopyingPhase.Idle, - }; + this.state = {}; } - public componentDidMount(): void { this.setupThread(this.props.mxEvent); this.dispatcherRef = dis.register(this.onAction); @@ -210,95 +183,12 @@ 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" - /> - - ) } - +
; }; diff --git a/src/components/views/context_menus/ThreadListContextMenu.tsx b/src/components/views/context_menus/ThreadListContextMenu.tsx new file mode 100644 index 0000000000..ec649f7166 --- /dev/null +++ b/src/components/views/context_menus/ThreadListContextMenu.tsx @@ -0,0 +1,103 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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, { useCallback, useState } from "react"; +import { MatrixEvent } from "matrix-js-sdk/src"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import dis from '../../../dispatcher/dispatcher'; +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { copyPlaintext } from "../../../utils/strings"; +import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu"; +import { _t } from "../../../languageHandler"; +import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; + +interface IProps { + mxEvent: MatrixEvent; + permalinkCreator: RoomPermalinkCreator; +} + +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 }; +}; + +export const ThreadListContextMenu: React.FC = ({ mxEvent, permalinkCreator }) => { + const [optionsPosition, setOptionsPosition] = useState(null); + const closeThreadOptions = useCallback(() => { + setOptionsPosition(null); + }, []); + + const viewInRoom = useCallback((evt: ButtonEvent): void => { + evt.preventDefault(); + evt.stopPropagation(); + dis.dispatch({ + action: 'view_room', + event_id: mxEvent.getId(), + highlighted: true, + room_id: mxEvent.getRoomId(), + }); + closeThreadOptions(); + }, [mxEvent, closeThreadOptions]); + + const copyLinkToThread = useCallback(async (evt: ButtonEvent) => { + evt.preventDefault(); + evt.stopPropagation(); + const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId()); + await copyPlaintext(matrixToUrl); + closeThreadOptions(); + }, [mxEvent, closeThreadOptions, permalinkCreator]); + + const toggleOptionsMenu = useCallback((ev: ButtonEvent): void => { + if (!!optionsPosition) { + closeThreadOptions(); + } else { + const position = ev.currentTarget.getBoundingClientRect(); + setOptionsPosition(position); + } + }, [closeThreadOptions, optionsPosition]); + + return + + { !!optionsPosition && ( + + viewInRoom(e)} + label={_t("View in room")} + iconClassName="mx_ThreadPanel_viewInRoom" + /> + copyLinkToThread(e)} + label={_t("Copy link to thread")} + iconClassName="mx_ThreadPanel_copyLinkToThread" + /> + + ) } + ; +}; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 63cc53e5b1..645dddd1dd 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -64,6 +64,9 @@ import { MessagePreviewStore } from '../../../stores/room-list/MessagePreviewSto import { logger } from "matrix-js-sdk/src/logger"; import { TimelineRenderingType } from "../../../contexts/RoomContext"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; +import Toolbar from '../../../accessibility/Toolbar'; +import { RovingAccessibleTooltipButton } from '../../../accessibility/roving/RovingAccessibleTooltipButton'; +import { ThreadListContextMenu } from '../context_menus/ThreadListContextMenu'; const eventTileTypes = { [EventType.RoomMessage]: 'messages.MessageEvent', @@ -547,6 +550,43 @@ export default class EventTile extends React.Component { } }; + private renderThreadLastMessagePreview(): JSX.Element | null { + if (!SettingsStore.getValue("feature_thread")) { + return null; + } + + /** + * Accessing the threads value through the room due to a race condition + * that will be solved when there are proper backend support for threads + * We currently have no reliable way to discover than an event is a thread + * when we are at the sync stage + */ + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + const thread = room?.threads.get(this.props.mxEvent.getId()); + + if (!thread || thread.length === 0) { + return null; + } + + const [lastEvent] = thread.events + .filter(event => event.isThreadRelation) + .slice(-1); + const threadMessagePreview = MessagePreviewStore.instance.generatePreviewForEvent(lastEvent); + + if (!threadMessagePreview || !lastEvent.sender) { + return null; + } + + return <> + +
+ + { threadMessagePreview } + +
+ ; + } + private renderThreadInfo(): React.ReactNode { if (!SettingsStore.getValue("feature_thread")) { return null; @@ -569,11 +609,6 @@ export default class EventTile extends React.Component { return null; } - const [lastEvent] = thread.events - .filter(event => event.isThreadRelation) - .slice(-1); - const threadMessagePreview = MessagePreviewStore.instance.generatePreviewForEvent(lastEvent); - return (
{ count: thread.length, }) } - { (threadMessagePreview && lastEvent.sender) && <> - -
- - { threadMessagePreview } - -
- } + { this.renderThreadLastMessagePreview() }
); } @@ -1199,6 +1227,20 @@ export default class EventTile extends React.Component { msgOption = readAvatars; } + const replyChain = haveTileForEvent(this.props.mxEvent) && + ReplyChain.hasReply(this.props.mxEvent) ? ( + ) : null; + switch (this.props.tileShape) { case TileShape.Notif: { const room = this.context.getRoom(this.props.mxEvent.getRoomId()); @@ -1235,19 +1277,6 @@ export default class EventTile extends React.Component { ]); } case TileShape.Thread: { - const replyChain = haveTileForEvent(this.props.mxEvent) && - ReplyChain.hasReply(this.props.mxEvent) ? ( - ) : null; const room = this.context.getRoom(this.props.mxEvent.getRoomId()); return React.createElement(this.props.as || "li", { "className": classes, @@ -1288,6 +1317,63 @@ export default class EventTile extends React.Component { reactionsRow, ]); } + case TileShape.ThreadPanel: { + 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 + return ( + React.createElement(this.props.as || "li", { + "ref": this.ref, + "className": classes, + "tabIndex": -1, + "aria-live": ariaLive, + "aria-atomic": "true", + "data-scroll-tokens": scrollToken, + "data-layout": this.props.layout, + "data-shape": this.props.tileShape, + "data-self": isOwnEvent, + "data-has-reply": !!replyChain, + "onMouseEnter": () => this.setState({ hover: true }), + "onMouseLeave": () => this.setState({ hover: false }), + "onClick": () => dispatchShowThreadEvent(this.props.mxEvent), + }, <> + { sender } + { avatar } +
+ { linkedTimestamp } + { this.renderE2EPadlock() } + { replyChain } + + { keyRequestInfo } + + dispatchShowThreadEvent(this.props.mxEvent)} + key="thread" + /> + + + { this.renderThreadLastMessagePreview() } +
+ { msgOption } + ) + ); + } case TileShape.FileGrid: { return React.createElement(this.props.as || "li", { "className": classes, @@ -1321,19 +1407,6 @@ export default class EventTile extends React.Component { } default: { - const replyChain = haveTileForEvent(this.props.mxEvent) && - ReplyChain.hasReply(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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 41f12b12b0..96da5b9bd0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1571,6 +1571,8 @@ "If your other sessions do not have the key for this message you will not be able to decrypt them.": "If your other sessions do not have the key for this message you will not be able to decrypt them.", "Key request sent.": "Key request sent.", "Re-request encryption keys from your other sessions.": "Re-request encryption keys from your other sessions.", + "Message Actions": "Message Actions", + "Thread": "Thread", "This message cannot be decrypted": "This message cannot be decrypted", "Encrypted by an unverified session": "Encrypted by an unverified session", "Unencrypted": "Unencrypted", @@ -1989,10 +1991,8 @@ "React": "React", "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", "Decrypt %(text)s": "Decrypt %(text)s", @@ -2733,6 +2733,8 @@ "Move up": "Move up", "Move down": "Move down", "View Community": "View Community", + "Thread options": "Thread options", + "Copy link to thread": "Copy link to thread", "Unable to start audio streaming.": "Unable to start audio streaming.", "Failed to start livestream": "Failed to start livestream", "Start audio stream": "Start audio stream", @@ -3006,8 +3008,6 @@ "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", diff --git a/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap b/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap index 016da53ddd..0be306a0a0 100644 --- a/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap +++ b/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap @@ -8,6 +8,7 @@ exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properl Threads