import React, {
  KeyboardEventHandler,
  MouseEvent as ReactMouseEvent,
  RefObject,
  forwardRef,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
import { useTranslation } from 'react-i18next';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types';
import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate';
import {
  Box,
  Dialog,
  Icon,
  IconButton,
  Icons,
  Overlay,
  OverlayBackdrop,
  OverlayCenter,
  PopOut,
  Scroll,
  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,
} 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 { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
import { useFileDropZone } from '../../hooks/useFileDrop';
import {
  TUploadItem,
  TUploadMetadata,
  draftKey,
  roomIdToMsgDraftAtomFamily,
  roomIdToReplyDraftAtomFamily,
  roomIdToUploadItemsAtomFamily,
  roomUploadAtomFamily,
} from '../../state/room/roomInputDrafts';
import { UploadCardRenderer } from '../../components/upload-card';
import {
  UploadBoard,
  UploadBoardContent,
  UploadBoardHeader,
  UploadBoardImperativeHandlers,
} from '../../components/upload-board';
import {
  Upload,
  UploadStatus,
  UploadSuccess,
  createUploadFamilyObserverAtom,
} from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
import { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { 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 { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
import { 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 is docked inline at the
    // top of the composer instead of floating as a pop-out.
    const dockEmojiBoard = mobileOrTablet() || screenSize === ScreenSize.Mobile;
    const [emojiBoardTab, setEmojiBoardTab] = useState<EmojiBoardTab | undefined>(undefined);
    // Crossing the dock/pop-out breakpoint remounts the trigger; drop any open
    // dock state so the board doesn't silently re-open after a resize round-trip.
    useEffect(() => {
      setEmojiBoardTab(undefined);
    }, [dockEmojiBoard]);
    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 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 [uploadBoard, setUploadBoard] = useState(true);
    const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(inputDraftKey));
    const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
      roomUploadAtomFamily,
      selectedFiles.map((f) => f.file)
    );
    const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();

    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 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;
        setUploadBoard(true);
        const safeFiles = files.map(safeFile);
        const fileItems: TUploadItem[] = [];

        if (room.hasEncryptionStateEvent()) {
          const encryptFiles = fulfilledPromiseSettledResult(
            await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
          );
          encryptFiles.forEach((ef) =>
            fileItems.push({
              ...ef,
              metadata: {
                markedAsSpoiler: false,
              },
            })
          );
        } else {
          safeFiles.forEach((f) =>
            fileItems.push({
              file: f,
              originalFile: f,
              encInfo: undefined,
              metadata: {
                markedAsSpoiler: false,
              },
            })
          );
        }
        setSelectedFiles({
          type: 'PUT',
          item: fileItems,
        });
      },
      [setSelectedFiles, room, 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]);

    // 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 upload board, text into the composer. The
    // user can still bail by tapping the [×] on each upload card 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.
    useEffect(() => {
      if (threadId) 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, pendingShare, setPendingShare, handleFiles, editor]);

    useEffect(
      () => () => {
        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]
    );

    const handleFileMetadata = useCallback(
      (fileItem: TUploadItem, metadata: TUploadMetadata) => {
        setSelectedFiles({
          type: 'REPLACE',
          item: fileItem,
          replacement: { ...fileItem, metadata },
        });
      },
      [setSelectedFiles]
    );

    const handleRemoveUpload = useCallback(
      (upload: TUploadContent | TUploadContent[]) => {
        const uploads = Array.isArray(upload) ? upload : [upload];
        setSelectedFiles({
          type: 'DELETE',
          item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
        });
        uploads.forEach((u) => roomUploadAtomFamily.remove(u));
      },
      [setSelectedFiles, selectedFiles]
    );

    const handleCancelUpload = (uploads: Upload[]) => {
      uploads.forEach((upload) => {
        if (upload.status === UploadStatus.Loading) {
          mx.cancelUpload(upload.promise);
        }
      });
      handleRemoveUpload(uploads.map((upload) => upload.file));
    };

    const handleSendUpload = async (uploads: UploadSuccess[]) => {
      const contentsPromises = uploads.map(async (upload) => {
        const fileItem = selectedFiles.find((f) => f.file === upload.file);
        if (!fileItem) throw new Error('Broken upload');

        if (fileItem.file.type.startsWith('image')) {
          return getImageMsgContent(mx, fileItem, upload.mxc);
        }
        if (fileItem.file.type.startsWith('video')) {
          return getVideoMsgContent(mx, fileItem, upload.mxc);
        }
        if (fileItem.file.type.startsWith('audio')) {
          return getAudioMsgContent(fileItem, upload.mxc);
        }
        return getFileMsgContent(fileItem, upload.mxc);
      });
      handleCancelUpload(uploads);
      const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
      contents.forEach((content) =>
        mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent)
      );
    };

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

    const submit = useCallback(() => {
      uploadBoardHandlers.current?.handleSend();

      const commandName = getBeginCommand(editor);
      let plainText = toPlainText(editor.children, isMarkdown).trim();
      let customHtml = trimCustomHtml(
        toMatrixCustomHTML(editor.children, {
          allowTextFormatting: true,
          allowBlockMarkdown: isMarkdown,
          allowInlineMarkdown: isMarkdown,
        })
      );
      let msgType = MsgType.Text;

      if (commandName) {
        plainText = trimCommand(commandName, plainText);
        customHtml = trimCommand(commandName, customHtml);
      }
      if (commandName === Command.Me) {
        msgType = MsgType.Emote;
      } else if (commandName === Command.Notice) {
        msgType = MsgType.Notice;
      } else if (commandName === Command.Shrug) {
        plainText = `${SHRUG} ${plainText}`;
        customHtml = `${SHRUG} ${customHtml}`;
      } else if (commandName === Command.TableFlip) {
        plainText = `${TABLEFLIP} ${plainText}`;
        customHtml = `${TABLEFLIP} ${customHtml}`;
      } else if (commandName === Command.UnFlip) {
        plainText = `${UNFLIP} ${plainText}`;
        customHtml = `${UNFLIP} ${customHtml}`;
      } else if (commandName) {
        const commandContent = commands[commandName as Command];
        if (commandContent) {
          commandContent.exe(plainText);
        }
        resetEditor(editor);
        resetEditorHistory(editor);
        sendTypingStatus(false);
        return;
      }

      if (plainText === '') return;

      const body = plainText;
      const formattedBody = customHtml;
      const mentionData = getMentions(mx, roomId, editor);

      const content: IContent = {
        msgtype: msgType,
        body,
      };

      if (replyDraft && replyDraft.userId !== mx.getUserId()) {
        mentionData.users.add(replyDraft.userId);
      }

      const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
      content['m.mentions'] = mMentions;

      if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
        content.format = 'org.matrix.custom.html';
        content.formatted_body = formattedBody;
      }
      if (replyDraft) {
        content['m.relates_to'] = {
          'm.in_reply_to': {
            event_id: replyDraft.eventId,
          },
        };
        if (replyDraft.relation?.rel_type === RelationType.Thread) {
          content['m.relates_to'].event_id = replyDraft.relation.event_id;
          content['m.relates_to'].rel_type = RelationType.Thread;
          content['m.relates_to'].is_falling_back = false;
        }
      }
      const pending = 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,
      sendTypingStatus,
      setReplyDraft,
      isMarkdown,
      commands,
    ]);

    const handleKeyDown: KeyboardEventHandler = useCallback(
      (evt) => {
        if (
          (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
          !isComposing(evt)
        ) {
          evt.preventDefault();
          submit();
        }
        if (isKeyHotkey('escape', evt)) {
          evt.preventDefault();
          if (autocompleteQuery) {
            setAutocompleteQuery(undefined);
            return;
          }
          setReplyDraft(undefined);
        }
      },
      [submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
    );

    const handleKeyUp: KeyboardEventHandler = useCallback(
      (evt) => {
        if (isKeyHotkey('escape', evt)) {
          evt.preventDefault();
          return;
        }

        if (!hideActivity) {
          sendTypingStatus(!isEmptyEditor(editor));
        }

        const prevWordRange = getPrevWorldRange(editor);
        const query = prevWordRange
          ? getAutocompleteQuery<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>
    );

    // The native dock renders the board in the composer's top slot, so its
    // open/close state lives here (only read on native). Desktop keeps its
    // state isolated inside the UseStateProvider below so opening the pop-out
    // doesn't re-render the whole composer.
    const dockedEmojiBoard = dockEmojiBoard && emojiBoardTab !== undefined && (
      <EmojiBoard
        tab={emojiBoardTab}
        onTabChange={setEmojiBoardTab}
        imagePackRooms={imagePackRooms}
        returnFocusOnDeactivate={false}
        dock
        onEmojiSelect={handleEmoticonSelect}
        onCustomEmojiSelect={handleEmoticonSelect}
        onStickerSelect={handleStickerSelect}
        requestClose={() => setEmojiBoardTab(undefined)}
      />
    );

    const emojiButton = dockEmojiBoard ? (
      <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}>
        {selectedFiles.length > 0 && (
          <UploadBoard
            header={
              <UploadBoardHeader
                open={uploadBoard}
                onToggle={() => setUploadBoard(!uploadBoard)}
                uploadFamilyObserverAtom={uploadFamilyObserverAtom}
                onSend={handleSendUpload}
                imperativeHandlerRef={uploadBoardHandlers}
                onCancel={handleCancelUpload}
              />
            }
          >
            {uploadBoard && (
              <Scroll size="300" hideTrack visibility="Hover">
                <UploadBoardContent>
                  {Array.from(selectedFiles)
                    .reverse()
                    .map((fileItem, index) => (
                      <UploadCardRenderer
                        // eslint-disable-next-line react/no-array-index-key
                        key={index}
                        isEncrypted={!!fileItem.encInfo}
                        fileItem={fileItem}
                        setMetadata={handleFileMetadata}
                        onRemove={handleRemoveUpload}
                      />
                    ))}
                </UploadBoardContent>
              </Scroll>
            )}
          </UploadBoard>
        )}
        <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={
            <>
              {dockedEmojiBoard}
              {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>
              )}
              {replyDraft && (
                <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 ? (
              <>
                {!textOnly && 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" />
                {!textOnly && voiceSupported && voiceDisabledBy === undefined && micButton}
                {!textOnly && emojiButton}
                {sendButton}
              </Box>
            )
          }
        />
      </div>
    );
  }
);
