import {
  Box,
  Button,
  Dialog,
  Header,
  Icon,
  IconButton,
  Icons,
  Input,
  Line,
  Menu,
  MenuItem,
  Modal,
  Overlay,
  OverlayBackdrop,
  OverlayCenter,
  PopOut,
  RectCords,
  Spinner,
  Text,
  as,
  color,
  config,
} from 'folds';
import React, {
  ComponentProps,
  FormEventHandler,
  KeyboardEventHandler,
  memo,
  MouseEventHandler,
  ReactNode,
  useCallback,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { useAtomValue, useSetAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import { isVoiceMessageContent } from '../../../../types/matrix/common';
import {
  BubbleLayout,
  CHANNEL_MESSAGE_SPACING,
  ChannelLayout,
  ChannelMessageAvatar,
  MessageBase,
  STREAM_MESSAGE_SPACING,
  Time,
  Username,
  UsernameBold,
} from '../../../components/message';
import { StreamMediaContext } from '../../../components/RenderMessageContent';
import { ChatComposer } from '../RoomView.css';
import { logMedia } from '../../../components/message/attachment/streamMediaDebug';
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { openMessageIdAtom } from '../../../state/room/openMessage';
import { DmReadStatusLine } from './DmReadStatusLine';
import * as css from './styles.css';
import { EventReaders } from '../../../components/event-readers';
import { TextViewer } from '../../../components/text-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { EmojiBoard } from '../../../components/emoji-board';
import { ReactionViewer } from '../reaction-viewer';
import { MessageEditor } from './MessageEditor';
import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
import colorMXID from '../../../../util/colorMXID';

export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;

type MessageQuickReactionsProps = {
  onReaction: ReactionHandler;
};
export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
  ({ onReaction, ...props }, ref) => {
    const mx = useMatrixClient();
    const recentEmojis = useRecentEmoji(mx, 4);

    if (recentEmojis.length === 0) return <span />;
    return (
      <>
        <Box
          style={{ padding: config.space.S200 }}
          alignItems="Center"
          justifyContent="Center"
          gap="200"
          {...props}
          ref={ref}
        >
          {recentEmojis.map((emoji) => (
            <IconButton
              key={emoji.unicode}
              className={css.MessageQuickReaction}
              size="300"
              variant="SurfaceVariant"
              radii="Pill"
              title={emoji.shortcode}
              aria-label={emoji.shortcode}
              onClick={() => onReaction(emoji.unicode, emoji.shortcode)}
            >
              <Text size="T500">{emoji.unicode}</Text>
            </IconButton>
          ))}
        </Box>
        <Line size="300" />
      </>
    );
  }
);

// Hover action-rail icons — thin stroke-outline style matching the composer's
// StreamComposerIcons (Dawn canon), replacing folds' stock filled glyphs so the
// rail reads distinct from upstream Cinny. folds Icon wraps these in
// `<svg viewBox="0 0 24 24" fill="none">`, so each returns just the shapes.
const RailIcons = {
  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"
      />
    </>
  ),
  Reply: () => (
    <>
      <path
        d="M9 17L4 12l5-5"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path
        d="M20 18v-2a4 4 0 0 0-4-4H4"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </>
  ),
  Thread: () => (
    <>
      <path
        d="M5 4h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H8l-4 4V5a1 1 0 0 1 1-1z"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinejoin="round"
      />
      <path d="M12 7.5v4M10 9.5h4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
    </>
  ),
  Edit: () => (
    <path
      d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"
      stroke="currentColor"
      strokeWidth="1.7"
      strokeLinecap="round"
      strokeLinejoin="round"
    />
  ),
  More: () => (
    <path
      d="M5 12h.01M12 12h.01M19 12h.01"
      stroke="currentColor"
      strokeWidth="2.6"
      strokeLinecap="round"
    />
  ),
  Receipts: () => (
    <>
      <path
        d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.7" />
    </>
  ),
  Source: () => (
    <path
      d="M16 18l6-6-6-6M8 6l-6 6 6 6"
      stroke="currentColor"
      strokeWidth="1.7"
      strokeLinecap="round"
      strokeLinejoin="round"
    />
  ),
  Link: () => (
    <>
      <path
        d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 1 0-7.07-7.07l-1.72 1.71"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path
        d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 1 0 7.07 7.07l1.71-1.71"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </>
  ),
  Pin: () => (
    <>
      <path d="M12 17v4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
      <path
        d="M8 4h8l-1.5 7L17 14H7l2.5-3L8 4z"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </>
  ),
  Delete: () => (
    <>
      <path
        d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m2 0v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path d="M10 11v6M14 11v6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
    </>
  ),
  Report: () => (
    <>
      <path
        d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"
        stroke="currentColor"
        strokeWidth="1.7"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path d="M4 22V4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
    </>
  ),
};

export const MessageAllReactionItem = as<
  'button',
  {
    room: Room;
    relations: Relations;
    onClose?: () => void;
  }
>(({ room, relations, onClose, ...props }, ref) => {
  const { t } = useTranslation();
  const [open, setOpen] = useState(false);

  const handleClose = () => {
    setOpen(false);
    onClose?.();
  };

  return (
    <>
      <Overlay
        onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => {
          evt.stopPropagation();
        }}
        open={open}
        backdrop={<OverlayBackdrop />}
      >
        <OverlayCenter>
          <FocusTrap
            focusTrapOptions={{
              initialFocus: false,
              returnFocusOnDeactivate: false,
              onDeactivate: () => handleClose(),
              clickOutsideDeactivates: true,
              escapeDeactivates: stopPropagation,
            }}
          >
            <Modal variant="Surface" size="300">
              <ReactionViewer
                room={room}
                relations={relations}
                requestClose={() => setOpen(false)}
              />
            </Modal>
          </FocusTrap>
        </OverlayCenter>
      </Overlay>
      <MenuItem
        size="300"
        after={<Icon size="100" src={RailIcons.Smile} />}
        radii="300"
        onClick={() => setOpen(true)}
        {...props}
        ref={ref}
        aria-pressed={open}
      >
        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
          {t('Room.view_reactions')}
        </Text>
      </MenuItem>
    </>
  );
});

export const MessageReadReceiptItem = as<
  'button',
  {
    room: Room;
    eventId: string;
    onClose?: () => void;
  }
>(({ room, eventId, onClose, ...props }, ref) => {
  const { t } = useTranslation();
  const [open, setOpen] = useState(false);

  const handleClose = () => {
    setOpen(false);
    onClose?.();
  };

  return (
    <>
      <Overlay open={open} backdrop={<OverlayBackdrop />}>
        <OverlayCenter>
          <FocusTrap
            focusTrapOptions={{
              initialFocus: false,
              onDeactivate: handleClose,
              clickOutsideDeactivates: true,
              escapeDeactivates: stopPropagation,
            }}
          >
            <Modal variant="Surface" size="300">
              <EventReaders room={room} eventId={eventId} requestClose={handleClose} />
            </Modal>
          </FocusTrap>
        </OverlayCenter>
      </Overlay>
      <MenuItem
        size="300"
        after={<Icon size="100" src={RailIcons.Receipts} />}
        radii="300"
        onClick={() => setOpen(true)}
        {...props}
        ref={ref}
        aria-pressed={open}
      >
        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
          {t('Room.read_receipts')}
        </Text>
      </MenuItem>
    </>
  );
});

