import React, { KeyboardEventHandler, MouseEventHandler, useCallback, useEffect, useState, } from 'react'; import { Box, Chip, Icon, IconButton, Icons, Line, PopOut, RectCords, Spinner, Text, as, config, } from 'folds'; import { Editor, Transforms } from 'slate'; import { ReactEditor } from 'slate-react'; import { IContent, IMentions, MatrixEvent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { isKeyHotkey } from 'is-hotkey'; import { AUTOCOMPLETE_PREFIXES, AutocompletePrefix, AutocompleteQuery, CustomEditor, EmoticonAutocomplete, RoomMentionAutocomplete, Toolbar, UserMentionAutocomplete, createEmoticonElement, customHtmlEqualsPlainText, getAutocompleteQuery, getPrevWorldRange, htmlToEditorInput, moveCursor, plainToEditorInput, toMatrixCustomHTML, toPlainText, trimCustomHtml, useEditor, getMentions, } from '../../../components/editor'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { UseStateProvider } from '../../../components/UseStateProvider'; import { EmojiBoard } from '../../../components/emoji-board'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getEditedEvent, getMemberName, getMentionContent, trimReplyFromFormattedBody, } from '../../../utils/room'; import { mobileOrTablet } from '../../../utils/user-agent'; import { useComposingCheck } from '../../../hooks/useComposingCheck'; type MessageEditorProps = { roomId: string; room: Room; mEvent: MatrixEvent; imagePackRooms?: Room[]; onCancel: () => void; }; export const MessageEditor = as<'div', MessageEditorProps>( ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => { const mx = useMatrixClient(); const editor = useEditor(); // Accessible name for the edit textbox so screen readers announce which // message is being edited (a11y, P3-4). const editSenderId = mEvent.getSender(); const editSenderName = editSenderId ? getMemberName(room, editSenderId) : ''; // Image/video messages carry an optional caption in `body` (a caption exists // when body !== filename). Editing such a message edits the caption while // preserving the media, rather than replacing the content with text. const editMsgType = mEvent.getContent().msgtype; const isMediaCaption = editMsgType === MsgType.Image || editMsgType === MsgType.Video; const editFilename = typeof mEvent.getContent().filename === 'string' ? (mEvent.getContent().filename as string) : undefined; const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [toolbar, setToolbar] = useState(globalToolbar); const isComposing = useComposingCheck(); const [autocompleteQuery, setAutocompleteQuery] = useState>(); const getPrevBodyAndFormattedBody = useCallback((): [ string | undefined, string | undefined, IMentions | undefined, ] => { const evtId = mEvent.getId()!; const evtTimeline = room.getTimelineForEvent(evtId); const editedEvent = evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet()); const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent(); const { body, formatted_body: customHtml }: Record = content; const mMentions: IMentions | undefined = content['m.mentions']; return [ typeof body === 'string' ? body : undefined, typeof customHtml === 'string' ? customHtml : undefined, mMentions, ]; }, [room, mEvent]); const [saveState, save] = useAsyncCallback( useCallback(async () => { const plainText = toPlainText(editor.children, isMarkdown).trim(); const customHtml = trimCustomHtml( toMatrixCustomHTML(editor.children, { allowTextFormatting: true, allowBlockMarkdown: isMarkdown, allowInlineMarkdown: isMarkdown, allowMath: true, }), ); // Media caption edit: preserve the media, change only body/formatted_body. // An empty caption is valid (it removes the caption → body falls back to // the filename). if (isMediaCaption) { const evtId = mEvent.getId()!; const evtTimeline = room.getTimelineForEvent(evtId); const editedEvent = evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet()); const orig: IContent = { ...(editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()), }; delete orig['m.relates_to']; delete orig['m.new_content']; const filename = typeof orig.filename === 'string' ? orig.filename : (editFilename ?? ''); const hasFormatting = !customHtmlEqualsPlainText(customHtml, plainText); const mediaContent: IContent = { ...orig }; if (plainText) { mediaContent.body = plainText; if (hasFormatting) { mediaContent.format = 'org.matrix.custom.html'; mediaContent.formatted_body = customHtml; } else { delete mediaContent.format; delete mediaContent.formatted_body; } } else { // Caption removed. mediaContent.body = filename; delete mediaContent.format; delete mediaContent.formatted_body; } // No-op guard: caption text/markup unchanged. if ( mediaContent.body === orig.body && mediaContent.formatted_body === orig.formatted_body ) { return undefined; } // Carry mentions typed into the caption (union with prior mentions), so // an @-mention in a caption edit notifies — mirrors the text path. const [, , prevMentions] = getPrevBodyAndFormattedBody(); const mentionData = getMentions(mx, roomId, editor); prevMentions?.user_ids?.forEach((id) => mentionData.users.add(id)); mediaContent['m.mentions'] = getMentionContent( Array.from(mentionData.users), mentionData.room, ); const content: IContent = { ...mediaContent, 'm.new_content': mediaContent, 'm.relates_to': { event_id: evtId, rel_type: RelationType.Replace, }, }; return mx.sendMessage(roomId, content as any); } const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody(); if (plainText === '') return undefined; if (prevBody) { if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) { return undefined; } if ( !prevCustomHtml && prevBody === plainText && customHtmlEqualsPlainText(customHtml, plainText) ) { return undefined; } } const newContent: IContent = { msgtype: mEvent.getContent().msgtype, body: plainText, }; const mentionData = getMentions(mx, roomId, editor); prevMentions?.user_ids?.forEach((prevMentionId) => { mentionData.users.add(prevMentionId); }); const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room); newContent['m.mentions'] = mMentions; if (!customHtmlEqualsPlainText(customHtml, plainText)) { newContent.format = 'org.matrix.custom.html'; newContent.formatted_body = customHtml; } const content: IContent = { ...newContent, body: `* ${plainText}`, 'm.new_content': newContent, 'm.relates_to': { event_id: mEvent.getId(), rel_type: RelationType.Replace, }, }; return mx.sendMessage(roomId, content as any); }, [ mx, editor, roomId, room, mEvent, isMarkdown, getPrevBodyAndFormattedBody, isMediaCaption, editFilename, ]), ); const handleSave = useCallback(() => { if (saveState.status !== AsyncStatus.Loading) { save(); } }, [saveState, save]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { if ( (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && !isComposing(evt) ) { evt.preventDefault(); handleSave(); } if (isKeyHotkey('escape', evt)) { evt.preventDefault(); onCancel(); } }, [onCancel, handleSave, enterForNewline, isComposing], ); const handleKeyUp: KeyboardEventHandler = useCallback( (evt) => { if (isKeyHotkey('escape', evt)) { evt.preventDefault(); return; } const prevWordRange = getPrevWorldRange(editor); const query = prevWordRange ? getAutocompleteQuery(editor, prevWordRange, AUTOCOMPLETE_PREFIXES) : undefined; setAutocompleteQuery(query); }, [editor], ); const handleCloseAutocomplete = useCallback(() => { ReactEditor.focus(editor); setAutocompleteQuery(undefined); }, [editor]); const handleEmoticonSelect = (key: string, shortcode: string) => { editor.insertNode(createEmoticonElement(key, shortcode)); moveCursor(editor); }; useEffect(() => { const [body, customHtml] = getPrevBodyAndFormattedBody(); // For media, seed from the caption only: an empty caption is `body === // filename`, so don't prefill the filename into the editor. let seedText = typeof body === 'string' ? body : ''; let seedHtml = typeof customHtml === 'string' ? customHtml : undefined; if (isMediaCaption && seedText === editFilename) { seedText = ''; seedHtml = undefined; } const initialValue = seedHtml !== undefined ? htmlToEditorInput(seedHtml, isMarkdown) : plainToEditorInput(seedText, isMarkdown); Transforms.select(editor, { anchor: Editor.start(editor, []), focus: Editor.end(editor, []), }); editor.insertFragment(initialValue); if (!mobileOrTablet()) ReactEditor.focus(editor); }, [editor, getPrevBodyAndFormattedBody, isMarkdown, isMediaCaption, editFilename]); useEffect(() => { if (saveState.status === AsyncStatus.Success) { onCancel(); } }, [saveState, onCancel]); return (
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( )} ) : undefined } > Save Cancel setToolbar(!toolbar)} aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'} aria-pressed={toolbar} > {(anchor: RectCords | undefined, setAnchor) => ( { setAnchor((v) => { if (v) { if (!mobileOrTablet()) ReactEditor.focus(editor); return undefined; } return v; }); }} /> } > setAnchor( evt.currentTarget.getBoundingClientRect(), )) as MouseEventHandler } variant="SurfaceVariant" size="300" radii="300" > )} {toolbar && (
)} } />
); }, );