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

@ -48,6 +48,8 @@ import Spinner from "../views/elements/Spinner";
import TileErrorBoundary from '../views/messages/TileErrorBoundary'; import TileErrorBoundary from '../views/messages/TileErrorBoundary';
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import EditorStateTransfer from "../../utils/EditorStateTransfer"; 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 CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
@ -287,6 +289,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
ghostReadMarkers, 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 => { private calculateRoomMembersCount = (): void => {
@ -550,10 +561,14 @@ export default class MessagePanel extends React.Component<IProps, IState> {
return { nextEvent, nextTile }; return { nextEvent, nextTile };
} }
private get roomHasPendingEdit(): string { private get pendingEditItem(): string | undefined {
return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`); try {
return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}_${this.context.timelineRenderingType}`);
} catch (err) {
logger.error(err);
return undefined;
}
} }
private getEventTiles(): ReactNode[] { private getEventTiles(): ReactNode[] {
this.eventNodes = {}; this.eventNodes = {};
@ -663,13 +678,6 @@ export default class MessagePanel extends React.Component<IProps, IState> {
} }
} }
if (!this.props.editState && this.roomHasPendingEdit) {
defaultDispatcher.dispatch({
action: "edit_event",
event: this.props.room.findEventById(this.roomHasPendingEdit),
});
}
if (grouper) { if (grouper) {
ret.push(...grouper.getTiles()); ret.push(...grouper.getTiles());
} }

View file

