Design thread list tiles according to mockups (#7078)

This commit is contained in:
Germain 2021-11-03 18:05:01 +00:00 committed by GitHub
parent 2a20d9a7df
commit 38750202ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 332 additions and 181 deletions

View file

@ -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 {

View file

@ -687,6 +687,7 @@ $left-gutter: 64px;
padding-left: 11px;
padding-right: 15px;
}
}
.mx_ThreadInfo_content {
text-overflow: ellipsis;
@ -710,6 +711,59 @@ $left-gutter: 64px;
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;
}
&::after {
content: "";
position: absolute;
left: var(--leftOffset);
right: 0;
height: 1px;
bottom: calc(-1 * var(--topOffset));
background-color: $quinary-content;
}
&: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;
}
}
.mx_ThreadView {

View file

@ -167,7 +167,7 @@ export const ThreadPanelHeader = ({ filterOption, setFilterOption }: {
</ContextMenu> : null;
return <div className="mx_ThreadPanel__header">
<span>{ _t("Threads") }</span>
<ContextMenuButton inputRef={button} isExpanded={menuDisplayed} onClick={() => menuDisplayed ? closeMenu() : openMenu()}>
<ContextMenuButton className="mx_ThreadPanel_dropdown" inputRef={button} isExpanded={menuDisplayed} onClick={() => menuDisplayed ? closeMenu() : openMenu()}>
{ `${_t('Show:')} ${value.label}` }
</ContextMenuButton>
{ contextMenu }

View file

@ -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<IProps, IState> {
static contextType = RoomContext;
@ -90,12 +67,8 @@ export default class ThreadView extends React.Component<IProps, IState> {
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<IProps, IState> {
}
};
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<void> => {
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 <div className="mx_ThreadPanel__header">
<span>{ _t("Thread") }</span>
<ContextMenuTooltipButton
className="mx_ThreadPanel_button mx_ThreadPanel_OptionsButton"
onClick={this.onThreadOptionsClick}
title={_t("Thread options")}
isExpanded={this.isThreadOptionsVisible}
/>
{ this.isThreadOptionsVisible && (<IconizedContextMenu
onFinished={this.closeThreadOptions}
className="mx_RoomTile_contextMenu"
compact
rightAligned
{...contextMenuBelow(this.state.threadOptionsPosition)}
>
<IconizedContextMenuOptionList>
<IconizedContextMenuOption
onClick={(e) => this.viewInRoom(e)}
label={_t("View in room")}
iconClassName="mx_ThreadPanel_viewInRoom"
/>
<IconizedContextMenuOption
onClick={(e) => this.copyLinkToThread(e)}
label={_t("Copy link to thread")}
iconClassName="mx_ThreadPanel_copyLinkToThread"
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>) }
<ThreadListContextMenu
mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator} />
</div>;
};

View file

@ -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<IProps> = ({ 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 <React.Fragment>
<ContextMenuTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
onClick={toggleOptionsMenu}
title={_t("Thread options")}
isExpanded={!!optionsPosition}
/>
{ !!optionsPosition && (<IconizedContextMenu
onFinished={closeThreadOptions}
className="mx_RoomTile_contextMenu"
compact
rightAligned
{...contextMenuBelow(optionsPosition)}
>
<IconizedContextMenuOptionList>
<IconizedContextMenuOption
onClick={(e) => viewInRoom(e)}
label={_t("View in room")}
iconClassName="mx_ThreadPanel_viewInRoom"
/>
<IconizedContextMenuOption
onClick={(e) => copyLinkToThread(e)}
label={_t("Copy link to thread")}
iconClassName="mx_ThreadPanel_copyLinkToThread"
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>) }
</React.Fragment>;
};

View file

@ -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<IProps, IState> {
}
};
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 <>
<MemberAvatar member={lastEvent.sender} style={{ float: "left", width: "24px", height: "24px" }} />
<div className="mx_ThreadInfo_content">
<span className="mx_ThreadInfo_message-preview">
{ threadMessagePreview }
</span>
</div>
</>;
}
private renderThreadInfo(): React.ReactNode {
if (!SettingsStore.getValue("feature_thread")) {
return null;
@ -569,11 +609,6 @@ export default class EventTile extends React.Component<IProps, IState> {
return null;
}
const [lastEvent] = thread.events
.filter(event => event.isThreadRelation)
.slice(-1);
const threadMessagePreview = MessagePreviewStore.instance.generatePreviewForEvent(lastEvent);
return (
<div
className="mx_ThreadInfo"
@ -589,14 +624,7 @@ export default class EventTile extends React.Component<IProps, IState> {
count: thread.length,
}) }
</span>
{ (threadMessagePreview && lastEvent.sender) && <>
<MemberAvatar member={lastEvent.sender} width={24} height={24} />
<div className="mx_ThreadInfo_content">
<span className="mx_ThreadInfo_message-preview">
{ threadMessagePreview }
</span>
</div>
</> }
{ this.renderThreadLastMessagePreview() }
</div>
);
}
@ -1199,6 +1227,20 @@ export default class EventTile extends React.Component<IProps, IState> {
msgOption = readAvatars;
}
const replyChain = haveTileForEvent(this.props.mxEvent) &&
ReplyChain.hasReply(this.props.mxEvent) ? (
<ReplyChain
parentEv={this.props.mxEvent}
onHeightChanged={this.props.onHeightChanged}
ref={this.replyChain}
forExport={this.props.forExport}
permalinkCreator={this.props.permalinkCreator}
layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover}
isQuoteExpanded={isQuoteExpanded}
setQuoteExpanded={this.setQuoteExpanded}
/>) : 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<IProps, IState> {
]);
}
case TileShape.Thread: {
const replyChain = haveTileForEvent(this.props.mxEvent) &&
ReplyChain.hasReply(this.props.mxEvent) ? (
<ReplyChain
parentEv={this.props.mxEvent}
onHeightChanged={this.props.onHeightChanged}
ref={this.replyChain}
forExport={this.props.forExport}
permalinkCreator={this.props.permalinkCreator}
layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover}
isQuoteExpanded={isQuoteExpanded}
setQuoteExpanded={this.setQuoteExpanded}
/>) : 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<IProps, IState> {
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 }
<div className={lineClasses} key="mx_EventTile_line">
{ linkedTimestamp }
{ this.renderE2EPadlock() }
{ replyChain }
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
forExport={this.props.forExport}
replacingEventId={this.props.replacingEventId}
editState={this.props.editState}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
permalinkCreator={this.props.permalinkCreator}
onHeightChanged={this.props.onHeightChanged}
callEventGrouper={this.props.callEventGrouper}
tileShape={this.props.tileShape}
/>
{ keyRequestInfo }
<Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton"
title={_t("Thread")}
onClick={() => dispatchShowThreadEvent(this.props.mxEvent)}
key="thread"
/>
<ThreadListContextMenu
mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator} />
</Toolbar>
{ this.renderThreadLastMessagePreview() }
</div>
{ msgOption }
</>)
);
}
case TileShape.FileGrid: {
return React.createElement(this.props.as || "li", {
"className": classes,
@ -1321,19 +1407,6 @@ export default class EventTile extends React.Component<IProps, IState> {
}
default: {
const replyChain = haveTileForEvent(this.props.mxEvent) &&
ReplyChain.hasReply(this.props.mxEvent) ? (
<ReplyChain
parentEv={this.props.mxEvent}
onHeightChanged={this.props.onHeightChanged}
ref={this.replyChain}
forExport={this.props.forExport}
permalinkCreator={this.props.permalinkCreator}
layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover}
isQuoteExpanded={isQuoteExpanded}
setQuoteExpanded={this.setQuoteExpanded}
/>) : 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

View file

@ -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.",
"<requestLink>Re-request encryption keys</requestLink> from your other sessions.": "<requestLink>Re-request encryption keys</requestLink> 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",

View file

@ -8,6 +8,7 @@ exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properl
Threads
</span>
<ContextMenuButton
className="mx_ThreadPanel_dropdown"
inputRef={
Object {
"current": null,
@ -29,6 +30,7 @@ exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly
Threads
</span>
<ContextMenuButton
className="mx_ThreadPanel_dropdown"
inputRef={
Object {
"current": null,