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 <germains@element.io>
This commit is contained in:
Dariusz Niemczyk 2021-10-01 15:35:54 +02:00 committed by GitHub
parent 5dede230f1
commit 1331e960fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 403 additions and 189 deletions

View file

@ -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<IReactButtonProps> = ({ mxEvent, reactions, onFocusC
</React.Fragment>;
};
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<IMessageActionBarProps> {
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<IMessageAction
private onEditClick = (ev: React.MouseEvent): void => {
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<IMessageAction
// Like the resend button, the react and reply buttons need to appear before the edit.
// The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply && this.props.renderingContext === ActionBarRenderingContext.Room) {
if (this.context.canReply && this.context.timelineRenderingType === TimelineRenderingType.Room) {
toolbarOpts.splice(0, 0, <>
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"

View file

@ -28,7 +28,6 @@ import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import { Action } from "../../../dispatcher/actions";
import CountlyAnalytics from "../../../CountlyAnalytics";
@ -46,6 +45,8 @@ import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog';
import SettingsStore from "../../../settings/SettingsStore";
import { logger } from "matrix-js-sdk/src/logger";
import { withMatrixClientHOC, MatrixClientProps } from '../../../contexts/MatrixClientContext';
import RoomContext from '../../../contexts/RoomContext';
function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body;
@ -108,25 +109,24 @@ function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IConte
}, contentBody);
}
interface IProps {
interface IEditMessageComposerProps extends MatrixClientProps {
editState: EditorStateTransfer;
className?: string;
}
interface IState {
saveDisabled: boolean;
}
@replaceableComponent("views.rooms.EditMessageComposer")
export default class EditMessageComposer extends React.Component<IProps, IState> {
static contextType = MatrixClientContext;
context!: React.ContextType<typeof MatrixClientContext>;
class EditMessageComposer extends React.Component<IEditMessageComposerProps, IState> {
static contextType = RoomContext;
context!: React.ContextType<typeof RoomContext>;
private readonly editorRef = createRef<BasicMessageComposer>();
private readonly dispatcherRef: string;
private model: EditorModel = null;
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
constructor(props: IEditMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
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<IProps, IState>
}
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<IProps, IState>
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<IProps, IState>
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<IProps, IState>
};
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<IProps, IState>
}
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<IProps, IState>
}
// 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<IProps, IState>
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<IProps, IState>
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<IProps, IState>
</div>);
}
}
const EditMessageComposerWithMatrixClient = withMatrixClientHOC(EditMessageComposer);
export default EditMessageComposerWithMatrixClient;

View file

@ -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<IProps, IState> {
}
const showMessageActionBar = !isEditing && !this.props.forExport;
const renderingContext = this.props.tileShape === TileShape.Thread
? ActionBarRenderingContext.Thread
: ActionBarRenderingContext.Room;
const actionBar = showMessageActionBar ? <MessageActionBar
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
@ -1073,7 +1070,6 @@ export default class EventTile extends React.Component<IProps, IState> {
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<IProps, IState> {
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged}
tileShape={this.props.tileShape}
editState={this.props.editState}
/>
</div>,
]);
@ -1211,6 +1208,7 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged}
tileShape={this.props.tileShape}
editState={this.props.editState}
/>
{ actionBar }
</div>,
@ -1231,6 +1229,7 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onHeightChanged={this.props.onHeightChanged}
editState={this.props.editState}
/>
</div>,
<a

View file

@ -45,7 +45,7 @@ import { RecordingState } from "../../../audio/VoiceRecording";
import Tooltip, { Alignment } from "../elements/Tooltip";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import { E2EStatus } from '../../../utils/ShieldUtils';
import SendMessageComposer from "./SendMessageComposer";
import SendMessageComposer, { SendMessageComposer as SendMessageComposerClass } from "./SendMessageComposer";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions";
import EditorModel from "../../../editor/model";
@ -219,8 +219,8 @@ interface IState {
@replaceableComponent("views.rooms.MessageComposer")
export default class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef: string;
private messageComposerInput: SendMessageComposer;
private voiceRecordingButton: VoiceRecordComposerTile;
private messageComposerInput = createRef<SendMessageComposerClass>();
private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number;
@ -378,14 +378,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
}
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<IProps, IState> {
buttons.push(
<AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_voiceMessage"
onClick={() => this.voiceRecordingButton?.onRecordStartEndClick()}
onClick={() => this.voiceRecordingButton.current?.onRecordStartEndClick()}
title={_t("Send voice message")}
/>,
);
@ -521,7 +521,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
if (!this.state.tombstone && this.state.canSendMessages) {
controls.push(
<SendMessageComposer
ref={(c) => 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<IProps, IState> {
controls.push(<VoiceRecordComposerTile
key="controls_voice_record"
ref={c => this.voiceRecordingButton = c}
ref={this.voiceRecordingButton}
room={this.props.room} />);
} else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];

View file

@ -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<IProps> {
static contextType = MatrixClientContext;
context!: React.ContextType<typeof MatrixClientContext>;
export class SendMessageComposer extends React.Component<ISendMessageComposerProps> {
static contextType = RoomContext;
private readonly prepareToEncrypt?: DebouncedFunc<() => void>;
private readonly editorRef = createRef<BasicMessageComposer>();
private model: EditorModel = null;
@ -152,26 +150,25 @@ export default class SendMessageComposer extends React.Component<IProps> {
private dispatcherRef: string;
private sendHistoryManager: SendHistoryManager;
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
constructor(props: ISendMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
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<IProps> {
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<IProps> {
}
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<IProps> {
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<IProps> {
});
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<IProps> {
// 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<IProps> {
// 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<IProps> {
);
}
}
const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer);
export default SendMessageComposerWithMatrixClient;