import React, { KeyboardEventHandler, MouseEvent as ReactMouseEvent, RefObject, forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai'; import { isKeyHotkey } from 'is-hotkey'; import FocusTrap from 'focus-trap-react'; import { useTranslation } from 'react-i18next'; import { EventStatus, EventType, IContent, IMentions, MatrixEvent, MsgType, RelationType, Room, RoomEvent, } from 'matrix-js-sdk'; import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types'; import { ReactEditor } from 'slate-react'; import { Transforms, Editor, Descendant } from 'slate'; import { Box, Dialog, Icon, IconButton, Icons, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Text, color, config, toRem, } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { CustomEditor, toMatrixCustomHTML, toPlainText, AUTOCOMPLETE_PREFIXES, AutocompletePrefix, AutocompleteQuery, getAutocompleteQuery, getPrevWorldRange, resetEditor, RoomMentionAutocomplete, UserMentionAutocomplete, EmoticonAutocomplete, createEmoticonElement, moveCursor, resetEditorHistory, customHtmlEqualsPlainText, trimCustomHtml, isEmptyEditor, getBeginCommand, trimCommand, getMentions, htmlToEditorInput, plainToEditorInput, } from '../../components/editor'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { UseStateProvider } from '../../components/UseStateProvider'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart, mxcUrlToHttp, } from '../../utils/matrix'; import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater'; import { useAlive } from '../../hooks/useAlive'; import { useFilePicker } from '../../hooks/useFilePicker'; import { useFilePasteHandler } from '../../hooks/useFilePasteHandler'; import { useFileDropZone } from '../../hooks/useFileDrop'; import { TUploadItem, TUploadMetadata, draftKey, roomIdToEditDraftAtomFamily, roomIdToMsgDraftAtomFamily, roomIdToReplyDraftAtomFamily, roomIdToUploadItemsAtomFamily, roomUploadAtomFamily, } from '../../state/room/roomInputDrafts'; import { ComposerAttachments } from './ComposerAttachments'; import { Upload, UploadStatus, UploadSuccess, createUploadFamilyObserverAtom, } from '../../state/upload'; import { getImageUrlBlob, loadImageElement } from '../../utils/dom'; import { stopPropagation } from '../../utils/keyboard'; 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, getVoiceMsgContent, } from './msgContent'; import { VoiceRecording, VoiceRecordingResult } from '../../utils/voiceRecording'; import { VoiceRecorder } from './VoiceRecorderForm'; import { useStateEvent } from '../../hooks/useStateEvent'; import { StateEvent } from '../../../types/matrix/room'; import { getEditedEvent, getMemberDisplayName, getMentionContent, trimReplyFromBody, trimReplyFromFormattedBody, } from '../../utils/room'; import { CommandAutocomplete } from './CommandAutocomplete'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; import { mobileOrTablet } from '../../utils/user-agent'; 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 { useIsOneOnOne } 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 { pendingShareAtom } from '../../state/pendingShare'; // Placeholder rotation set — 13 i18n keys (1 default + 12 alternates) under // the `Room` namespace; the hour-of-day slot picks one. The two-beat joke is // split across two entries on purpose (alt_8 "Вы смотрите на плейсхолдер…" / // alt_12 "Плейсхолдер смотрит на вас…"): rotation surfaces one half at one // hour and the other half at another, so the gag plays out across the day // instead of overflowing one line. Every entry is kept short enough to fit // the composer on narrow screens without truncation. With the variants // spread over 24 hours each is stable within an hour and a user is most // likely to notice the change when they navigate to another room across an // hour boundary. Keep this list in sync with `public/locales/{en,ru}.json` // `Room.send_message*` entries. const COMPOSER_PLACEHOLDER_KEYS = [ 'Room.send_message', 'Room.send_message_alt_1', 'Room.send_message_alt_2', 'Room.send_message_alt_3', 'Room.send_message_alt_4', 'Room.send_message_alt_5', 'Room.send_message_alt_6', 'Room.send_message_alt_7', 'Room.send_message_alt_8', 'Room.send_message_alt_9', 'Room.send_message_alt_10', 'Room.send_message_alt_11', 'Room.send_message_alt_12', ] as const; // Composer action-row icons — stroke-based outline style from the Dawn // canon (docs/design/new-direct-messages-design/project/shared.jsx line // 3-22). folds Icon wraps these in ``, // so each IconSrc returns just the path/shape elements. Matches the // Gemini-style flat composer aesthetic: thin 1.6-1.8 strokes, round caps, // no fills. const StreamComposerIcons = { Plus: () => ( ), Smile: () => ( <> ), Send: () => ( <> ), Mic: () => ( <> ), }; interface RoomInputProps { editor: Editor; fileDropContainerRef: RefObject; roomId: string; room: Room; // M2: when set, all sendMessage / sendEvent paths emit with this // threadId so the SDK attaches the m.thread relation. Channel and DM // composers leave this undefined so messages land in the main timeline // unchanged. The drawer composer passes threadId={rootId}. threadId?: string; // Optional: fired with the real server event id once a text message send // resolves. The AI bot's "new chat" composer uses it to navigate into the // freshly-rooted thread (the just-sent top-level event IS the thread root). // Channel / DM / thread composers omit it and stay fire-and-forget. onSend?: (eventId: string) => void; // Text-only mode: hides the file/sticker affordances and no-ops file // paste/drop. The AI "new chat" landing uses it because only a text send // navigates into the freshly-rooted thread (onSend) — a sticker/file first // move would otherwise strand the user on the landing. textOnly?: boolean; // Single-row layout: the action buttons sit INLINE beside the textarea (plus // on the left, mic/emoji/send on the right) instead of on a second row below // it, so the composer is one short line. Used by the AI-bot chat. Default // (false) keeps the two-row strip everywhere else. singleRow?: boolean; } export const RoomInput = forwardRef( ({ editor, fileDropContainerRef, roomId, room, threadId, onSend, textOnly, singleRow }, ref) => { const { t } = useTranslation(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const isOneOnOne = useIsOneOnOne(); const commands = useCommands(mx, room); // Voice messages — per-room "allowed" preference (default-on) + recorder // state. `voiceDisabledBy` is set (mxid, possibly empty) when the room has // `enabled: false`; undefined ⇒ allowed. See docs/plans/voice_messages.md. const voiceMsgEvent = useStateEvent(room, StateEvent.VoiceMessages); const voiceMsgContent = voiceMsgEvent?.getContent<{ enabled?: boolean; disabled_by?: string; }>(); const voiceDisabledBy = voiceMsgContent?.enabled === false ? voiceMsgContent.disabled_by ?? '' : undefined; const voiceSupported = useMemo(() => VoiceRecording.isSupported(), []); const [voiceMode, setVoiceMode] = useState(false); const [voiceError, setVoiceError] = useState(null); // Drop the stale "voice disabled" banner once the room re-enables voice. useEffect(() => { if (voiceDisabledBy === undefined) setVoiceError(null); }, [voiceDisabledBy]); const emojiBtnRef = useRef(null); const screenSize = useScreenSizeContext(); // On native / narrow screens the emoji-sticker board opens as a CENTERED // overlay window — the same presentation the message rail's reaction // picker uses (an anchored PopOut drifts off the small viewport, and the // old in-composer dock crowded the input). Desktop keeps the anchored // pop-out by the emoji button. const centerEmojiBoard = mobileOrTablet() || screenSize === ScreenSize.Mobile; const [emojiBoardTab, setEmojiBoardTab] = useState(undefined); // Crossing the center/pop-out breakpoint remounts the trigger; drop any // open state so the board doesn't silently re-open after a resize // round-trip. useEffect(() => { setEmojiBoardTab(undefined); }, [centerEmojiBoard]); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); // Per-(room, thread) draft scope: drawer composer (threadId={rootId}) // and channel/DM composer (threadId=undefined → 'main') no longer // clobber each other's drafts. See `roomInputDrafts.ts::DraftKey`. const inputDraftKey = draftKey(roomId, threadId); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(inputDraftKey)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(inputDraftKey)); const [editDraft, setEditDraft] = useAtom(roomIdToEditDraftAtomFamily(inputDraftKey)); 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 = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor; const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(inputDraftKey)); // Memoised on the file list — a fresh derived atom every render would // churn submit()'s identity (it sits in submit's deps) and with it every // downstream useCallback. const uploadFamilyObserverAtom = useMemo( () => createUploadFamilyObserverAtom( roomUploadAtomFamily, selectedFiles.map((f) => f.file) ), [selectedFiles] ); // Imperative store handle — submit() reads the live upload states once // at send time instead of subscribing the whole composer to every // (throttled) progress tick. The attachment strip's per-item components // are the only progress subscribers. const jotaiStore = useStore(); // Re-entrancy guard for the attachment send — mirrors the old // UploadBoardHeader's sendingRef. const sendingAttachmentsRef = useRef(false); const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents); const [autocompleteQuery, setAutocompleteQuery] = useState>(); // Suppress typing notifications from the drawer composer — see // useTypingStatusUpdater for the spec-level reason (m.typing has // no thread_id, so a thread-composer typing event would surface // in the main channel chat as if the user were typing there). const sendTypingStatus = useTypingStatusUpdater(mx, roomId, !!threadId); const alive = useAlive(); const handleFiles = useCallback( async (files: File[]) => { // Text-only composer (AI new-chat landing) ignores every file vector — // picker, paste and drop all funnel through here. if (textOnly) return; 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, textOnly] ); const pickFile = useFilePicker(handleFiles, true); const handlePaste = useFilePasteHandler(handleFiles); const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles); const isComposing = useComposingCheck(); // Subscribe write-side only: the value is read inside the effect below // via the snapshot the effect captures from its closure deps. const pendingShare = useAtomValue(pendingShareAtom); const setPendingShare = useSetAtom(pendingShareAtom); useEffect(() => { Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); // ── Edit-in-composer (Telegram-style) ───────────────────────────── // «Edit» on an own message sets the per-scope edit draft; this block // loads the message body into THIS composer, shows the banner (in the // `top` slot below) and routes submit() through the m.replace path. // The user's unsent draft is stashed on entry and restored when the // edit ends (cancel or save) — same behaviour as Telegram. // Reads the target's CURRENT body (following any prior m.replace // aggregation) — mirror of the old MessageEditor's // getPrevBodyAndFormattedBody. const getEditTargetContent = useCallback( ( eventId: string ): { body: string | undefined; customHtml: string | undefined; mentions: IMentions | undefined; msgtype: string | undefined; threadRootId: string | undefined; } => { const mEvent = room.findEventById(eventId); if (!mEvent) { return { body: undefined, customHtml: undefined, mentions: undefined, msgtype: undefined, threadRootId: undefined, }; } const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet()); const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent(); const { body, formatted_body: customHtml }: Record = content; return { body: typeof body === 'string' ? body : undefined, customHtml: typeof customHtml === 'string' ? customHtml : undefined, mentions: content['m.mentions'], msgtype: mEvent.getContent().msgtype, threadRootId: mEvent.threadRootId, }; }, [room] ); // Unsent text stashed while the editor hosts the edit body. const preEditStashRef = useRef(null); // A failed m.replace send re-enters edit mode with the USER'S edited // text (set by submitEdit's .catch, consumed by the edit-load effect) // instead of re-loading the target's old body — the edit banner // re-opening with their text intact IS the failure feedback. const failedEditRef = useRef<{ eventId: string; children: Descendant[] } | null>(null); // Tracks which eventId the editor currently holds, so re-renders with // the same draft don't re-load, while retargeting (edit A → edit B) // and exit both do. const loadedEditIdRef = useRef(); useEffect(() => { const evtId = editDraft?.eventId; if (evtId === loadedEditIdRef.current) return; const wasEditing = loadedEditIdRef.current !== undefined; loadedEditIdRef.current = evtId; if (!evtId) { // Edit ended (cancel / save / external clear) — restore the stash. resetEditor(editor); resetEditorHistory(editor); const stash = preEditStashRef.current; preEditStashRef.current = null; if (stash && stash.length > 0) { Transforms.insertFragment(editor, stash); } if (!mobileOrTablet()) ReactEditor.focus(editor); return; } // Entering edit (stash once) or retargeting (keep the original stash). if (!wasEditing && !isEmptyEditor(editor)) { preEditStashRef.current = JSON.parse(JSON.stringify(editor.children)); } const failed = failedEditRef.current; failedEditRef.current = null; const initialValue = failed && failed.eventId === evtId ? failed.children : (() => { const { body, customHtml } = getEditTargetContent(evtId); return typeof customHtml === 'string' ? htmlToEditorInput(customHtml, isMarkdown) : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown); })(); resetEditor(editor); resetEditorHistory(editor); Transforms.insertFragment(editor, initialValue); if (!mobileOrTablet()) ReactEditor.focus(editor); // isMarkdown intentionally NOT a dep — toggling the setting mid-edit // must not blow away the user's in-progress changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [editDraft, editor, getEditTargetContent]); const editDraftRef = useRef(editDraft); editDraftRef.current = editDraft; // Editing a still-sending message: the draft stores the local-echo // '~txn' id, and the remote echo DELETES that id from the timeline set // (EventTimelineSet.replaceEventId) — without retargeting, submit would // build an m.replace against a dead id (and the SDK throws on a '~' // relation under Chronological pending ordering). Follow the swap and // keep the user's in-progress text (bump loadedEditIdRef FIRST so the // edit-load effect doesn't reload the body over their changes). useEffect(() => { const onLocalEchoUpdated = (event: MatrixEvent, _room: Room, oldEventId?: string): void => { const draft = editDraftRef.current; if (!draft) return; // The edit TARGET failed to send: an m.replace can never apply to // an event that never reached the server, so Save would stay a // silent no-op forever ('~' guard in submitEdit). Cancel the edit // (restoring the stash) — the failed message keeps its own // retry/delete affordances in the timeline. if (event.status === EventStatus.NOT_SENT && event.getId() === draft.eventId) { setEditDraft(undefined); return; } if (!oldEventId || oldEventId !== draft.eventId) return; const newId = event.getId(); if (!newId || newId === oldEventId) return; loadedEditIdRef.current = newId; setEditDraft({ eventId: newId }); }; room.on(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated); return () => { room.off(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated); }; }, [room, setEditDraft]); const editTarget = editDraft ? getEditTargetContent(editDraft.eventId) : undefined; // Drain the global share-target hand-off into THIS chat. The system // share-sheet flow doesn't open a picker — it just lights up the // ShareTargetStrip banner and lets the user pick a chat by navigating // normally. The first RoomInput that mounts (or, if the user was // already inside a chat when the share arrived, the current RoomInput // re-running this effect with a non-null `pendingShare`) consumes the // payload: files into the composer's attachment strip, text into the // editor. The user can still bail by tapping the [×] on each // attachment before pressing Send. // // Declared AFTER the msgDraft restore so the share text appends to // any saved draft instead of getting overwritten by it. // // Thread composers (threadId set) deliberately skip — sharing into a // thread isn't a flow users ask for, and would silently consume the // share leaving the main composer empty. // // Edit mode also defers: the editor currently holds the EDIT body, and // inserting the shared text there would pollute the m.replace (or lose // the share on cancel). The payload stays in pendingShareAtom (the // strip banner stays up); `editDraft` in the deps re-runs the drain // when the edit ends — by then the edit-end effect above (declared // earlier, runs first in the same commit) has restored the user's // stashed draft, so the share appends to their real draft as designed. useEffect(() => { if (threadId) return; if (editDraft) return; if (!pendingShare) return; // Clear first so a re-render mid-handleFiles can't queue another // run for the same payload. const consumed = pendingShare; setPendingShare(null); if (consumed.files.length > 0) { handleFiles(consumed.files); } const { text } = consumed; if (text) { Transforms.insertText(editor, text); } }, [threadId, editDraft, pendingShare, setPendingShare, handleFiles, editor]); useEffect( () => () => { if (loadedEditIdRef.current !== undefined) { // Unmounted mid-edit (navigation, thread drawer opening over the // main composer): drop the edit — the editor holds the EDIT body, // which must not be saved as the user's message draft. Persist // the pre-edit stash instead so their unsent text survives. setEditDraft(undefined); loadedEditIdRef.current = undefined; const stash = preEditStashRef.current; preEditStashRef.current = null; setMsgDraft(stash && stash.length > 0 ? stash : []); } else if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); } else { setMsgDraft([]); } resetEditor(editor); resetEditorHistory(editor); }, // `threadId` included so a future caller passing dynamic // threadId without remount still routes the cleanup-on-unmount // setter to the correct atom. Today the drawer is fully // remounted on rootId change via Room.tsx key, but the dep // pins the invariant explicitly. [roomId, threadId, editor, setMsgDraft, setEditDraft] ); 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] ); // useCallback (not a plain function) — submit() depends on it. 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'); 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); }); // Settle the content builds BEFORE clearing the strip: an item // whose build failed (image decode, thumbnailing) stays visible // for another Send instead of silently vanishing unsent. const settled = await Promise.allSettled(contentsPromises); const builtUploads = uploads.filter((_, i) => settled[i].status === 'fulfilled'); handleCancelUpload(builtUploads); const contents = fulfilledPromiseSettledResult(settled); contents.forEach((content) => mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent) ); }, [mx, roomId, threadId, selectedFiles, handleCancelUpload] ); const voiceBlockedName = useCallback( () => (voiceDisabledBy && (getMemberDisplayName(room, voiceDisabledBy) ?? getMxIdLocalPart(voiceDisabledBy))) || undefined, [voiceDisabledBy, room] ); // Open the voice recorder form (it replaces the text input). Blocked when // the room has voice disabled — surface the inline error instead. Idempotent // so pressing the mic (pointerdown to start recording) and the following // click don't double-open. Starting the recorder here ties getUserMedia / // AudioContext.resume to the user gesture. const handleOpenVoice = useCallback(() => { if (textOnly || voiceMode) return; if (voiceDisabledBy !== undefined) { const name = voiceBlockedName(); setVoiceError(name ? t('Room.voice_disabled', { name }) : t('Room.voice_disabled_generic')); return; } setVoiceError(null); setVoiceMode(true); }, [textOnly, voiceMode, voiceDisabledBy, voiceBlockedName, t]); const handleCloseVoice = useCallback(() => { setVoiceMode(false); }, []); // Encrypt (when E2EE) + upload + send a finished recording. Called by the // recorder's Send action. The recorder unmounts (voiceMode → false) which // stops the preview + releases the mic. See docs/plans/voice_messages.md. const handleVoiceSend = useCallback( async (result: VoiceRecordingResult) => { setVoiceMode(false); // Too short — an accidental tap; drop silently. if (result.durationMs < 500) return; // A real recording that produced no audio (encode failed / timed out) — // surface it instead of silently losing the message. if (result.blob.size === 0) { setVoiceError(t('Room.voice_send_error')); return; } // Re-check the gate at send time — state may have changed mid-recording. if (voiceDisabledBy !== undefined) { const name = voiceBlockedName(); setVoiceError( name ? t('Room.voice_disabled', { name }) : t('Room.voice_disabled_generic') ); return; } try { const file = safeFile( new File([result.blob], 'Voice message.ogg', { type: 'audio/ogg' }) ); let item: TUploadItem; if (room.hasEncryptionStateEvent()) { const enc = await encryptFile(file); item = { ...enc, metadata: { markedAsSpoiler: false } }; } else { item = { file, originalFile: file, encInfo: undefined, metadata: { markedAsSpoiler: false }, }; } const data = await mx.uploadContent(item.file); const mxc = data?.content_uri; if (!mxc) throw new Error('Failed to upload voice message'); const content = getVoiceMsgContent(item, mxc, result.durationMs, result.waveform); await mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent); } catch { setVoiceError(t('Room.voice_send_error')); } }, [voiceDisabledBy, voiceBlockedName, room, mx, roomId, threadId, t] ); // Edit submit — builds the m.replace content the way the old // MessageEditor did: `m.new_content` carries the real body, the // top-level body gets the `* ` fallback prefix, mentions merge the // previous revision's users. No-op edits (unchanged / emptied text) // just exit edit mode. threadRootId comes from the TARGET event so a // thread message edited from the drawer keeps its thread relation // (SDK sets the local-echo `.thread` pointer; m.replace itself owns // `m.relates_to`, spec-correct — see the old MessageEditor notes). const submitEdit = useCallback(() => { if (!editDraft) return; const { eventId } = editDraft; // Still-sending local echo: a '~txn' relation makes the SDK throw // synchronously (getPendingEvents under Chronological ordering) — // stay in edit mode and wait for the LocalEchoUpdated retarget above // to swap in the real id (usually a beat later). if (eventId.startsWith('~')) return; // Target vanished (redacted / not in the timeline set any more): // there is nothing to replace — cancel the edit, restoring the stash. if (!room.findEventById(eventId)) { setEditDraft(undefined); return; } const target = getEditTargetContent(eventId); const plainText = toPlainText(editor.children, isMarkdown).trim(); const customHtml = trimCustomHtml( toMatrixCustomHTML(editor.children, { allowTextFormatting: true, allowBlockMarkdown: isMarkdown, allowInlineMarkdown: isMarkdown, }) ); const exitEdit = () => { setEditDraft(undefined); sendTypingStatus(false); }; if (plainText === '') { // Emptied-out edit: do nothing (deleting is the Delete action's job). exitEdit(); return; } if (target.body) { const unchangedHtml = target.customHtml && trimReplyFromFormattedBody(target.customHtml) === customHtml; const unchangedPlain = !target.customHtml && target.body === plainText && customHtmlEqualsPlainText(customHtml, plainText); if (unchangedHtml || unchangedPlain) { exitEdit(); return; } } const newContent: IContent = { msgtype: target.msgtype, body: plainText, }; // Spec (event replacements / m.mentions): `m.new_content` carries the // FULL resolved mention set of the revision, while the TOP-LEVEL // m.mentions must list only the mentions ADDED by this edit — anyone // already mentioned was pinged by the original and must not be // re-notified on every revision. const mentionData = getMentions(mx, roomId, editor); const prevUserIds = new Set(target.mentions?.user_ids ?? []); const prevRoom = target.mentions?.room === true; const addedUsers = Array.from(mentionData.users).filter((u) => !prevUserIds.has(u)); const fullUsers = new Set(mentionData.users); prevUserIds.forEach((u) => fullUsers.add(u)); newContent['m.mentions'] = getMentionContent( Array.from(fullUsers), mentionData.room || prevRoom ); if (!customHtmlEqualsPlainText(customHtml, plainText)) { newContent.format = 'org.matrix.custom.html'; newContent.formatted_body = customHtml; } const content: IContent = { ...newContent, 'm.mentions': getMentionContent(addedUsers, mentionData.room && !prevRoom), body: `* ${plainText}`, 'm.new_content': newContent, 'm.relates_to': { event_id: eventId, rel_type: RelationType.Replace, }, }; // The `* ` fallback prefix applies to the formatted fallback too // (mirrors element-web's edit content shape). if (typeof newContent.formatted_body === 'string') { content.formatted_body = `* ${newContent.formatted_body}`; } // Optimistic exit (Telegram-style: banner closes immediately). The // failure leg re-enters edit mode with the user's text via // failedEditRef — unless they have already started another edit by // the time the send settles, or this composer is long gone. const editedChildren: Descendant[] = JSON.parse(JSON.stringify(editor.children)); mx.sendMessage(roomId, target.threadRootId ?? null, content as RoomMessageEventContent).catch( () => { if (!alive()) return; if (editDraftRef.current) return; failedEditRef.current = { eventId, children: editedChildren }; setEditDraft({ eventId }); } ); exitEdit(); }, [ mx, room, roomId, editor, editDraft, getEditTargetContent, isMarkdown, setEditDraft, sendTypingStatus, alive, ]); const submit = useCallback(() => { // Edit mode owns the send path entirely — uploads/replies/commands // don't apply to an m.replace. if (editDraft) { submitEdit(); return; } // Ship every FINISHED upload from the attachment strip. Still-running // and failed ones stay in the strip (the user retries or sends again // once they finish) — same semantics the old UploadBoard's Send had. // Read the live states from the store so the composer doesn't // subscribe to per-tick progress. // // The text send below CHAINS on the drain: handleSendUpload builds // media contents asynchronously, and queueing the text in this tick // would land the caption ABOVE the photos for everyone. const uploads = jotaiStore.get(uploadFamilyObserverAtom); const readyUploads = uploads.filter( (upload): upload is UploadSuccess => upload.status === UploadStatus.Success ); let attachmentsDrain: Promise = Promise.resolve(); if (readyUploads.length > 0 && !sendingAttachmentsRef.current) { sendingAttachmentsRef.current = true; attachmentsDrain = handleSendUpload(readyUploads) .catch(() => undefined) .finally(() => { sendingAttachmentsRef.current = false; }); } 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; } } // Queue the text AFTER the attachment drain so a caption lands below // its media. The composer resets immediately regardless — the user's // send gesture already happened. const pending = attachmentsDrain.then(() => mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent) ); if (onSend) { pending.then((res) => onSend(res.event_id)).catch(() => undefined); } resetEditor(editor); resetEditorHistory(editor); setReplyDraft(undefined); sendTypingStatus(false); }, [ mx, roomId, threadId, onSend, editor, replyDraft, editDraft, submitEdit, sendTypingStatus, setReplyDraft, isMarkdown, commands, jotaiStore, uploadFamilyObserverAtom, handleSendUpload, ]); 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; } // Edit first: Esc cancels the edit (restoring the stashed draft); // a second Esc then clears the reply preview as before. if (editDraft) { setEditDraft(undefined); return; } setReplyDraft(undefined); } }, [ submit, setReplyDraft, editDraft, setEditDraft, 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 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, threadId ?? null, EventType.Sticker, { body: label, url: mxc, info, }); }; // Action-row buttons extracted into JSX consts so the bottom-slot // markup stays readable. Single source for each button across whatever // layout the composer evolves into; today the layout is a two-row // strip — textarea on top, this trio below — on every platform. const plusButton = ( pickFile('*')} variant="SurfaceVariant" fill="None" size="300" radii="300" > ); // Voice-record trigger — sits left of the emoji button. A tap opens the // recorder (which morphs the input into the audio form). Hidden when the // engine can't record or voice is disabled for the room. const micButton = ( ); // Mobile / native: the board opens as a centered overlay WINDOW — same // presentation as the message rail's reaction picker — so its open // state lives here. Desktop keeps its state isolated inside the // UseStateProvider below so opening the pop-out doesn't re-render the // whole composer. const centeredEmojiBoard = centerEmojiBoard && emojiBoardTab !== undefined && ( }> setEmojiBoardTab(undefined), escapeDeactivates: stopPropagation, }} > setEmojiBoardTab(undefined)} /> ); const emojiButton = centerEmojiBoard ? ( setEmojiBoardTab(emojiBoardTab ? undefined : EmojiBoardTab.Emoji)} variant="SurfaceVariant" fill="None" size="300" radii="300" > ) : ( {(emojiTab: EmojiBoardTab | undefined, setEmojiTab) => ( { setEmojiTab((tab) => { if (tab) { if (!mobileOrTablet()) ReactEditor.focus(editor); return undefined; } return tab; }); }} /> } > setEmojiTab(EmojiBoardTab.Emoji)} variant="SurfaceVariant" fill="None" size="300" radii="300" > )} ); const sendButton = ( ) => { submit(); // Defense-in-depth against the Android WebView's synthetic // :hover/:focus-visible persistence (the CSS gate in // RoomView.css.ts handles the same class of bug, but // explicitly blurring the tap target also clears any // lingering :focus state regardless of input-mode // attribution). evt.currentTarget.blur(); }} variant="SurfaceVariant" fill="None" size="300" radii="300" > ); // Hour-of-day picks the placeholder variant. Memoised against // (roomId, threadId) so the placeholder stays stable while the user is // in one chat/thread and only re-rolls when they navigate elsewhere — // and even then it's stable within the same wall-clock hour, so the // change is most likely to surface to the user when they cross an // hour boundary AND switch rooms (or reload the app). That cadence // keeps the joke surprising without flipping mid-session. const placeholderKey = useMemo(() => { const idx = new Date().getHours() % COMPOSER_PLACEHOLDER_KEYS.length; return COMPOSER_PLACEHOLDER_KEYS[idx]; // roomId and threadId are intentional re-roll triggers (re-memoize on // chat/thread navigation), not values read inside the body. // eslint-disable-next-line react-hooks/exhaustive-deps }, [roomId, threadId]); return (
} style={{ pointerEvents: 'none' }} > {t('Room.drop_files', { name: room?.name || 'Room' })} {t('Room.drag_drop_desc')} {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.Command && ( )} ) : undefined } top={ <> {centeredEmojiBoard} {/* Attachment strip — the in-form record of attached media (replaces the old floating UploadBoard window). Visible in edit mode too: the m.replace send ignores it, and the attachments are still there when the edit ends. */} {voiceError && ( setVoiceError(null)} variant="SurfaceVariant" size="300" radii="300" aria-label={t('Room.voice_dismiss_error')} > {voiceError} )} {/* Edit banner — Telegram-style: pencil + «Editing» + the original text, with [×] to cancel (Esc does the same). Same chrome slot/structure as the reply preview below. */} {editDraft && (
setEditDraft(undefined)} variant="SurfaceVariant" size="300" radii="300" aria-label={t('Room.editing_cancel')} > {t('Room.editing_message')} } > {editTarget?.body ? trimReplyFromBody(editTarget.body) : ''}
)} {/* Reply preview hides while editing (the edit owns the send); the reply draft itself is preserved and returns after. */} {replyDraft && !editDraft && (
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)}
)} } // Single-row (AI-bot chat): the + sits inline to the LEFT of the // textarea; voiceMode replaces the whole editable row so this is // ignored while recording. before={singleRow && !voiceMode && !textOnly ? plusButton : undefined} // Single-row: mic / emoji / send sit inline to the RIGHT of the // textarea, collapsing the old second action row. after={ singleRow && !voiceMode ? ( <> {/* Mic hides while editing — a recording would ship a NEW message under a pending edit banner. */} {!textOnly && !editDraft && voiceSupported && voiceDisabledBy === undefined && micButton} {!textOnly && emojiButton} {sendButton} ) : undefined } bottom={ singleRow || voiceMode ? null : ( {!textOnly && plusButton} {/* Mic hides while editing — a recording would ship a NEW message under a pending edit banner. */} {!textOnly && !editDraft && voiceSupported && voiceDisabledBy === undefined && micButton} {!textOnly && emojiButton} {sendButton} ) } />
); } );