import React, { KeyboardEventHandler, RefObject, forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useAtom } from 'jotai'; import isHotkey from 'is-hotkey'; import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { Transforms, Range, Editor, Element } from 'slate'; import { Box, Dialog, Icon, IconButton, Icons, Line, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Text, config, toRem, } from 'folds'; import to from 'await-to-js'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { CustomEditor, EditorChangeHandler, useEditor, Toolbar, toMatrixCustomHTML, toPlainText, AUTOCOMPLETE_PREFIXES, AutocompletePrefix, AutocompleteQuery, getAutocompleteQuery, getPrevWorldRange, resetEditor, RoomMentionAutocomplete, UserMentionAutocomplete, EmoticonAutocomplete, createEmoticonElement, moveCursor, resetEditorHistory, } from '../../components/editor'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { UseStateProvider } from '../../components/UseStateProvider'; import initMatrix from '../../../client/initMatrix'; import { TUploadContent, encryptFile, getImageInfo } from '../../utils/matrix'; import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater'; import { useFilePicker } from '../../hooks/useFilePicker'; import { useFilePasteHandler } from '../../hooks/useFilePasteHandler'; import { useFileDropZone } from '../../hooks/useFileDrop'; import { TUploadItem, roomIdToMsgDraftAtomFamily, roomIdToReplyDraftAtomFamily, roomIdToUploadItemsAtomFamily, roomUploadAtomFamily, } from '../../state/roomInputDrafts'; import { UploadCardRenderer } from '../../components/upload-card'; import { UploadBoard, UploadBoardContent, UploadBoardHeader, UploadBoardImperativeHandlers, } from '../../components/upload-board'; import { Upload, UploadStatus, UploadSuccess, createUploadFamilyObserverAtom, } from '../../state/upload'; import { getImageUrlBlob, loadImageElement } from '../../utils/dom'; import { safeFile } from '../../utils/mimeTypes'; import { fulfilledPromiseSettledResult } from '../../utils/common'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { getAudioMsgContent, getFileMsgContent, getImageMsgContent, getVideoMsgContent, } from './msgContent'; import navigation from '../../../client/state/navigation'; import cons from '../../../client/state/cons'; import { MessageReply } from '../../molecules/message/Message'; import colorMXID from '../../../util/colorMXID'; import { parseReplyBody, parseReplyFormattedBody } from '../../utils/room'; import { sanitizeText } from '../../utils/sanitize'; import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver'; interface RoomInputProps { roomViewRef: RefObject; roomId: string; } export const RoomInput = forwardRef( ({ roomViewRef, roomId }, ref) => { const mx = useMatrixClient(); const editor = useEditor(); const room = mx.getRoom(roomId); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); const [uploadBoard, setUploadBoard] = useState(true); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId)); const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( roomUploadAtomFamily, selectedFiles.map((f) => f.file) ); const uploadBoardHandlers = useRef(); const imagePackRooms: Room[] = useMemo(() => { const allParentSpaces = [roomId, ...(initMatrix.roomList?.getAllParentSpaces(roomId) ?? [])]; return allParentSpaces.reduce((list, rId) => { const r = mx.getRoom(rId); if (r) list.push(r); return list; }, []); }, [mx, roomId]); const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [autocompleteQuery, setAutocompleteQuery] = useState>(); const sendTypingStatus = useTypingStatusUpdater(mx, roomId); const handleFiles = useCallback( async (files: File[]) => { setUploadBoard(true); const safeFiles = files.map(safeFile); const fileItems: TUploadItem[] = []; if (mx.isRoomEncrypted(roomId)) { const encryptFiles = fulfilledPromiseSettledResult( await Promise.allSettled(safeFiles.map((f) => encryptFile(f))) ); encryptFiles.forEach((ef) => fileItems.push(ef)); } else { safeFiles.forEach((f) => fileItems.push({ file: f, originalFile: f, encInfo: undefined }) ); } setSelectedFiles({ type: 'PUT', item: fileItems, }); }, [setSelectedFiles, roomId, mx] ); const pickFile = useFilePicker(handleFiles, true); const handlePaste = useFilePasteHandler(handleFiles); const dropZoneVisible = useFileDropZone(roomViewRef, handleFiles); const [mobile, setMobile] = useState(document.body.clientWidth < 500); useResizeObserver( document.body, useCallback((entries) => { const bodyEntry = getResizeObserverEntry(document.body, entries); if (bodyEntry && bodyEntry.contentRect.width < 500) setMobile(true); else setMobile(false); }, []) ); useEffect(() => { Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); useEffect(() => { ReactEditor.focus(editor); return () => { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); resetEditor(editor); resetEditorHistory(editor); }; }, [roomId, editor, setMsgDraft]); useEffect(() => { const handleReplyTo = ( userId: string, eventId: string, body: string, formattedBody: string ) => { setReplyDraft({ userId, eventId, body, formattedBody, }); ReactEditor.focus(editor); }; navigation.on(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo); return () => { navigation.removeListener(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo); }; }, [setReplyDraft, editor]); const handleRemoveUpload = useCallback( (upload: TUploadContent | TUploadContent[]) => { const uploads = Array.isArray(upload) ? upload : [upload]; setSelectedFiles({ type: 'DELETE', item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)), }); uploads.forEach((u) => roomUploadAtomFamily.remove(u)); }, [setSelectedFiles, selectedFiles] ); const handleCancelUpload = (uploads: Upload[]) => { uploads.forEach((upload) => { if (upload.status === UploadStatus.Loading) { mx.cancelUpload(upload.promise); } }); handleRemoveUpload(uploads.map((upload) => upload.file)); }; const handleSendUpload = async (uploads: UploadSuccess[]) => { const sendPromises = uploads.map(async (upload) => { const fileItem = selectedFiles.find((f) => f.file === upload.file); if (fileItem && fileItem.file.type.startsWith('image')) { const [imgError, imgContent] = await to(getImageMsgContent(fileItem, upload.mxc)); if (imgError) console.warn(imgError); if (imgContent) mx.sendMessage(roomId, imgContent); return; } if (fileItem && fileItem.file.type.startsWith('video')) { const [videoError, videoContent] = await to(getVideoMsgContent(mx, fileItem, upload.mxc)); if (videoError) console.warn(videoError); if (videoContent) mx.sendMessage(roomId, videoContent); return; } if (fileItem && fileItem.file.type.startsWith('audio')) { mx.sendMessage(roomId, getAudioMsgContent(fileItem, upload.mxc)); return; } if (fileItem) { mx.sendMessage(roomId, getFileMsgContent(fileItem, upload.mxc)); } }); handleCancelUpload(uploads); await Promise.allSettled(sendPromises); }; const submit = useCallback(() => { uploadBoardHandlers.current?.handleSend(); const plainText = toPlainText(editor.children).trim(); const customHtml = toMatrixCustomHTML(editor.children); if (plainText === '') return; let body = plainText; let formattedBody = customHtml; if (replyDraft) { body = parseReplyBody(replyDraft.userId, replyDraft.userId) + body; formattedBody = parseReplyFormattedBody( roomId, replyDraft.userId, replyDraft.eventId, replyDraft.formattedBody ?? sanitizeText(replyDraft.body) ) + formattedBody; } const content: IContent = { msgtype: MsgType.Text, body, format: 'org.matrix.custom.html', formatted_body: formattedBody, }; if (replyDraft) { content['m.relates_to'] = { 'm.in_reply_to': { event_id: replyDraft.eventId, }, }; } mx.sendMessage(roomId, content); resetEditor(editor); resetEditorHistory(editor); setReplyDraft(); sendTypingStatus(false); }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { const { selection } = editor; if (isHotkey('enter', evt)) { evt.preventDefault(); submit(); } if (isHotkey('escape', evt)) { evt.preventDefault(); setReplyDraft(); } if (selection && Range.isCollapsed(selection)) { if (isHotkey('arrowleft', evt)) { evt.preventDefault(); Transforms.move(editor, { unit: 'offset', reverse: true }); } if (isHotkey('arrowright', evt)) { evt.preventDefault(); Transforms.move(editor, { unit: 'offset' }); } } }, [submit, editor, setReplyDraft] ); const handleChange: EditorChangeHandler = (value) => { const prevWordRange = getPrevWorldRange(editor); const query = prevWordRange ? getAutocompleteQuery(editor, prevWordRange, AUTOCOMPLETE_PREFIXES) : undefined; setAutocompleteQuery(query); const descendant = value[0]; if (descendant && Element.isElement(descendant)) { const isEmpty = value.length === 1 && Editor.isEmpty(editor, descendant); sendTypingStatus(!isEmpty); } }; const handleEmoticonSelect = (key: string, shortcode: string) => { editor.insertNode(createEmoticonElement(key, shortcode)); moveCursor(editor); }; const handleStickerSelect = async (mxc: string, shortcode: string) => { const stickerUrl = mx.mxcUrlToHttp(mxc); if (!stickerUrl) return; const info = await getImageInfo( await loadImageElement(stickerUrl), await getImageUrlBlob(stickerUrl) ); mx.sendEvent(roomId, EventType.Sticker, { body: shortcode, url: mxc, info, }); }; return (
{selectedFiles.length > 0 && ( setUploadBoard(!uploadBoard)} uploadFamilyObserverAtom={uploadFamilyObserverAtom} onSend={handleSendUpload} imperativeHandlerRef={uploadBoardHandlers} onCancel={handleCancelUpload} /> } > {uploadBoard && ( {Array.from(selectedFiles) .reverse() .map((fileItem, index) => ( ))} )} )} } style={{ pointerEvents: 'none' }} > {`Drop Files in "${room?.name || 'Room'}"`} Drag and drop files here or click for selection dialog {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && ( setAutocompleteQuery(undefined)} /> )} {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( setAutocompleteQuery(undefined)} /> )} {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( setAutocompleteQuery(undefined)} /> )} setReplyDraft()} variant="SurfaceVariant" size="300" radii="300" >
) } before={ pickFile('*')} variant="SurfaceVariant" size="300" radii="300" > } after={ <> setToolbar(!toolbar)} > {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( { setEmojiBoardTab(undefined); ReactEditor.focus(editor); }} /> } > {(anchorRef) => ( <> {!mobile && ( setEmojiBoardTab(EmojiBoardTab.Sticker)} variant="SurfaceVariant" size="300" radii="300" > )} setEmojiBoardTab(EmojiBoardTab.Emoji)} variant="SurfaceVariant" size="300" radii="300" > )} )} } bottom={ toolbar && (
) } /> ); } );