@ -48,8 +48,8 @@ import { Layout } from "../../settings/Layout";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore"; import RightPanelStore from "../../stores/RightPanelStore";
import { haveTileForEvent } from "../views/rooms/EventTile"; import { haveTileForEvent } from "../views/rooms/EventTile";
import RoomContext from "../../contexts/RoomContext"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext, { withMatrixClientHOC, MatrixClientProps } from "../../contexts/MatrixClientContext";
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { IMatrixClientCreds } from "../../MatrixClientPeg"; import { IMatrixClientCreds } from "../../MatrixClientPeg";
@ -91,6 +91,7 @@ import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
import SpaceStore from "../../stores/SpaceStore"; import SpaceStore from "../../stores/SpaceStore";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
const DEBUG = false; const DEBUG = false;
let debuglog = function(msg: string) {}; let debuglog = function(msg: string) {};
@ -102,7 +103,7 @@ if (DEBUG) {
debuglog = logger.log.bind(console); debuglog = logger.log.bind(console);
} }
interface IProps { interface IRoomProps extends MatrixClientProps {
threepidInvite: IThreepidInvite; threepidInvite: IThreepidInvite;
oobData?: IOOBData; oobData?: IOOBData;
@ -113,7 +114,7 @@ interface IProps {
onRegistered?(credentials: IMatrixClientCreds): void; onRegistered?(credentials: IMatrixClientCreds): void;
} }
export interface IState { export interface IRoomState {
room?: Room; room?: Room;
roomId?: string; roomId?: string;
roomAlias?: 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. // if it did we don't want the room to be marked as read as soon as it is loaded.
wasContextSwitch?: boolean; wasContextSwitch?: boolean;
editState?: EditorStateTransfer; editState?: EditorStateTransfer;
timelineRenderingType: TimelineRenderingType;
liveTimeline?: EventTimeline;
} }
@replaceableComponent("structures.RoomView") @replaceableComponent("structures.RoomView")
export default class RoomView extends React.Component<IProps, IState> { export class RoomView extends React.Component<IRoomProps, IRoomState> {
private readonly dispatcherRef: string; private readonly dispatcherRef: string;
private readonly roomStoreToken: EventSubscription; private readonly roomStoreToken: EventSubscription;
private readonly rightPanelStoreToken: EventSubscription; private readonly rightPanelStoreToken: EventSubscription;
@ -247,6 +250,8 @@ export default class RoomView extends React.Component<IProps, IState> {
showDisplaynameChanges: true, showDisplaynameChanges: true,
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
dragCounter: 0, dragCounter: 0,
timelineRenderingType: TimelineRenderingType.Room,
liveTimeline: undefined,
}; };
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
@ -336,7 +341,7 @@ export default class RoomView extends React.Component<IProps, IState> {
const roomId = RoomViewStore.getRoomId(); const roomId = RoomViewStore.getRoomId();
const newState: Pick<IState, any> = { const newState: Pick<IRoomState, any> = {
roomId, roomId,
roomAlias: RoomViewStore.getRoomAlias(), roomAlias: RoomViewStore.getRoomAlias(),
roomLoading: RoomViewStore.isRoomLoading(), roomLoading: RoomViewStore.isRoomLoading(),
@ -808,7 +813,9 @@ export default class RoomView extends React.Component<IProps, IState> {
this.onSearchClick(); this.onSearchClick();
break; 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; const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
this.setState({ editState }, () => { this.setState({ editState }, () => {
if (payload.event) { if (payload.event) {
@ -932,6 +939,10 @@ export default class RoomView extends React.Component<IProps, IState> {
this.updateE2EStatus(room); this.updateE2EStatus(room);
this.updatePermissions(room); this.updatePermissions(room);
this.checkWidgets(room); this.checkWidgets(room);
this.setState({
liveTimeline: room.getLiveTimeline(),
});
}; };
private async calculateRecommendedVersion(room: Room) { private async calculateRecommendedVersion(room: Room) {
@ -2086,3 +2097,6 @@ export default class RoomView extends React.Component<IProps, IState> {
); );
} }
} }
const RoomViewWithMatrixClient = withMatrixClientHOC(RoomView);
export default RoomViewWithMatrixClient;

View file

@ -34,6 +34,8 @@ import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPan
import { Action } from '../../dispatcher/actions'; import { Action } from '../../dispatcher/actions';
import { MatrixClientPeg } from '../../MatrixClientPeg'; import { MatrixClientPeg } from '../../MatrixClientPeg';
import { E2EStatus } from '../../utils/ShieldUtils'; import { E2EStatus } from '../../utils/ShieldUtils';
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext';
interface IProps { interface IProps {
room: Room; room: Room;
@ -47,10 +49,14 @@ interface IProps {
interface IState { interface IState {
replyToEvent?: MatrixEvent; replyToEvent?: MatrixEvent;
thread?: Thread; thread?: Thread;
editState?: EditorStateTransfer;
} }
@replaceableComponent("structures.ThreadView") @replaceableComponent("structures.ThreadView")
export default class ThreadView extends React.Component<IProps, IState> { export default class ThreadView extends React.Component<IProps, IState> {
static contextType = RoomContext;
private dispatcherRef: string; private dispatcherRef: string;
private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef(); private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef();
@ -90,6 +96,23 @@ export default class ThreadView extends React.Component<IProps, IState> {
this.setupThread(payload.event); 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) => { private setupThread = (mxEv: MatrixEvent) => {
@ -124,6 +147,12 @@ export default class ThreadView extends React.Component<IProps, IState> {
public render(): JSX.Element { public render(): JSX.Element {
return ( return (
<RoomContext.Provider value={{
...this.context,
timelineRenderingType: TimelineRenderingType.Thread,
liveTimeline: this.state?.thread?.timelineSet?.getLiveTimeline(),
}}>
<BaseCard <BaseCard
className="mx_ThreadView" className="mx_ThreadView"
onClose={this.props.onClose} onClose={this.props.onClose}
@ -149,9 +178,11 @@ export default class ThreadView extends React.Component<IProps, IState> {
className="mx_RoomView_messagePanel mx_GroupLayout" className="mx_RoomView_messagePanel mx_GroupLayout"
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
membersLoaded={true} membersLoaded={true}
editState={this.state.editState}
/> />
) } ) }
<MessageComposer
{ this.state?.thread?.timelineSet && (<MessageComposer
room={this.props.room} room={this.props.room}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
replyInThread={true} replyInThread={true}
@ -160,8 +191,9 @@ export default class ThreadView extends React.Component<IProps, IState> {
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus} e2eStatus={this.props.e2eStatus}
compact={true} compact={true}
/> />) }
</BaseCard> </BaseCard>
</RoomContext.Provider>
); );
} }
} }

View file

@ -27,7 +27,7 @@ import { Action } from '../../../dispatcher/actions';
import { RightPanelPhases } from '../../../stores/RightPanelStorePhases'; import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
import RoomContext from "../../../contexts/RoomContext"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import Toolbar from "../../../accessibility/Toolbar"; import Toolbar from "../../../accessibility/Toolbar";
import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -128,11 +128,6 @@ const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusC
</React.Fragment>; </React.Fragment>;
}; };
export enum ActionBarRenderingContext {
Room,
Thread
}
interface IMessageActionBarProps { interface IMessageActionBarProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
reactions?: Relations; reactions?: Relations;
@ -142,7 +137,6 @@ interface IMessageActionBarProps {
permalinkCreator?: RoomPermalinkCreator; permalinkCreator?: RoomPermalinkCreator;
onFocusChange?: (menuDisplayed: boolean) => void; onFocusChange?: (menuDisplayed: boolean) => void;
toggleThreadExpanded: () => void; toggleThreadExpanded: () => void;
renderingContext?: ActionBarRenderingContext;
isQuoteExpanded?: boolean; isQuoteExpanded?: boolean;
} }
@ -150,10 +144,6 @@ interface IMessageActionBarProps {
export default class MessageActionBar extends React.PureComponent<IMessageActionBarProps> { export default class MessageActionBar extends React.PureComponent<IMessageActionBarProps> {
public static contextType = RoomContext; public static contextType = RoomContext;
public static defaultProps = {
renderingContext: ActionBarRenderingContext.Room,
};
public componentDidMount(): void { public componentDidMount(): void {
if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) { if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) {
this.props.mxEvent.on("Event.status", this.onSent); 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 => { private onEditClick = (ev: React.MouseEvent): void => {
dis.dispatch({ dis.dispatch({
action: 'edit_event', action: Action.EditEvent,
event: this.props.mxEvent, 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. // 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 // 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()`. // 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, <> toolbarOpts.splice(0, 0, <>
<RovingAccessibleTooltipButton <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton" 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 { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer"; import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Command, CommandCategories, getCommand } from '../../../SlashCommands'; import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
@ -46,6 +45,8 @@ import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { withMatrixClientHOC, MatrixClientProps } from '../../../contexts/MatrixClientContext';
import RoomContext from '../../../contexts/RoomContext';
function getHtmlReplyFallback(mxEvent: MatrixEvent): string { function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body; const html = mxEvent.getContent().formatted_body;
@ -108,25 +109,24 @@ function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IConte
}, contentBody); }, contentBody);
} }
interface IProps { interface IEditMessageComposerProps extends MatrixClientProps {
editState: EditorStateTransfer; editState: EditorStateTransfer;
className?: string; className?: string;
} }
interface IState { interface IState {
saveDisabled: boolean; saveDisabled: boolean;
} }
@replaceableComponent("views.rooms.EditMessageComposer") @replaceableComponent("views.rooms.EditMessageComposer")
export default class EditMessageComposer extends React.Component<IProps, IState> { class EditMessageComposer extends React.Component<IEditMessageComposerProps, IState> {
static contextType = MatrixClientContext; static contextType = RoomContext;
context!: React.ContextType<typeof MatrixClientContext>; context!: React.ContextType<typeof RoomContext>;
private readonly editorRef = createRef<BasicMessageComposer>(); private readonly editorRef = createRef<BasicMessageComposer>();
private readonly dispatcherRef: string; private readonly dispatcherRef: string;
private model: EditorModel = null; private model: EditorModel = null;
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) { constructor(props: IEditMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
super(props); super(props);
this.context = context; // otherwise React will only set it prior to render due to type def above 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 { 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 => { 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()) { if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtStart()) {
return; return;
} }
const previousEvent = findEditableEvent(this.getRoom(), false, const previousEvent = findEditableEvent({
this.props.editState.getEvent().getId()); events: this.events,
isForward: false,
fromEventId: this.props.editState.getEvent().getId(),
});
if (previousEvent) { if (previousEvent) {
dis.dispatch({ action: 'edit_event', event: previousEvent }); dis.dispatch({
action: Action.EditEvent,
event: previousEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
event.preventDefault(); event.preventDefault();
} }
break; break;
@ -174,12 +181,24 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) { if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) {
return; 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) { if (nextEvent) {
dis.dispatch({ action: 'edit_event', event: nextEvent }); dis.dispatch({
action: Action.EditEvent,
event: nextEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
} else { } else {
this.clearStoredEditorState(); this.clearStoredEditorState();
dis.dispatch({ action: 'edit_event', event: null }); dis.dispatch({
action: Action.EditEvent,
event: null,
timelineRenderingType: this.context.timelineRenderingType,
});
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
} }
event.preventDefault(); event.preventDefault();
@ -189,16 +208,27 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
}; };
private get editorRoomKey(): string { 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 { private get editorStateKey(): string {
return `mx_edit_state_${this.props.editState.getEvent().getId()}`; 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 => { private cancelEdit = (): void => {
this.clearStoredEditorState(); this.clearStoredEditorState();
dis.dispatch({ action: "edit_event", event: null }); dis.dispatch({
action: Action.EditEvent,
event: null,
timelineRenderingType: this.context.timelineRenderingType,
});
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
}; };
@ -381,7 +411,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
} }
if (shouldSend) { if (shouldSend) {
this.cancelPreviousPendingEdit(); this.cancelPreviousPendingEdit();
const prom = this.context.sendMessage(roomId, editContent); const prom = this.props.mxClient.sendMessage(roomId, editContent);
this.clearStoredEditorState(); this.clearStoredEditorState();
dis.dispatch({ action: "message_sent" }); dis.dispatch({ action: "message_sent" });
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); 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 // 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); dis.fire(Action.FocusSendMessageComposer);
}; };
@ -400,7 +434,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
previousEdit.status === EventStatus.QUEUED || previousEdit.status === EventStatus.QUEUED ||
previousEdit.status === EventStatus.NOT_SENT 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 { private createEditorModel(): boolean {
const { editState } = this.props; const { editState } = this.props;
const room = this.getRoom(); const room = this.getRoom();
const partCreator = new CommandPartCreator(room, this.context); const partCreator = new CommandPartCreator(room, this.props.mxClient);
let parts; let parts;
let isRestored = false; let isRestored = false;
@ -493,3 +527,6 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
</div>); </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 MessageTimestamp from '../messages/MessageTimestamp';
import TooltipButton from '../elements/TooltipButton'; import TooltipButton from '../elements/TooltipButton';
import ReadReceiptMarker from "./ReadReceiptMarker"; import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar, { ActionBarRenderingContext } from "../messages/MessageActionBar"; import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow'; import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils'; import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; 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 showMessageActionBar = !isEditing && !this.props.forExport;
const renderingContext = this.props.tileShape === TileShape.Thread
? ActionBarRenderingContext.Thread
: ActionBarRenderingContext.Room;
const actionBar = showMessageActionBar ? <MessageActionBar const actionBar = showMessageActionBar ? <MessageActionBar
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
reactions={this.state.reactions} reactions={this.state.reactions}
@ -1073,7 +1070,6 @@ export default class EventTile extends React.Component<IProps, IState> {
getTile={this.getTile} getTile={this.getTile}
getReplyThread={this.getReplyThread} getReplyThread={this.getReplyThread}
onFocusChange={this.onActionBarFocusChange} onFocusChange={this.onActionBarFocusChange}
renderingContext={renderingContext}
isQuoteExpanded={isQuoteExpanded} isQuoteExpanded={isQuoteExpanded}
toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)} toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
/> : undefined; /> : undefined;
@ -1178,6 +1174,7 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
editState={this.props.editState}
/> />
</div>, </div>,
]); ]);
@ -1211,6 +1208,7 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
editState={this.props.editState}
/> />
{ actionBar } { actionBar }
</div>, </div>,
@ -1231,6 +1229,7 @@ export default class EventTile extends React.Component<IProps, IState> {
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
editState={this.props.editState}
/> />
</div>, </div>,
<a <a

View file

@ -45,7 +45,7 @@ import { RecordingState } from "../../../audio/VoiceRecording";
import Tooltip, { Alignment } from "../elements/Tooltip"; import Tooltip, { Alignment } from "../elements/Tooltip";
import ResizeNotifier from "../../../utils/ResizeNotifier"; import ResizeNotifier from "../../../utils/ResizeNotifier";
import { E2EStatus } from '../../../utils/ShieldUtils'; import { E2EStatus } from '../../../utils/ShieldUtils';
import SendMessageComposer from "./SendMessageComposer"; import SendMessageComposer, { SendMessageComposer as SendMessageComposerClass } from "./SendMessageComposer";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import EditorModel from "../../../editor/model"; import EditorModel from "../../../editor/model";
@ -219,8 +219,8 @@ interface IState {
@replaceableComponent("views.rooms.MessageComposer") @replaceableComponent("views.rooms.MessageComposer")
export default class MessageComposer extends React.Component<IProps, IState> { export default class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef: string; private dispatcherRef: string;
private messageComposerInput: SendMessageComposer; private messageComposerInput = createRef<SendMessageComposerClass>();
private voiceRecordingButton: VoiceRecordComposerTile; private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
private ref: React.RefObject<HTMLDivElement> = createRef(); private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number; private instanceId: number;
@ -378,14 +378,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
} }
private sendMessage = async () => { 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 // There shouldn't be any text message to send when a voice recording is active, so
// just send out the voice recording. // just send out the voice recording.
await this.voiceRecordingButton.send(); await this.voiceRecordingButton.current?.send();
return; return;
} }
this.messageComposerInput.sendMessage(); this.messageComposerInput.current?.sendMessage();
}; };
private onChange = (model: EditorModel) => { private onChange = (model: EditorModel) => {
@ -460,7 +460,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
buttons.push( buttons.push(
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_MessageComposer_button mx_MessageComposer_voiceMessage" className="mx_MessageComposer_button mx_MessageComposer_voiceMessage"
onClick={() => this.voiceRecordingButton?.onRecordStartEndClick()} onClick={() => this.voiceRecordingButton.current?.onRecordStartEndClick()}
title={_t("Send voice message")} 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) { if (!this.state.tombstone && this.state.canSendMessages) {
controls.push( controls.push(
<SendMessageComposer <SendMessageComposer
ref={(c) => this.messageComposerInput = c} ref={this.messageComposerInput}
key="controls_input" key="controls_input"
room={this.props.room} room={this.props.room}
placeholder={this.renderPlaceholderText()} placeholder={this.renderPlaceholderText()}
@ -535,7 +535,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
controls.push(<VoiceRecordComposerTile controls.push(<VoiceRecordComposerTile
key="controls_voice_record" key="controls_voice_record"
ref={c => this.voiceRecordingButton = c} ref={this.voiceRecordingButton}
room={this.props.room} />); room={this.props.room} />);
} else if (this.state.tombstone) { } else if (this.state.tombstone) {
const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; 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 { IContent, MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { DebouncedFunc, throttle } from 'lodash'; import { DebouncedFunc, throttle } from 'lodash';
import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
import { logger } from "matrix-js-sdk/src/logger";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import EditorModel from '../../../editor/model'; import EditorModel from '../../../editor/model';
@ -40,7 +41,7 @@ import { Command, CommandCategories, getCommand } from '../../../SlashCommands';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages'; import ContentMessages from '../../../ContentMessages';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { withMatrixClientHOC, MatrixClientProps } from "../../../contexts/MatrixClientContext";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { containsEmoji } from "../../../effects/utils"; import { containsEmoji } from "../../../effects/utils";
import { CHAT_EFFECTS } from '../../../effects'; import { CHAT_EFFECTS } from '../../../effects';
@ -55,8 +56,7 @@ import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics"; import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
import RoomContext from '../../../contexts/RoomContext';
import { logger } from "matrix-js-sdk/src/logger";
function addReplyToMessageContent( function addReplyToMessageContent(
content: IContent, content: IContent,
@ -130,7 +130,7 @@ export function isQuickReaction(model: EditorModel): boolean {
return false; return false;
} }
interface IProps { interface ISendMessageComposerProps extends MatrixClientProps {
room: Room; room: Room;
placeholder?: string; placeholder?: string;
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
@ -141,10 +141,8 @@ interface IProps {
} }
@replaceableComponent("views.rooms.SendMessageComposer") @replaceableComponent("views.rooms.SendMessageComposer")
export default class SendMessageComposer extends React.Component<IProps> { export class SendMessageComposer extends React.Component<ISendMessageComposerProps> {
static contextType = MatrixClientContext; static contextType = RoomContext;
context!: React.ContextType<typeof MatrixClientContext>;
private readonly prepareToEncrypt?: DebouncedFunc<() => void>; private readonly prepareToEncrypt?: DebouncedFunc<() => void>;
private readonly editorRef = createRef<BasicMessageComposer>(); private readonly editorRef = createRef<BasicMessageComposer>();
private model: EditorModel = null; private model: EditorModel = null;
@ -152,26 +150,25 @@ export default class SendMessageComposer extends React.Component<IProps> {
private dispatcherRef: string; private dispatcherRef: string;
private sendHistoryManager: SendHistoryManager; private sendHistoryManager: SendHistoryManager;
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) { constructor(props: ISendMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
super(props); super(props);
this.context = context; // otherwise React will only set it prior to render due to type def above if (this.props.mxClient.isCryptoEnabled() && this.props.mxClient.isRoomEncrypted(this.props.room.roomId)) {
if (this.context.isCryptoEnabled() && this.context.isRoomEncrypted(this.props.room.roomId)) {
this.prepareToEncrypt = throttle(() => { this.prepareToEncrypt = throttle(() => {
this.context.prepareToEncrypt(this.props.room); this.props.mxClient.prepareToEncrypt(this.props.room);
}, 60000, { leading: true, trailing: false }); }, 60000, { leading: true, trailing: false });
} }
window.addEventListener("beforeunload", this.saveStoredEditorState); 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); const replyToEventChanged = this.props.replyInThread && (this.props.replyToEvent !== prevProps.replyToEvent);
if (replyToEventChanged) { if (replyToEventChanged) {
this.model.reset([]); this.model.reset([]);
} }
if (this.props.replyInThread && this.props.replyToEvent && (!prevProps.replyToEvent || replyToEventChanged)) { 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) || []; const parts = this.restoreStoredEditorState(partCreator) || [];
this.model.reset(parts); this.model.reset(parts);
this.editorRef.current?.focus(); this.editorRef.current?.focus();
@ -202,13 +199,20 @@ export default class SendMessageComposer extends React.Component<IProps> {
case MessageComposerAction.EditPrevMessage: case MessageComposerAction.EditPrevMessage:
// selection must be collapsed and caret at start // selection must be collapsed and caret at start
if (this.editorRef.current?.isSelectionCollapsed() && this.editorRef.current?.isCaretAtStart()) { 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) { if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else // We're selecting history, so prevent the key event from doing anything else
event.preventDefault(); event.preventDefault();
dis.dispatch({ dis.dispatch({
action: 'edit_event', action: Action.EditEvent,
event: editEvent, event: editEvent,
timelineRenderingType: this.context.timelineRenderingType,
}); });
} }
} }
@ -275,7 +279,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
} }
private sendQuickReaction(): void { private sendQuickReaction(): void {
const timeline = this.props.room.getLiveTimeline(); const timeline = this.context.liveTimeline();
const events = timeline.getEvents(); const events = timeline.getEvents();
const reaction = this.model.parts[1].text; const reaction = this.model.parts[1].text;
for (let i = events.length - 1; i >= 0; i--) { for (let i = events.length - 1; i >= 0; i--) {
@ -448,7 +452,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
decorateStartSendingTime(content); decorateStartSendingTime(content);
} }
const prom = this.context.sendMessage(roomId, content); const prom = this.props.mxClient.sendMessage(roomId, content);
if (replyToEvent) { if (replyToEvent) {
// Clear reply_to_event as we put the message into the queue // Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending. // 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")) { if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
prom.then(resp => { 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); 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 // TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount() { // eslint-disable-line 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) || []; const parts = this.restoreStoredEditorState(partCreator) || [];
this.model = new EditorModel(parts, partCreator); this.model = new EditorModel(parts, partCreator);
this.dispatcherRef = dis.register(this.onAction); 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. // it puts the filename in as text/plain which we want to ignore.
if (clipboardData.files.length && !clipboardData.types.includes("text/rtf")) { if (clipboardData.files.length && !clipboardData.types.includes("text/rtf")) {
ContentMessages.sharedInstance().sendContentListToRoom( 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 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;

View file

@ -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<MatrixClient>(undefined);
MatrixClientContext.displayName = "MatrixClientContext";
export default MatrixClientContext;

View file

@ -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<MatrixClient>(undefined);
MatrixClientContext.displayName = "MatrixClientContext";
export default MatrixClientContext;
export interface MatrixClientProps {
mxClient: MatrixClient;
}
const matrixHOC = <ComposedComponentProps extends {}>(
ComposedComponent: ComponentClass<ComposedComponentProps>,
) => {
type ComposedComponentInstance = InstanceType<typeof ComposedComponent>;
// eslint-disable-next-line react-hooks/rules-of-hooks
const TypedComponent = ComposedComponent;
return forwardRef<ComposedComponentInstance, Omit<ComposedComponentProps, 'mxClient'>>(
(props, ref) => {
const client = useContext(MatrixClientContext);
// @ts-ignore
return <TypedComponent ref={ref} {...props} mxClient={client} />;
},
);
};
export const withMatrixClientHOC = matrixHOC;

View file

@ -16,10 +16,15 @@ limitations under the License.
import { createContext } from "react"; import { createContext } from "react";
import { IState } from "../components/structures/RoomView"; import { IRoomState } from "../components/structures/RoomView";
import { Layout } from "../settings/Layout"; import { Layout } from "../settings/Layout";
const RoomContext = createContext<IState>({ export enum TimelineRenderingType {
Room,
Thread
}
const RoomContext = createContext<IRoomState>({
roomLoading: true, roomLoading: true,
peekLoading: false, peekLoading: false,
shouldPeek: true, shouldPeek: true,
@ -53,6 +58,8 @@ const RoomContext = createContext<IState>({
showDisplaynameChanges: true, showDisplaynameChanges: true,
matrixClientIsReady: false, matrixClientIsReady: false,
dragCounter: 0, dragCounter: 0,
timelineRenderingType: TimelineRenderingType.Room,
liveTimeline: undefined,
}); });
RoomContext.displayName = "RoomContext"; RoomContext.displayName = "RoomContext";
export default RoomContext; export default RoomContext;

View file

@ -205,4 +205,9 @@ export enum Action {
* Should be used with SettingUpdatedPayload. * Should be used with SettingUpdatedPayload.
*/ */
SettingUpdated = "setting_updated", SettingUpdated = "setting_updated",
/**
* Fires when a user starts to edit event (e.g. up arrow in compositor)
*/
EditEvent = "edit_event",
} }

View file

@ -17,7 +17,7 @@
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { IState } from "./components/structures/RoomView"; import { IRoomState } from "./components/structures/RoomView";
interface IDiff { interface IDiff {
isMemberEvent: boolean; isMemberEvent: boolean;
@ -54,7 +54,7 @@ function memberEventDiff(ev: MatrixEvent): IDiff {
* @param ctx An optional RoomContext to pull cached settings values from to avoid * @param ctx An optional RoomContext to pull cached settings values from to avoid
* hitting the settings store * 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, // Accessing the settings store directly can be expensive if done frequently,
// so we should prefer using cached values if a RoomContext is available // so we should prefer using cached values if a RoomContext is available
const isEnabled = ctx ? const isEnabled = ctx ?

View file

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event'; import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event';
import { MatrixClientPeg } from '../MatrixClientPeg'; import { MatrixClientPeg } from '../MatrixClientPeg';
@ -73,9 +72,15 @@ export function canEditOwnEvent(mxEvent: MatrixEvent): boolean {
} }
const MAX_JUMP_DISTANCE = 100; const MAX_JUMP_DISTANCE = 100;
export function findEditableEvent(room: Room, isForward: boolean, fromEventId: string = undefined): MatrixEvent { export function findEditableEvent({
const liveTimeline = room.getLiveTimeline(); events,
const events = liveTimeline.getEvents().concat(room.getPendingEvents()); isForward,
fromEventId,
}: {
events: MatrixEvent[];
isForward: boolean;
fromEventId?: string;
}): MatrixEvent {
const maxIdx = events.length - 1; const maxIdx = events.length - 1;
const inc = isForward ? 1 : -1; const inc = isForward ? 1 : -1;
const beginIdx = isForward ? 0 : maxIdx; const beginIdx = isForward ? 0 : maxIdx;

View file

@ -24,8 +24,10 @@ import { sleep } from "matrix-js-sdk/src/utils";
import SendMessageComposer, { import SendMessageComposer, {
createMessageContent, createMessageContent,
isQuickReaction, isQuickReaction,
SendMessageComposer as SendMessageComposerClass,
} from "../../../../src/components/views/rooms/SendMessageComposer"; } from "../../../../src/components/views/rooms/SendMessageComposer";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
import EditorModel from "../../../../src/editor/model"; import EditorModel from "../../../../src/editor/model";
import { createPartCreator, createRenderer } from "../../../editor/mock"; import { createPartCreator, createRenderer } from "../../../editor/mock";
import { createTestClient, mkEvent, mkStubRoom } from "../../../test-utils"; 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 { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import SpecPermalinkConstructor from "../../../../src/utils/permalinks/SpecPermalinkConstructor"; import SpecPermalinkConstructor from "../../../../src/utils/permalinks/SpecPermalinkConstructor";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import DocumentOffset from '../../../../src/editor/offset';
import { Layout } from '../../../../src/settings/Layout';
jest.mock("../../../../src/stores/RoomViewStore"); jest.mock("../../../../src/stores/RoomViewStore");
configure({ adapter: new Adapter() }); configure({ adapter: new Adapter() });
describe('<SendMessageComposer/>', () => { describe('<SendMessageComposer/>', () => {
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", () => { describe("createMessageContent", () => {
const permalinkCreator = jest.fn(); const permalinkCreator = jest.fn() as any;
it("sends plaintext messages correctly", () => { it("sends plaintext messages correctly", () => {
const model = new EditorModel([], createPartCreator(), createRenderer()); 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); const content = createMessageContent(model, null, false, permalinkCreator);
@ -56,7 +98,8 @@ describe('<SendMessageComposer/>', () => {
it("sends markdown messages correctly", () => { it("sends markdown messages correctly", () => {
const model = new EditorModel([], createPartCreator(), createRenderer()); 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); const content = createMessageContent(model, null, false, permalinkCreator);
@ -70,7 +113,8 @@ describe('<SendMessageComposer/>', () => {
it("strips /me from messages and marks them as m.emote accordingly", () => { it("strips /me from messages and marks them as m.emote accordingly", () => {
const model = new EditorModel([], createPartCreator(), createRenderer()); 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); const content = createMessageContent(model, null, false, permalinkCreator);
@ -84,7 +128,9 @@ describe('<SendMessageComposer/>', () => {
it("allows sending double-slash escaped slash commands correctly", () => { it("allows sending double-slash escaped slash commands correctly", () => {
const model = new EditorModel([], createPartCreator(), createRenderer()); 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); const content = createMessageContent(model, null, false, permalinkCreator);
@ -97,9 +143,11 @@ describe('<SendMessageComposer/>', () => {
describe("functions correctly mounted", () => { describe("functions correctly mounted", () => {
const mockClient = MatrixClientPeg.matrixClient = createTestClient(); const mockClient = MatrixClientPeg.matrixClient = createTestClient();
const mockRoom = mkStubRoom(); const mockRoom = mkStubRoom('myfakeroom') as any;
const mockEvent = mkEvent({ const mockEvent = mkEvent({
type: "m.room.message", type: "m.room.message",
room: 'myfakeroom',
user: 'myfakeuser',
content: "Replying to this", content: "Replying to this",
event: true, event: true,
}); });
@ -116,11 +164,13 @@ describe('<SendMessageComposer/>', () => {
it("renders text and placeholder correctly", () => { it("renders text and placeholder correctly", () => {
const wrapper = mount(<MatrixClientContext.Provider value={mockClient}> const wrapper = mount(<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={roomContext}>
<SendMessageComposer <SendMessageComposer
room={mockRoom} room={mockRoom as any}
placeholder="placeholder string" placeholder="placeholder string"
permalinkCreator={new SpecPermalinkConstructor()} permalinkCreator={new SpecPermalinkConstructor() as any}
/> />
</RoomContext.Provider>
</MatrixClientContext.Provider>); </MatrixClientContext.Provider>);
expect(wrapper.find('[aria-label="placeholder string"]')).toHaveLength(1); expect(wrapper.find('[aria-label="placeholder string"]')).toHaveLength(1);
@ -135,12 +185,15 @@ describe('<SendMessageComposer/>', () => {
it("correctly persists state to and from localStorage", () => { it("correctly persists state to and from localStorage", () => {
const wrapper = mount(<MatrixClientContext.Provider value={mockClient}> const wrapper = mount(<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={roomContext}>
<SendMessageComposer <SendMessageComposer
room={mockRoom} room={mockRoom as any}
placeholder="" placeholder=""
permalinkCreator={new SpecPermalinkConstructor()} permalinkCreator={new SpecPermalinkConstructor() as any}
replyToEvent={mockEvent} replyToEvent={mockEvent}
/> />
</RoomContext.Provider>
</MatrixClientContext.Provider>); </MatrixClientContext.Provider>);
act(() => { act(() => {
@ -148,7 +201,7 @@ describe('<SendMessageComposer/>', () => {
wrapper.update(); wrapper.update();
}); });
const key = wrapper.find(SendMessageComposer).instance().editorStateKey; const key = wrapper.find(SendMessageComposerClass).instance().editorStateKey;
expect(wrapper.text()).toBe("Test Text"); expect(wrapper.text()).toBe("Test Text");
expect(localStorage.getItem(key)).toBeNull(); expect(localStorage.getItem(key)).toBeNull();
@ -177,11 +230,14 @@ describe('<SendMessageComposer/>', () => {
it("persists state correctly without replyToEvent onbeforeunload", () => { it("persists state correctly without replyToEvent onbeforeunload", () => {
const wrapper = mount(<MatrixClientContext.Provider value={mockClient}> const wrapper = mount(<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={roomContext}>
<SendMessageComposer <SendMessageComposer
room={mockRoom} room={mockRoom as any}
placeholder="" placeholder=""
permalinkCreator={new SpecPermalinkConstructor()} permalinkCreator={new SpecPermalinkConstructor() as any}
/> />
</RoomContext.Provider>
</MatrixClientContext.Provider>); </MatrixClientContext.Provider>);
act(() => { act(() => {
@ -189,7 +245,7 @@ describe('<SendMessageComposer/>', () => {
wrapper.update(); wrapper.update();
}); });
const key = wrapper.find(SendMessageComposer).instance().editorStateKey; const key = wrapper.find(SendMessageComposerClass).instance().editorStateKey;
expect(wrapper.text()).toBe("Hello World"); expect(wrapper.text()).toBe("Hello World");
expect(localStorage.getItem(key)).toBeNull(); expect(localStorage.getItem(key)).toBeNull();
@ -203,12 +259,15 @@ describe('<SendMessageComposer/>', () => {
it("persists to session history upon sending", async () => { it("persists to session history upon sending", async () => {
const wrapper = mount(<MatrixClientContext.Provider value={mockClient}> const wrapper = mount(<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={roomContext}>
<SendMessageComposer <SendMessageComposer
room={mockRoom} room={mockRoom as any}
placeholder="placeholder" placeholder="placeholder"
permalinkCreator={new SpecPermalinkConstructor()} permalinkCreator={new SpecPermalinkConstructor() as any}
replyToEvent={mockEvent} replyToEvent={mockEvent}
/> />
</RoomContext.Provider>
</MatrixClientContext.Provider>); </MatrixClientContext.Provider>);
act(() => { act(() => {
@ -230,12 +289,38 @@ describe('<SendMessageComposer/>', () => {
replyEventId: mockEvent.getId(), replyEventId: mockEvent.getId(),
}); });
}); });
it('correctly sets the editorStateKey for threads', () => {
const mockThread ={
getThread: () => {
return {
id: 'myFakeThreadId',
};
},
} as any;
const wrapper = mount(<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={roomContext}>
<SendMessageComposer
room={mockRoom as any}
placeholder=""
permalinkCreator={new SpecPermalinkConstructor() as any}
replyToEvent={mockThread}
/>
</RoomContext.Provider>
</MatrixClientContext.Provider>);
const instance = wrapper.find(SendMessageComposerClass).instance();
const key = instance.editorStateKey;
expect(key).toEqual('mx_cider_state_myfakeroom_myFakeThreadId');
});
}); });
describe("isQuickReaction", () => { describe("isQuickReaction", () => {
it("correctly detects quick reaction", () => { it("correctly detects quick reaction", () => {
const model = new EditorModel([], createPartCreator(), createRenderer()); const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("+😊", "insertText", { offset: 3, atNodeEnd: true }); model.update("+😊", "insertText", new DocumentOffset(3, true));
const isReaction = isQuickReaction(model); const isReaction = isQuickReaction(model);
@ -244,7 +329,7 @@ describe('<SendMessageComposer/>', () => {
it("correctly detects quick reaction with space", () => { it("correctly detects quick reaction with space", () => {
const model = new EditorModel([], createPartCreator(), createRenderer()); const model = new EditorModel([], createPartCreator(), createRenderer());
model.update("+ 😊", "insertText", { offset: 4, atNodeEnd: true }); model.update("+ 😊", "insertText", new DocumentOffset(4, true));
const isReaction = isQuickReaction(model); const isReaction = isQuickReaction(model);
@ -256,10 +341,10 @@ describe('<SendMessageComposer/>', () => {
const model2 = new EditorModel([], createPartCreator(), createRenderer()); const model2 = new EditorModel([], createPartCreator(), createRenderer());
const model3 = new EditorModel([], createPartCreator(), createRenderer()); const model3 = new EditorModel([], createPartCreator(), createRenderer());
const model4 = new EditorModel([], createPartCreator(), createRenderer()); const model4 = new EditorModel([], createPartCreator(), createRenderer());
model.update("+😊hello", "insertText", { offset: 8, atNodeEnd: true }); model.update("+😊hello", "insertText", new DocumentOffset( 8, true));
model2.update(" +😊", "insertText", { offset: 4, atNodeEnd: true }); model2.update(" +😊", "insertText", new DocumentOffset( 4, true));
model3.update("+ 😊😊", "insertText", { offset: 6, atNodeEnd: true }); model3.update("+ 😊😊", "insertText", new DocumentOffset( 6, true));
model4.update("+smiley", "insertText", { offset: 7, atNodeEnd: true }); model4.update("+smiley", "insertText", new DocumentOffset( 7, true));
expect(isQuickReaction(model)).toBeFalsy(); expect(isQuickReaction(model)).toBeFalsy();
expect(isQuickReaction(model2)).toBeFalsy(); expect(isQuickReaction(model2)).toBeFalsy();