export const MessageSourceCodeItem = as<
  'button',
  {
    room: Room;
    mEvent: MatrixEvent;
    onClose?: () => void;
  }
>(({ room, mEvent, onClose, ...props }, ref) => {
  const { t } = useTranslation();
  const [open, setOpen] = useState(false);

  const getContent = (evt: MatrixEvent) =>
    evt.isEncrypted()
      ? {
          [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
          [`<== ORIGINAL_EVENT ==>`]: evt.event,
        }
      : evt.event;

  const getText = (): string => {
    const evtId = mEvent.getId();
    if (!evtId) return '';
    const evtTimeline = room.getTimelineForEvent(evtId);
    const edits =
      evtTimeline &&
      getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();

    if (!edits) return JSON.stringify(getContent(mEvent), null, 2);

    const content: Record<string, unknown> = {
      '<== MAIN_EVENT ==>': getContent(mEvent),
    };

    edits.forEach((editEvt, index) => {
      content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt);
    });

    return JSON.stringify(content, null, 2);
  };

  const handleClose = () => {
    setOpen(false);
    onClose?.();
  };

  return (
    <>
      <Overlay open={open} backdrop={<OverlayBackdrop />}>
        <OverlayCenter>
          <FocusTrap
            focusTrapOptions={{
              initialFocus: false,
              onDeactivate: handleClose,
              clickOutsideDeactivates: true,
              escapeDeactivates: stopPropagation,
            }}
          >
            <Modal variant="Surface" size="500">
              <TextViewer
                name={t('Room.source_code')}
                langName="json"
                text={getText()}
                requestClose={handleClose}
              />
            </Modal>
          </FocusTrap>
        </OverlayCenter>
      </Overlay>
      <MenuItem
        size="300"
        after={<Icon size="100" src={RailIcons.Source} />}
        radii="300"
        onClick={() => setOpen(true)}
        {...props}
        ref={ref}
        aria-pressed={open}
      >
        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
          {t('Room.view_source')}
        </Text>
      </MenuItem>
    </>
  );
});

export const MessageCopyLinkItem = as<
  'button',
  {
    room: Room;
    mEvent: MatrixEvent;
    onClose?: () => void;
  }
>(({ room, mEvent, onClose, ...props }, ref) => {
  const { t } = useTranslation();

  const handleCopy = () => {
    const eventId = mEvent.getId();
    if (!eventId) return;
    copyToClipboard(getMatrixToRoomEvent(room.roomId, eventId, getViaServers(room)));
    onClose?.();
  };

  return (
    <MenuItem
      size="300"
      after={<Icon size="100" src={RailIcons.Link} />}
      radii="300"
      onClick={handleCopy}
      {...props}
      ref={ref}
    >
      <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
        {t('Room.copy_link')}
      </Text>
    </MenuItem>
  );
});

export const MessagePinItem = as<
  'button',
  {
    room: Room;
    mEvent: MatrixEvent;
    onClose?: () => void;
  }
>(({ room, mEvent, onClose, ...props }, ref) => {
  const { t } = useTranslation();
  const mx = useMatrixClient();
  const pinnedEvents = useRoomPinnedEvents(room);
  const isPinned = pinnedEvents.includes(mEvent.getId() ?? '');

  const handlePin = () => {
    const eventId = mEvent.getId();
    const pinContent: RoomPinnedEventsEventContent = {
      pinned: Array.from(pinnedEvents).filter((id) => id !== eventId),
    };
    if (!isPinned && eventId) {
      pinContent.pinned.push(eventId);
    }
    mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as keyof StateEvents, pinContent);
    onClose?.();
  };

  return (
    <MenuItem
      size="300"
      after={<Icon size="100" src={RailIcons.Pin} />}
      radii="300"
      onClick={handlePin}
      {...props}
      ref={ref}
    >
      <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
        {isPinned ? t('Room.unpin_message') : t('Room.pin_message')}
      </Text>
    </MenuItem>
  );
});

export const MessageDeleteItem = as<
  'button',
  {
    room: Room;
    mEvent: MatrixEvent;
    onClose?: () => void;
  }
>(({ room, mEvent, onClose, ...props }, ref) => {
  const { t } = useTranslation();
  const mx = useMatrixClient();
  const [open, setOpen] = useState(false);

  const [deleteState, deleteMessage] = useAsyncCallback(
    useCallback(
      (eventId: string, reason?: string) =>
        mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
      [mx, room]
    )
  );

  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
    evt.preventDefault();
    const eventId = mEvent.getId();
    if (
      !eventId ||
      deleteState.status === AsyncStatus.Loading ||
      deleteState.status === AsyncStatus.Success
    )
      return;
    const target = evt.target as HTMLFormElement | undefined;
    const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
    const reason = reasonInput && reasonInput.value.trim();
    deleteMessage(eventId, reason);
  };

  const handleClose = () => {
    setOpen(false);
    onClose?.();
  };

  return (
    <>
      <Overlay open={open} backdrop={<OverlayBackdrop />}>
        <OverlayCenter>
          <FocusTrap
            focusTrapOptions={{
              initialFocus: false,
              onDeactivate: handleClose,
              clickOutsideDeactivates: true,
              escapeDeactivates: stopPropagation,
            }}
          >
            <Dialog variant="Surface">
              <Header
                style={{
                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
                  borderBottomWidth: config.borderWidth.B300,
                }}
                variant="Surface"
                size="500"
              >
                <Box grow="Yes">
                  <Text size="H4">{t('Room.delete_message')}</Text>
                </Box>
                <IconButton size="300" onClick={handleClose} radii="300">
                  <Icon src={Icons.Cross} />
                </IconButton>
              </Header>
              <Box
                as="form"
                onSubmit={handleSubmit}
                style={{ padding: config.space.S400 }}
                direction="Column"
                gap="400"
              >
                <Text priority="400">{t('Room.delete_confirm')}</Text>
                <Box direction="Column" gap="100">
                  <Text size="L400">
                    {t('Room.reason')}{' '}
                    <Text as="span" size="T200">
                      ({t('Room.optional')})
                    </Text>
                  </Text>
                  <Input name="reasonInput" variant="Background" />
                  {deleteState.status === AsyncStatus.Error && (
                    <Text style={{ color: color.Critical.Main }} size="T300">
                      {t('Room.delete_error')}
                    </Text>
                  )}
                </Box>
                <Button
                  type="submit"
                  variant="Critical"
                  before={
                    deleteState.status === AsyncStatus.Loading ? (
                      <Spinner fill="Solid" variant="Critical" size="200" />
                    ) : undefined
                  }
                  aria-disabled={deleteState.status === AsyncStatus.Loading}
                >
                  <Text size="B400">
                    {deleteState.status === AsyncStatus.Loading
                      ? t('Room.deleting')
                      : t('Room.delete')}
                  </Text>
                </Button>
              </Box>
            </Dialog>
          </FocusTrap>
        </OverlayCenter>
      </Overlay>
      <Button
        variant="Critical"
        fill="None"
        size="300"
        after={<Icon size="100" src={RailIcons.Delete} />}
        radii="300"
        onClick={() => setOpen(true)}
        aria-pressed={open}
        {...props}
        ref={ref}
      >
        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
          {t('Room.delete')}
        </Text>
      </Button>
    </>
  );
});

