diff --git a/cypress/e2e/5-threads/threads.spec.ts b/cypress/e2e/5-threads/threads.spec.ts index 3decae38761..66cde418c60 100644 --- a/cypress/e2e/5-threads/threads.spec.ts +++ b/cypress/e2e/5-threads/threads.spec.ts @@ -212,4 +212,34 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content") .should("contain", "I'm very good thanks :)"); }); + + it("right panel behaves correctly", () => { + // Create room + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.visit("/#/room/" + roomId); + }); + // Send message + cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); + + // Create thread + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .realHover().find(".mx_MessageActionBar_threadButton").click(); + cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); + + // Send message to thread + cy.get(".mx_BaseCard .mx_BasicMessageComposer_input").type("Hello Mr. User{enter}"); + cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. User"); + + // Close thread + cy.get(".mx_BaseCard_close").click(); + + // Open existing thread + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .realHover().find(".mx_MessageActionBar_threadButton").click(); + cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); + cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. Bot"); + cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. User"); + }); }); diff --git a/res/css/views/context_menus/_MessageContextMenu.pcss b/res/css/views/context_menus/_MessageContextMenu.pcss index 751a85f50c7..27f3966db2b 100644 --- a/res/css/views/context_menus/_MessageContextMenu.pcss +++ b/res/css/views/context_menus/_MessageContextMenu.pcss @@ -107,6 +107,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/message-bar/reply.svg'); } + .mx_MessageContextMenu_iconReplyInThread::before { + mask-image: url('$(res)/img/element-icons/message/thread.svg'); + } + .mx_MessageContextMenu_iconReact::before { mask-image: url('$(res)/img/element-icons/room/message-bar/emoji.svg'); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 534d82036cc..db0d84e3d0d 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -130,6 +130,10 @@ import { SnakedObject } from "../../utils/SnakedObject"; import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; import VideoChannelStore from "../../stores/VideoChannelStore"; import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators"; +import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; +import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases"; +import RightPanelStore from "../../stores/right-panel/RightPanelStore"; +import { TimelineRenderingType } from "../../contexts/RoomContext"; import { UseCaseSelection } from '../views/elements/UseCaseSelection'; import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig'; import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; @@ -803,6 +807,41 @@ export default class MatrixChat extends React.PureComponent { hideAnalyticsToast(); SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, false); break; + case Action.ShowThread: { + const { + rootEvent, + initialEvent, + highlighted, + scrollIntoView, + push, + } = payload as ShowThreadPayload; + + const threadViewCard = { + phase: RightPanelPhases.ThreadView, + state: { + threadHeadEvent: rootEvent, + initialEvent: initialEvent, + isInitialEventHighlighted: highlighted, + initialEventScrollIntoView: scrollIntoView, + }, + }; + if (push ?? false) { + RightPanelStore.instance.pushCard(threadViewCard); + } else { + RightPanelStore.instance.setCards([ + { phase: RightPanelPhases.ThreadPanel }, + threadViewCard, + ]); + } + + // Focus the composer + dis.dispatch({ + action: Action.FocusSendMessageComposer, + context: TimelineRenderingType.Thread, + }); + + break; + } } }; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 0bc25a38dc0..1b2f02d8971 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -97,7 +97,6 @@ import RoomStatusBar from "./RoomStatusBar"; import MessageComposer from '../views/rooms/MessageComposer'; import JumpToBottomButton from "../views/rooms/JumpToBottomButton"; import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; -import { showThread } from '../../dispatcher/dispatch-actions/threads'; import { fetchInitialEvent } from "../../utils/EventUtils"; import { ComposerInsertPayload, ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload"; import AppsDrawer from '../views/rooms/AppsDrawer'; @@ -111,6 +110,7 @@ import FileDropTarget from './FileDropTarget'; import Measured from '../views/elements/Measured'; import { FocusComposerPayload } from '../../dispatcher/payloads/FocusComposerPayload'; import { haveRendererForEvent } from "../../events/EventTileFactory"; +import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -452,7 +452,8 @@ export class RoomView extends React.Component { const thread = initialEvent?.getThread(); if (thread && !initialEvent?.isThreadRoot) { - showThread({ + dis.dispatch({ + action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, highlighted: RoomViewStore.instance.isInitialEventHighlighted(), @@ -464,7 +465,8 @@ export class RoomView extends React.Component { newState.initialEventScrollIntoView = RoomViewStore.instance.initialEventScrollIntoView(); if (thread && initialEvent?.isThreadRoot) { - showThread({ + dis.dispatch({ + action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, highlighted: RoomViewStore.instance.isInitialEventHighlighted(), diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 11e173975dd..9452fa28268 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -16,12 +16,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; +import React, { createRef, useContext } from 'react'; import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; import { Relations } from 'matrix-js-sdk/src/models/relations'; import { RoomMemberEvent } from "matrix-js-sdk/src/models/room-member"; import { M_POLL_START } from "matrix-events-sdk"; +import { Thread } from "matrix-js-sdk/src/models/thread"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import dis from '../../../dispatcher/dispatcher'; @@ -58,6 +59,59 @@ import { OpenReportEventDialogPayload } from "../../../dispatcher/payloads/OpenR import { createMapSiteLinkFromEvent } from '../../../utils/location'; import { getForwardableEvent } from '../../../events/forward/getForwardableEvent'; import { getShareableLocationEvent } from '../../../events/location/getShareableLocationEvent'; +import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; +import { CardContext } from "../right_panel/context"; +import { UserTab } from "../dialogs/UserTab"; + +interface IReplyInThreadButton { + mxEvent: MatrixEvent; + closeMenu: () => void; +} + +const ReplyInThreadButton = ({ mxEvent, closeMenu }: IReplyInThreadButton) => { + const context = useContext(CardContext); + const relationType = mxEvent?.getRelation()?.rel_type; + + // Can't create a thread from an event with an existing relation + if (Boolean(relationType) && relationType !== RelationType.Thread) return; + + const onClick = (): void => { + if (!localStorage.getItem("mx_seen_feature_thread")) { + localStorage.setItem("mx_seen_feature_thread", "true"); + } + + if (!SettingsStore.getValue("feature_thread")) { + dis.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Labs, + }); + } else if (mxEvent.getThread() && !mxEvent.isThreadRoot) { + dis.dispatch({ + action: Action.ShowThread, + rootEvent: mxEvent.getThread().rootEvent, + initialEvent: mxEvent, + scroll_into_view: true, + highlighted: true, + push: context.isCard, + }); + } else { + dis.dispatch({ + action: Action.ShowThread, + rootEvent: mxEvent, + push: context.isCard, + }); + } + closeMenu(); + }; + + return ( + + ); +}; interface IProps extends IPosition { chevronFace: ChevronFace; @@ -582,6 +636,23 @@ export default class MessageContextMenu extends React.Component ); } + let replyInThreadButton: JSX.Element; + if ( + rightClick && + contentActionable && + canSendMessages && + SettingsStore.getValue("feature_thread") && + Thread.hasServerSideSupport && + timelineRenderingType !== TimelineRenderingType.Thread + ) { + replyInThreadButton = ( + + ); + } + let reactButton; if (rightClick && contentActionable && canReact) { reactButton = ( @@ -621,6 +692,7 @@ export default class MessageContextMenu extends React.Component { reactButton } { replyButton } + { replyInThreadButton } { editButton } ); diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 4256cb54c14..4451d8d06aa 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -25,7 +25,7 @@ import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon'; import type { Relations } from 'matrix-js-sdk/src/models/relations'; import { _t } from '../../../languageHandler'; -import dis from '../../../dispatcher/dispatcher'; +import dis, { defaultDispatcher } from '../../../dispatcher/dispatcher'; import ContextMenu, { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import { isContentActionable, canEditContent, editEvent, canCancel } from '../../../utils/EventUtils'; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; @@ -41,13 +41,13 @@ import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import ReplyChain from '../elements/ReplyChain'; import ReactionPicker from "../emojipicker/ReactionPicker"; import { CardContext } from '../right_panel/context'; -import { showThread } from "../../../dispatcher/dispatch-actions/threads"; import { shouldDisplayReply } from '../../../utils/Reply'; import { Key } from "../../../Keyboard"; import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts"; import { UserTab } from '../dialogs/UserTab'; import { Action } from '../../../dispatcher/actions'; import SdkConfig from "../../../SdkConfig"; +import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import useFavouriteMessages from '../../../hooks/useFavouriteMessages'; interface IOptionsButtonProps { @@ -191,7 +191,8 @@ const ReplyInThreadButton = ({ mxEvent }: IReplyInThreadButton) => { initialTabId: UserTab.Labs, }); } else if (mxEvent.getThread() && !mxEvent.isThreadRoot) { - showThread({ + defaultDispatcher.dispatch({ + action: Action.ShowThread, rootEvent: mxEvent.getThread().rootEvent, initialEvent: mxEvent, scroll_into_view: true, @@ -199,7 +200,8 @@ const ReplyInThreadButton = ({ mxEvent }: IReplyInThreadButton) => { push: context.isCard, }); } else { - showThread({ + defaultDispatcher.dispatch({ + action: Action.ShowThread, rootEvent: mxEvent, push: context.isCard, }); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 8e7038a255f..881366f408f 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -58,7 +58,6 @@ import MessageActionBar from "../messages/MessageActionBar"; import ReactionsRow from '../messages/ReactionsRow'; import { getEventDisplayInfo } from '../../../utils/EventRenderingUtils'; import SettingsStore from "../../../settings/SettingsStore"; -import { showThread } from '../../../dispatcher/dispatch-actions/threads'; import { MessagePreviewStore } from '../../../stores/room-list/MessagePreviewStore'; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; @@ -80,6 +79,7 @@ import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../event import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; import { ReadReceiptGroup } from './ReadReceiptGroup'; import { useTooltip } from "../../../utils/useTooltip"; +import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -1357,7 +1357,11 @@ export class UnwrappedEventTile extends React.Component { "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), "onClick": (ev: MouseEvent) => { - showThread({ rootEvent: this.props.mxEvent, push: true }); + dis.dispatch({ + action: Action.ShowThread, + rootEvent: this.props.mxEvent, + push: true, + }); const target = ev.currentTarget as HTMLElement; const index = Array.from(target.parentElement.children).indexOf(target); PosthogTrackers.trackInteraction("WebThreadsPanelThreadItem", ev, index); diff --git a/src/components/views/rooms/ThreadSummary.tsx b/src/components/views/rooms/ThreadSummary.tsx index 968727022f9..c14a4cc9e14 100644 --- a/src/components/views/rooms/ThreadSummary.tsx +++ b/src/components/views/rooms/ThreadSummary.tsx @@ -21,7 +21,6 @@ import { IContent, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/model import { _t } from "../../../languageHandler"; import { CardContext } from "../right_panel/context"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; -import { showThread } from "../../../dispatcher/dispatch-actions/threads"; import PosthogTrackers from "../../../PosthogTrackers"; import { useTypedEventEmitter, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; import RoomContext from "../../../contexts/RoomContext"; @@ -29,6 +28,9 @@ import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewSto import MemberAvatar from "../avatars/MemberAvatar"; import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { Action } from "../../../dispatcher/actions"; +import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; interface IProps { mxEvent: MatrixEvent; @@ -50,7 +52,8 @@ const ThreadSummary = ({ mxEvent, thread }: IProps) => { { - showThread({ + defaultDispatcher.dispatch({ + action: Action.ShowThread, rootEvent: mxEvent, push: cardContext.isCard, }); diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 3d1ac9969b6..4e161a70051 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -326,4 +326,9 @@ export enum Action { * Fires with the PlatformSetPayload. */ PlatformSet = "platform_set", + + /** + * Fired when we want to view a thread, either a new one or an existing one + */ + ShowThread = "show_thread", } diff --git a/src/dispatcher/dispatch-actions/threads.ts b/src/dispatcher/dispatch-actions/threads.ts index db01d0b5af2..4bd1d295f31 100644 --- a/src/dispatcher/dispatch-actions/threads.ts +++ b/src/dispatcher/dispatch-actions/threads.ts @@ -14,46 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; - import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases"; -import dis from "../dispatcher"; -import { Action } from "../actions"; -import { TimelineRenderingType } from "../../contexts/RoomContext"; - -export const showThread = (props: { - rootEvent: MatrixEvent; - initialEvent?: MatrixEvent; - highlighted?: boolean; - scroll_into_view?: boolean; - push?: boolean; -}) => { - const push = props.push ?? false; - const threadViewCard = { - phase: RightPanelPhases.ThreadView, - state: { - threadHeadEvent: props.rootEvent, - initialEvent: props.initialEvent, - isInitialEventHighlighted: props.highlighted, - initialEventScrollIntoView: props.scroll_into_view, - }, - }; - if (push) { - RightPanelStore.instance.pushCard(threadViewCard); - } else { - RightPanelStore.instance.setCards([ - { phase: RightPanelPhases.ThreadPanel }, - threadViewCard, - ]); - } - - // Focus the composer - dis.dispatch({ - action: Action.FocusSendMessageComposer, - context: TimelineRenderingType.Thread, - }); -}; export const showThreadPanel = () => { RightPanelStore.instance.setCard({ phase: RightPanelPhases.ThreadPanel }); diff --git a/src/dispatcher/payloads/ShowThreadPayload.ts b/src/dispatcher/payloads/ShowThreadPayload.ts new file mode 100644 index 00000000000..254a33caa45 --- /dev/null +++ b/src/dispatcher/payloads/ShowThreadPayload.ts @@ -0,0 +1,30 @@ +/* +Copyright 2022 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 { ActionPayload } from "../payloads"; +import { Action } from "../actions"; + +export interface ShowThreadPayload extends ActionPayload { + action: Action.ShowThread; + + rootEvent: MatrixEvent; + initialEvent?: MatrixEvent; + highlighted?: boolean; + scrollIntoView?: boolean; + push?: boolean; +} diff --git a/test/components/views/context_menus/MessageContextMenu-test.tsx b/test/components/views/context_menus/MessageContextMenu-test.tsx index 9a8699b2784..72d5ebb5eb0 100644 --- a/test/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/components/views/context_menus/MessageContextMenu-test.tsx @@ -40,6 +40,7 @@ import { makeBeaconEvent, makeBeaconInfoEvent, makeLocationEvent, stubClient } f import dispatcher from '../../../../src/dispatcher/dispatcher'; import SettingsStore from '../../../../src/settings/SettingsStore'; import { ReadPinsEventId } from '../../../../src/components/views/right_panel/types'; +import { Action } from "../../../../src/dispatcher/actions"; jest.mock("../../../../src/utils/strings", () => ({ copyPlaintext: jest.fn(), @@ -50,6 +51,7 @@ jest.mock("../../../../src/utils/EventUtils", () => ({ ...jest.requireActual("../../../../src/utils/EventUtils"), canEditContent: jest.fn(), })); +jest.mock('../../../../src/dispatcher/dispatcher'); const roomId = 'roomid'; @@ -461,6 +463,29 @@ describe('MessageContextMenu', () => { const reactButton = menu.find('div[aria-label="View in room"]'); expect(reactButton).toHaveLength(0); }); + + it('creates a new thread on reply in thread click', () => { + const eventContent = MessageEvent.from("hello"); + const mxEvent = new MatrixEvent(eventContent.serialize()); + + Thread.hasServerSideSupport = true; + const context = { + canSendMessages: true, + }; + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(true); + + const menu = createRightClickMenu(mxEvent, context); + + const replyInThreadButton = menu.find('div[aria-label="Reply in thread"]'); + expect(replyInThreadButton).toHaveLength(1); + replyInThreadButton.simulate("click"); + + expect(dispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.ShowThread, + rootEvent: mxEvent, + push: false, + }); + }); }); }); @@ -471,6 +496,10 @@ function createRightClickMenuWithContent( return createMenuWithContent(eventContent, { rightClick: true }, context); } +function createRightClickMenu(mxEvent: MatrixEvent, context?: Partial): ReactWrapper { + return createMenu(mxEvent, { rightClick: true }, context); +} + function createMenuWithContent( eventContent: ExtensibleEvent, props?: Partial>, diff --git a/test/components/views/messages/MessageActionBar-test.tsx b/test/components/views/messages/MessageActionBar-test.tsx index 2d17544b2c7..52cf498a452 100644 --- a/test/components/views/messages/MessageActionBar-test.tsx +++ b/test/components/views/messages/MessageActionBar-test.tsx @@ -41,12 +41,8 @@ import dispatcher from '../../../../src/dispatcher/dispatcher'; import SettingsStore from '../../../../src/settings/SettingsStore'; import { Action } from '../../../../src/dispatcher/actions'; import { UserTab } from '../../../../src/components/views/dialogs/UserTab'; -import { showThread } from '../../../../src/dispatcher/dispatch-actions/threads'; jest.mock('../../../../src/dispatcher/dispatcher'); -jest.mock('../../../../src/dispatcher/dispatch-actions/threads', () => ({ - showThread: jest.fn(), -})); describe('', () => { const userId = '@alice:server.org'; @@ -447,7 +443,8 @@ describe('', () => { fireEvent.click(getByLabelText('Reply in thread')); }); - expect(showThread).toHaveBeenCalledWith({ + expect(dispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.ShowThread, rootEvent: alicesMessageEvent, push: false, }); @@ -475,7 +472,8 @@ describe('', () => { fireEvent.click(getByLabelText('Reply in thread')); }); - expect(showThread).toHaveBeenCalledWith({ + expect(dispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.ShowThread, rootEvent: alicesMessageEvent, initialEvent: threadReplyEvent, highlighted: true,