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>
    );
  }
);