export const MessageReportItem = as<
  'button',
  {
    room: Room;
    mEvent: MatrixEvent;
    onClose?: () => void;
  }
>(({ room, mEvent, onClose, ...props }, ref) => {
  const { t } = useTranslation();
  const mx = useMatrixClient();
  const [open, setOpen] = useState(false);

  const [reportState, reportMessage] = useAsyncCallback(
    useCallback(
      (eventId: string, score: number, reason: string) =>
        mx.reportEvent(room.roomId, eventId, score, reason),
      [mx, room]
    )
  );

  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
    evt.preventDefault();
    const eventId = mEvent.getId();
    if (
      !eventId ||
      reportState.status === AsyncStatus.Loading ||
      reportState.status === AsyncStatus.Success
    )
      return;
    const target = evt.target as HTMLFormElement | undefined;
    const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
    const reason = reasonInput && reasonInput.value.trim();
    if (reasonInput) reasonInput.value = '';
    reportMessage(eventId, reason ? -100 : -50, reason || t('Room.no_reason'));
  };

  const handleClose = () => {
    setOpen(false);
    onClose?.();
  };

  return (
    <>
      <Overlay open={open} backdrop={<OverlayBackdrop />}>
        <OverlayCenter>
          <FocusTrap
            focusTrapOptions={{
              initialFocus: false,
              onDeactivate: handleClose,
              clickOutsideDeactivates: true,
              escapeDeactivates: stopPropagation,
            }}
          >
            <Dialog variant="Surface">
              <Header
                style={{
                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
                  borderBottomWidth: config.borderWidth.B300,
                }}
                variant="Surface"
                size="500"
              >
                <Box grow="Yes">
                  <Text size="H4">{t('Room.report_message')}</Text>
                </Box>
                <IconButton size="300" onClick={handleClose} radii="300">
                  <Icon src={Icons.Cross} />
                </IconButton>
              </Header>
              <Box
                as="form"
                onSubmit={handleSubmit}
                style={{ padding: config.space.S400 }}
                direction="Column"
                gap="400"
              >
                <Text priority="400">{t('Room.report_desc')}</Text>
                <Box direction="Column" gap="100">
                  <Text size="L400">{t('Room.report_reason')}</Text>
                  <Input name="reasonInput" variant="Background" required />
                  {reportState.status === AsyncStatus.Error && (
                    <Text style={{ color: color.Critical.Main }} size="T300">
                      {t('Room.report_error')}
                    </Text>
                  )}
                  {reportState.status === AsyncStatus.Success && (
                    <Text style={{ color: color.Success.Main }} size="T300">
                      {t('Room.report_success')}
                    </Text>
                  )}
                </Box>
                <Button
                  type="submit"
                  variant="Critical"
                  before={
                    reportState.status === AsyncStatus.Loading ? (
                      <Spinner fill="Solid" variant="Critical" size="200" />
                    ) : undefined
                  }
                  aria-disabled={
                    reportState.status === AsyncStatus.Loading ||
                    reportState.status === AsyncStatus.Success
                  }
                >
                  <Text size="B400">
                    {reportState.status === AsyncStatus.Loading
                      ? t('Room.reporting')
                      : t('Room.report')}
                  </Text>
                </Button>
              </Box>
            </Dialog>
          </FocusTrap>
        </OverlayCenter>
      </Overlay>
      <Button
        variant="Critical"
        fill="None"
        size="300"
        after={<Icon size="100" src={RailIcons.Report} />}
        radii="300"
        onClick={() => setOpen(true)}
        aria-pressed={open}
        {...props}
        ref={ref}
      >
        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
          {t('Room.report')}
        </Text>
      </Button>
    </>
  );
});

export type MessageProps = {
  room: Room;
  mEvent: MatrixEvent;
  collapse: boolean;
  // Stream layout only: suppress the rail segment on this row — set for a
  // trailing continuation that sits after the last dot, so the rail stops at
  // the last dot instead of bleeding down the dot-less tail of a run. Ignored
  // by the channel layout.
  railHidden?: boolean;
  highlight: boolean;
  edit?: boolean;
  canDelete?: boolean;
  canSendReaction?: boolean;
  canPinEvent?: boolean;
  imagePackRooms?: Room[];
  relations?: Relations;
  onUserClick: MouseEventHandler<HTMLButtonElement>;
  onUsernameClick: MouseEventHandler<HTMLButtonElement>;
  onReplyClick: (
    ev: Parameters<MouseEventHandler<HTMLButtonElement>>[0],
    startThread?: boolean
  ) => void;
  onEditId?: (eventId?: string) => void;
  onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
  reply?: ReactNode;
  reactions?: ReactNode;
  hideReadReceipts?: boolean;
  showDeveloperTools?: boolean;
  memberPowerTag?: MemberPowerTag;
  accessibleTagColors?: Map<string, string>;
  legacyUsernameColor?: boolean;
  streamRailStart?: boolean;
  streamRailEnd?: boolean;
  // M2: hide the «Reply in Thread» menu/quick action. Set by
  // RoomTimeline outside channels-mode (where threads aren't surfaced
  // anywhere) and inside bridged channels (where the bridge has no
  // thread semantic). When true, the ThreadPlus button + the
  // `reply_in_thread` menu item are skipped; the regular «Reply»
  // affordance stays untouched.
  hideThreadReplyAffordance?: boolean;
  // M2: hide the regular «Reply» (m.in_reply_to) menu/quick action.
  // Set by RoomTimeline when the thread drawer is open — the channel
  // composer is unmounted in that state, so the reply chip would
  // write to an unread atom and the focus call would no-op silently.
  // ThreadPlus button stays usable for navigating to a different
  // thread root.
  hideMainReplyAffordance?: boolean;
  // Snapshot of `mEvent.getContent().msgtype` from the caller. Threaded as
  // a prop (not derived locally) so encrypted-then-decrypted events flip
  // mediaMode reliably when EncryptedContent re-renders post-decrypt —
  // local useState would race against the commit↔effect gap.
  msgType?: string;
  // M3a: thread summary pill (count + last reply preview) rendered
  // between the bubble and reactions. Caller (`RoomTimeline`) decides
  // whether to mount the card based on `channelsMode && !isBridged` —
  // outside channels-mode this stays `undefined` and the slot collapses.
  threadSummary?: React.ReactNode;
  // Choose timeline visual. `'stream'` = the simple 1:1 DM bubble chat
  // (peer = plain text, own = right bubble, muted corner time — like the
  // AI-bot chat) — used for 1:1 DMs and 1:1 bot control rooms.
  // `'channel'` = avatar + bubble with an in-bubble username/time header —
  // used for every non-1:1 room (group DMs, group rooms under /space/,
  // channels). Default `'stream'` so card-preview callers (pin menu, message
  // search) get the DM look.
  layout?: 'stream' | 'channel';
  // Bubble layout only: this is the user's LAST own message at the live end
  // of the timeline → render the read/delivery status line under it. Folded
  // into `renderSig` by the caller so the prior tail repaints when it stops
  // being the latest. Ignored by the channel layout.
  isLatestOwn?: boolean;
  // Bubble layout only: show this row's timestamp. The caller hides it (false)
  // on every row but the last of a same-sender same-minute group, so a burst
  // collapses to one stamp. Defaults to true; ignored by the channel layout.
  showTime?: boolean;
  // Bubble layout only: this row is the tail of a same-minute series, so its
  // timestamp drops BELOW the bubble (like media) instead of sitting on the
  // side. A standalone message keeps the side timestamp. Ignored by channel.
  timeBelow?: boolean;
  // Forwarded to `ChannelLayout.headerInBubble`. Every current timeline
  // caller passes `true` alongside `layout='channel'`; kept as a prop so
  // un-bubbled `ChannelLayout` consumers stay possible.
  channelHeaderInBubble?: boolean;
  // Parent-computed render snapshot (see getMessageRenderSig). Drives the
  // React.memo comparator so in-place MatrixEvent mutations (edit/redact/
  // decrypt/echo) and rename/urlPreview changes repaint the row.
  renderSig?: string;
};

