import React, { KeyboardEventHandler, RefObject, forwardRef, useCallback, useEffect, useRef, useState, } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { isKeyHotkey } from 'is-hotkey'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { Transforms, Editor } from 'slate'; import { Box, Dialog, Icon, IconButton, Icons, Line, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Text, config, toRem, } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { CustomEditor, Toolbar, toMatrixCustomHTML, toPlainText, AUTOCOMPLETE_PREFIXES, AutocompletePrefix, AutocompleteQuery, getAutocompleteQuery, getPrevWorldRange, resetEditor, RoomMentionAutocomplete, UserMentionAutocomplete, EmoticonAutocomplete, createEmoticonElement, moveCursor, resetEditorHistory, customHtmlEqualsPlainText, trimCustomHtml, isEmptyEditor, getBeginCommand, trimCommand, getMentions, } from '../../components/editor'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { UseStateProvider } from '../../components/UseStateProvider'; import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart, mxcUrlToHttp, } 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, TUploadMetadata, roomIdToMsgDraftAtomFamily, roomIdToReplyDraftAtomFamily, roomIdToUploadItemsAtomFamily, roomUploadAtomFamily, } from '../../state/room/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 { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; import { CommandAutocomplete } from './CommandAutocomplete'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; import { mobileOrTablet } from '../../utils/user-agent'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { ReplyLayout, ThreadIndicator } from '../../components/message'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags'; import { powerLevelAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels'; import colorMXID from '../../../util/colorMXID'; import { useIsDirectRoom } from '../../hooks/useRoom'; interface RoomInputProps { editor: Editor; fileDropContainerRef: RefObject; roomId: string; room: Room; getPowerLevelTag: GetPowerLevelTag; accessibleTagColors: Map; } export const RoomInput = forwardRef( ({ editor, fileDropContainerRef, roomId, room, getPowerLevelTag, accessibleTagColors }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); const direct = useIsDirectRoom(); const commands = useCommands(mx, room); const emojiBtnRef = useRef(null); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevelsContext(); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); const replyUserID = replyDraft?.userId; const replyPowerTag = getPowerLevelTag(powerLevelAPI.getPowerLevel(powerLevels, replyUserID)); const replyPowerColor = replyPowerTag.color ? accessibleTagColors.get(replyPowerTag.color) : undefined; const replyUsernameColor = legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor; 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[] = useImagePackRooms(roomId, roomToParents); 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 (room.hasEncryptionStateEvent()) { const encryptFiles = fulfilledPromiseSettledResult( await Promise.allSettled(safeFiles.map((f) => encryptFile(f))) ); encryptFiles.forEach((ef) => fileItems.push({ ...ef, metadata: { markedAsSpoiler: false, }, }) ); } else { safeFiles.forEach((f) => fileItems.push({ file: f, originalFile: f, encInfo: undefined, metadata: { markedAsSpoiler: false, }, }) ); } setSelectedFiles({ type: 'PUT', item: fileItems, }); }, [setSelectedFiles, room] ); const pickFile = useFilePicker(handleFiles, true); const handlePaste = useFilePasteHandler(handleFiles); const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles); const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500); useElementSizeObserver( useCallback(() => document.body, []), useCallback((width) => setHideStickerBtn(width < 500), []) ); useEffect(() => { Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); useEffect( () => () => { if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); } else { setMsgDraft([]); } resetEditor(editor); resetEditorHistory(editor); }, [roomId, editor, setMsgDraft] ); const handleFileMetadata = useCallback( (fileItem: TUploadItem, metadata: TUploadMetadata) => { setSelectedFiles({ type: 'REPLACE', item: fileItem, replacement: { ...fileItem, metadata }, }); }, [setSelectedFiles] ); 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 contentsPromises = uploads.map(async (upload) => { const fileItem = selectedFiles.find((f) => f.file === upload.file); if (!fileItem) throw new Error('Broken upload'); if (fileItem.file.type.startsWith('image')) { return getImageMsgContent(mx, fileItem, upload.mxc); } if (fileItem.file.type.startsWith('video')) { return getVideoMsgContent(mx, fileItem, upload.mxc); } if (fileItem.file.type.startsWith('audio')) { return getAudioMsgContent(fileItem, upload.mxc); } return getFileMsgContent(fileItem, upload.mxc); }); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); contents.forEach((content) => mx.sendMessage(roomId, content)); }; const submit = useCallback(() => { uploadBoardHandlers.current?.handleSend(); const commandName = getBeginCommand(editor); let plainText = toPlainText(editor.children, isMarkdown).trim(); let customHtml = trimCustomHtml( toMatrixCustomHTML(editor.children, { allowTextFormatting: true, allowBlockMarkdown: isMarkdown, allowInlineMarkdown: isMarkdown, }) ); let msgType = MsgType.Text; if (commandName) { plainText = trimCommand(commandName, plainText); customHtml = trimCommand(commandName, customHtml); } if (commandName === Command.Me) { msgType = MsgType.Emote; } else if (commandName === Command.Notice) { msgType = MsgType.Notice; } else if (commandName === Command.Shrug) { plainText = `${SHRUG} ${plainText}`; customHtml = `${SHRUG} ${customHtml}`; } else if (commandName === Command.TableFlip) { plainText = `${TABLEFLIP} ${plainText}`; customHtml = `${TABLEFLIP} ${customHtml}`; } else if (commandName === Command.UnFlip) { plainText = `${UNFLIP} ${plainText}`; customHtml = `${UNFLIP} ${customHtml}`; } else if (commandName) { const commandContent = commands[commandName as Command]; if (commandContent) { commandContent.exe(plainText); } resetEditor(editor); resetEditorHistory(editor); sendTypingStatus(false); return; } if (plainText === '') return; const body = plainText; const formattedBody = customHtml; const mentionData = getMentions(mx, roomId, editor); const content: IContent = { msgtype: msgType, body, }; if (replyDraft && replyDraft.userId !== mx.getUserId()) { mentionData.users.add(replyDraft.userId); } const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room); content['m.mentions'] = mMentions; if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) { content.format = 'org.matrix.custom.html'; content.formatted_body = formattedBody; } if (replyDraft) { content['m.relates_to'] = { 'm.in_reply_to': { event_id: replyDraft.eventId, }, }; if (replyDraft.relation?.rel_type === RelationType.Thread) { content['m.relates_to'].event_id = replyDraft.relation.event_id; content['m.relates_to'].rel_type = RelationType.Thread; content['m.relates_to'].is_falling_back = false; } } mx.sendMessage(roomId, content); resetEditor(editor); resetEditorHistory(editor); setReplyDraft(undefined); sendTypingStatus(false); }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { if ( (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && !evt.nativeEvent.isComposing ) { evt.preventDefault(); submit(); } if (isKeyHotkey('escape', evt)) { evt.preventDefault(); if (autocompleteQuery) { setAutocompleteQuery(undefined); return; } setReplyDraft(undefined); } }, [submit, setReplyDraft, enterForNewline, autocompleteQuery] ); const handleKeyUp: KeyboardEventHandler = useCallback( (evt) => { if (isKeyHotkey('escape', evt)) { evt.preventDefault(); return; } if (!hideActivity) { sendTypingStatus(!isEmptyEditor(editor)); } const prevWordRange = getPrevWorldRange(editor); const query = prevWordRange ? getAutocompleteQuery(editor, prevWordRange, AUTOCOMPLETE_PREFIXES) : undefined; setAutocompleteQuery(query); }, [editor, sendTypingStatus, hideActivity] ); const handleCloseAutocomplete = useCallback(() => { setAutocompleteQuery(undefined); ReactEditor.focus(editor); }, [editor]); const handleEmoticonSelect = (key: string, shortcode: string) => { editor.insertNode(createEmoticonElement(key, shortcode)); moveCursor(editor); }; const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => { const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication); if (!stickerUrl) return; const info = await getImageInfo( await loadImageElement(stickerUrl), await getImageUrlBlob(stickerUrl) ); mx.sendEvent(roomId, EventType.Sticker, { body: label, 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 && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.Command && ( )} setReplyDraft(undefined)} variant="SurfaceVariant" size="300" radii="300" > {replyDraft.relation?.rel_type === RelationType.Thread && } {getMemberDisplayName(room, replyDraft.userId) ?? getMxIdLocalPart(replyDraft.userId) ?? replyDraft.userId} } > {trimReplyFromBody(replyDraft.body)}
) } before={ pickFile('*')} variant="SurfaceVariant" size="300" radii="300" > } after={ <> setToolbar(!toolbar)} > {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( { setEmojiBoardTab((t) => { if (t) { if (!mobileOrTablet()) ReactEditor.focus(editor); return undefined; } return t; }); }} /> } > {!hideStickerBtn && ( setEmojiBoardTab(EmojiBoardTab.Sticker)} variant="SurfaceVariant" size="300" radii="300" > )} setEmojiBoardTab(EmojiBoardTab.Emoji)} variant="SurfaceVariant" size="300" radii="300" > )} } bottom={ toolbar && (
) } /> ); } );