/* 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(); 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 { public static contextType = RoomContext; public declare context: React.ContextType; private readonly prepareToEncrypt?: DebouncedFunc<() => void>; private readonly editorRef = createRef(); 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) { 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(); 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 { 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(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: 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 (
); } } const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer); export default SendMessageComposerWithMatrixClient;