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, Spinner, Text, config, toRem, } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useClientConfig } from '../../hooks/useClientConfig'; 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 { EmojiBoardTab } from '../../components/emoji-board/types'; 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 { useAlive } from '../../hooks/useAlive'; 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 { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import colorMXID from '../../../util/colorMXID'; import { useIsDirectRoom } from '../../hooks/useRoom'; import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder'; const GifPicker = React.lazy(() => import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })), ); const EmojiBoard = React.lazy(() => import('../../components/emoji-board').then((m) => ({ default: m.EmojiBoard })), ); interface RoomInputProps { editor: Editor; fileDropContainerRef: RefObject; roomId: string; room: Room; } export const RoomInput = forwardRef( ({ editor, fileDropContainerRef, roomId, room }, 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 creators = useRoomCreators(room); const alive = useAlive(); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); const replyUserID = replyDraft?.userId; const powerLevelTags = usePowerLevelTags(room, powerLevels); const creatorsTag = useRoomCreatorsTag(); const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); const theme = useTheme(); const accessibleTagColors = useAccessiblePowerTagColors( theme.kind, creatorsTag, powerLevelTags, ); const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined; 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(undefined); const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents); const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [locating, setLocating] = React.useState(false); const [locationError, setLocationError] = React.useState(null); const handleShareLocation = () => { if (!navigator.geolocation) { setLocationError('Geolocation not supported.'); setTimeout(() => setLocationError(null), 4000); return; } setLocating(true); navigator.geolocation.getCurrentPosition( (pos) => { setLocating(false); const { latitude, longitude } = pos.coords; const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`; mx.sendMessage(roomId, { msgtype: 'm.location', body: `Location: ${geoUri}`, geo_uri: geoUri, } as any); }, (err) => { setLocating(false); const msg = err.code === 1 ? 'Location access denied.' : err.code === 3 ? 'Location timed out.' : 'Failed to get location.'; setLocationError(msg); setTimeout(() => setLocationError(null), 4000); }, { timeout: 10000 }, ); }; const handleVoiceSend = useCallback( async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => { const baseContent: IContent = { msgtype: MsgType.Audio, body: 'Voice message', filename: 'voice-message.ogg', 'org.matrix.msc3245.voice': {}, 'org.matrix.msc1767.audio': { duration: durationMs, waveform }, info: { mimetype: mimeType, size: blob.size, duration: durationMs }, }; if (room.hasEncryptionStateEvent()) { const { encInfo, file: encBlob } = await encryptFile(blob); const uploadResult = await mx.uploadContent(encBlob); mx.sendMessage(roomId, { ...baseContent, file: { ...encInfo, url: uploadResult.content_uri }, } as any); } else { const uploadResult = await mx.uploadContent(blob, { name: 'voice-message.ogg', type: mimeType, }); mx.sendMessage(roomId, { ...baseContent, url: uploadResult.content_uri, } as any); } }, [mx, room, roomId], ); 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 { gifApiKey } = useClientConfig(); const gifBtnRef = useRef(null); const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500); const [gifError, setGifError] = React.useState(null); const [gifUploading, setGifUploading] = React.useState(false); const isComposing = useComposingCheck(); useElementSizeObserver( useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]), useCallback((width) => setHideStickerBtn(width < 500), []), ); const didRestoreDraft = React.useRef(false); useEffect(() => { if (didRestoreDraft.current) return; didRestoreDraft.current = true; if (msgDraft.length > 0) { Transforms.insertFragment(editor, msgDraft); } else { // Jotai draft is empty (page reload) — try localStorage fallback try { const stored = localStorage.getItem(`draft-msg-${roomId}`); if (stored) { const nodes = JSON.parse(stored); if (Array.isArray(nodes) && nodes.length > 0) { Transforms.insertFragment(editor, nodes); } } } catch { // Ignore malformed stored draft } } }, [editor, msgDraft, roomId]); useEffect( () => () => { if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); localStorage.setItem(`draft-msg-${roomId}`, JSON.stringify(parsedDraft)); } else { setMsgDraft([]); localStorage.removeItem(`draft-msg-${roomId}`); } 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 as any)); }; 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 as any); resetEditor(editor); resetEditorHistory(editor); localStorage.removeItem(`draft-msg-${roomId}`); 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))) && !isComposing(evt) ) { evt.preventDefault(); submit(); } if (isKeyHotkey('escape', evt)) { evt.preventDefault(); if (autocompleteQuery) { setAutocompleteQuery(undefined); return; } setReplyDraft(undefined); } }, [submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing], ); 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 handleGifSelect = useCallback( async (gifUrl: string, w: number, h: number) => { setGifUploading(true); try { // Only fetch from trusted Giphy CDN domains (match any *.giphy.com subdomain) const { hostname } = new URL(gifUrl); if (!hostname.endsWith('.giphy.com') && hostname !== 'giphy.com') { setGifError('GIF source not trusted.'); setTimeout(() => setGifError(null), 4000); return; } const res = await fetch(gifUrl); if (!res.ok) { setGifError('Failed to download GIF from Giphy.'); setTimeout(() => setGifError(null), 4000); return; } const contentType = res.headers.get('content-type') ?? ''; if (!contentType.startsWith('image/')) { setGifError('Unexpected GIF format. Please try another.'); setTimeout(() => setGifError(null), 4000); return; } const blob = await res.blob(); if (blob.size > 20 * 1024 * 1024) { setGifError('GIF is too large (max 20 MB).'); setTimeout(() => setGifError(null), 4000); return; } const uploadRes = await mx.uploadContent( new File([blob], 'image.gif', { type: 'image/gif' }), { type: 'image/gif', name: 'image.gif', includeFilename: false }, ); const mxcUrl = (uploadRes as { content_uri: string }).content_uri; if (!mxcUrl) return; mx.sendMessage(roomId, { msgtype: MsgType.Image, body: 'image.gif', url: mxcUrl, info: { mimetype: 'image/gif', w, h, size: blob.size }, }); } catch (e) { console.error('GIF send failed', e); if (!alive()) return; setGifError('Failed to send GIF. Please try again.'); setTimeout(() => setGifError(null), 4000); } finally { if (alive()) setGifUploading(false); } }, [mx, roomId, alive], ); 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, }); }; if (room.getType() === 'm.server_notice') { return ( } direction="Column" alignItems="Center" justifyContent="Center" style={{ padding: config.space.S300 }} > This room contains system messages from your homeserver. Replies are not permitted. ); } 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)} aria-label="Dismiss reply" 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('*')} aria-label="Attach file" 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" > )} {!!gifApiKey && ( {(gifOpen: boolean, setGifOpen) => ( setGifOpen(false)} /> } > !gifUploading && setGifOpen(!gifOpen)} variant="SurfaceVariant" size="300" radii="300" disabled={gifUploading} > {gifUploading ? ( ) : ( GIF )} )} )} {gifError && ( {gifError} )} {locationError && ( {locationError} )} {locating ? ( ) : ( )} { setLocationError(err); setTimeout(() => setLocationError(null), 4000); }} /> } bottom={ toolbar && (
) } /> ); }, );