element-portable/src/components/views/rooms/SendMessageComposer.tsx
Florian Duros 571ada37a7
Replace MatrixClient.isCryptoEnabled by MatrixClient.getCrypto (#140)
* Replace `MatrixClient.isCryptoEnabled` by `MatrixClient.getCrypto`

* Cast `cryptoEnabled` as `boolean`

* Fix `MatrixChat-test`

(cherry picked from commit 950ab1940bfcea9443f03284f9175d319c13a44c)
2024-10-16 09:42:18 +01:00

786 lines
32 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef, KeyboardEvent, SyntheticEvent } from "react";
import {
IContent,
MatrixEvent,
IEventRelation,
IMentions,
Room,
EventType,
MsgType,
RelationType,
THREAD_RELATION_TYPE,
} from "matrix-js-sdk/src/matrix";
import { DebouncedFunc, throttle } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
import { RoomMessageEventContent } from "matrix-js-sdk/src/types";
import dis from "../../../dispatcher/dispatcher";
import EditorModel from "../../../editor/model";
import {
containsEmote,
htmlSerializeIfNeeded,
startsWith,
stripEmoteCommand,
stripPrefix,
textSerialize,
unescapeMessage,
} from "../../../editor/serialize";
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from "../../../editor/parts";
import { findEditableEvent } from "../../../utils/EventUtils";
import SendHistoryManager from "../../../SendHistoryManager";
import { CommandCategories } from "../../../SlashCommands";
import ContentMessages from "../../../ContentMessages";
import { withMatrixClientHOC, MatrixClientProps } from "../../../contexts/MatrixClientContext";
import { Action } from "../../../dispatcher/actions";
import { containsEmoji } from "../../../effects/utils";
import { CHAT_EFFECTS } from "../../../effects";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import SettingsStore from "../../../settings/SettingsStore";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { ActionPayload } from "../../../dispatcher/payloads";
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import DocumentPosition from "../../../editor/position";
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { getSlashCommand, isSlashCommand, runSlashCommand, shouldSendAnyway } from "../../../editor/commands";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { PosthogAnalytics } from "../../../PosthogAnalytics";
import { addReplyToMessageContent } from "../../../utils/Reply";
import { doMaybeLocalRoomAction } from "../../../utils/local-room";
import { Caret } from "../../../editor/caret";
import { IDiff } from "../../../editor/diff";
import { getBlobSafeMimeType } from "../../../utils/blobs";
import { EMOJI_REGEX } from "../../../HtmlUtils";
// The prefix used when persisting editor drafts to localstorage.
export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_";
/**
* Build the mentions information based on the editor model (and any related events):
*
* 1. Search the model parts for room or user pills and fill in the mentions object.
* 2. If this is a reply to another event, include any user mentions from that
* (but do not include a room mention).
*
* @param sender - The Matrix ID of the user sending the event.
* @param content - The event content.
* @param model - The editor model to search for mentions, null if there is no editor.
* @param replyToEvent - The event being replied to or undefined if it is not a reply.
* @param editedContent - The content of the parent event being edited.
*/
export function attachMentions(
sender: string,
content: IContent,
model: EditorModel | null,
replyToEvent: MatrixEvent | undefined,
editedContent: IContent | null = null,
): void {
// We always attach the mentions even if the home server doesn't yet support
// intentional mentions. This is safe because m.mentions is an additive change
// that should simply be ignored by incapable home servers.
// The mentions property *always* gets included to disable legacy push rules.
const mentions: IMentions = (content["m.mentions"] = {});
const userMentions = new Set<string>();
let roomMention = false;
// If there's a reply, initialize the mentioned users as the sender of that
// event + any mentioned users in that event.
if (replyToEvent) {
userMentions.add(replyToEvent.sender!.userId);
// TODO What do we do if the reply event *doeesn't* have this property?
// Try to fish out replies from the contents?
const userIds = replyToEvent.getContent()["m.mentions"]?.user_ids;
if (Array.isArray(userIds)) {
userIds.forEach((userId) => userMentions.add(userId));
}
}
// If user provided content is available, check to see if any users are mentioned.
if (model) {
// Add any mentioned users in the current content.
for (const part of model.parts) {
if (part.type === Type.UserPill) {
userMentions.add(part.resourceId);
} else if (part.type === Type.AtRoomPill) {
roomMention = true;
}
}
}
// Ensure the *current* user isn't listed in the mentioned users.
userMentions.delete(sender);
// Finally, if this event is editing a previous event, only include users who
// were not previously mentioned and a room mention if the previous event was
// not a room mention.
if (editedContent) {
// First, the new event content gets the *full* set of users.
const newContent = content["m.new_content"];
const newMentions: IMentions = (newContent["m.mentions"] = {});
// Only include the users/room if there is any content.
if (userMentions.size) {
newMentions.user_ids = [...userMentions];
}
if (roomMention) {
newMentions.room = true;
}
// Fetch the mentions from the original event and remove any previously
// mentioned users.
const prevMentions = editedContent["m.mentions"];
if (Array.isArray(prevMentions?.user_ids)) {
prevMentions!.user_ids.forEach((userId) => userMentions.delete(userId));
}
// If the original event mentioned the room, nothing to do here.
if (prevMentions?.room) {
roomMention = false;
}
}
// Only include the users/room if there is any content.
if (userMentions.size) {
mentions.user_ids = [...userMentions];
}
if (roomMention) {
mentions.room = true;
}
}
// Merges favouring the given relation
export function attachRelation(content: IContent, relation?: IEventRelation): void {
if (relation) {
content["m.relates_to"] = {
...(content["m.relates_to"] || {}),
...relation,
};
}
}
// exported for tests
export function createMessageContent(
sender: string,
model: EditorModel,
replyToEvent: MatrixEvent | undefined,
relation: IEventRelation | undefined,
permalinkCreator?: RoomPermalinkCreator,
includeReplyLegacyFallback = true,
): RoomMessageEventContent {
const isEmote = containsEmote(model);
if (isEmote) {
model = stripEmoteCommand(model);
}
if (startsWith(model, "//")) {
model = stripPrefix(model, "/");
}
model = unescapeMessage(model);
const body = textSerialize(model);
const content: RoomMessageEventContent = {
msgtype: isEmote ? MsgType.Emote : MsgType.Text,
body: body,
};
const formattedBody = htmlSerializeIfNeeded(model, {
forceHTML: !!replyToEvent,
useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
});
if (formattedBody) {
content.format = "org.matrix.custom.html";
content.formatted_body = formattedBody;
}
// Build the mentions property and add it to the event content.
attachMentions(sender, content, model, replyToEvent);
attachRelation(content, relation);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator,
includeLegacyFallback: includeReplyLegacyFallback,
});
}
return content;
}
// exported for tests
export function isQuickReaction(model: EditorModel): boolean {
const parts = model.parts;
if (parts.length == 0) return false;
const text = textSerialize(model);
// shortcut takes the form "+:emoji:" or "+ :emoji:""
// can be in 1 or 2 parts
if (parts.length <= 2) {
const hasShortcut = text.startsWith("+") || text.startsWith("+ ");
const emojiMatch = text.match(EMOJI_REGEX);
if (hasShortcut && emojiMatch && emojiMatch.length == 1) {
return emojiMatch[0] === text.substring(1) || emojiMatch[0] === text.substring(2);
}
}
return false;
}
interface ISendMessageComposerProps extends MatrixClientProps {
room: Room;
placeholder?: string;
permalinkCreator?: RoomPermalinkCreator;
relation?: IEventRelation;
replyToEvent?: MatrixEvent;
disabled?: boolean;
onChange?(model: EditorModel): void;
includeReplyLegacyFallback?: boolean;
toggleStickerPickerOpen: () => void;
}
export class SendMessageComposer extends React.Component<ISendMessageComposerProps> {
public static contextType = RoomContext;
public declare context: React.ContextType<typeof RoomContext>;
private readonly prepareToEncrypt?: DebouncedFunc<() => void>;
private readonly editorRef = createRef<BasicMessageComposer>();
private model: EditorModel;
private currentlyComposedEditorState: SerializedPart[] | null = null;
private dispatcherRef: string;
private sendHistoryManager: SendHistoryManager;
public static defaultProps = {
includeReplyLegacyFallback: true,
};
public constructor(props: ISendMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
if (this.props.mxClient.getCrypto() && this.props.mxClient.isRoomEncrypted(this.props.room.roomId)) {
this.prepareToEncrypt = throttle(
() => {
this.props.mxClient.getCrypto()?.prepareToEncrypt(this.props.room);
},
60000,
{ leading: true, trailing: false },
);
}
window.addEventListener("beforeunload", this.saveStoredEditorState);
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);
this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, "mx_cider_history_");
}
public componentDidUpdate(prevProps: ISendMessageComposerProps): void {
const replyingToThread = this.props.relation?.key === THREAD_RELATION_TYPE.name;
const differentEventTarget = this.props.relation?.event_id !== prevProps.relation?.event_id;
const threadChanged = replyingToThread && differentEventTarget;
if (threadChanged) {
const partCreator = new CommandPartCreator(this.props.room, this.props.mxClient);
const parts = this.restoreStoredEditorState(partCreator) || [];
this.model.reset(parts);
this.editorRef.current?.focus();
}
}
private onKeyDown = (event: KeyboardEvent): void => {
// ignore any keypress while doing IME compositions
if (this.editorRef.current?.isComposing(event)) {
return;
}
const replyingToThread = this.props.relation?.key === THREAD_RELATION_TYPE.name;
const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) {
case KeyBindingAction.SendMessage:
this.sendMessage();
event.preventDefault();
break;
case KeyBindingAction.SelectPrevSendHistory:
case KeyBindingAction.SelectNextSendHistory: {
// Try select composer history
const selected = this.selectSendHistory(action === KeyBindingAction.SelectPrevSendHistory);
if (selected) {
// We're selecting history, so prevent the key event from doing anything else
event.preventDefault();
}
break;
}
case KeyBindingAction.ShowStickerPicker: {
if (!SettingsStore.getValue("MessageComposerInput.showStickersButton")) {
return; // Do nothing if there is no Stickers button
}
this.props.toggleStickerPickerOpen();
event.preventDefault();
break;
}
case KeyBindingAction.EditPrevMessage:
// selection must be collapsed and caret at start
if (this.editorRef.current?.isSelectionCollapsed() && this.editorRef.current?.isCaretAtStart()) {
const events = this.context.liveTimeline
?.getEvents()
.concat(replyingToThread ? [] : this.props.room.getPendingEvents());
const editEvent = events
? findEditableEvent({
events,
isForward: false,
matrixClient: MatrixClientPeg.safeGet(),
})
: undefined;
if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else
event.preventDefault();
dis.dispatch({
action: Action.EditEvent,
event: editEvent,
timelineRenderingType: this.context.timelineRenderingType,
});
}
}
break;
case KeyBindingAction.CancelReplyOrEdit:
if (!!this.context.replyToEvent) {
dis.dispatch({
action: "reply_to_event",
event: null,
context: this.context.timelineRenderingType,
});
event.preventDefault();
event.stopPropagation();
}
break;
}
};
// we keep sent messages/commands in a separate history (separate from undo history)
// so you can alt+up/down in them
private selectSendHistory(up: boolean): boolean {
const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message
if (this.sendHistoryManager.currentIndex === this.sendHistoryManager.history.length) {
// We can't go any further - there isn't any more history, so nop.
if (!up) {
return false;
}
this.currentlyComposedEditorState = this.model.serializeParts();
} else if (
this.currentlyComposedEditorState &&
this.sendHistoryManager.currentIndex + delta === this.sendHistoryManager.history.length
) {
// True when we return to the message being composed currently
this.model.reset(this.currentlyComposedEditorState);
this.sendHistoryManager.currentIndex = this.sendHistoryManager.history.length;
return true;
}
const { parts, replyEventId } = this.sendHistoryManager.getItem(delta);
dis.dispatch({
action: "reply_to_event",
event: replyEventId ? this.props.room.findEventById(replyEventId) : null,
context: this.context.timelineRenderingType,
});
if (parts) {
this.model.reset(parts);
this.editorRef.current?.focus();
}
return true;
}
private sendQuickReaction(): void {
const timeline = this.context.liveTimeline;
if (!timeline) return;
const events = timeline.getEvents();
const reaction = this.model.parts[1].text;
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].getType() === EventType.RoomMessage) {
let shouldReact = true;
const lastMessage = events[i];
const userId = MatrixClientPeg.safeGet().getSafeUserId();
const messageReactions = this.props.room.relations.getChildEventsForEvent(
lastMessage.getId()!,
RelationType.Annotation,
EventType.Reaction,
);
// if we have already sent this reaction, don't redact but don't re-send
if (messageReactions) {
const myReactionEvents =
messageReactions.getAnnotationsBySender()?.[userId] || new Set<MatrixEvent>();
const myReactionKeys = [...myReactionEvents]
.filter((event) => !event.isRedacted())
.map((event) => event.getRelation()?.key);
shouldReact = !myReactionKeys.includes(reaction);
}
if (shouldReact) {
MatrixClientPeg.safeGet().sendEvent(lastMessage.getRoomId()!, EventType.Reaction, {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: lastMessage.getId()!,
key: reaction,
},
});
dis.dispatch({ action: "message_sent" });
}
break;
}
}
}
public async sendMessage(): Promise<void> {
const model = this.model;
if (model.isEmpty) {
return;
}
const posthogEvent: ComposerEvent = {
eventName: "Composer",
isEditing: false,
messageType: "Text",
isReply: !!this.props.replyToEvent,
inThread: this.props.relation?.rel_type === THREAD_RELATION_TYPE.name,
};
if (posthogEvent.inThread && this.props.relation!.event_id) {
const threadRoot = this.props.room.findEventById(this.props.relation!.event_id);
posthogEvent.startsThread = threadRoot?.getThread()?.events.length === 1;
}
PosthogAnalytics.instance.trackEvent<ComposerEvent>(posthogEvent);
// Replace emoticon at the end of the message
if (SettingsStore.getValue("MessageComposerInput.autoReplaceEmoji")) {
const indexOfLastPart = model.parts.length - 1;
const positionInLastPart = model.parts[indexOfLastPart].text.length;
this.editorRef.current?.replaceEmoticon(
new DocumentPosition(indexOfLastPart, positionInLastPart),
REGEX_EMOTICON,
);
}
const replyToEvent = this.props.replyToEvent;
let shouldSend = true;
let content: RoomMessageEventContent | null = null;
if (!containsEmote(model) && isSlashCommand(this.model)) {
const [cmd, args, commandText] = getSlashCommand(this.model);
if (cmd) {
const threadId =
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation?.event_id : null;
let commandSuccessful: boolean;
[content, commandSuccessful] = await runSlashCommand(
MatrixClientPeg.safeGet(),
cmd,
args,
this.props.room.roomId,
threadId ?? null,
);
if (!commandSuccessful) {
return; // errored
}
if (
content &&
[CommandCategories.messages as string, CommandCategories.effects as string].includes(cmd.category)
) {
// Attach any mentions which might be contained in the command content.
attachMentions(this.props.mxClient.getSafeUserId(), content, model, replyToEvent);
attachRelation(content, this.props.relation);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, {
permalinkCreator: this.props.permalinkCreator,
// Exclude the legacy fallback for custom event types such as those used by /fireworks
includeLegacyFallback: content.msgtype?.startsWith("m.") ?? true,
});
}
} else {
shouldSend = false;
}
} else {
const sendAnyway = await shouldSendAnyway(commandText);
// re-focus the composer after QuestionDialog is closed
dis.dispatch({
action: Action.FocusAComposer,
context: this.context.timelineRenderingType,
});
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
}
}
if (isQuickReaction(model)) {
shouldSend = false;
this.sendQuickReaction();
}
if (shouldSend) {
const { roomId } = this.props.room;
if (!content) {
content = createMessageContent(
this.props.mxClient.getSafeUserId(),
model,
replyToEvent,
this.props.relation,
this.props.permalinkCreator,
this.props.includeReplyLegacyFallback,
);
}
// don't bother sending an empty message
if (!content.body.trim()) return;
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
decorateStartSendingTime(content);
}
const threadId =
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : null;
const prom = doMaybeLocalRoomAction(
roomId,
(actualRoomId: string) => this.props.mxClient.sendMessage(actualRoomId, threadId ?? null, content!),
this.props.mxClient,
);
if (replyToEvent) {
// Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending.
dis.dispatch({
action: "reply_to_event",
event: null,
context: this.context.timelineRenderingType,
});
}
dis.dispatch({ action: "message_sent" });
CHAT_EFFECTS.forEach((effect) => {
if (containsEmoji(content!, effect.emojis)) {
// For initial threads launch, chat effects are disabled
// see #19731
const isNotThread = this.props.relation?.rel_type !== THREAD_RELATION_TYPE.name;
if (isNotThread) {
dis.dispatch({ action: `effects.${effect.command}` });
}
}
});
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
prom.then((resp) => {
sendRoundTripMetric(this.props.mxClient, roomId, resp.event_id);
});
}
}
this.sendHistoryManager.save(model, replyToEvent);
// clear composer
model.reset([]);
this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus();
this.clearStoredEditorState();
if (shouldSend && SettingsStore.getValue("scrollToBottomOnMessageSent")) {
dis.dispatch({
action: "scroll_to_bottom",
timelineRenderingType: this.context.timelineRenderingType,
});
}
}
public componentWillUnmount(): void {
dis.unregister(this.dispatcherRef);
window.removeEventListener("beforeunload", this.saveStoredEditorState);
this.saveStoredEditorState();
}
private get editorStateKey(): string {
let key = EDITOR_STATE_STORAGE_PREFIX + this.props.room.roomId;
if (this.props.relation?.rel_type === THREAD_RELATION_TYPE.name) {
key += `_${this.props.relation.event_id}`;
}
return key;
}
private clearStoredEditorState(): void {
localStorage.removeItem(this.editorStateKey);
}
private restoreStoredEditorState(partCreator: PartCreator): Part[] | null {
const replyingToThread = this.props.relation?.key === THREAD_RELATION_TYPE.name;
if (replyingToThread) {
return null;
}
const json = localStorage.getItem(this.editorStateKey);
if (json) {
try {
const { parts: serializedParts, replyEventId } = JSON.parse(json);
const parts: Part[] = serializedParts.map((p: SerializedPart) => partCreator.deserializePart(p));
if (replyEventId) {
dis.dispatch({
action: "reply_to_event",
event: this.props.room.findEventById(replyEventId),
context: this.context.timelineRenderingType,
});
}
return parts;
} catch (e) {
logger.error(e);
}
}
return null;
}
// should save state when editor has contents or reply is open
private shouldSaveStoredEditorState = (): boolean => {
return !this.model.isEmpty || !!this.props.replyToEvent;
};
private saveStoredEditorState = (): void => {
if (this.shouldSaveStoredEditorState()) {
const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent);
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
} else {
this.clearStoredEditorState();
}
};
private onAction = (payload: ActionPayload): void => {
// don't let the user into the composer if it is disabled - all of these branches lead
// to the cursor being in the composer
if (this.props.disabled) return;
switch (payload.action) {
case "reply_to_event":
case Action.FocusSendMessageComposer:
if ((payload.context ?? TimelineRenderingType.Room) === this.context.timelineRenderingType) {
this.editorRef.current?.focus();
}
break;
case Action.ComposerInsert:
if (payload.timelineRenderingType !== this.context.timelineRenderingType) break;
if (payload.composerType !== ComposerType.Send) break;
if (payload.userId) {
this.editorRef.current?.insertMention(payload.userId);
} else if (payload.event) {
this.editorRef.current?.insertQuotedMessage(payload.event);
} else if (payload.text) {
this.editorRef.current?.insertPlaintext(payload.text);
}
break;
}
};
private onPaste = (event: Event | SyntheticEvent, data: DataTransfer): boolean => {
// Prioritize text on the clipboard over files if RTF is present as Office on macOS puts a bitmap
// in the clipboard as well as the content being copied. Modern versions of Office seem to not do this anymore.
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
// it puts the filename in as text/plain which we want to ignore.
if (data.files.length && !data.types.includes("text/rtf")) {
ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(data.files),
this.props.room.roomId,
this.props.relation,
this.props.mxClient,
this.context.timelineRenderingType,
);
return true; // to skip internal onPaste handler
}
// Safari `Insert from iPhone or iPad`
// data.getData("text/html") returns a string like: <img src="blob:https://...">
if (data.types.includes("text/html")) {
const imgElementStr = data.getData("text/html");
const parser = new DOMParser();
const imgDoc = parser.parseFromString(imgElementStr, "text/html");
if (
imgDoc.getElementsByTagName("img").length !== 1 ||
!imgDoc.querySelector("img")?.src.startsWith("blob:") ||
imgDoc.childNodes.length !== 1
) {
console.log("Failed to handle pasted content as Safari inserted content");
// Fallback to internal onPaste handler
return false;
}
const imgSrc = imgDoc!.querySelector("img")!.src;
fetch(imgSrc).then(
(response) => {
response.blob().then(
(imgBlob) => {
const type = imgBlob.type;
const safetype = getBlobSafeMimeType(type);
const ext = type.split("/")[1];
const parts = response.url.split("/");
const filename = parts[parts.length - 1];
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
ContentMessages.sharedInstance().sendContentToRoom(
file,
this.props.room.roomId,
this.props.relation,
this.props.mxClient,
this.context.replyToEvent,
);
},
(error) => {
console.log(error);
},
);
},
(error) => {
console.log(error);
},
);
// Skip internal onPaste handler
return true;
}
return false;
};
private onChange = (selection?: Caret, inputType?: string, diff?: IDiff): void => {
// We call this in here rather than onKeyDown as that would trip it on global shortcuts e.g. Ctrl-k also
if (!!diff) {
this.prepareToEncrypt?.();
}
this.props.onChange?.(this.model);
};
private focusComposer = (): void => {
this.editorRef.current?.focus();
};
public render(): React.ReactNode {
const threadId =
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : undefined;
return (
<div className="mx_SendMessageComposer" onClick={this.focusComposer} onKeyDown={this.onKeyDown}>
<BasicMessageComposer
onChange={this.onChange}
ref={this.editorRef}
model={this.model}
room={this.props.room}
threadId={threadId}
label={this.props.placeholder}
placeholder={this.props.placeholder}
onPaste={this.onPaste}
disabled={this.props.disabled}
/>
</div>
);
}
}
const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer);
export default SendMessageComposerWithMatrixClient;