Improve Thread View UI (#7063)

This commit is contained in:
Germain 2021-11-02 13:18:51 +00:00 committed by GitHub
parent 351c426d2a
commit 0bae79d3c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 222 additions and 42 deletions

View file

@ -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%);
}

View file

@ -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');
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<IProps, IState> {
'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 = {};

View file

@ -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<IProps, IState> {
static contextType = RoomContext;
@ -64,7 +88,10 @@ export default class ThreadView extends React.Component<IProps, IState> {
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<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>) }
</div>;
};
public render(): JSX.Element {
const highlightedEventId = this.props.initialEventHighlighted
? this.props.initialEvent?.getId()
@ -193,10 +312,11 @@ export default class ThreadView extends React.Component<IProps, IState> {
}}>
<BaseCard
className="mx_ThreadView"
className="mx_ThreadView mx_ThreadPanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.ThreadPanel}
withoutScrollContainer={true}
header={this.renderThreadViewHeader()}
>
{ this.state.thread && (
<TimelinePanel
@ -209,7 +329,6 @@ export default class ThreadView extends React.Component<IProps, IState> {
showUrlPreview={true}
tileShape={TileShape.Thread}
empty={<div>empty</div>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
hidden={false}

View file

@ -128,7 +128,10 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
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']}
/> }
<HeaderButton

View file

@ -1251,6 +1251,8 @@ export default class EventTile extends React.Component<IProps, IState> {
"aria-atomic": true,
"data-scroll-tokens": scrollToken,
"data-has-reply": !!replyChain,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
}, [
<div className="mx_EventTile_roomName" key="mx_EventTile_roomName">
<RoomAvatar room={room} width={28} height={28} />
@ -1262,7 +1264,6 @@ export default class EventTile extends React.Component<IProps, IState> {
{ avatar }
<a href={permalink} onClick={this.onPermalinkClicked}>
{ sender }
{ timestamp }
</a>
</div>,
<div className={lineClasses} key="mx_EventTile_line">
@ -1278,6 +1279,7 @@ export default class EventTile extends React.Component<IProps, IState> {
replacingEventId={this.props.replacingEventId}
/>
{ actionBar }
{ timestamp }
</div>,
reactionsRow,
]);

View file

@ -640,6 +640,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
"mx_MessageComposer": true,
"mx_GroupLayout": true,
"mx_MessageComposer--compact": this.props.compact,
"mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined,
});
return (

View file

@ -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",