From 562a880c7df27b93a660c2453ba1388befce105b Mon Sep 17 00:00:00 2001
From: Dariusz Niemczyk <3636685+Palid@users.noreply.github.com>
Date: Thu, 14 Oct 2021 15:27:35 +0200
Subject: [PATCH] Create room threads list view  (#6904)

Implement https://github.com/vector-im/element-web/issues/18957 following requirements:
* Create a new right panel view to list all the threads in a given room.
* Change ThreadView previous phase to be ThreadPanel rather than RoomSummary
* Implement local filters for My and All threads

In addition:
* Create a new TileShape for proper rendering requirements (hiding typing indicator)
* Create new timelineRenderingType for proper rendering requirements
---
 res/css/_components.scss                      |   1 +
 res/css/structures/_RightPanel.scss           |   4 +
 res/css/views/right_panel/_ThreadPanel.scss   | 152 +++++++++++
 res/img/element-icons/room/thread.svg         |   1 +
 src/components/structures/MessagePanel.tsx    |   9 +-
 src/components/structures/RightPanel.tsx      |   7 +-
 src/components/structures/ThreadPanel.tsx     | 246 ++++++++++++++----
 src/components/structures/ThreadView.tsx      |   2 +-
 .../views/right_panel/RoomHeaderButtons.tsx   |   9 +
 .../views/right_panel/RoomSummaryCard.tsx     |  10 +-
 src/components/views/rooms/EventTile.tsx      |  15 +-
 src/components/views/rooms/RoomHeader.tsx     |  89 +++----
 src/contexts/RoomContext.ts                   |   3 +-
 src/dispatcher/dispatch-actions/threads.ts    |  38 +++
 src/i18n/strings/en_EN.json                   |   6 +
 .../structures/ThreadPanel-test.tsx           |  81 ++++++
 .../__snapshots__/ThreadPanel-test.tsx.snap   |  71 +++++
 17 files changed, 623 insertions(+), 121 deletions(-)
 create mode 100644 res/css/views/right_panel/_ThreadPanel.scss
 create mode 100644 res/img/element-icons/room/thread.svg
 create mode 100644 src/dispatcher/dispatch-actions/threads.ts
 create mode 100644 test/components/structures/ThreadPanel-test.tsx
 create mode 100644 test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap

diff --git a/res/css/_components.scss b/res/css/_components.scss
index 0e197e0e50..4abcbbc9d4 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -203,6 +203,7 @@
 @import "./views/right_panel/_UserInfo.scss";
 @import "./views/right_panel/_VerificationPanel.scss";
 @import "./views/right_panel/_WidgetCard.scss";
+@import "./views/right_panel/_ThreadPanel.scss";
 @import "./views/room_settings/_AliasSettings.scss";
 @import "./views/rooms/_AppsDrawer.scss";
 @import "./views/rooms/_Autocomplete.scss";
diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss
index 5316cba61d..a96e8a7e76 100644
--- a/res/css/structures/_RightPanel.scss
+++ b/res/css/structures/_RightPanel.scss
@@ -79,6 +79,10 @@ limitations under the License.
     }
 }
 
