From 1331e960fa497ee8e920b00fe155a4fdf71806ef Mon Sep 17 00:00:00 2001 From: Dariusz Niemczyk <3636685+Palid@users.noreply.github.com> Date: Fri, 1 Oct 2021 15:35:54 +0200 Subject: [PATCH] Add ability to properly edit messages in Threads. (#6877) * Fix infinite rerender loop when editing message * Refactor "edit_event" to Action.EditEvent * Make up-arrow edit working in Threads * Properly handle timeline events edit state * Properly traverse messages to be edited * Add MatrixClientContextHOC * Refactor RoomContext to use AppRenderingContext * Typescriptify test Co-authored-by: Germain --- src/components/structures/MessagePanel.tsx | 28 ++-- src/components/structures/RoomView.tsx | 28 +++- src/components/structures/ThreadView.tsx | 106 +++++++----- .../views/messages/MessageActionBar.tsx | 17 +- .../views/rooms/EditMessageComposer.tsx | 77 ++++++--- src/components/views/rooms/EventTile.tsx | 9 +- .../views/rooms/MessageComposer.tsx | 18 +- .../views/rooms/SendMessageComposer.tsx | 49 +++--- src/contexts/MatrixClientContext.ts | 22 --- src/contexts/MatrixClientContext.tsx | 46 +++++ src/contexts/RoomContext.ts | 11 +- src/dispatcher/actions.ts | 7 +- src/shouldHideEvent.ts | 4 +- src/utils/EventUtils.ts | 13 +- ...r-test.js => SendMessageComposer-test.tsx} | 157 ++++++++++++++---- 15 files changed, 403 insertions(+), 189 deletions(-) delete mode 100644 src/contexts/MatrixClientContext.ts create mode 100644 src/contexts/MatrixClientContext.tsx rename test/components/views/rooms/{SendMessageComposer-test.js => SendMessageComposer-test.tsx} (64%) diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index fe5f8699b4..42980efc57 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -48,6 +48,8 @@ import Spinner from "../views/elements/Spinner"; import TileErrorBoundary from '../views/messages/TileErrorBoundary'; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import EditorStateTransfer from "../../utils/EditorStateTransfer"; +import { logger } from 'matrix-js-sdk/src/logger'; +import { Action } from '../../dispatcher/actions'; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -287,6 +289,15 @@ export default class MessagePanel extends React.Component { ghostReadMarkers, }); } + + const pendingEditItem = this.pendingEditItem; + if (!this.props.editState && this.props.room && pendingEditItem) { + defaultDispatcher.dispatch({ + action: Action.EditEvent, + event: this.props.room.findEventById(pendingEditItem), + timelineRenderingType: this.context.timelineRenderingType, + }); + } } private calculateRoomMembersCount = (): void => { @@ -550,10 +561,14 @@ export default class MessagePanel extends React.Component { return { nextEvent, nextTile }; } - private get roomHasPendingEdit(): string { - return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`); + private get pendingEditItem(): string | undefined { + try { + return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}_${this.context.timelineRenderingType}`); + } catch (err) { + logger.error(err); + return undefined; + } } - private getEventTiles(): ReactNode[] { this.eventNodes = {}; @@ -663,13 +678,6 @@ export default class MessagePanel extends React.Component { } } - if (!this.props.editState && this.roomHasPendingEdit) { - defaultDispatcher.dispatch({ - action: "edit_event", - event: this.props.room.findEventById(this.roomHasPendingEdit), - }); - } - if (grouper) { ret.push(...grouper.getTiles()); } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 15bf327a74..e5067f1fcf 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -48,8 +48,8 @@ import { Layout } from "../../settings/Layout"; import AccessibleButton from "../views/elements/AccessibleButton"; import RightPanelStore from "../../stores/RightPanelStore"; import { haveTileForEvent } from "../views/rooms/EventTile"; -import RoomContext from "../../contexts/RoomContext"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; +import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; +import MatrixClientContext, { withMatrixClientHOC, MatrixClientProps } from "../../contexts/MatrixClientContext"; import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; import { Action } from "../../dispatcher/actions"; import { IMatrixClientCreds } from "../../MatrixClientPeg"; @@ -91,6 +91,7 @@ import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; import SpaceStore from "../../stores/SpaceStore"; import { logger } from "matrix-js-sdk/src/logger"; +import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -102,7 +103,7 @@ if (DEBUG) { debuglog = logger.log.bind(console); } -interface IProps { +interface IRoomProps extends MatrixClientProps { threepidInvite: IThreepidInvite; oobData?: IOOBData; @@ -113,7 +114,7 @@ interface IProps { onRegistered?(credentials: IMatrixClientCreds): void; } -export interface IState { +export interface IRoomState { room?: Room; roomId?: string; roomAlias?: string; @@ -187,10 +188,12 @@ export interface IState { // if it did we don't want the room to be marked as read as soon as it is loaded. wasContextSwitch?: boolean; editState?: EditorStateTransfer; + timelineRenderingType: TimelineRenderingType; + liveTimeline?: EventTimeline; } @replaceableComponent("structures.RoomView") -export default class RoomView extends React.Component { +export class RoomView extends React.Component { private readonly dispatcherRef: string; private readonly roomStoreToken: EventSubscription; private readonly rightPanelStoreToken: EventSubscription; @@ -247,6 +250,8 @@ export default class RoomView extends React.Component { showDisplaynameChanges: true, matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), dragCounter: 0, + timelineRenderingType: TimelineRenderingType.Room, + liveTimeline: undefined, }; this.dispatcherRef = dis.register(this.onAction); @@ -336,7 +341,7 @@ export default class RoomView extends React.Component { const roomId = RoomViewStore.getRoomId(); - const newState: Pick = { + const newState: Pick = { roomId, roomAlias: RoomViewStore.getRoomAlias(), roomLoading: RoomViewStore.isRoomLoading(), @@ -808,7 +813,9 @@ export default class RoomView extends React.Component { this.onSearchClick(); break; - case "edit_event": { + case Action.EditEvent: { + // Quit early if we're trying to edit events in wrong rendering context + if (payload.timelineRenderingType !== this.state.timelineRenderingType) return; const editState = payload.event ? new EditorStateTransfer(payload.event) : null; this.setState({ editState }, () => { if (payload.event) { @@ -932,6 +939,10 @@ export default class RoomView extends React.Component { this.updateE2EStatus(room); this.updatePermissions(room); this.checkWidgets(room); + + this.setState({ + liveTimeline: room.getLiveTimeline(), + }); }; private async calculateRecommendedVersion(room: Room) { @@ -2086,3 +2097,6 @@ export default class RoomView extends React.Component { ); } } + +const RoomViewWithMatrixClient = withMatrixClientHOC(RoomView); +export default RoomViewWithMatrixClient; diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 180a870cd5..8fac538bbc 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -34,6 +34,8 @@ import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPan import { Action } from '../../dispatcher/actions'; import { MatrixClientPeg } from '../../MatrixClientPeg'; import { E2EStatus } from '../../utils/ShieldUtils'; +import EditorStateTransfer from '../../utils/EditorStateTransfer'; +import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext'; interface IProps { room: Room; @@ -47,10 +49,14 @@ interface IProps { interface IState { replyToEvent?: MatrixEvent; thread?: Thread; + editState?: EditorStateTransfer; + } @replaceableComponent("structures.ThreadView") export default class ThreadView extends React.Component { + static contextType = RoomContext; + private dispatcherRef: string; private timelinePanelRef: React.RefObject = React.createRef(); @@ -90,6 +96,23 @@ export default class ThreadView extends React.Component { this.setupThread(payload.event); } } + switch (payload.action) { + case Action.EditEvent: { + // Quit early if it's not a thread context + if (payload.timelineRenderingType !== TimelineRenderingType.Thread) return; + // Quit early if that's not a thread event + if (payload.event && !payload.event.getThread()) return; + const editState = payload.event ? new EditorStateTransfer(payload.event) : null; + this.setState({ editState }, () => { + if (payload.event) { + this.timelinePanelRef.current?.scrollToEventIfNeeded(payload.event.getId()); + } + }); + break; + } + default: + break; + } }; private setupThread = (mxEv: MatrixEvent) => { @@ -124,44 +147,53 @@ export default class ThreadView extends React.Component { public render(): JSX.Element { return ( - - { this.state.thread && ( - empty} - alwaysShowTimestamps={true} - layout={Layout.Group} - hideThreadedMessages={false} - hidden={false} - showReactions={true} - className="mx_RoomView_messagePanel mx_GroupLayout" + + + + { this.state.thread && ( + empty} + alwaysShowTimestamps={true} + layout={Layout.Group} + hideThreadedMessages={false} + hidden={false} + showReactions={true} + className="mx_RoomView_messagePanel mx_GroupLayout" + permalinkCreator={this.props.permalinkCreator} + membersLoaded={true} + editState={this.state.editState} + /> + ) } + + { this.state?.thread?.timelineSet && ( - ) } - - + e2eStatus={this.props.e2eStatus} + compact={true} + />) } + + ); } } diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 06817b910a..7ee951a812 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -27,7 +27,7 @@ import { Action } from '../../../dispatcher/actions'; import { RightPanelPhases } from '../../../stores/RightPanelStorePhases'; import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; -import RoomContext from "../../../contexts/RoomContext"; +import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import Toolbar from "../../../accessibility/Toolbar"; import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -128,11 +128,6 @@ const ReactButton: React.FC = ({ mxEvent, reactions, onFocusC ; }; -export enum ActionBarRenderingContext { - Room, - Thread -} - interface IMessageActionBarProps { mxEvent: MatrixEvent; reactions?: Relations; @@ -142,7 +137,6 @@ interface IMessageActionBarProps { permalinkCreator?: RoomPermalinkCreator; onFocusChange?: (menuDisplayed: boolean) => void; toggleThreadExpanded: () => void; - renderingContext?: ActionBarRenderingContext; isQuoteExpanded?: boolean; } @@ -150,10 +144,6 @@ interface IMessageActionBarProps { export default class MessageActionBar extends React.PureComponent { public static contextType = RoomContext; - public static defaultProps = { - renderingContext: ActionBarRenderingContext.Room, - }; - public componentDidMount(): void { if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) { this.props.mxEvent.on("Event.status", this.onSent); @@ -217,8 +207,9 @@ export default class MessageActionBar extends React.PureComponent { dis.dispatch({ - action: 'edit_event', + action: Action.EditEvent, event: this.props.mxEvent, + timelineRenderingType: this.context.timelineRenderingType, }); }; @@ -298,7 +289,7 @@ export default class MessageActionBar extends React.PureComponent { - static contextType = MatrixClientContext; - context!: React.ContextType; +class EditMessageComposer extends React.Component { + static contextType = RoomContext; + context!: React.ContextType; private readonly editorRef = createRef(); private readonly dispatcherRef: string; private model: EditorModel = null; - constructor(props: IProps, context: React.ContextType) { + constructor(props: IEditMessageComposerProps, context: React.ContextType) { super(props); this.context = context; // otherwise React will only set it prior to render due to type def above @@ -141,7 +141,7 @@ export default class EditMessageComposer extends React.Component } private getRoom(): Room { - return this.context.getRoom(this.props.editState.getEvent().getRoomId()); + return this.props.mxClient.getRoom(this.props.editState.getEvent().getRoomId()); } private onKeyDown = (event: KeyboardEvent): void => { @@ -162,10 +162,17 @@ export default class EditMessageComposer extends React.Component if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtStart()) { return; } - const previousEvent = findEditableEvent(this.getRoom(), false, - this.props.editState.getEvent().getId()); + const previousEvent = findEditableEvent({ + events: this.events, + isForward: false, + fromEventId: this.props.editState.getEvent().getId(), + }); if (previousEvent) { - dis.dispatch({ action: 'edit_event', event: previousEvent }); + dis.dispatch({ + action: Action.EditEvent, + event: previousEvent, + timelineRenderingType: this.context.timelineRenderingType, + }); event.preventDefault(); } break; @@ -174,12 +181,24 @@ export default class EditMessageComposer extends React.Component if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) { return; } - const nextEvent = findEditableEvent(this.getRoom(), true, this.props.editState.getEvent().getId()); + const nextEvent = findEditableEvent({ + events: this.events, + isForward: true, + fromEventId: this.props.editState.getEvent().getId(), + }); if (nextEvent) { - dis.dispatch({ action: 'edit_event', event: nextEvent }); + dis.dispatch({ + action: Action.EditEvent, + event: nextEvent, + timelineRenderingType: this.context.timelineRenderingType, + }); } else { this.clearStoredEditorState(); - dis.dispatch({ action: 'edit_event', event: null }); + dis.dispatch({ + action: Action.EditEvent, + event: null, + timelineRenderingType: this.context.timelineRenderingType, + }); dis.fire(Action.FocusSendMessageComposer); } event.preventDefault(); @@ -189,16 +208,27 @@ export default class EditMessageComposer extends React.Component }; private get editorRoomKey(): string { - return `mx_edit_room_${this.getRoom().roomId}`; + return `mx_edit_room_${this.getRoom().roomId}_${this.context.timelineRenderingType}`; } private get editorStateKey(): string { return `mx_edit_state_${this.props.editState.getEvent().getId()}`; } + private get events(): MatrixEvent[] { + const liveTimelineEvents = this.context.liveTimeline.getEvents(); + const pendingEvents = this.getRoom().getPendingEvents(); + const isInThread = Boolean(this.props.editState.getEvent().getThread()); + return liveTimelineEvents.concat(isInThread ? [] : pendingEvents); + } + private cancelEdit = (): void => { this.clearStoredEditorState(); - dis.dispatch({ action: "edit_event", event: null }); + dis.dispatch({ + action: Action.EditEvent, + event: null, + timelineRenderingType: this.context.timelineRenderingType, + }); dis.fire(Action.FocusSendMessageComposer); }; @@ -381,7 +411,7 @@ export default class EditMessageComposer extends React.Component } if (shouldSend) { this.cancelPreviousPendingEdit(); - const prom = this.context.sendMessage(roomId, editContent); + const prom = this.props.mxClient.sendMessage(roomId, editContent); this.clearStoredEditorState(); dis.dispatch({ action: "message_sent" }); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); @@ -389,7 +419,11 @@ export default class EditMessageComposer extends React.Component } // close the event editing and focus composer - dis.dispatch({ action: "edit_event", event: null }); + dis.dispatch({ + action: Action.EditEvent, + event: null, + timelineRenderingType: this.context.timelineRenderingType, + }); dis.fire(Action.FocusSendMessageComposer); }; @@ -400,7 +434,7 @@ export default class EditMessageComposer extends React.Component previousEdit.status === EventStatus.QUEUED || previousEdit.status === EventStatus.NOT_SENT )) { - this.context.cancelPendingEvent(previousEdit); + this.props.mxClient.cancelPendingEvent(previousEdit); } } @@ -428,7 +462,7 @@ export default class EditMessageComposer extends React.Component private createEditorModel(): boolean { const { editState } = this.props; const room = this.getRoom(); - const partCreator = new CommandPartCreator(room, this.context); + const partCreator = new CommandPartCreator(room, this.props.mxClient); let parts; let isRestored = false; @@ -493,3 +527,6 @@ export default class EditMessageComposer extends React.Component ); } } + +const EditMessageComposerWithMatrixClient = withMatrixClientHOC(EditMessageComposer); +export default EditMessageComposerWithMatrixClient; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index cfdedc8310..2f3173fa56 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -53,7 +53,7 @@ import SenderProfile from '../messages/SenderProfile'; import MessageTimestamp from '../messages/MessageTimestamp'; import TooltipButton from '../elements/TooltipButton'; import ReadReceiptMarker from "./ReadReceiptMarker"; -import MessageActionBar, { ActionBarRenderingContext } from "../messages/MessageActionBar"; +import MessageActionBar from "../messages/MessageActionBar"; import ReactionsRow from '../messages/ReactionsRow'; import { getEventDisplayInfo } from '../../../utils/EventUtils'; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; @@ -1063,9 +1063,6 @@ export default class EventTile extends React.Component { } const showMessageActionBar = !isEditing && !this.props.forExport; - const renderingContext = this.props.tileShape === TileShape.Thread - ? ActionBarRenderingContext.Thread - : ActionBarRenderingContext.Room; const actionBar = showMessageActionBar ? { getTile={this.getTile} getReplyThread={this.getReplyThread} onFocusChange={this.onActionBarFocusChange} - renderingContext={renderingContext} isQuoteExpanded={isQuoteExpanded} toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)} /> : undefined; @@ -1178,6 +1174,7 @@ export default class EventTile extends React.Component { showUrlPreview={this.props.showUrlPreview} onHeightChanged={this.props.onHeightChanged} tileShape={this.props.tileShape} + editState={this.props.editState} /> , ]); @@ -1211,6 +1208,7 @@ export default class EventTile extends React.Component { showUrlPreview={this.props.showUrlPreview} onHeightChanged={this.props.onHeightChanged} tileShape={this.props.tileShape} + editState={this.props.editState} /> { actionBar } , @@ -1231,6 +1229,7 @@ export default class EventTile extends React.Component { showUrlPreview={this.props.showUrlPreview} tileShape={this.props.tileShape} onHeightChanged={this.props.onHeightChanged} + editState={this.props.editState} /> , { private dispatcherRef: string; - private messageComposerInput: SendMessageComposer; - private voiceRecordingButton: VoiceRecordComposerTile; + private messageComposerInput = createRef(); + private voiceRecordingButton = createRef(); private ref: React.RefObject = createRef(); private instanceId: number; @@ -378,14 +378,14 @@ export default class MessageComposer extends React.Component { } private sendMessage = async () => { - if (this.state.haveRecording && this.voiceRecordingButton) { + if (this.state.haveRecording && this.voiceRecordingButton.current) { // There shouldn't be any text message to send when a voice recording is active, so // just send out the voice recording. - await this.voiceRecordingButton.send(); + await this.voiceRecordingButton.current?.send(); return; } - this.messageComposerInput.sendMessage(); + this.messageComposerInput.current?.sendMessage(); }; private onChange = (model: EditorModel) => { @@ -460,7 +460,7 @@ export default class MessageComposer extends React.Component { buttons.push( this.voiceRecordingButton?.onRecordStartEndClick()} + onClick={() => this.voiceRecordingButton.current?.onRecordStartEndClick()} title={_t("Send voice message")} />, ); @@ -521,7 +521,7 @@ export default class MessageComposer extends React.Component { if (!this.state.tombstone && this.state.canSendMessages) { controls.push( this.messageComposerInput = c} + ref={this.messageComposerInput} key="controls_input" room={this.props.room} placeholder={this.renderPlaceholderText()} @@ -535,7 +535,7 @@ export default class MessageComposer extends React.Component { controls.push( this.voiceRecordingButton = c} + ref={this.voiceRecordingButton} room={this.props.room} />); } else if (this.state.tombstone) { const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index cc27ccf153..35a028aadc 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -19,6 +19,7 @@ import EMOJI_REGEX from 'emojibase-regex'; import { IContent, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { DebouncedFunc, throttle } from 'lodash'; import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; +import { logger } from "matrix-js-sdk/src/logger"; import dis from '../../../dispatcher/dispatcher'; import EditorModel from '../../../editor/model'; @@ -40,7 +41,7 @@ import { Command, CommandCategories, getCommand } from '../../../SlashCommands'; import Modal from '../../../Modal'; import { _t, _td } from '../../../languageHandler'; import ContentMessages from '../../../ContentMessages'; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { withMatrixClientHOC, MatrixClientProps } from "../../../contexts/MatrixClientContext"; import { Action } from "../../../dispatcher/actions"; import { containsEmoji } from "../../../effects/utils"; import { CHAT_EFFECTS } from '../../../effects'; @@ -55,8 +56,7 @@ import ErrorDialog from "../dialogs/ErrorDialog"; import QuestionDialog from "../dialogs/QuestionDialog"; import { ActionPayload } from "../../../dispatcher/payloads"; import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics"; - -import { logger } from "matrix-js-sdk/src/logger"; +import RoomContext from '../../../contexts/RoomContext'; function addReplyToMessageContent( content: IContent, @@ -130,7 +130,7 @@ export function isQuickReaction(model: EditorModel): boolean { return false; } -interface IProps { +interface ISendMessageComposerProps extends MatrixClientProps { room: Room; placeholder?: string; permalinkCreator: RoomPermalinkCreator; @@ -141,10 +141,8 @@ interface IProps { } @replaceableComponent("views.rooms.SendMessageComposer") -export default class SendMessageComposer extends React.Component { - static contextType = MatrixClientContext; - context!: React.ContextType; - +export class SendMessageComposer extends React.Component { + static contextType = RoomContext; private readonly prepareToEncrypt?: DebouncedFunc<() => void>; private readonly editorRef = createRef(); private model: EditorModel = null; @@ -152,26 +150,25 @@ export default class SendMessageComposer extends React.Component { private dispatcherRef: string; private sendHistoryManager: SendHistoryManager; - constructor(props: IProps, context: React.ContextType) { + constructor(props: ISendMessageComposerProps, context: React.ContextType) { super(props); - this.context = context; // otherwise React will only set it prior to render due to type def above - if (this.context.isCryptoEnabled() && this.context.isRoomEncrypted(this.props.room.roomId)) { + if (this.props.mxClient.isCryptoEnabled() && this.props.mxClient.isRoomEncrypted(this.props.room.roomId)) { this.prepareToEncrypt = throttle(() => { - this.context.prepareToEncrypt(this.props.room); + this.props.mxClient.prepareToEncrypt(this.props.room); }, 60000, { leading: true, trailing: false }); } window.addEventListener("beforeunload", this.saveStoredEditorState); } - public componentDidUpdate(prevProps: IProps): void { + public componentDidUpdate(prevProps: ISendMessageComposerProps): void { const replyToEventChanged = this.props.replyInThread && (this.props.replyToEvent !== prevProps.replyToEvent); if (replyToEventChanged) { this.model.reset([]); } if (this.props.replyInThread && this.props.replyToEvent && (!prevProps.replyToEvent || replyToEventChanged)) { - const partCreator = new CommandPartCreator(this.props.room, this.context); + const partCreator = new CommandPartCreator(this.props.room, this.props.mxClient); const parts = this.restoreStoredEditorState(partCreator) || []; this.model.reset(parts); this.editorRef.current?.focus(); @@ -202,13 +199,20 @@ export default class SendMessageComposer extends React.Component { case MessageComposerAction.EditPrevMessage: // selection must be collapsed and caret at start if (this.editorRef.current?.isSelectionCollapsed() && this.editorRef.current?.isCaretAtStart()) { - const editEvent = findEditableEvent(this.props.room, false); + const events = + this.context.liveTimeline.getEvents() + .concat(this.props.replyInThread ? [] : this.props.room.getPendingEvents()); + const editEvent = findEditableEvent({ + events, + isForward: false, + }); if (editEvent) { // We're selecting history, so prevent the key event from doing anything else event.preventDefault(); dis.dispatch({ - action: 'edit_event', + action: Action.EditEvent, event: editEvent, + timelineRenderingType: this.context.timelineRenderingType, }); } } @@ -275,7 +279,7 @@ export default class SendMessageComposer extends React.Component { } private sendQuickReaction(): void { - const timeline = this.props.room.getLiveTimeline(); + const timeline = this.context.liveTimeline(); const events = timeline.getEvents(); const reaction = this.model.parts[1].text; for (let i = events.length - 1; i >= 0; i--) { @@ -448,7 +452,7 @@ export default class SendMessageComposer extends React.Component { decorateStartSendingTime(content); } - const prom = this.context.sendMessage(roomId, content); + const prom = this.props.mxClient.sendMessage(roomId, content); if (replyToEvent) { // Clear reply_to_event as we put the message into the queue // if the send fails, retry will handle resending. @@ -465,7 +469,7 @@ export default class SendMessageComposer extends React.Component { }); if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { prom.then(resp => { - sendRoundTripMetric(this.context, roomId, resp.event_id); + sendRoundTripMetric(this.props.mxClient, roomId, resp.event_id); }); } CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content); @@ -490,7 +494,7 @@ export default class SendMessageComposer extends React.Component { // TODO: [REACT-WARNING] Move this to constructor UNSAFE_componentWillMount() { // eslint-disable-line - const partCreator = new CommandPartCreator(this.props.room, this.context); + const partCreator = new CommandPartCreator(this.props.room, this.props.mxClient); const parts = this.restoreStoredEditorState(partCreator) || []; this.model = new EditorModel(parts, partCreator); this.dispatcherRef = dis.register(this.onAction); @@ -577,7 +581,7 @@ export default class SendMessageComposer extends React.Component { // it puts the filename in as text/plain which we want to ignore. if (clipboardData.files.length && !clipboardData.types.includes("text/rtf")) { ContentMessages.sharedInstance().sendContentListToRoom( - Array.from(clipboardData.files), this.props.room.roomId, this.context, + Array.from(clipboardData.files), this.props.room.roomId, this.props.mxClient, ); return true; // to skip internal onPaste handler } @@ -608,3 +612,6 @@ export default class SendMessageComposer extends React.Component { ); } } + +const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer); +export default SendMessageComposerWithMatrixClient; diff --git a/src/contexts/MatrixClientContext.ts b/src/contexts/MatrixClientContext.ts deleted file mode 100644 index 7e8a92064d..0000000000 --- a/src/contexts/MatrixClientContext.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* -Copyright 2019 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 { createContext } from "react"; -import { MatrixClient } from "matrix-js-sdk/src/client"; - -const MatrixClientContext = createContext(undefined); -MatrixClientContext.displayName = "MatrixClientContext"; -export default MatrixClientContext; diff --git a/src/contexts/MatrixClientContext.tsx b/src/contexts/MatrixClientContext.tsx new file mode 100644 index 0000000000..292c1e34d8 --- /dev/null +++ b/src/contexts/MatrixClientContext.tsx @@ -0,0 +1,46 @@ +/* +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, { ComponentClass, createContext, forwardRef, useContext } from "react"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + +const MatrixClientContext = createContext(undefined); +MatrixClientContext.displayName = "MatrixClientContext"; +export default MatrixClientContext; + +export interface MatrixClientProps { + mxClient: MatrixClient; +} + +const matrixHOC = ( + ComposedComponent: ComponentClass, +) => { + type ComposedComponentInstance = InstanceType; + + // eslint-disable-next-line react-hooks/rules-of-hooks + + const TypedComponent = ComposedComponent; + + return forwardRef>( + (props, ref) => { + const client = useContext(MatrixClientContext); + + // @ts-ignore + return ; + }, + ); +}; +export const withMatrixClientHOC = matrixHOC; diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 0507a3e252..a57c14d90f 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -16,10 +16,15 @@ limitations under the License. import { createContext } from "react"; -import { IState } from "../components/structures/RoomView"; +import { IRoomState } from "../components/structures/RoomView"; import { Layout } from "../settings/Layout"; -const RoomContext = createContext({ +export enum TimelineRenderingType { + Room, + Thread +} + +const RoomContext = createContext({ roomLoading: true, peekLoading: false, shouldPeek: true, @@ -53,6 +58,8 @@ const RoomContext = createContext({ showDisplaynameChanges: true, matrixClientIsReady: false, dragCounter: 0, + timelineRenderingType: TimelineRenderingType.Room, + liveTimeline: undefined, }); RoomContext.displayName = "RoomContext"; export default RoomContext; diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 2a8ce7a08b..796dbbeeb6 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -128,7 +128,7 @@ export enum Action { * Start a call transfer to a phone number * payload: TransferCallPayload */ - TransferCallToPhoneNumber = "transfer_call_to_phone_number", + TransferCallToPhoneNumber = "transfer_call_to_phone_number", /** * Fired when CallHandler has checked for PSTN protocol support @@ -205,4 +205,9 @@ export enum Action { * Should be used with SettingUpdatedPayload. */ SettingUpdated = "setting_updated", + + /** + * Fires when a user starts to edit event (e.g. up arrow in compositor) + */ + EditEvent = "edit_event", } diff --git a/src/shouldHideEvent.ts b/src/shouldHideEvent.ts index 985eae85a0..c31da5bd4f 100644 --- a/src/shouldHideEvent.ts +++ b/src/shouldHideEvent.ts @@ -17,7 +17,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import SettingsStore from "./settings/SettingsStore"; -import { IState } from "./components/structures/RoomView"; +import { IRoomState } from "./components/structures/RoomView"; interface IDiff { isMemberEvent: boolean; @@ -54,7 +54,7 @@ function memberEventDiff(ev: MatrixEvent): IDiff { * @param ctx An optional RoomContext to pull cached settings values from to avoid * hitting the settings store */ -export default function shouldHideEvent(ev: MatrixEvent, ctx?: IState): boolean { +export default function shouldHideEvent(ev: MatrixEvent, ctx?: IRoomState): boolean { // Accessing the settings store directly can be expensive if done frequently, // so we should prefer using cached values if a RoomContext is available const isEnabled = ctx ? diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index ee8d9bceae..e16e58e0d2 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room } from 'matrix-js-sdk/src/models/room'; import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event'; import { MatrixClientPeg } from '../MatrixClientPeg'; @@ -73,9 +72,15 @@ export function canEditOwnEvent(mxEvent: MatrixEvent): boolean { } const MAX_JUMP_DISTANCE = 100; -export function findEditableEvent(room: Room, isForward: boolean, fromEventId: string = undefined): MatrixEvent { - const liveTimeline = room.getLiveTimeline(); - const events = liveTimeline.getEvents().concat(room.getPendingEvents()); +export function findEditableEvent({ + events, + isForward, + fromEventId, +}: { + events: MatrixEvent[]; + isForward: boolean; + fromEventId?: string; +}): MatrixEvent { const maxIdx = events.length - 1; const inc = isForward ? 1 : -1; const beginIdx = isForward ? 0 : maxIdx; diff --git a/test/components/views/rooms/SendMessageComposer-test.js b/test/components/views/rooms/SendMessageComposer-test.tsx similarity index 64% rename from test/components/views/rooms/SendMessageComposer-test.js rename to test/components/views/rooms/SendMessageComposer-test.tsx index db5b55df90..73fa388767 100644 --- a/test/components/views/rooms/SendMessageComposer-test.js +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -24,8 +24,10 @@ import { sleep } from "matrix-js-sdk/src/utils"; import SendMessageComposer, { createMessageContent, isQuickReaction, + SendMessageComposer as SendMessageComposerClass, } from "../../../../src/components/views/rooms/SendMessageComposer"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; import EditorModel from "../../../../src/editor/model"; import { createPartCreator, createRenderer } from "../../../editor/mock"; import { createTestClient, mkEvent, mkStubRoom } from "../../../test-utils"; @@ -33,18 +35,58 @@ import BasicMessageComposer from "../../../../src/components/views/rooms/BasicMe import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import SpecPermalinkConstructor from "../../../../src/utils/permalinks/SpecPermalinkConstructor"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; +import DocumentOffset from '../../../../src/editor/offset'; +import { Layout } from '../../../../src/settings/Layout'; jest.mock("../../../../src/stores/RoomViewStore"); configure({ adapter: new Adapter() }); describe('', () => { + const roomContext = { + roomLoading: true, + peekLoading: false, + shouldPeek: true, + membersLoaded: false, + numUnreadMessages: 0, + draggingFile: false, + searching: false, + guestsCanJoin: false, + canPeek: false, + showApps: false, + isPeeking: false, + showRightPanel: true, + joining: false, + atEndOfLiveTimeline: true, + atEndOfLiveTimelineInit: false, + showTopUnreadMessagesBar: false, + statusBarVisible: false, + canReact: false, + canReply: false, + layout: Layout.Group, + lowBandwidth: false, + alwaysShowTimestamps: false, + showTwelveHourTimestamps: false, + readMarkerInViewThresholdMs: 3000, + readMarkerOutOfViewThresholdMs: 30000, + showHiddenEventsInTimeline: false, + showReadReceipts: true, + showRedactions: true, + showJoinLeaves: true, + showAvatarChanges: true, + showDisplaynameChanges: true, + matrixClientIsReady: false, + dragCounter: 0, + timelineRenderingType: TimelineRenderingType.Room, + liveTimeline: undefined, + }; describe("createMessageContent", () => { - const permalinkCreator = jest.fn(); + const permalinkCreator = jest.fn() as any; it("sends plaintext messages correctly", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("hello world", "insertText", { offset: 11, atNodeEnd: true }); + const documentOffset = new DocumentOffset(11, true); + model.update("hello world", "insertText", documentOffset); const content = createMessageContent(model, null, false, permalinkCreator); @@ -56,7 +98,8 @@ describe('', () => { it("sends markdown messages correctly", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("hello *world*", "insertText", { offset: 13, atNodeEnd: true }); + const documentOffset = new DocumentOffset(13, true); + model.update("hello *world*", "insertText", documentOffset); const content = createMessageContent(model, null, false, permalinkCreator); @@ -70,7 +113,8 @@ describe('', () => { it("strips /me from messages and marks them as m.emote accordingly", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("/me blinks __quickly__", "insertText", { offset: 22, atNodeEnd: true }); + const documentOffset = new DocumentOffset(22, true); + model.update("/me blinks __quickly__", "insertText", documentOffset); const content = createMessageContent(model, null, false, permalinkCreator); @@ -84,7 +128,9 @@ describe('', () => { it("allows sending double-slash escaped slash commands correctly", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("//dev/null is my favourite place", "insertText", { offset: 32, atNodeEnd: true }); + const documentOffset = new DocumentOffset(32, true); + + model.update("//dev/null is my favourite place", "insertText", documentOffset); const content = createMessageContent(model, null, false, permalinkCreator); @@ -97,9 +143,11 @@ describe('', () => { describe("functions correctly mounted", () => { const mockClient = MatrixClientPeg.matrixClient = createTestClient(); - const mockRoom = mkStubRoom(); + const mockRoom = mkStubRoom('myfakeroom') as any; const mockEvent = mkEvent({ type: "m.room.message", + room: 'myfakeroom', + user: 'myfakeuser', content: "Replying to this", event: true, }); @@ -116,11 +164,13 @@ describe('', () => { it("renders text and placeholder correctly", () => { const wrapper = mount( - + + + ); expect(wrapper.find('[aria-label="placeholder string"]')).toHaveLength(1); @@ -135,12 +185,15 @@ describe('', () => { it("correctly persists state to and from localStorage", () => { const wrapper = mount( - + + + + ); act(() => { @@ -148,7 +201,7 @@ describe('', () => { wrapper.update(); }); - const key = wrapper.find(SendMessageComposer).instance().editorStateKey; + const key = wrapper.find(SendMessageComposerClass).instance().editorStateKey; expect(wrapper.text()).toBe("Test Text"); expect(localStorage.getItem(key)).toBeNull(); @@ -177,11 +230,14 @@ describe('', () => { it("persists state correctly without replyToEvent onbeforeunload", () => { const wrapper = mount( - + + + + ); act(() => { @@ -189,7 +245,7 @@ describe('', () => { wrapper.update(); }); - const key = wrapper.find(SendMessageComposer).instance().editorStateKey; + const key = wrapper.find(SendMessageComposerClass).instance().editorStateKey; expect(wrapper.text()).toBe("Hello World"); expect(localStorage.getItem(key)).toBeNull(); @@ -203,12 +259,15 @@ describe('', () => { it("persists to session history upon sending", async () => { const wrapper = mount( - + + + + ); act(() => { @@ -230,12 +289,38 @@ describe('', () => { replyEventId: mockEvent.getId(), }); }); + + it('correctly sets the editorStateKey for threads', () => { + const mockThread ={ + getThread: () => { + return { + id: 'myFakeThreadId', + }; + }, + } as any; + const wrapper = mount( + + + + + ); + + const instance = wrapper.find(SendMessageComposerClass).instance(); + const key = instance.editorStateKey; + + expect(key).toEqual('mx_cider_state_myfakeroom_myFakeThreadId'); + }); }); describe("isQuickReaction", () => { it("correctly detects quick reaction", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("+😊", "insertText", { offset: 3, atNodeEnd: true }); + model.update("+😊", "insertText", new DocumentOffset(3, true)); const isReaction = isQuickReaction(model); @@ -244,7 +329,7 @@ describe('', () => { it("correctly detects quick reaction with space", () => { const model = new EditorModel([], createPartCreator(), createRenderer()); - model.update("+ 😊", "insertText", { offset: 4, atNodeEnd: true }); + model.update("+ 😊", "insertText", new DocumentOffset(4, true)); const isReaction = isQuickReaction(model); @@ -256,10 +341,10 @@ describe('', () => { const model2 = new EditorModel([], createPartCreator(), createRenderer()); const model3 = new EditorModel([], createPartCreator(), createRenderer()); const model4 = new EditorModel([], createPartCreator(), createRenderer()); - model.update("+😊hello", "insertText", { offset: 8, atNodeEnd: true }); - model2.update(" +😊", "insertText", { offset: 4, atNodeEnd: true }); - model3.update("+ 😊😊", "insertText", { offset: 6, atNodeEnd: true }); - model4.update("+smiley", "insertText", { offset: 7, atNodeEnd: true }); + model.update("+😊hello", "insertText", new DocumentOffset( 8, true)); + model2.update(" +😊", "insertText", new DocumentOffset( 4, true)); + model3.update("+ 😊😊", "insertText", new DocumentOffset( 6, true)); + model4.update("+smiley", "insertText", new DocumentOffset( 7, true)); expect(isQuickReaction(model)).toBeFalsy(); expect(isQuickReaction(model2)).toBeFalsy();