// Bubble (1:1 DM) tap-rail wrapper: floats the action bar at the tap point, just
// above the finger/cursor, and clamps it inside the viewport so it never spills
// off-screen on a narrow native window. `position: fixed` (css.TapRail) is
// viewport-relative here because no message-row ancestor is transformed; the
// layout effect measures the bar and nudges `left`/`top` within an 8px margin
// before paint (so there's no visible jump). It renders inside the message row
// (`[data-message-id]`), so the outside-tap close handler exempts it.
function BubbleTapRail({
  point,
  children,
}: {
  point: { x: number; y: number };
  children: ReactNode;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const [pos, setPos] = useState<{ left: number; top: number }>({ left: point.x, top: point.y });
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const { width, height } = el.getBoundingClientRect();
    const m = 8;
    const left = Math.max(m, Math.min(point.x - width / 2, window.innerWidth - width - m));
    const top = Math.max(m, Math.min(point.y - height - m, window.innerHeight - height - m));
    setPos({ left, top });
  }, [point.x, point.y]);
  return (
    <div ref={ref} className={css.TapRail} style={{ left: pos.left, top: pos.top }}>
      {children}
    </div>
  );
}

const MessageInner = as<'div', MessageProps>(
  (
    {
      className,
      room,
      mEvent,
      collapse,
      // Vestigial Stream-rail props — accepted (RoomTimeline still passes them)
      // but unused by the bubble/channel layouts. Pending the stream-rail cleanup.
      railHidden: _railHidden,
      highlight,
      edit,
      canDelete,
      canSendReaction,
      canPinEvent,
      imagePackRooms,
      relations,
      onUserClick,
      onUsernameClick,
      onReplyClick,
      onReactionToggle,
      onEditId,
      reply,
      reactions,
      hideReadReceipts,
      showDeveloperTools,
      memberPowerTag,
      accessibleTagColors,
      legacyUsernameColor,
      streamRailStart: _streamRailStart,
      streamRailEnd: _streamRailEnd,
      hideThreadReplyAffordance,
      hideMainReplyAffordance,
      msgType,
      threadSummary,
      layout = 'stream',
      isLatestOwn,
      showTime = true,
      timeBelow,
      channelHeaderInBubble,
      children,
      ...props
    },
    ref
  ) => {
    const { t } = useTranslation();
    const mx = useMatrixClient();
    const senderId = mEvent.getSender() ?? '';

    const [hover, setHover] = useState(false);
    const { hoverProps } = useHover({ onHoverChange: setHover });
    const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
    const [menuAnchor, setMenuAnchor] = useState<RectCords>();
    const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();

    const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
    const senderDisplayName =
      getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;

    const tagColor = memberPowerTag?.color
      ? accessibleTagColors?.get(memberPowerTag.color)
      : undefined;

    const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;

    const screenSize = useScreenSizeContext();
    const isMobile = screenSize === ScreenSize.Mobile;
    // Touch-friendly action-rail sizing — one step larger on mobile so the rail
    // is comfortable under a finger (desktop stays compact for the mouse).
    const railBtnSize = isMobile ? '400' : '300';
    const railIconSize = isMobile ? '200' : '100';
    const railGap = isMobile ? '200' : '100';

    // Bubble (1:1 DM) layout: a tap toggles THIS row's action rail (single-open
    // via the shared atom) instead of hover. `selectAtom` keeps the subscription
    // per-row, so a tap only re-renders the two rows whose open-state flips.
    const isBubble = layout !== 'channel';
    const railOpenSelector = useMemo(
      () => selectAtom(openMessageIdAtom, (id) => id === mEvent.getId()),
      [mEvent]
    );
    const railOpen = useAtomValue(railOpenSelector);
    const setOpenMessageId = useSetAtom(openMessageIdAtom);
    // Viewport point (clientX/Y of the tap) where the rail should float — just
    // above it, clamped on-screen by BubbleTapRail. Captured on each open tap.
    const [railPoint, setRailPoint] = useState<{ x: number; y: number } | null>(null);
    const toggleRail = () => {
      const id = mEvent.getId();
      if (id) setOpenMessageId((prev) => (prev === id ? null : id));
    };
    const handleBubbleToggle: MouseEventHandler<HTMLDivElement> = (evt) => {
      if (!isBubble || edit) return;
      // Don't hijack a text selection, nor taps on interactive children (links,
      // buttons, media players, the waveform slider, the action rail, reaction
      // chips) — those run their own action and must not also toggle the rail.
      if (!window.getSelection()?.isCollapsed) return;
      if (
        (evt.target as Element | null)?.closest(
          'a, button, input, textarea, label, select, img, video, audio, canvas, [role="button"], [role="slider"], [contenteditable="true"], [data-no-rail-toggle]'
        )
      )
        return;
      // Float the rail right where the finger/cursor landed (clamped on-screen).
      setRailPoint({ x: evt.clientX, y: evt.clientY });
      toggleRail();
    };
    // Keyboard a11y: the row is focusable (tabIndex 0); Enter/Space toggles the
    // rail — the keyboard equivalent of the tap. Gated to when the row ITSELF
    // holds focus (not a child link/button), so Enter on a focused link still
    // follows it. Replaces the focus-within reveal the hover rail used to give.
    const handleBubbleKeyDown: KeyboardEventHandler<HTMLDivElement> = (evt) => {
      if (!isBubble || edit) return;
      if (evt.target !== evt.currentTarget) return;
      if (evt.key === 'Enter' || evt.key === ' ') {
        evt.preventDefault();
        // No pointer for the keyboard path — anchor the rail above the row's top edge.
        const r = evt.currentTarget.getBoundingClientRect();
        setRailPoint({ x: r.left + r.width / 2, y: r.top + 8 });
        toggleRail();
      }
    };
    const bubbleSelected = isBubble && (railOpen || !!menuAnchor || !!emojiBoardAnchor);

    // msgType comes from the parent — RoomTimeline reads
    // `mEvent.getContent().msgtype` synchronously and re-evaluates inside
    // EncryptedContent's render-prop, which re-runs on Decrypted. Avoiding
    // a local useState here sidesteps the commit↔effect race where
    // decryption fires between render and listener attach.
    const isMediaMessage = msgType === MsgType.Image || msgType === MsgType.Video;
    const mediaMode = isMediaMessage && !edit;
    // Voice notes are self-chromed cards — VoiceContent draws its own avatar +
    // bubble. Collapse the asymmetric Stream bubble (DM) and drop the channel
    // avatar (group) so a voice note renders identically for own/other, the only
    // difference being the bubble fill. See docs/plans/voice_messages.md §5.
    const isVoiceMessage = msgType === MsgType.Audio && isVoiceMessageContent(mEvent.getContent());
    const voiceMode = isVoiceMessage && !edit;

    if (msgType === MsgType.Image || msgType === MsgType.Video || msgType === MsgType.File) {
      logMedia('Message', {
        eventId: mEvent.getId(),
        msgType,
        isMediaMessage,
        edit,
        mediaMode,
        isMobile,
        screenSize,
      });
    }

    const streamMediaCtx = useMemo(
      () =>
        // Image / video chrome (the StreamMediaImage/Video shell) — used in
        // BOTH the 1:1 Stream layout and the Discord-style channel layout, so
        // group-chat media renders identically to 1:1 (rounded shell, capped
        // size) instead of the legacy boxed attachment. Only `own` is needed
        // (the bubble's notch corner); the nick is rendered above by the header.
        mediaMode ? { own: isOwnMessage } : null,
      [mediaMode, isOwnMessage]
    );

    const msgContentJSX = (
      <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%', width: '100%' }}>
        {reply}
        {edit && onEditId ? (
          // Edit form styled as the main composer — a single rounded card
          // (`ChatComposer` paints the inner Editor with Surface.Container + 32px
          // radius). The bubble drops its own chrome while editing (see
          // BubbleLayout `editing`) so it's ONE box, not a bubble inside a bubble.
          <div className={ChatComposer} style={{ width: '100%' }}>
            <MessageEditor
              style={{ maxWidth: '100%', width: '100%' }}
              roomId={room.roomId}
              room={room}
              mEvent={mEvent}
              imagePackRooms={imagePackRooms}
              onCancel={() => onEditId()}
            />
          </div>
        ) : (
          children
        )}
      </Box>
    );

    const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
      if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
      const tag = (evt.target as Element).tagName;
      if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
      evt.preventDefault();
      setMenuAnchor({
        x: evt.clientX,
        y: evt.clientY,
        width: 0,
        height: 0,
      });
    };

    const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
      const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
      setMenuAnchor(target.getBoundingClientRect());
    };

    const closeMenu = () => {
      setMenuAnchor(undefined);
    };

    const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = (evt) => {
      const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
      setEmojiBoardAnchor(target.getBoundingClientRect());
    };
    const handleAddReactions: MouseEventHandler<HTMLButtonElement> = () => {
      const rect = menuAnchor;
      closeMenu();
      // open it with timeout because closeMenu
      // FocusTrap will return focus from emojiBoard

      setTimeout(() => {
        setEmojiBoardAnchor(rect);
      }, 100);
    };

    const isThreadedMessage = mEvent.threadRootId !== undefined;
    // After a Thread is created on a root event, SDK sets
    // `mEvent.threadRootId === mEvent.getId()` and `isThreadRoot ===
    // true`. We want the «Reply in Thread» button to keep working on
    // the root (re-open drawer) — only hide when the message is itself
    // a thread reply (no thread can be started on a reply, and in
    // channels-mode replies aren't visible in main timeline anyway).
    const isThreadReply = isThreadedMessage && !mEvent.isThreadRoot;

    // The action rail (react / reply / thread / edit / …). Extracted so the same
    // bar can render in two places: as a row-anchored overlay for channel/stream
    // rows, and — for the bubble layout — passed into BubbleLayout so it floats
    // centred just above the bubble itself.
    const railVisible =
      !edit && ((isBubble ? railOpen : hover) || !!menuAnchor || !!emojiBoardAnchor);
    // The reaction emoji board, shared by the desktop anchored PopOut and the
    // mobile centred Overlay (an anchored PopOut drifts off-screen on the small
    // native viewport — `съезжает` — so mobile centres it instead).
    const emojiBoardEl = (
      <EmojiBoard
        imagePackRooms={imagePackRooms ?? []}
        returnFocusOnDeactivate={false}
        allowTextCustomEmoji
        onEmojiSelect={(key) => {
          const evtId = mEvent.getId();
          if (evtId) onReactionToggle(evtId, key);
          setEmojiBoardAnchor(undefined);
        }}
        onCustomEmojiSelect={(mxc, shortcode) => {
          const evtId = mEvent.getId();
          if (evtId) onReactionToggle(evtId, mxc, shortcode);
          setEmojiBoardAnchor(undefined);
        }}
        requestClose={() => {
          setEmojiBoardAnchor(undefined);
        }}
      />
    );
    const railBar = railVisible ? (
      <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
        <Box gap={railGap}>
          {canSendReaction &&
            (isMobile ? (
              // Mobile: the board opens as a centred Overlay (rendered in
              // MessageBase), so the button is a plain toggle here.
              <IconButton
                onClick={handleOpenEmojiBoard}
                variant="SurfaceVariant"
                size={railBtnSize}
                radii="300"
                aria-pressed={!!emojiBoardAnchor}
              >
                <Icon src={RailIcons.Smile} size={railIconSize} />
              </IconButton>
            ) : (
              <PopOut
                position="Bottom"
                align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
                offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
                anchor={emojiBoardAnchor}
                content={emojiBoardEl}
              >
                <IconButton
                  onClick={handleOpenEmojiBoard}
                  variant="SurfaceVariant"
                  size={railBtnSize}
                  radii="300"
                  aria-pressed={!!emojiBoardAnchor}
                >
                  <Icon src={RailIcons.Smile} size={railIconSize} />
                </IconButton>
              </PopOut>
            ))}
          {!hideMainReplyAffordance && (
            <IconButton
              onClick={onReplyClick}
              data-event-id={mEvent.getId()}
              variant="SurfaceVariant"
              size={railBtnSize}
              radii="300"
            >
              <Icon src={RailIcons.Reply} size={railIconSize} />
            </IconButton>
          )}
          {!isThreadReply && !hideThreadReplyAffordance && (
            <IconButton
              onClick={(ev: Parameters<typeof onReplyClick>[0]) => onReplyClick(ev, true)}
              data-event-id={mEvent.getId()}
              variant="SurfaceVariant"
              size={railBtnSize}
              radii="300"
            >
              <Icon src={RailIcons.Thread} size={railIconSize} />
            </IconButton>
          )}
          {canEditEvent(mx, mEvent) && onEditId && (
            <IconButton
              onClick={() => onEditId(mEvent.getId())}
              variant="SurfaceVariant"
              size={railBtnSize}
              radii="300"
            >
              <Icon src={RailIcons.Edit} size={railIconSize} />
            </IconButton>
          )}
          <PopOut
            anchor={menuAnchor}
            position="Bottom"
            align={menuAnchor?.width === 0 ? 'Start' : 'End'}
            offset={menuAnchor?.width === 0 ? 0 : undefined}
            content={
              <FocusTrap
                focusTrapOptions={{
                  initialFocus: false,
                  onDeactivate: () => setMenuAnchor(undefined),
                  clickOutsideDeactivates: true,
                  isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
                  isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
                  escapeDeactivates: stopPropagation,
                }}
              >
                <Menu>
                  {canSendReaction && (
                    <MessageQuickReactions
                      onReaction={(key, shortcode) => {
                        const evtId = mEvent.getId();
                        if (evtId) onReactionToggle(evtId, key, shortcode);
                        closeMenu();
                      }}
                    />
                  )}
                  <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
                    {canSendReaction && (
                      <MenuItem
                        size="300"
                        after={<Icon size="100" src={RailIcons.Smile} />}
                        radii="300"
                        onClick={handleAddReactions}
                      >
                        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
                          {t('Room.add_reaction')}
                        </Text>
                      </MenuItem>
                    )}
                    {relations && (
                      <MessageAllReactionItem
                        room={room}
                        relations={relations}
                        onClose={closeMenu}
                      />
                    )}
                    {!hideMainReplyAffordance && (
                      <MenuItem
                        size="300"
                        after={<Icon size="100" src={RailIcons.Reply} />}
                        radii="300"
                        data-event-id={mEvent.getId()}
                        onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
                          onReplyClick(evt);
                          closeMenu();
                        }}
                      >
                        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
                          {t('Room.reply')}
                        </Text>
                      </MenuItem>
                    )}
                    {!isThreadReply && !hideThreadReplyAffordance && (
                      <MenuItem
                        size="300"
                        after={<Icon src={RailIcons.Thread} size="100" />}
                        radii="300"
                        data-event-id={mEvent.getId()}
                        onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
                          onReplyClick(evt, true);
                          closeMenu();
                        }}
                      >
                        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
                          {t('Room.reply_in_thread')}
                        </Text>
                      </MenuItem>
                    )}
                    {canEditEvent(mx, mEvent) && onEditId && (
                      <MenuItem
                        size="300"
                        after={<Icon size="100" src={RailIcons.Edit} />}
                        radii="300"
                        data-event-id={mEvent.getId()}
                        onClick={() => {
                          onEditId(mEvent.getId());
                          closeMenu();
                        }}
                      >
                        <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
                          {t('Room.edit_message')}
                        </Text>
                      </MenuItem>
                    )}
                    {!hideReadReceipts && (
                      <MessageReadReceiptItem
                        room={room}
                        eventId={mEvent.getId() ?? ''}
                        onClose={closeMenu}
                      />
                    )}
                    {showDeveloperTools && (
                      <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
                    )}
                    <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
                    {canPinEvent && (
                      <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
                    )}
                  </Box>
                  {((!mEvent.isRedacted() && canDelete) ||
                    mEvent.getSender() !== mx.getUserId()) && (
                    <>
                      <Line size="300" />
                      <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
                        {!mEvent.isRedacted() && canDelete && (
                          <MessageDeleteItem room={room} mEvent={mEvent} onClose={closeMenu} />
                        )}
                        {mEvent.getSender() !== mx.getUserId() && (
                          <MessageReportItem room={room} mEvent={mEvent} onClose={closeMenu} />
                        )}
                      </Box>
                    </>
                  )}
                </Menu>
              </FocusTrap>
            }
          >
            <IconButton
              variant="SurfaceVariant"
              size={railBtnSize}
              radii="300"
              onClick={handleOpenMenu}
              aria-pressed={!!menuAnchor}
            >
              <Icon src={RailIcons.More} size={railIconSize} />
            </IconButton>
          </PopOut>
        </Box>
      </Menu>
    ) : null;

    return (
      <MessageBase
        className={classNames(css.MessageBase, className)}
        tabIndex={0}
        // Bubble (1:1 DM): drop the row's S400/S200 horizontal gutter so the
        // message edge inset is owned entirely by the centred band (12px/40px),
        // identical to the AI-bot chat. Channel rows keep the gutter.
        bubble={isBubble}
        space={layout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING}
        // Stream rows always render with collapsed marginTop (=0) so the
        // bubble-to-bubble gap is uniform regardless of same-sender grouping.
        // Total gap = 4×S100 (StreamRoot + MessageBase vertical padding on
        // both sides) = 16px. The rail-bridge in layout.css.ts is still S400
        // each side — it overshoots slightly across the smaller gap, but the
        // overlap lands inside DotColumn behind the dot halo and stays
        // invisible. `collapse` still drives avatar/header visibility inside
        // ChannelLayout — channel mode keeps the original behaviour.
        collapse={layout === 'channel' ? collapse : true}
        highlight={highlight}
        // Bubble layout scopes its «selected» ring to the bubble itself (see
        // BubbleLayout); the full-width MessageBase fill is only for channel rows.
        selected={!isBubble && (!!menuAnchor || !!emojiBoardAnchor)}
        // Bubble layout: keyboard toggle of the action rail (no-op elsewhere).
        onKeyDown={handleBubbleKeyDown}
        {...props}
        {...hoverProps}
        {...focusWithinProps}
        ref={ref}
      >
        {/* Channel rows anchor the rail to the row's top corner; the bubble layout
            floats it at the tap point (BubbleTapRail), clamped on-screen. */}
        {!isBubble && railBar && <div className={css.MessageOptionsBase}>{railBar}</div>}
        {isBubble && railBar && railPoint && (
          <BubbleTapRail point={railPoint}>{railBar}</BubbleTapRail>
        )}
        {/* Mobile: the reaction emoji board renders as a centred Overlay (an
            anchored PopOut drifts off the small native viewport). */}
        {isMobile && canSendReaction && !edit && !!emojiBoardAnchor && (
          <Overlay open backdrop={<OverlayBackdrop />}>
            <OverlayCenter>
              <FocusTrap
                focusTrapOptions={{
                  initialFocus: false,
                  clickOutsideDeactivates: true,
                  onDeactivate: () => setEmojiBoardAnchor(undefined),
                  escapeDeactivates: stopPropagation,
                }}
              >
                {emojiBoardEl}
              </FocusTrap>
            </OverlayCenter>
          </Overlay>
        )}
        {layout === 'channel' ? (
          <ChannelLayout
            isOwn={isOwnMessage}
            headerInBubble={channelHeaderInBubble}
            avatar={
              !collapse ? (
                <ChannelMessageAvatar
                  room={room}
                  senderId={senderId}
                  senderDisplayName={senderDisplayName}
                />
              ) : undefined
            }
            header={
              !collapse ? (
                <>
                  <Username
                    as="button"
                    style={{ color: usernameColor ?? color.Primary.Main }}
                    data-user-id={senderId}
                    onContextMenu={onUserClick}
                    onClick={onUsernameClick}
                  >
                    {/* In-bubble (thread) uses Stream's compact T200 size
                        so the bubble header matches the DM-chat rhythm.
                        Channels main timeline keeps T400 for the prominent
                        avatar-and-name row above an unbordered body. */}
                    <Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate>
                      {/* Own messages show the user's own nick too (not a «me»
                          label) — matches the 1:1 Stream layout. */}
                      <UsernameBold>{senderDisplayName}</UsernameBold>
                    </Text>
                  </Username>
                  <Time ts={mEvent.getTs()} compact size="T200" priority="300" />
                </>
              ) : undefined
            }
            reactions={reactions}
            threadSummary={threadSummary}
            onContextMenu={handleContextMenu}
          >
            <StreamMediaContext.Provider value={streamMediaCtx}>
              {msgContentJSX}
            </StreamMediaContext.Provider>
          </ChannelLayout>
        ) : (
          <BubbleLayout
            time={showTime ? <Time ts={mEvent.getTs()} compact /> : undefined}
            isOwn={isOwnMessage}
            // Same-sender continuation row: tuck tighter under the previous one
            // (no sender-gap). Time stays per-message regardless. See RoomTimeline
            // `collapsed`.
            collapsed={collapse}
            selected={bubbleSelected}
            // While editing, the body IS a composer card — drop the bubble chrome
            // so it reads as one box, not a composer nested in a bubble.
            editing={!!edit}
            // Grouped same-minute series → timestamp below the bubble; singles keep
            // it on the side (RoomTimeline computes timeBelow). Media is always below.
            timeBelow={timeBelow}
            mediaMode={mediaMode || voiceMode}
            reactions={reactions}
            threadSummary={threadSummary}
            // Read/delivery status under the user's LAST own message only — at
            // the live end of the timeline (RoomTimeline computes isLatestOwn).
            readStatus={
              isOwnMessage && isLatestOwn && !edit ? (
                <DmReadStatusLine room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
              ) : undefined
            }
            onClick={handleBubbleToggle}
            onContextMenu={handleContextMenu}
          >
            <StreamMediaContext.Provider value={streamMediaCtx}>
              {msgContentJSX}
            </StreamMediaContext.Provider>
          </BubbleLayout>
        )}
      </MessageBase>
    );
  }
);