+.mx_RightPanel_threadsButton::before {
+    mask-image: url('$(res)/img/element-icons/room/thread.svg');
+}
+
 .mx_RightPanel_notifsButton::before {
     mask-image: url('$(res)/img/element-icons/notifications.svg');
     mask-position: center;
diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss
new file mode 100644
index 0000000000..d06981a715
--- /dev/null
+++ b/res/css/views/right_panel/_ThreadPanel.scss
@@ -0,0 +1,152 @@
+/*
+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.
+*/
+
+
+.mx_ThreadPanel {
+    display: flex;
+    flex-direction: column;
+
+    .mx_BaseCard_header {
+        padding: 6px 0;
+
+        .mx_BaseCard_close {
+            margin-top: 15px;
+        }
+    }
+
+    .mx_AccessibleButton.mx_BaseCard_back {
+        display: none;
+    }
+
+    &__header {
+        width: calc(100% - 40px);
+        display: flex;
+        flex: 1;
+        justify-content: space-between;
+
+        span:first-of-type {
+            font-weight: 600;
+            font-size: 15px;
+            line-height: 18px;
+            color: $secondary-content;
+        }
+
+        .mx_AccessibleButton {
+            font-size: 12px;
+            color: $secondary-content;
+        }
+
+        .mx_ContextualMenu_wrapper {
+            // It's added here due to some weird error if I pass it directly in the style, even though it's a numeric value, so it's being passed 0 instead.
+            // The error: react_devtools_backend.js:2526 Warning: `NaN` is an invalid value for the `top` css style property.
+            top: 43px;
+        }
+
+        .mx_ContextualMenu {
+            position: initial;
+            span:first-of-type {
+                font-weight: 600;
+                font-size: 12px;
+                color: $primary-content;
+            }
+
+            font-size: 12px;
+            color: $secondary-content;
+        }
+
+        .mx_ThreadPanel_Header_FilterOptionItem {
+            display: flex;
+            flex-grow: 1;
+            justify-content: space-between;
+            flex-direction: column;
+            overflow: visible;
+            width: 100%;
+            padding: 20px;
+            padding-left: 30px;
+            position: relative;
+
+            &:hover {
+                background-color: $event-selected-color;
+            }
+            &[aria-selected="true"] {
+                &::before {
+                    content: "";
+                    width: 12px;
+                    height: 12px;
+                    grid-column: 1;
+                    grid-row: 1;
+                    mask-image: url("$(res)/img/feather-customised/check.svg");
+                    mask-size: 100%;
+                    mask-repeat: no-repeat;
+                    position: absolute;
+                    top: 22px;
+                    left: 10px;
+                    background-color: $primary-content;
+                }
+            }
+        }
+    }
+
+    .mx_RoomView_messageListWrapper {
+        background-color: $background;
+        border-radius: 8px;
+        padding-top: 8px;
+        padding-bottom: 12px;
+    }
+
+    .mx_ScrollPanel {
+        .mx_RoomView_MessageList {
+            padding: 0;
+        }
+    }
+
+    .mx_EventTile, .mx_EventListSummary {
+        // 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;
+        }
+
+        .mx_ThreadInfo {
+            position: relative;
+            padding-right: 11px;
+            &::after {
+                content: '';
+                display: block;
+                position: absolute;
+                left: 0;
+                bottom: -16px;
+                height: 1px;
+                width: 100%;
+                border-bottom: 1px solid $message-action-bar-border-color;
+            }
+        }
+
+        .mx_DateSeparator {
+            display: none;
+        }
+    }
+}
diff --git a/res/img/element-icons/room/thread.svg b/res/img/element-icons/room/thread.svg
new file mode 100644
index 0000000000..d1b8b35c91
--- /dev/null
+++ b/res/img/element-icons/room/thread.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="#17191C" fill-rule="evenodd" d="M2 5a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7.667a1 1 0 0 0-.6.2L3.6 22.8A1 1 0 0 1 2 22V5Zm3 4a1 1 0 0 1 1-1h12a1 1 0 1 1 0 2H6a1 1 0 0 1-1-1Zm1 3a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2H6Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx
index 42980efc57..79c010cb8f 100644
--- a/src/components/structures/MessagePanel.tsx
+++ b/src/components/structures/MessagePanel.tsx
@@ -26,7 +26,7 @@ import shouldHideEvent from '../../shouldHideEvent';
 import { wantsDateSeparator } from '../../DateUtils';
 import { MatrixClientPeg } from '../../MatrixClientPeg';
 import SettingsStore from '../../settings/SettingsStore';
-import RoomContext from "../../contexts/RoomContext";
+import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
 import { Layout } from "../../settings/Layout";
 import { _t } from "../../languageHandler";
 import EventTile, { haveTileForEvent, IReadReceiptProps, TileShape } from "../views/rooms/EventTile";
@@ -66,7 +66,9 @@ export function shouldFormContinuation(
     prevEvent: MatrixEvent,
     mxEvent: MatrixEvent,
     showHiddenEvents: boolean,
+    timelineRenderingType?: TimelineRenderingType,
 ): boolean {
+    if (timelineRenderingType === TimelineRenderingType.ThreadsList) return false;
     // sanity check inputs
     if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
     // check if within the max continuation period
@@ -722,7 +724,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
 
         // is this a continuation of the previous message?
         const continuation = !wantsDateSeparator &&
-            shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents);
+            shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents, this.context.timelineRenderingType);
 
         const eventId = mxEv.getId();
         const highlight = (eventId === this.props.highlightedEventId);
