vojo/src/app/features/room/RoomInput.tsx
2026-06-13 12:50:55 +03:00

1495 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 `<svg viewBox="0 0 24 24" fill="none">`,
// 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: () => (
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
),
Smile: () => (
<>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<path
d="M8 14s1.5 2 4 2 4-2 4-2M9 10h.01M15 10h.01"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</>
),
Send: () => (
<>
<path
d="M22 2L11 13"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M22 2L15 22L11 13L2 9L22 2Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
),
Mic: () => (
<>
<rect x="9" y="2" width="6" height="11" rx="3" stroke="currentColor" strokeWidth="1.6" />
<path
d="M5 11a7 7 0 0 0 14 0M12 18v3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
),
};
interface RoomInputProps {
editor: Editor;
fileDropContainerRef: RefObject<HTMLElement>;
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<HTMLDivElement, RoomInputProps>(
({ 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<string | null>(null);
// Drop the stale "voice disabled" banner once the room re-enables voice.
useEffect(() => {
if (voiceDisabledBy === undefined) setVoiceError(null);
}, [voiceDisabledBy]);
const emojiBtnRef = useRef<HTMLButtonElement>(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<EmojiBoardTab | undefined>(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<AutocompleteQuery<AutocompletePrefix>>();
// 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<string, unknown> = 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<Descendant[] | null>(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<string>();
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<unknown> = 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<AutocompletePrefix>(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 = (
<IconButton
onClick={() => pickFile('*')}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Plus} />
</IconButton>
);
// 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 = (
<IconButton
onClick={handleOpenVoice}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
aria-label={t('Room.voice_record')}
>
<Icon src={StreamComposerIcons.Mic} />
</IconButton>
);
// 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 && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: () => setEmojiBoardTab(undefined),
escapeDeactivates: stopPropagation,
}}
>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => setEmojiBoardTab(undefined)}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
const emojiButton = centerEmojiBoard ? (
<IconButton
ref={emojiBtnRef}
aria-pressed={!!emojiBoardTab}
onClick={() => setEmojiBoardTab(emojiBoardTab ? undefined : EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Smile} />
</IconButton>
) : (
<UseStateProvider initial={undefined}>
{(emojiTab: EmojiBoardTab | undefined, setEmojiTab) => (
<PopOut
offset={16}
alignOffset={-44}
position="Top"
align="End"
anchor={
emojiTab === undefined
? undefined
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
}
content={
<EmojiBoard
tab={emojiTab ?? EmojiBoardTab.Emoji}
onTabChange={setEmojiTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiTab((tab) => {
if (tab) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return tab;
});
}}
/>
}
>
<IconButton
ref={emojiBtnRef}
aria-pressed={!!emojiTab}
onClick={() => setEmojiTab(EmojiBoardTab.Emoji)}
variant="SurfaceVariant"
fill="None"
size="300"
radii="300"
>
<Icon src={StreamComposerIcons.Smile} />
</IconButton>
</PopOut>
)}
</UseStateProvider>
);
const sendButton = (
<IconButton
onClick={(evt: ReactMouseEvent<HTMLButtonElement>) => {
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"
>
<Icon src={StreamComposerIcons.Send} />
</IconButton>
);
// 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 (
<div ref={ref}>
<Overlay
open={dropZoneVisible && !textOnly}
backdrop={<OverlayBackdrop />}
style={{ pointerEvents: 'none' }}
>
<OverlayCenter>
<Dialog variant="Primary">
<Box
direction="Column"
justifyContent="Center"
alignItems="Center"
gap="500"
style={{ padding: toRem(60) }}
>
<Icon size="600" src={Icons.File} />
<Text size="H4" align="Center">
{t('Room.drop_files', { name: room?.name || 'Room' })}
</Text>
<Text align="Center">{t('Room.drag_drop_desc')}</Text>
</Box>
</Dialog>
</OverlayCenter>
</Overlay>
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
<RoomMentionAutocomplete
roomId={roomId}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
<UserMentionAutocomplete
room={room}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
<EmoticonAutocomplete
imagePackRooms={imagePackRooms}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
<CommandAutocomplete
room={room}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
<CustomEditor
editableName="RoomInput"
editor={editor}
placeholder={t(placeholderKey)}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
replaceEditable={
voiceMode ? (
<VoiceRecorder onSend={handleVoiceSend} onClose={handleCloseVoice} />
) : 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. */}
<ComposerAttachments
selectedFiles={selectedFiles}
setMetadata={handleFileMetadata}
onRemove={handleRemoveUpload}
/>
{voiceError && (
<Box
alignItems="Center"
gap="200"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => setVoiceError(null)}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={t('Room.voice_dismiss_error')}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Text size="T300" style={{ color: color.Critical.Main }}>
{voiceError}
</Text>
</Box>
)}
{/* 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 && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => setEditDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={t('Room.editing_cancel')}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
<Icon
src={Icons.Pencil}
size="50"
style={{ color: color.Primary.Main, flexShrink: 0 }}
/>
<ReplyLayout
userColor={color.Primary.Main}
username={
<Text size="T300" truncate>
<b>{t('Room.editing_message')}</b>
</Text>
}
>
<Text size="T300" truncate>
{editTarget?.body ? trimReplyFromBody(editTarget.body) : ''}
</Text>
</ReplyLayout>
</Box>
</Box>
</div>
)}
{/* Reply preview hides while editing (the edit owns the send);
the reply draft itself is preserved and returns after. */}
{replyDraft && !editDraft && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
</Box>
</Box>
</div>
)}
</>
}
// 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 : (
<Box
alignItems="Center"
gap="200"
style={{ padding: `${toRem(2)} ${toRem(8)} ${toRem(4)}` }}
>
{!textOnly && plusButton}
<Box grow="Yes" />
{/* 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}
</Box>
)
}
/>
</div>
);
}
);