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