@@ -794,6 +796,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
     }
 
     public wantsDateSeparator(prevEvent: MatrixEvent, nextEventDate: Date): boolean {
+        if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) {
+            return false;
+        }
         if (prevEvent == null) {
             // first event in the panel: depends if we could back-paginate from
             // here.
diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx
index 5d9d2a0b6a..29aafd16ff 100644
--- a/src/components/structures/RightPanel.tsx
+++ b/src/components/structures/RightPanel.tsx
@@ -53,7 +53,7 @@ import { throttle } from 'lodash';
 import SpaceStore from "../../stores/SpaceStore";
 import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
 import { E2EStatus } from '../../utils/ShieldUtils';
-import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload';
+import { dispatchShowThreadsPanelEvent } from '../../dispatcher/dispatch-actions/threads';
 
 interface IProps {
     room?: Room; // if showing panels for a given room, this is set
@@ -199,10 +199,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
         const isChangingRoom = payload.action === 'view_room' && payload.room_id !== this.props.room.roomId;
         const isViewingThread = this.state.phase === RightPanelPhases.ThreadView;
         if (isChangingRoom && isViewingThread) {
-            dis.dispatch<SetRightPanelPhasePayload>({
-                action: Action.SetRightPanelPhase,
-                phase: RightPanelPhases.ThreadPanel,
-            });
+            dispatchShowThreadsPanelEvent();
         }
 
         if (payload.action === Action.AfterRightPanelPhaseChange) {
diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx
index ccf9d9d416..fbb5547641 100644
--- a/src/components/structures/ThreadPanel.tsx
+++ b/src/components/structures/ThreadPanel.tsx
@@ -14,17 +14,26 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from 'react';
-import { MatrixEvent, Room } from 'matrix-js-sdk/src';
+import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
+import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
 import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
+import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set';
+import { Room } from 'matrix-js-sdk/src/models/room';
 
 import BaseCard from "../views/right_panel/BaseCard";
 import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
-import { replaceableComponent } from "../../utils/replaceableComponent";
-import { MatrixClientPeg } from '../../MatrixClientPeg';
 
 import ResizeNotifier from '../../utils/ResizeNotifier';
-import EventTile from '../views/rooms/EventTile';
+import EventTile, { TileShape } from '../views/rooms/EventTile';
+import MatrixClientContext from '../../contexts/MatrixClientContext';
+import { _t } from '../../languageHandler';
+import { ContextMenuButton } from '../../accessibility/context_menu/ContextMenuButton';
+import ContextMenu, { useContextMenu } from './ContextMenu';
+import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
+import TimelinePanel from './TimelinePanel';
+import { Layout } from '../../settings/Layout';
+import { useEventEmitter } from '../../hooks/useEventEmitter';
+import AccessibleButton from '../views/elements/AccessibleButton';
 
 interface IProps {
     roomId: string;
@@ -32,62 +41,199 @@ interface IProps {
     resizeNotifier: ResizeNotifier;
 }
 
-interface IState {
-    threads?: Thread[];
+export const ThreadPanelItem: React.FC<{ event: MatrixEvent }> = ({ event }) => {
+    return <EventTile
+        key={event.getId()}
+        mxEvent={event}
+        enableFlair={false}
+        showReadReceipts={false}
+        as="div"
+        tileShape={TileShape.Thread}
+        alwaysShowTimestamps={true}
+    />;
+};
+
+export enum ThreadFilterType {
+    "My",
+    "All"
 }
 
-@replaceableComponent("structures.ThreadView")
-export default class ThreadPanel extends React.Component<IProps, IState> {
-    private room: Room;
+type ThreadPanelHeaderOption = {
+    label: string;
+    description: string;
+    key: ThreadFilterType;
+};
 
-    constructor(props: IProps) {
-        super(props);
-        this.room = MatrixClientPeg.get().getRoom(this.props.roomId);
-    }
+const useFilteredThreadsTimelinePanel = ({
+    threads,
+    room,
+    filterOption,
+    userId,
+    updateTimeline,
+}: {
+    threads: Set<Thread>;
+    room: Room;
+    userId: string;
+    filterOption: ThreadFilterType;
+    updateTimeline: () => void;
+}) => {
+    const timelineSet = useMemo(() => new EventTimelineSet(room, {
+        unstableClientRelationAggregation: true,
+        timelineSupport: true,
+    }), [room]);
 
-    public componentDidMount(): void {
-        this.room.on(ThreadEvent.Update, this.onThreadEventReceived);
-        this.room.on(ThreadEvent.Ready, this.onThreadEventReceived);
-    }
+    useEffect(() => {
+        let filteredThreads = Array.from(threads);
+        if (filterOption === ThreadFilterType.My) {
+            filteredThreads = filteredThreads.filter(thread => {
+                return thread.rootEvent.getSender() === userId;
+            });
+        }
+        // NOTE: Temporarily reverse the list until https://github.com/vector-im/element-web/issues/19393 gets properly resolved
+        // The proper list order should be top-to-bottom, like in social-media newsfeeds.
+        filteredThreads.reverse().forEach(thread => {
+            const event = thread.rootEvent;
+            if (timelineSet.findEventById(event.getId()) || event.status !== null) return;
+            timelineSet.addEventToTimeline(
+                event,
+                timelineSet.getLiveTimeline(),
+                true,
+            );
+        });
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [room, timelineSet]);
 
-    public componentWillUnmount(): void {
-        this.room.removeListener(ThreadEvent.Update, this.onThreadEventReceived);
-        this.room.removeListener(ThreadEvent.Ready, this.onThreadEventReceived);
-    }
+    useEventEmitter(room, ThreadEvent.Update, (thread) => {
+        const event = thread.rootEvent;
+        if (
+            // If that's a reply and not an event
+            event !== thread.replyToEvent &&
+            timelineSet.findEventById(event.getId()) ||
+            event.status !== null
+        ) return;
+        if (event !== thread.events[thread.events.length - 1]) {
+            timelineSet.removeEvent(thread.events[thread.events.length - 1]);
+            timelineSet.removeEvent(event);
+        }
+        timelineSet.addEventToTimeline(
+            event,
+            timelineSet.getLiveTimeline(),
+            false,
+        );
+        updateTimeline();
+    });
 
-    private onThreadEventReceived = () => this.updateThreads();
+    return timelineSet;
+};
 
-    private updateThreads = (callback?: () => void): void => {
-        this.setState({
-            threads: this.room.getThreads(),
-        }, callback);
-    };
+export const ThreadPanelHeaderFilterOptionItem = ({
+    label,
+    description,
+    onClick,
+    isSelected,
+}: ThreadPanelHeaderOption & {
+    onClick: () => void;
+    isSelected: boolean;
+}) => {
+    return <AccessibleButton
+        aria-selected={isSelected}
+        className="mx_ThreadPanel_Header_FilterOptionItem"
+        onClick={onClick}
+    >
+        <span>{ label }</span>
+        <span>{ description }</span>
+    </AccessibleButton>;
+};
 
-    private renderEventTile(event: MatrixEvent): JSX.Element {
-        return <EventTile
-            key={event.getId()}
-            mxEvent={event}
-            enableFlair={false}
-            showReadReceipts={false}
-            as="div"
-        />;
-    }
+export const ThreadPanelHeader = ({ filterOption, setFilterOption }: {
+    filterOption: ThreadFilterType;
+    setFilterOption: (filterOption: ThreadFilterType) => void;
+}) => {
+    const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
+    const options: readonly ThreadPanelHeaderOption[] = [
+        {
+            label: _t("My threads"),
+            description: _t("Shows all threads you’ve participated in"),
+            key: ThreadFilterType.My,
+        },
+        {
+            label: _t("All threads"),
+            description: _t('Shows all threads from current room'),
+            key: ThreadFilterType.All,
+        },
+    ];
 
-    public render(): JSX.Element {
-        return (
+    const value = options.find(option => option.key === filterOption);
+    const contextMenuOptions = options.map(opt => <ThreadPanelHeaderFilterOptionItem
+        key={opt.key}
+        label={opt.label}
+        description={opt.description}
+        onClick={() => {
+            setFilterOption(opt.key);
+            closeMenu();
+        }}
+        isSelected={opt === value}
+    />);
+    const contextMenu = menuDisplayed ? <ContextMenu top={0} right={25} onFinished={closeMenu} managed={false}>
+        { contextMenuOptions }
+    </ContextMenu> : null;
+    return <div className="mx_ThreadPanel__header">
+        <span>{ _t("Threads") }</span>
+        <ContextMenuButton inputRef={button} isExpanded={menuDisplayed} onClick={() => menuDisplayed ? closeMenu() : openMenu()}>
+            { `${_t('Show:')} ${value.label}` }
+        </ContextMenuButton>
+        { contextMenu }
+    </div>;
+};
+
+const ThreadPanel: React.FC<IProps> = ({ roomId, onClose }) => {
+    const mxClient = useContext(MatrixClientContext);
+    const roomContext = useContext(RoomContext);
+    const room = mxClient.getRoom(roomId);
+    const [filterOption, setFilterOption] = useState<ThreadFilterType>(ThreadFilterType.All);
+    const ref = useRef<TimelinePanel>();
+
+    const filteredTimelineSet = useFilteredThreadsTimelinePanel({
+        threads: room.threads,
+        room,
+        filterOption,
+        userId: mxClient.getUserId(),
+        updateTimeline: () => ref.current?.refreshTimeline(),
+    });
+
+    return (
+        <RoomContext.Provider value={{
+            ...roomContext,
+            timelineRenderingType: TimelineRenderingType.ThreadsList,
+            liveTimeline: filteredTimelineSet.getLiveTimeline(),
+            showHiddenEventsInTimeline: true,
+        }}>
             <BaseCard
+                header={<ThreadPanelHeader filterOption={filterOption} setFilterOption={setFilterOption} />}
                 className="mx_ThreadPanel"
-                onClose={this.props.onClose}
+                onClose={onClose}
                 previousPhase={RightPanelPhases.RoomSummary}
             >
-                {
-                    this.state?.threads.map((thread: Thread) => {
-                        if (thread.ready) {
-                            return this.renderEventTile(thread.rootEvent);
-                        }
-                    })
-                }
+                <TimelinePanel
+                    ref={ref}
+                    showReadReceipts={false} // No RR support in thread's MVP
+                    manageReadReceipts={false} // No RR support in thread's MVP
+                    manageReadMarkers={false} // No RM support in thread's MVP
+                    sendReadReceiptOnLoad={false} // No RR support in thread's MVP
+                    timelineSet={filteredTimelineSet}
+                    showUrlPreview={true}
+                    empty={<div>empty</div>}
+                    alwaysShowTimestamps={true}
+                    layout={Layout.Group}
+                    hideThreadedMessages={false}
+                    hidden={false}
+                    showReactions={true}
+                    className="mx_RoomView_messagePanel mx_GroupLayout"
+                    membersLoaded={true}
+                    tileShape={TileShape.ThreadPanel}
+                />
             </BaseCard>
-        );
-    }
-}
+        </RoomContext.Provider>
+    );
+};
+export default ThreadPanel;
diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx
index 8fac538bbc..d444ed5d50 100644
--- a/src/components/structures/ThreadView.tsx
+++ b/src/components/structures/ThreadView.tsx
@@ -156,7 +156,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
                 <BaseCard
                     className="mx_ThreadView"
                     onClose={this.props.onClose}
-                    previousPhase={RightPanelPhases.RoomSummary}
+                    previousPhase={RightPanelPhases.ThreadPanel}
                     withoutScrollContainer={true}
                 >
                     { this.state.thread && (
diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx
index 4d256bf4f4..9aac2361f0 100644
--- a/src/components/views/right_panel/RoomHeaderButtons.tsx
+++ b/src/components/views/right_panel/RoomHeaderButtons.tsx
@@ -31,6 +31,8 @@ import RightPanelStore from "../../../stores/RightPanelStore";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { useSettingValue } from "../../../hooks/useSettings";
 import { useReadPinnedEvents, usePinnedEvents } from './PinnedMessagesCard';
+import { dispatchShowThreadsPanelEvent } from "../../../dispatcher/dispatch-actions/threads";
+import SettingsStore from "../../../settings/SettingsStore";
 
 const ROOM_INFO_PHASES = [
     RightPanelPhases.RoomSummary,
@@ -122,6 +124,13 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
                 isHighlighted={this.isPhase(RightPanelPhases.PinnedMessages)}
                 onClick={this.onPinnedMessagesClicked}
             />
+            { SettingsStore.getValue("feature_thread") && <HeaderButton
+                name="threadsButton"
+                title={_t("Threads")}
+                onClick={dispatchShowThreadsPanelEvent}
+                isHighlighted={this.isPhase(RightPanelPhases.ThreadPanel)}
+                analytics={['Right Panel', 'Threads List Button', 'click']}
+            /> }
             <HeaderButton
                 name="notifsButton"
                 title={_t('Notifications')}
diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx
index 1fe556dde2..f17a723621 100644
--- a/src/components/views/right_panel/RoomSummaryCard.tsx
+++ b/src/components/views/right_panel/RoomSummaryCard.tsx
@@ -48,6 +48,7 @@ import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widget
 import RoomName from "../elements/RoomName";
 import UIStore from "../../../stores/UIStore";
 import ExportDialog from "../dialogs/ExportDialog";
+import { dispatchShowThreadsPanelEvent } from "../../../dispatcher/dispatch-actions/threads";
 
 interface IProps {
     room: Room;
@@ -221,13 +222,6 @@ const onRoomFilesClick = () => {
     });
 };
 
-const onRoomThreadsClick = () => {
-    defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
-        action: Action.SetRightPanelPhase,
-        phase: RightPanelPhases.ThreadPanel,
-    });
-};
-
 const onRoomSettingsClick = () => {
     defaultDispatcher.dispatch({ action: "open_room_settings" });
 };
@@ -291,7 +285,7 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
                 { _t("Export chat") }
             </Button>
             { SettingsStore.getValue("feature_thread") && (
-                <Button className="mx_RoomSummaryCard_icon_threads" onClick={onRoomThreadsClick}>
+                <Button className="mx_RoomSummaryCard_icon_threads" onClick={dispatchShowThreadsPanelEvent}>
                     { _t("Show threads") }
                 </Button>
             ) }
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index b74ee3a8cf..9d608c2833 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -56,9 +56,9 @@ import ReadReceiptMarker from "./ReadReceiptMarker";
 import MessageActionBar from "../messages/MessageActionBar";
 import ReactionsRow from '../messages/ReactionsRow';
 import { getEventDisplayInfo } from '../../../utils/EventUtils';
-import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
 import SettingsStore from "../../../settings/SettingsStore";
 import MKeyVerificationConclusion from "../messages/MKeyVerificationConclusion";
+import { dispatchShowThreadEvent } from '../../../dispatcher/dispatch-actions/threads';
 
 const eventTileTypes = {
     [EventType.RoomMessage]: 'messages.MessageEvent',
@@ -193,6 +193,7 @@ export enum TileShape {
     FileGrid = "file_grid",
     Pinned = "pinned",
     Thread = "thread",
+    ThreadPanel = "thread_list"
 }
 
 interface IProps {
@@ -511,6 +512,10 @@ export default class EventTile extends React.Component<IProps, IState> {
         if (this.props.showReactions) {
             this.props.mxEvent.removeListener("Event.relationsCreated", this.onReactionsCreated);
         }
+        if (SettingsStore.getValue("feature_thread")) {
+            this.props.mxEvent.off(ThreadEvent.Ready, this.updateThread);
+            this.props.mxEvent.off(ThreadEvent.Update, this.updateThread);
+        }
     }
 
     componentDidUpdate(prevProps, prevState, snapshot) {
@@ -541,13 +546,7 @@ export default class EventTile extends React.Component<IProps, IState> {
             <div
                 className="mx_ThreadInfo"
                 onClick={() => {
-                    dis.dispatch({
-                        action: Action.SetRightPanelPhase,
-                        phase: RightPanelPhases.ThreadView,
-                        refireParams: {
-                            event: this.props.mxEvent,
-                        },
-                    });
+                    dispatchShowThreadEvent(this.props.mxEvent);
                 }}
             >
                 <span className="mx_EventListSummary_avatars">
diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx
index d0e438bcda..e3b4804ae6 100644
--- a/src/components/views/rooms/RoomHeader.tsx
+++ b/src/components/views/rooms/RoomHeader.tsx
@@ -155,58 +155,55 @@ export default class RoomHeader extends React.Component<IProps> {
             />;
         }
 
-        let forgetButton;
-        if (this.props.onForgetClick) {
-            forgetButton =
-                <AccessibleTooltipButton
-                    className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
-                    onClick={this.props.onForgetClick}
-                    title={_t("Forget room")} />;
-        }
+        const buttons: JSX.Element[] = [];
 
-        let appsButton;
-        if (this.props.onAppsClick) {
-            appsButton =
-                <AccessibleTooltipButton
-                    className={classNames("mx_RoomHeader_button mx_RoomHeader_appsButton", {
-                        mx_RoomHeader_appsButton_highlight: this.props.appsShown,
-                    })}
-                    onClick={this.props.onAppsClick}
-                    title={this.props.appsShown ? _t("Hide Widgets") : _t("Show Widgets")} />;
-        }
-
-        let searchButton;
-        if (this.props.onSearchClick && this.props.inRoom) {
-            searchButton =
-                <AccessibleTooltipButton
-                    className="mx_RoomHeader_button mx_RoomHeader_searchButton"
-                    onClick={this.props.onSearchClick}
-                    title={_t("Search")} />;
-        }
-
-        let voiceCallButton;
-        let videoCallButton;
         if (this.props.inRoom && SettingsStore.getValue("showCallButtonsInComposer")) {
-            voiceCallButton =
-                <AccessibleTooltipButton
-                    className="mx_RoomHeader_button mx_RoomHeader_voiceCallButton"
-                    onClick={() => this.props.onCallPlaced(PlaceCallType.Voice)}
-                    title={_t("Voice call")} />;
-            videoCallButton =
-                <AccessibleTooltipButton
-                    className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
-                    onClick={(ev: React.MouseEvent<Element>) => ev.shiftKey ?
-                        this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)}
-                    title={_t("Video call")} />;
+            const voiceCallButton = <AccessibleTooltipButton
+                className="mx_RoomHeader_button mx_RoomHeader_voiceCallButton"
+                onClick={() => this.props.onCallPlaced(PlaceCallType.Voice)}
+                title={_t("Voice call")}
+            />;
+            const videoCallButton = <AccessibleTooltipButton
+                className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
+                onClick={(ev: React.MouseEvent<Element>) => ev.shiftKey ?
+                    this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)}
+                title={_t("Video call")}
+            />;
+            buttons.push(voiceCallButton, videoCallButton);
+        }
+
+        if (this.props.onForgetClick) {
+            const forgetButton = <AccessibleTooltipButton
+                className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
+                onClick={this.props.onForgetClick}
+                title={_t("Forget room")}
+            />;
+            buttons.push(forgetButton);
+        }
+
+        if (this.props.onAppsClick) {
+            const appsButton = <AccessibleTooltipButton
+                className={classNames("mx_RoomHeader_button mx_RoomHeader_appsButton", {
+                    mx_RoomHeader_appsButton_highlight: this.props.appsShown,
+                })}
+                onClick={this.props.onAppsClick}
+                title={this.props.appsShown ? _t("Hide Widgets") : _t("Show Widgets")}
+            />;
+            buttons.push(appsButton);
+        }
+
+        if (this.props.onSearchClick && this.props.inRoom) {
+            const searchButton = <AccessibleTooltipButton
+                className="mx_RoomHeader_button mx_RoomHeader_searchButton"
+                onClick={this.props.onSearchClick}
+                title={_t("Search")}
+            />;
+            buttons.push(searchButton);
         }
 
         const rightRow =
             <div className="mx_RoomHeader_buttons">
-                { videoCallButton }
-                { voiceCallButton }
-                { forgetButton }
-                { appsButton }
-                { searchButton }
+                { buttons }
             </div>;
 
         const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined;
diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts
index d3a4fadd19..c5426e0dcd 100644
--- a/src/contexts/RoomContext.ts
+++ b/src/contexts/RoomContext.ts
@@ -21,9 +21,10 @@ import { Layout } from "../settings/Layout";
 
 export enum TimelineRenderingType {
     Room,
+    Thread,
+    ThreadsList,
     File,
     Notification,
-    Thread
 }
 
 const RoomContext = createContext<IRoomState>({
diff --git a/src/dispatcher/dispatch-actions/threads.ts b/src/dispatcher/dispatch-actions/threads.ts
new file mode 100644
index 0000000000..cda2f55707
--- /dev/null
+++ b/src/dispatcher/dispatch-actions/threads.ts
@@ -0,0 +1,38 @@
+/*
+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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
+import { Action } from "../actions";
+import dis from '../dispatcher';
+import { SetRightPanelPhasePayload } from "../payloads/SetRightPanelPhasePayload";
+
+export const dispatchShowThreadEvent = (event: MatrixEvent) => {
+    dis.dispatch({
+        action: Action.SetRightPanelPhase,
+        phase: RightPanelPhases.ThreadView,
+        refireParams: {
+            event,
+        },
+    });
+};
+
+export const dispatchShowThreadsPanelEvent = () => {
+    dis.dispatch<SetRightPanelPhasePayload>({
+        action: Action.SetRightPanelPhase,
+        phase: RightPanelPhases.ThreadPanel,
+    });
+};
+
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index fc68c60943..3e61146acb 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1831,6 +1831,7 @@
     "Nothing pinned, yet": "Nothing pinned, yet",
     "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
     "Pinned messages": "Pinned messages",
+    "Threads": "Threads",
     "Room Info": "Room Info",
     "You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets",
     "Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel",
@@ -2974,6 +2975,11 @@
     "You can add more later too, including already existing ones.": "You can add more later too, including already existing ones.",
     "What projects are you working on?": "What projects are you working on?",
     "We'll create rooms for each of them. You can add more later too, including already existing ones.": "We'll create rooms for each of them. You can add more later too, including already existing ones.",
+    "My threads": "My threads",
+    "Shows all threads you’ve participated in": "Shows all threads you’ve participated in",
+    "All threads": "All threads",
+    "Shows all threads from current room": "Shows all threads from current room",
+    "Show:": "Show:",
     "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/ThreadPanel-test.tsx b/test/components/structures/ThreadPanel-test.tsx
new file mode 100644
index 0000000000..d6de2ebde6
--- /dev/null
+++ b/test/components/structures/ThreadPanel-test.tsx
@@ -0,0 +1,81 @@
+/*
+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 from 'react';
+import { shallow, mount, configure } from "enzyme";
+import '../../skinned-sdk';
+import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
+
+import {
+    ThreadFilterType,
+    ThreadPanelHeader,
+    ThreadPanelHeaderFilterOptionItem,
+} from '../../../src/components/structures/ThreadPanel';
+import { ContextMenuButton } from '../../../src/accessibility/context_menu/ContextMenuButton';
+import ContextMenu from '../../../src/components/structures/ContextMenu';
+import { _t } from '../../../src/languageHandler';
+
+configure({ adapter: new Adapter() });
+
+describe('ThreadPanel', () => {
+    describe('Header', () => {
+        it('expect that All filter for ThreadPanelHeader properly renders Show: All threads', () => {
+            const wrapper = shallow(
+                <ThreadPanelHeader
+                    filterOption={ThreadFilterType.All}
+                    setFilterOption={() => undefined} />,
+            );
+            expect(wrapper).toMatchSnapshot();
+        });
+
+        it('expect that My filter for ThreadPanelHeader properly renders Show: My threads', () => {
+            const wrapper = shallow(
+                <ThreadPanelHeader
+                    filterOption={ThreadFilterType.My}
+                    setFilterOption={() => undefined} />,
+            );
+            expect(wrapper).toMatchSnapshot();
+        });
+
+        it('expect that ThreadPanelHeader properly opens a context menu when clicked on the button', () => {
+            const wrapper = mount(
+                <ThreadPanelHeader
+                    filterOption={ThreadFilterType.All}
+                    setFilterOption={() => undefined} />,
+            );
+            const found = wrapper.find(ContextMenuButton);
+            expect(found).not.toBe(undefined);
+            expect(found).not.toBe(null);
+            expect(wrapper.exists(ContextMenu)).toEqual(false);
+            found.simulate('click');
+            expect(wrapper.exists(ContextMenu)).toEqual(true);
+        });
+
+        it('expect that ThreadPanelHeader has the correct option selected in the context menu', () => {
+            const wrapper = mount(
+                <ThreadPanelHeader
+                    filterOption={ThreadFilterType.All}
+                    setFilterOption={() => undefined} />,
+            );
+            wrapper.find(ContextMenuButton).simulate('click');
+            const found = wrapper.find(ThreadPanelHeaderFilterOptionItem);
+            expect(found.length).toEqual(2);
+            const foundButton = found.find('[aria-selected=true]').first();
+            expect(foundButton.text()).toEqual(`${_t("All threads")}${_t('Shows all threads from current room')}`);
+            expect(foundButton).toMatchSnapshot();
+        });
+    });
+});
diff --git a/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap b/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap
new file mode 100644
index 0000000000..016da53ddd
--- /dev/null
+++ b/test/components/structures/__snapshots__/ThreadPanel-test.tsx.snap
@@ -0,0 +1,71 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properly renders Show: All threads 1`] = `
+<div
+  className="mx_ThreadPanel__header"
+>
+  <span>
+    Threads
+  </span>
+  <ContextMenuButton
+    inputRef={
+      Object {
+        "current": null,
+      }
+    }
+    isExpanded={false}
+    onClick={[Function]}
+  >
+    Show: All threads
+  </ContextMenuButton>
+</div>
+`;
+
+exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly renders Show: My threads 1`] = `
+<div
+  className="mx_ThreadPanel__header"
+>
+  <span>
+    Threads
+  </span>
+  <ContextMenuButton
+    inputRef={
+      Object {
+        "current": null,
+      }
+    }
+    isExpanded={false}
+    onClick={[Function]}
+  >
+    Show: My threads
+  </ContextMenuButton>
+</div>
+`;
+
+exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option selected in the context menu 1`] = `
+<AccessibleButton
+  aria-selected={true}
+  className="mx_ThreadPanel_Header_FilterOptionItem"
+  element="div"
+  onClick={[Function]}
+  role="button"
+  tabIndex={0}
+>
+  <div
+    aria-selected={true}
+    className="mx_AccessibleButton mx_ThreadPanel_Header_FilterOptionItem"
+    onClick={[Function]}
+    onKeyDown={[Function]}
+    onKeyUp={[Function]}
+    role="button"
+    tabIndex={0}
+  >
+    <span>
+      All threads
+    </span>
+    <span>
+      Shows all threads from current room
+    </span>
+  </div>
+</AccessibleButton>
+`;