import React, { KeyboardEventHandler, ReactNode, RefObject, forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useAtom, useAtomValue, useSetAtom } 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, Element as SlateElement, Node as SlateNode, Range } from 'slate'; import { Button, Box, Dialog, Icon, IconButton, Icons, Line, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Spinner, Text, color, config, toRem, } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useClientConfig } from '../../hooks/useClientConfig'; import { CustomEditor, Toolbar, toMatrixCustomHTML, toPlainText, AUTOCOMPLETE_PREFIXES, AutocompletePrefix, AutocompleteQuery, getAutocompleteQuery, getPrevWordRange, resetEditor, RoomMentionAutocomplete, UserMentionAutocomplete, EmoticonAutocomplete, createEmoticonElement, moveCursor, resetEditorHistory, customHtmlEqualsPlainText, trimCustomHtml, isEmptyEditor, getBeginCommand, trimCommand, getMentions, BlockType, } from '../../components/editor'; import { EmojiBoardTab } from '../../components/emoji-board/types'; import { UseStateProvider } from '../../components/UseStateProvider'; import { TUploadContent, encryptFile, getImageInfo, mxcUrlToHttp, tryDeleteMxcContent, } from '../../utils/matrix'; import { compressImage, isCompressible } from '../../utils/imageCompression'; 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 { filesToUploadItems } from '../../utils/uploadItems'; import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb'; import { fulfilledPromiseSettledResult } from '../../utils/common'; import { useSetting } from '../../state/hooks/settings'; import { looksLikeCode } from '../../utils/looksLikeCode'; import { useAlive } from '../../hooks/useAlive'; import { ComposerToolbarButtonKey, normalizeComposerToolbarOrder, settingsAtom, } from '../../state/settings'; import { buildCompressedUploadItem, getAudioMsgContent, getFileMsgContent, getImageMsgContent, getVideoMsgContent, } from './msgContent'; import { getMemberName, getMentionContent, sendRoomMessage, trimReplyFromBody, } from '../../utils/room'; import { CommandAutocomplete } from './CommandAutocomplete'; import { Command, CommandError, 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 { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { MobileTouchTarget } from '../../styles/mobile.css'; import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder'; import { PollCreator } from './PollCreator'; import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus'; import { ScheduleMessageModal } from './ScheduleMessageModal'; import { ScheduledMessagesTray } from './ScheduledMessagesTray'; import { DraftIndicator } from './DraftIndicator'; import { scheduledMessagesAtom } from '../../state/scheduledMessages'; import { createErrorToast, toastQueueAtom } from '../../state/toast'; import { getThreadDraftKey } from '../../state/room/thread'; import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking'; 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 })), ); /** [Gitea #37] Debounce for persisting the composer draft while typing. */ /** [Gitea #129] Same picked file, allowing for re-wrapped File objects. */ const isAutoFocusTarget = (file: TUploadContent, target: File | undefined): boolean => !!target && file instanceof File && file.name === target.name && file.lastModified === target.lastModified; const DRAFT_PERSIST_DEBOUNCE_MS = 500; interface RoomInputProps { editor: Editor; fileDropContainerRef: RefObject; roomId: string; room: Room; threadRootId?: string; // Identifies this composer to global key handlers (e.g. the up-arrow "edit last // message" handler). Threads pass a distinct name so the main timeline's handler // doesn't fire for the thread composer, and vice-versa. editableName?: string; // [Gitea #218] Force the one-row "+ | input | emoji | send" layout regardless // of viewport — the 360 px thread panel can't fit the full toolbar. compactLayout?: boolean; } export const RoomInput = forwardRef( ( { editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput', compactLayout = false, }, 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 [warnOnUnverifiedDevices] = useSetting(settingsAtom, 'warnOnUnverifiedDevices'); const crypto = mx.getCrypto(); const roomUnverifiedDeviceCount = useRoomUnverifiedDeviceCount(crypto, room); const isEncrypted = room.hasEncryptionStateEvent(); const showUnverifiedWarning = warnOnUnverifiedDevices && isEncrypted && roomUnverifiedDeviceCount !== undefined && roomUnverifiedDeviceCount > 0; const direct = useIsDirectRoom(); const commands = useCommands(mx, room); const emojiBtnRef = useRef(null); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); const [charCount, setCharCount] = useState(0); useEffect(() => { setCharCount(0); }, [roomId]); const [pollOpen, setPollOpen] = useState(false); const [scheduleOpen, setScheduleOpen] = useState(false); const [scheduleContent, setScheduleContent] = useState(null); const setScheduledMessages = useSetAtom(scheduledMessagesAtom); const setToast = useSetAtom(toastQueueAtom); const alive = useAlive(); // Scope drafts/replies/uploads by thread so a thread composer stays fully // isolated from the main room composer (and from other threads). const draftKey = threadRootId ? getThreadDraftKey(roomId, threadRootId) : roomId; const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey)); const replyUserID = replyDraft?.userId; // [Gitea #151] The quoted event, for a media thumbnail in the draft preview. const replyDraftEvent = replyDraft ? room.findEventById(replyDraft.eventId) : undefined; 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(draftKey)); const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( roomUploadAtomFamily, selectedFiles.map((f) => f.file), ); const uploadBoardHandlers = useRef(undefined); // [Gitea #129] The file whose caption input should take focus once its card mounts. // (Matched by name + mtime: the metadata strip and safeFile may re-wrap the File.) const autoFocusCaptionRef = useRef(undefined); const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents); const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons'); const isMobile = mobileOrTablet(); const screenSize = useScreenSizeContext(); // Compact composer: the secondary action buttons (attach, GIF, poll, // location, voice, formatting, schedule) collapse behind a "+" toggle so the // input stays ONE row — [ + | input | emoji | send ]. Keyed on the viewport, // not only the user agent: a phone-width window on a desktop UA (iPad in // desktop mode, a split-screen PWA, a docked window) otherwise renders all // ten controls inline and clips the Send button behind the editor's // overflow:hidden. Touch-sized (44px) targets stay UA-gated: a narrow // desktop window still has a mouse. const compact = isMobile || screenSize === ScreenSize.Mobile || compactLayout; const [mobileToolsOpen, setMobileToolsOpen] = useState(false); // Both gates on purpose: the class covers phone-width viewports (matches // VoiceMessageRecorder's idle button, which lives in the same row), the // inline style covers touch UAs with a wider viewport (tablet landscape). const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined; const showFormat = composerToolbarButtons?.showFormat ?? true; const showEmoji = composerToolbarButtons?.showEmoji ?? true; const showSticker = composerToolbarButtons?.showSticker ?? true; // [Gitea #68] The GIF picker is opt-in (searches go to Giphy); hide the // toolbar button entirely when it's off so it never opens an empty popover. const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled'); // [Gitea #103] Privacy: drop tracking params from links on paste and on send. const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams'); // [Gitea #107] "Paste as code block?" offer for the top-level blocks a // paste just filled. Dismissed by typing, by No, or after 8 s. const [offerCodePaste] = useSetting(settingsAtom, 'offerCodePaste'); const [codeOffer, setCodeOffer] = useState<{ start: number; end: number; lang?: string }>(); useEffect(() => { if (!codeOffer) return undefined; const t = window.setTimeout(() => setCodeOffer(undefined), 8000); return () => window.clearTimeout(t); }, [codeOffer]); const acceptCodeOffer = useCallback(() => { if (!codeOffer) return; const { start, end, lang } = codeOffer; setCodeOffer(undefined); if (end >= editor.children.length || start > end) return; const blocks = editor.children.slice(start, end + 1); // Only plain paragraphs (what a paste produces); anything else, leave it. if (blocks.some((b) => !SlateElement.isElement(b) || b.type !== BlockType.Paragraph)) return; // Rebuild from plain text: code lines hold text only, and a paste may // have produced links/mentions. const lines = blocks.map((b) => SlateNode.string(b)); Editor.withoutNormalizing(editor, () => { for (let i = end; i >= start; i -= 1) Transforms.removeNodes(editor, { at: [i] }); Transforms.insertNodes( editor, { type: BlockType.CodeBlock, lang, children: lines.map((text) => ({ type: BlockType.CodeLine, children: [{ text }] })), }, { at: [start] }, ); if (start + 1 >= editor.children.length) { Transforms.insertNodes( editor, { type: BlockType.Paragraph, children: [{ text: '' }] }, { at: [start + 1] }, ); } }); Transforms.select(editor, Editor.start(editor, [start + 1])); ReactEditor.focus(editor); }, [codeOffer, editor]); const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata'); const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled; const showLocation = composerToolbarButtons?.showLocation ?? true; const showPoll = composerToolbarButtons?.showPoll ?? true; const showVoice = composerToolbarButtons?.showVoice ?? true; // Schedule-send is hidden in thread mode (v1 reduction) and in encrypted rooms: // MSC4140 delayed events are PUT as plaintext m.room.message, bypassing the // SDK's encryption pipeline, so scheduling in an E2EE room would leak the body. const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId && !isEncrypted; const composerButtonOrder = useMemo( () => normalizeComposerToolbarOrder(composerToolbarButtons?.order), [composerToolbarButtons?.order], ); const [locating, setLocating] = React.useState(false); const [locationError, setLocationError] = React.useState(null); const handleShareLocation = useCallback(() => { 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)}`; const ts = Date.now(); // MSC3488 extensible location: send the geo_uri (legacy) alongside the // m.location/m.asset/m.ts blocks so Element and other clients render a // proper "shared location" pin instead of falling back to plain text. sendRoomMessage(mx, roomId, threadRootId ?? null, { msgtype: 'm.location', body: `Shared a location: ${geoUri}`, geo_uri: geoUri, 'org.matrix.msc3488.location': { uri: geoUri }, 'org.matrix.msc3488.asset': { type: 'm.self' }, 'org.matrix.msc3488.ts': ts, 'm.ts': ts, }); }, (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 }, ); }, [mx, roomId, threadRootId]); 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); sendRoomMessage(mx, roomId, threadRootId ?? null, { ...baseContent, file: { ...encInfo, url: uploadResult.content_uri }, }); } else { const uploadResult = await mx.uploadContent(blob, { name: 'voice-message.ogg', type: mimeType, }); sendRoomMessage(mx, roomId, threadRootId ?? null, { ...baseContent, url: uploadResult.content_uri, }); } }, [mx, room, roomId, threadRootId], ); const [autocompleteQuery, setAutocompleteQuery] = useState>(); const sendTypingStatus = useTypingStatusUpdater(mx, roomId); const handleFiles = useCallback( async (files: File[]) => { setUploadBoard(true); // [Gitea #129] One image into an empty composer: land in its caption // field so "here's the bug" is one motion (Enter sends, Esc returns). const focusCaption = files.length === 1 && files[0].type.startsWith('image/') && isEmptyEditor(editor); setSelectedFiles({ type: 'PUT', item: await filesToUploadItems(room, files, stripImageMetadata), }); if (focusCaption) autoFocusCaptionRef.current = files[0]; }, [setSelectedFiles, room, stripImageMetadata, editor], ); const pickFile = useFilePicker(handleFiles, true); const handleFilePaste = useFilePasteHandler(handleFiles); const handlePaste = useCallback( (evt) => { handleFilePaste(evt); if (evt.defaultPrevented) return; if (offerCodePaste && editor.selection) { const pasted = evt.clipboardData?.getData('text/plain') ?? ''; const startBlock = Range.start(editor.selection).path[0]; const startNode = editor.children[startBlock]; const inCode = SlateElement.isElement(startNode) && startNode.type === BlockType.CodeBlock; const guess = !inCode && pasted ? looksLikeCode(pasted, evt.clipboardData?.getData('text/html')) : undefined; if (guess?.isCode) { // Slate inserts after this handler returns; read where it ended. window.setTimeout(() => { const endBlock = editor.selection ? Range.end(editor.selection).path[0] : startBlock; setCodeOffer({ start: startBlock, end: endBlock, lang: guess.lang }); }, 0); } } if (!stripTracking) return; const text = evt.clipboardData?.getData('text/plain'); if (!text) return; const cleaned = stripTrackingParamsInText(text); if (cleaned === text) return; // Re-run Slate's own plain-text insertion with the cleaned string so // multi-line pastes still split into paragraphs exactly as before. if (typeof DataTransfer === 'undefined') return; evt.preventDefault(); const dt = new DataTransfer(); dt.setData('text/plain', cleaned); ReactEditor.insertData(editor, dt); }, [handleFilePaste, stripTracking, editor, offerCodePaste], ); 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-${draftKey}`); if (stored) { const parsed = JSON.parse(stored); // [Gitea #41] Only restore a draft this same account wrote. A legacy // draft (stored as a bare array, pre-dating user-scoping) or one // written by a different userId is foreign — drop it rather than // risk pre-filling another account's unsent text into the composer. const foreign = !parsed || typeof parsed !== 'object' || Array.isArray(parsed) || parsed.userId !== mx.getUserId(); if (foreign) { localStorage.removeItem(`draft-msg-${draftKey}`); } else { const nodes = parsed.nodes; if (Array.isArray(nodes) && nodes.length > 0) { Transforms.insertFragment(editor, nodes); // Mirror the restored draft into the atom so the draft indicator // (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft // after a page reload — not only on same-session room re-entry. setMsgDraft(nodes); } } } } catch { // Ignore malformed stored draft } } }, [editor, msgDraft, draftKey, setMsgDraft, mx]); // Persist the current editor state to the draft atom + localStorage; an empty // editor clears both so the draft indicators never claim a draft that isn't there. const persistDraft = useCallback(() => { if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); // [Gitea #41] Tag the persisted draft with the writing user's id so a // different account logging into this browser can't have it hydrated // into their composer (see useHydrateMsgDrafts / clearPlaintextCaches). localStorage.setItem( `draft-msg-${draftKey}`, JSON.stringify({ userId: mx.getUserId(), nodes: parsedDraft }), ); } else { setMsgDraft([]); localStorage.removeItem(`draft-msg-${draftKey}`); } }, [draftKey, editor, setMsgDraft, mx]); // [Gitea #37] Drafts used to be written only in the unmount cleanup below, so // a reload/tab-close in the open room lost them (and DraftIndicator never // showed for the room being typed in). Debounce a persist while typing and // flush it on pagehide; the cleanup still persists on unmount / draftKey change. const persistDraftTimer = useRef | null>(null); const schedulePersistDraft = useCallback(() => { if (persistDraftTimer.current) clearTimeout(persistDraftTimer.current); persistDraftTimer.current = setTimeout(() => { persistDraftTimer.current = null; persistDraft(); }, DRAFT_PERSIST_DEBOUNCE_MS); }, [persistDraft]); useEffect(() => { const flush = () => { if (persistDraftTimer.current) { clearTimeout(persistDraftTimer.current); persistDraftTimer.current = null; } persistDraft(); }; window.addEventListener('pagehide', flush); return () => { window.removeEventListener('pagehide', flush); flush(); resetEditor(editor); resetEditorHistory(editor); }; }, [editor, persistDraft]); 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 = useCallback( (uploads: Upload[]) => { uploads.forEach((upload) => { if (upload.status === UploadStatus.Loading) { mx.cancelUpload(upload.promise); } }); handleRemoveUpload(uploads.map((upload) => upload.file)); }, [mx, handleRemoveUpload], ); const handleSendUpload = useCallback( 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'); // Resolve the MXC URL to use — may be overridden if compression is enabled let mxc = upload.mxc; if (fileItem.metadata.compressImage && isCompressible(fileItem.originalFile)) { // Use the cached compression result if available, otherwise compute it now let compressionResult = fileItem.metadata.compressionResult; if (compressionResult === undefined) { compressionResult = await compressImage(fileItem.originalFile); } if (compressionResult) { const originalFile = fileItem.originalFile as File; // compressImage re-encodes as JPEG; swap the extension so the file // name and MIME type agree (avoids e.g. a JPEG named "photo.png"). const compressedType = compressionResult.type; const compressedName = `${originalFile.name.replace(/\.[^./\\]+$/, '')}.jpg`; const compressedFile = new File([compressionResult.blob], compressedName, { type: compressedType, }); // Compression re-encodes the image, so in an encrypted room the new // bytes must be encrypted before upload (and the event must carry the // *new* encInfo) — reusing the original's encInfo would publish the // image in the clear and yield an undecryptable attachment. const encrypted = fileItem.encInfo ? await encryptFile(compressedFile) : undefined; const uploadRes = encrypted ? await mx.uploadContent(encrypted.file) : await mx.uploadContent(compressedFile, { name: compressedName, type: compressedType, }); const compressedMxc = (uploadRes as { content_uri: string }).content_uri; if (compressedMxc) { // Delete the pre-uploaded original so only one copy lives on the server. tryDeleteMxcContent(mx, upload.mxc); mxc = compressedMxc; // Synthetic fileItem referring to the compressed file so // getImageMsgContent picks up the correct dimensions, type and encInfo. const compressedItem = buildCompressedUploadItem( fileItem, compressedFile, encrypted, ); return getImageMsgContent(mx, compressedItem, mxc); } } } if (fileItem.file.type.startsWith('image')) { return getImageMsgContent(mx, fileItem, mxc); } if (fileItem.file.type.startsWith('video')) { return getVideoMsgContent(mx, fileItem, mxc); } if (fileItem.file.type.startsWith('audio')) { return getAudioMsgContent(fileItem, mxc); } return getFileMsgContent(fileItem, mxc); }); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); contents.forEach((content) => sendRoomMessage(mx, roomId, threadRootId ?? null, content)); }, [mx, roomId, threadRootId, selectedFiles, handleCancelUpload], ); const submit = useCallback(() => { uploadBoardHandlers.current?.handleSend(); // Slash commands work in threads too: content-transform commands (/me, // /notice, /shrug, /tableflip, /unflip) flow into the normal send below, // which routes to the thread via `threadRootId`; the rest (/invite, /kick, // …) are room-level actions. This also matches the command autocomplete, // which is already shown in the thread composer. const commandName = getBeginCommand(editor); let plainText = toPlainText(editor.children, isMarkdown).trim(); let customHtml = trimCustomHtml( toMatrixCustomHTML(editor.children, { allowTextFormatting: true, allowBlockMarkdown: isMarkdown, allowInlineMarkdown: isMarkdown, allowMath: true, }), ); let msgType = MsgType.Text; if (commandName) { plainText = trimCommand(commandName, plainText); customHtml = trimCommand(commandName, customHtml); } if (stripTracking) { plainText = stripTrackingParamsInText(plainText); customHtml = stripTrackingParamsInHtml(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) { // Fire-and-forget by design (the editor resets immediately for UX), but // surface a rejection instead of failing silently. /kick and /ban // throw a CommandError whose message already names who and why // (#216); anything else gets the generic sentence. commandContent.exe(plainText).catch((err) => { console.error(`Failed to run /${commandName} command:`, err); setToast( createErrorToast( err instanceof CommandError ? err.message : `The /${commandName} command failed. Please try again.`, Icons.Warning, 'Command failed', ), ); }); } resetEditor(editor); resetEditorHistory(editor); setCharCount(0); 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; } } sendRoomMessage(mx, roomId, threadRootId ?? null, content); resetEditor(editor); resetEditorHistory(editor); setCharCount(0); setMsgDraft([]); localStorage.removeItem(`draft-msg-${draftKey}`); setReplyDraft(undefined); sendTypingStatus(false); }, [ mx, roomId, threadRootId, draftKey, editor, replyDraft, sendTypingStatus, setReplyDraft, setMsgDraft, isMarkdown, commands, setToast, stripTracking, ]); /** * Build a text message content object from the current editor state. * Returns null if the editor is empty or the input is a command. */ const buildCurrentTextContent = useCallback((): IContent | null => { const commandName = getBeginCommand(editor); // Don't schedule commands if (commandName) return null; const plainText = toPlainText(editor.children, isMarkdown).trim(); const customHtml = trimCustomHtml( toMatrixCustomHTML(editor.children, { allowTextFormatting: true, allowBlockMarkdown: isMarkdown, allowInlineMarkdown: isMarkdown, allowMath: true, }), ); if (plainText === '') return null; const body = stripTracking ? stripTrackingParamsInText(plainText) : plainText; const formattedBody = stripTracking ? stripTrackingParamsInHtml(customHtml) : customHtml; const mentionData = getMentions(mx, roomId, editor); const content: IContent = { msgtype: MsgType.Text, body, }; if (replyDraft && replyDraft.userId !== mx.getUserId()) { mentionData.users.add(replyDraft.userId); } content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); 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; } } return content; }, [editor, isMarkdown, mx, roomId, replyDraft, stripTracking]); const handleScheduleClick = useCallback(() => { // Defense in depth: scheduling sends an unencrypted m.room.message, so never // open the modal for an encrypted room even if the button somehow renders. if (isEncrypted) return; // Pre-fill from editor if there's content; open blank if editor is empty. const content = buildCurrentTextContent(); setScheduleContent(content); setScheduleOpen(true); }, [buildCurrentTextContent, isEncrypted]); const handleScheduled = useCallback( (delayId: string, sendAt: number, content: IContent) => { setScheduledMessages((prev) => { const next = new Map(prev); const current = next.get(roomId) ?? []; next.set(roomId, [...current, { delayId, roomId, content, sendAt }]); return next; }); // [Gitea #36] Only reached after scheduleMessage() succeeded, so the reply // draft (already baked into `content['m.relates_to']`) can be cleared here; // a failed/cancelled schedule keeps the composer and reply target intact. resetEditor(editor); resetEditorHistory(editor); setMsgDraft([]); localStorage.removeItem(`draft-msg-${draftKey}`); setReplyDraft(undefined); sendTypingStatus(false); }, [ setScheduledMessages, roomId, draftKey, editor, setReplyDraft, setMsgDraft, sendTypingStatus, ], ); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { // Typing (not Tab/arrows/modifiers) dismisses the paste-as-code offer. if ( evt.key.length === 1 || evt.key === 'Backspace' || evt.key === 'Delete' || evt.key === 'Enter' ) { setCodeOffer(undefined); } if ( (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && !isComposing(evt) ) { evt.preventDefault(); submit(); } if (isKeyHotkey('escape', evt)) { // Only consume Escape (and stop it bubbling to the thread panel / room // window handlers) when the composer actually has something to dismiss. // If we did nothing, let Escape propagate so those handlers can run. if (autocompleteQuery) { evt.preventDefault(); evt.stopPropagation(); setAutocompleteQuery(undefined); return; } if (replyDraft) { evt.preventDefault(); evt.stopPropagation(); setReplyDraft(undefined); } } }, [submit, replyDraft, setReplyDraft, enterForNewline, autocompleteQuery, isComposing], ); const handleKeyUp: KeyboardEventHandler = useCallback( (evt) => { if (isKeyHotkey('escape', evt)) { evt.preventDefault(); return; } if (!hideActivity) { sendTypingStatus(!isEmptyEditor(editor)); } const prevWordRange = getPrevWordRange(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 = useCallback( (key: string, shortcode: string) => { editor.insertNode(createEmoticonElement(key, shortcode)); moveCursor(editor); }, [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 gifFile = new File([blob], 'image.gif', { type: 'image/gif' }); const baseContent = { msgtype: MsgType.Image, body: 'image.gif', info: { mimetype: 'image/gif', w, h, size: blob.size }, }; // Mirror the attachment/voice paths: in an encrypted room the media // itself must be encrypted, otherwise the homeserver (and anyone with // the mxc URI) can see the GIF even though the event body is encrypted. if (room.hasEncryptionStateEvent()) { const { encInfo, file: encBlob } = await encryptFile(gifFile); const uploadRes = await mx.uploadContent(encBlob); const mxcUrl = (uploadRes as { content_uri: string }).content_uri; if (!mxcUrl) return; sendRoomMessage(mx, roomId, threadRootId ?? null, { ...baseContent, file: { ...encInfo, url: mxcUrl }, }); } else { const uploadRes = await mx.uploadContent(gifFile, { type: 'image/gif', name: 'image.gif', includeFilename: false, }); const mxcUrl = (uploadRes as { content_uri: string }).content_uri; if (!mxcUrl) return; sendRoomMessage(mx, roomId, threadRootId ?? null, { ...baseContent, url: mxcUrl, }); } } catch (e) { console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error'); if (!alive()) return; setGifError('Failed to send GIF. Please try again.'); setTimeout(() => setGifError(null), 4000); } finally { if (alive()) setGifUploading(false); } }, [mx, room, roomId, threadRootId, alive], ); const handleStickerSelect = useCallback( 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, threadRootId ?? null, EventType.Sticker, { body: label, url: mxc, info, }); }, [mx, roomId, threadRootId, useAuthentication], ); 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. ); } // Mobile "+" overflow: the `after` builder stashes the collapsed secondary // buttons here and the `bottom` slot renders them when the toggle is open. // React evaluates JSX props in source order (before → after → bottom), so // `after` assigns this before `bottom` reads it within the same render. let composerOverflow: ReactNode = null; return (
{selectedFiles.length > 0 && ( setUploadBoard(!uploadBoard)} uploadFamilyObserverAtom={uploadFamilyObserverAtom} onSend={handleSendUpload} imperativeHandlerRef={uploadBoardHandlers} onCancel={handleCancelUpload} /> } > {uploadBoard && ( {Array.from(selectedFiles) .reverse() .map((fileItem, index) => ( ReactEditor.focus(editor)} /> ))} )} )} } 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 && ( )} {showUnverifiedWarning && ( {roomUnverifiedDeviceCount}{' '} {roomUnverifiedDeviceCount === 1 ? 'unverified device' : 'unverified devices'} in this room )} {codeOffer && ( That looks like code. Format it as a code block {codeOffer.lang ? ` (${codeOffer.lang})` : ''}? )} { setCharCount(toPlainText(value, isMarkdown).trim().length); schedulePersistDraft(); }} top={ replyDraft && (
setReplyDraft(undefined)} aria-label="Dismiss reply" variant="SurfaceVariant" size="300" radii="300" > {replyDraft.relation?.rel_type === RelationType.Thread && } {getMemberName(room, replyDraft.userId)} } > {replyDraftEvent && hasReplyMedia(replyDraftEvent) && ( )} {trimReplyFromBody(replyDraft.body)}
) } before={ compact ? ( setMobileToolsOpen((open) => !open)} aria-label="More actions" aria-expanded={mobileToolsOpen} aria-controls="composer-more-actions" variant="SurfaceVariant" size="300" radii="300" style={touchTarget} className={MobileTouchTarget} > ) : ( pickFile('*')} aria-label="Attach file" variant="SurfaceVariant" size="300" radii="300" style={touchTarget} className={MobileTouchTarget} > ) } after={(() => { const formatButton = showFormat ? ( setToolbar(!toolbar)} > ) : null; // Emoji and Sticker share a single EmojiBoard PopOut anchored to the // emoji button, so they are rendered together as one unit. Their // relative order still follows the saved order. const emojiStickerBlock = showEmoji || showSticker ? ( {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => { const stickerBtn = showSticker && !hideStickerBtn ? ( setEmojiBoardTab(EmojiBoardTab.Sticker)} variant="SurfaceVariant" size="300" radii="300" style={touchTarget} className={MobileTouchTarget} > ) : null; const emojiBtn = showEmoji ? ( setEmojiBoardTab(EmojiBoardTab.Emoji)} variant="SurfaceVariant" size="300" radii="300" style={touchTarget} className={MobileTouchTarget} > ) : null; const emojiFirst = composerButtonOrder.indexOf('showEmoji') < composerButtonOrder.indexOf('showSticker'); return ( { setEmojiBoardTab((t) => { if (t) { if (!mobileOrTablet()) ReactEditor.focus(editor); return undefined; } return t; }); }} /> } > {emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]} ); }} ) : null; const gifButton = !!gifApiKey && showGif ? ( {(gifOpen: boolean, setGifOpen) => ( setGifOpen(false)} /> } > !gifUploading && setGifOpen(!gifOpen)} variant="SurfaceVariant" size="300" radii="300" disabled={gifUploading} style={touchTarget} className={MobileTouchTarget} > {/* Sized like a default Icon (1.5rem) so the button matches its 32px neighbours instead of a 28×19 stub. */} {gifUploading ? ( ) : ( GIF )} )} ) : null; const locationButton = showLocation ? ( {locating ? ( ) : ( )} ) : null; const pollButton = showPoll ? ( setPollOpen(true)} aria-label="Create poll" variant="SurfaceVariant" size="300" radii="300" title="Create poll" style={touchTarget} className={MobileTouchTarget} > ) : null; const voiceButton = showVoice ? ( { setLocationError(err); setTimeout(() => setLocationError(null), 4000); }} /> ) : null; const scheduleButton = showSchedule ? ( ) : null; const orderedButtons: ReactNode[] = []; let emojiStickerRendered = false; composerButtonOrder.forEach((key: ComposerToolbarButtonKey) => { switch (key) { case 'showFormat': if (formatButton) orderedButtons.push(formatButton); break; case 'showEmoji': case 'showSticker': // Rendered once as a combined unit at whichever of the two // keys comes first in the order. if (!emojiStickerRendered) { emojiStickerRendered = true; if (emojiStickerBlock) orderedButtons.push(emojiStickerBlock); } break; case 'showGif': if (gifButton) orderedButtons.push(gifButton); break; case 'showLocation': if (locationButton) orderedButtons.push(locationButton); break; case 'showPoll': if (pollButton) orderedButtons.push(pollButton); break; case 'showVoice': if (voiceButton) orderedButtons.push(voiceButton); break; case 'showSchedule': if (scheduleButton) orderedButtons.push(scheduleButton); break; default: break; } }); // Compact: keep only emoji/sticker inline beside Send; the rest move // into the "+" overflow row (rendered via `bottom`), led by the attach // button that `before` gives up on, and trailed by the draft label so // the inline row stays [ emoji | count | send ]. Wide viewports render // everything inline. const emojiInline = orderedButtons.filter( (node) => React.isValidElement(node) && node.key === 'showEmojiSticker', ); const overflowButtons = orderedButtons.filter( (node) => !(React.isValidElement(node) && node.key === 'showEmojiSticker'), ); if (compact) { composerOverflow = ( <> pickFile('*')} aria-label="Attach file" variant="SurfaceVariant" size="300" radii="300" style={touchTarget} className={MobileTouchTarget} > {overflowButtons} ); } return ( <> {compact ? emojiInline : orderedButtons} {gifError && ( {gifError} )} {locationError && ( {locationError} )} {!compact && } {charCount > 0 && ( {charCount} )} ); })()} bottom={ <> {compact && mobileToolsOpen && composerOverflow && (
{composerOverflow}
)} {toolbar && (
)} } /> {pollOpen && ( setPollOpen(false)} /> )} {scheduleOpen && ( { setScheduleOpen(false); setScheduleContent(null); }} /> )}
); }, );