// Parent-computed render snapshot for a timeline message row. This MUST be
// called during the PARENT's render and threaded in as the `renderSig` prop;
// it must NOT be recomputed from `mEvent` inside the memo comparator.
//
// matrix-js-sdk mutates the `MatrixEvent` IN PLACE on edit / redaction /
// decryption / local-echo (makeReplaced / makeRedacted / setClearData /
// handleRemoteEcho), and the timeline reuses one MatrixEvent instance per id,
// so `prevProps.mEvent === nextProps.mEvent`. A signature read off that live
// object inside the comparator would return the SAME (already-mutated) value
// for both prev and next and could never detect the change -- the classic
// stale-row trap element-web documents on `EventTile` (`isRedacted` is a prop
// there for exactly this reason). Capturing the snapshot here freezes it into
// the prop value, so React's retained prevProps holds the OLD snapshot and the
// comparator can diff old-vs-new. It also folds in the sender display name and
// the urlPreview setting, so a live rename / preview-toggle repaints the row.
export function getMessageRenderSig(
  room: Room,
  mEvent: MatrixEvent,
  editedEvent: MatrixEvent | undefined,
  urlPreview: boolean | undefined,
  // Bubble layout: whether this is the user's last own message (drives the
  // read-status line). Folded in so the prior tail repaints the instant it
  // stops being latest — the memo comparator only diffs renderSig.
  isLatestOwn?: boolean,
  // Bubble layout: whether this row shows its timestamp (false when a same-
  // sender same-minute row follows). Folded in so a row drops its stamp the
  // instant the next same-minute message arrives.
  showTime?: boolean,
  // Bubble layout: whether the timestamp drops below (same-minute series tail) vs
  // sits on the side (single). Folded in so the placement flips when a row joins
  // or leaves a minute series.
  timeBelow?: boolean
): string {
  const senderId = mEvent.getSender() ?? '';
  const senderDisplayName =
    getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
  const content = mEvent.getContent();
  return [
    mEvent.getId() ?? '',
    mEvent.getType(),
    typeof content.msgtype === 'string' ? content.msgtype : '',
    mEvent.isRedacted() ? '1' : '0',
    editedEvent?.getId() ?? '',
    mEvent.status ?? '',
    senderDisplayName,
    urlPreview ? '1' : '0',
    isLatestOwn ? '1' : '0',
    showTime === false ? '0' : '1',
    timeBelow ? '1' : '0',
  ].join('\u0001');
}

