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> +`;