// Custom equality for the timeline row. RoomTimeline re-renders on every live
// event / receipt / scroll-state change and rebuilds all ~80 visible rows; this
// lets React skip rows whose visible output is unchanged. SAFE because the
// dynamic children self-subscribe to their own data (Reactions→useRelations,
// Reply→useRoomEvent, ThreadSummaryCard→ThreadEvent) and the bubble read-status
// line self-subscribes (DmReadStatusLine→useMessageStatus→RoomEvent.Receipt /
// LocalEchoUpdated) — so we only need to compare
// the row's own scalar/identity props, the PRESENCE of the dynamic node slots,
// and the parent-computed renderSig snapshot. The comparator errs toward re-rendering:
// any prop whose identity is unstable simply defeats the memo (no perf win) but
// never causes staleness. Returns true to SKIP the re-render.
//
// The mutable-event vectors (edit / redaction / decryption / local-echo)
// AND the children-only inputs (sender display name, urlPreview) are all
// folded into the parent-computed `renderSig` prop (see getMessageRenderSig),
// so they repaint correctly. `mediaAutoLoad` is mount-time (autoPlay /
// initial fetch), so it needs no signature term.
function areMessagePropsEqual(
  prev: ComponentProps<typeof MessageInner>,
  next: ComponentProps<typeof MessageInner>
): boolean {
  return (
    prev.room === next.room &&
    prev.mEvent === next.mEvent &&
    prev.relations === next.relations &&
    prev.collapse === next.collapse &&
    prev.railHidden === next.railHidden &&
    prev.highlight === next.highlight &&
    prev.edit === next.edit &&
    prev.canDelete === next.canDelete &&
    prev.canSendReaction === next.canSendReaction &&
    prev.canPinEvent === next.canPinEvent &&
    prev.imagePackRooms === next.imagePackRooms &&
    prev.hideReadReceipts === next.hideReadReceipts &&
    prev.showDeveloperTools === next.showDeveloperTools &&
    prev.memberPowerTag === next.memberPowerTag &&
    prev.accessibleTagColors === next.accessibleTagColors &&
    prev.legacyUsernameColor === next.legacyUsernameColor &&
    prev.streamRailStart === next.streamRailStart &&
    prev.streamRailEnd === next.streamRailEnd &&
    prev.hideThreadReplyAffordance === next.hideThreadReplyAffordance &&
    prev.hideMainReplyAffordance === next.hideMainReplyAffordance &&
    prev.msgType === next.msgType &&
    prev.layout === next.layout &&
    prev.channelHeaderInBubble === next.channelHeaderInBubble &&
    prev.className === next.className &&
    prev.onUserClick === next.onUserClick &&
    prev.onUsernameClick === next.onUsernameClick &&
    prev.onReplyClick === next.onReplyClick &&
    prev.onReactionToggle === next.onReactionToggle &&
    prev.onEditId === next.onEditId &&
    Boolean(prev.reply) === Boolean(next.reply) &&
    Boolean(prev.reactions) === Boolean(next.reactions) &&
    Boolean(prev.threadSummary) === Boolean(next.threadSummary) &&
    // `data-message-item` is the row's ABSOLUTE virtual-paginator index, queried
    // by scrollToItem(). It shifts for existing rows when older history
    // paginates in at the top, so a skipped render must not leave it stale —
    // live appends at the bottom don't move it, so the memo still fires there.
    (prev as Record<string, unknown>)['data-message-item'] ===
      (next as Record<string, unknown>)['data-message-item'] &&
    prev.renderSig === next.renderSig
  );
}

export const Message = memo(MessageInner, areMessagePropsEqual);

export type EventProps = {
  room: Room;
  mEvent: MatrixEvent;
  highlight: boolean;
  canDelete?: boolean;
  hideReadReceipts?: boolean;
  showDeveloperTools?: boolean;
  // Bubble (1:1 DM) layout: drop the row's horizontal gutter (the band owns the
  // edge inset). Set by SyslineMessage / CallMessage when layout !== 'channel'.
  bubble?: boolean;
};
export const Event = as<'div', EventProps>(
  (
    {
      className,
      room,
      mEvent,
      highlight,
      canDelete,
      hideReadReceipts,
      showDeveloperTools,
      bubble,
      children,
      ...props
    },
    ref
  ) => {
    const mx = useMatrixClient();
    const [hover, setHover] = useState(false);
    const { hoverProps } = useHover({ onHoverChange: setHover });
    const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
    const [menuAnchor, setMenuAnchor] = useState<RectCords>();
    const stateEvent = typeof mEvent.getStateKey() === 'string';
    // Touch-friendly action-rail sizing — mirrors MessageInner so the system/
    // state-event rail also reads comfortably under a finger on mobile.
    const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
    const railBtnSize = isMobile ? '400' : '300';
    const railIconSize = isMobile ? '200' : '100';
    const railGap = isMobile ? '200' : '100';

    const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
      if (evt.altKey || !window.getSelection()?.isCollapsed) return;
      const tag = (evt.target as Element).tagName;
      if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
      evt.preventDefault();
      setMenuAnchor({
        x: evt.clientX,
        y: evt.clientY,
        width: 0,
        height: 0,
      });
    };

    const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
      const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget;
      setMenuAnchor(target.getBoundingClientRect());
    };

    const closeMenu = () => {
      setMenuAnchor(undefined);
    };

    return (
      <MessageBase
        className={classNames(css.MessageBase, className)}
        tabIndex={0}
        bubble={bubble}
        space={STREAM_MESSAGE_SPACING}
        autoCollapse
        highlight={highlight}
        selected={!!menuAnchor}
        {...props}
        {...hoverProps}
        {...focusWithinProps}
        ref={ref}
      >
        {(hover || !!menuAnchor) && (
          <div className={css.MessageOptionsBase}>
            <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
              <Box gap={railGap}>
                <PopOut
                  anchor={menuAnchor}
                  position="Bottom"
                  align={menuAnchor?.width === 0 ? 'Start' : 'End'}
                  offset={menuAnchor?.width === 0 ? 0 : undefined}
                  content={
                    <FocusTrap
                      focusTrapOptions={{
                        initialFocus: false,
                        onDeactivate: () => setMenuAnchor(undefined),
                        clickOutsideDeactivates: true,
                        isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
                        isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
                        escapeDeactivates: stopPropagation,
                      }}
                    >
                      <Menu {...props} ref={ref}>
                        <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
                          {!hideReadReceipts && (
                            <MessageReadReceiptItem
                              room={room}
                              eventId={mEvent.getId() ?? ''}
                              onClose={closeMenu}
                            />
                          )}
                          {showDeveloperTools && (
                            <MessageSourceCodeItem
                              room={room}
                              mEvent={mEvent}
                              onClose={closeMenu}
                            />
                          )}
                          <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
                        </Box>
                        {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
                          (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
                          <>
                            <Line size="300" />
                            <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
                              {!mEvent.isRedacted() && canDelete && (
                                <MessageDeleteItem
                                  room={room}
                                  mEvent={mEvent}
                                  onClose={closeMenu}
                                />
                              )}
                              {mEvent.getSender() !== mx.getUserId() && (
                                <MessageReportItem
                                  room={room}
                                  mEvent={mEvent}
                                  onClose={closeMenu}
                                />
                              )}
                            </Box>
                          </>
                        )}
                      </Menu>
                    </FocusTrap>
                  }
                >
                  <IconButton
                    variant="SurfaceVariant"
                    size={railBtnSize}
                    radii="300"
                    onClick={handleOpenMenu}
                    aria-pressed={!!menuAnchor}
                  >
                    <Icon src={RailIcons.More} size={railIconSize} />
                  </IconButton>
                </PopOut>
              </Box>
            </Menu>
          </div>
        )}
        <div onContextMenu={handleContextMenu}>{children}</div>
      </MessageBase>
    );
  }
);
