import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { Box, Button, Header, Icon, IconButton, Icons, Scroll, Spinner, Text, config } from 'folds';
import {
  Direction,
  EventTimeline,
  EventTimelineSet,
  EventType,
  MatrixEvent,
  MatrixEventEvent,
  RelationType,
  Room,
  Thread,
  ThreadEvent,
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts as LinkifyOpts } from 'linkifyjs';

import * as css from './ThreadDrawer.css';
import { ChatComposer } from './RoomView.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { scrollToBottom } from '../../utils/dom';
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
import { markAsThreadRead } from '../../utils/notifications';
import { useEditor } from '../../components/editor';
import {
  getEditedEvent,
  getEventReactions,
  getMemberAvatarMxc,
  getMemberDisplayName,
  isBridgedRoom,
  reactionOrEditEvent,
} from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import {
  ImageContent,
  MessageNotDecryptedContent,
  MessageUnsupportedContent,
  MSticker,
  RedactedContent,
  Reply,
} from '../../components/message';
import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { MessageErrorBoundary } from './MessageErrorBoundary';
import { RoomInput } from './RoomInput';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import {
  EncryptedContent,
  Message,
  Reactions,
  getMessageRenderSig,
  useMessageInteractionHandlers,
} from './message';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useChannelsMode } from '../../hooks/useChannelsMode';
import { useIsOneOnOne } from '../../hooks/useRoom';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { useTheme } from '../../hooks/useTheme';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
  THREAD_DRAWER_WIDTH_MIN,
  clampThreadDrawerWidth,
  threadDrawerWidthAtom,
} from '../../state/threadDrawerWidth';
import {
  factoryRenderLinkifyWithMention,
  getReactCustomHtmlParser,
  LINKIFY_OPTS,
  makeMentionCustomProps,
  renderMatrixMention,
} from '../../plugins/react-custom-html-parser';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';

// IntersectionObserver ratio at which the bottom sentinel still counts
// as «at the bottom of the replies list». 0.9 lets a few pixels of
// overscroll still register as at-bottom (matrix-react-sdk PR #9606
// pattern: don't toggle stuckAtBottom on tiny scroll movements).
const AT_BOTTOM_THRESHOLD = 0.9;
// Top sentinel triggers on minimal intersection so back-pagination
// fires while the user is still scrolling toward the top, not after
// the sentinel is fully on-screen — avoids a perceptible pause at the
// edge before the next page loads.
const TOP_SENTINEL_THRESHOLD = 0.1;
// Reply page size for both the cold-load relations fetch and the
// incremental back-pagination via paginateEventTimeline. 50 matches
// element-web's default thread page; large enough to fill a typical
// drawer viewport in one shot.
const REPLY_PAGE_SIZE = 50;

// Live event check — returns true if the thread's liveTimeline holds
// any user-visible reply event (not the root, not an aggregation like
// edit / reaction / redaction). Module-level so it doesn't get
// re-allocated inside repair / render passes; pure given inputs.
const liveTimelineHasReplies = (thread: Thread, rootId: string): boolean =>
  thread.liveTimeline
    .getEvents()
    .some((evt) => evt.getId() !== rootId && !reactionOrEditEvent(evt));

type ThreadDrawerProps = {
  room: Room;
  rootId: string;
  // The /channels parent path that the close button navigates back to.
  // Pre-resolved by `Room.tsx` so the drawer doesn't reimplement the
  // routing rules; on close we replace history (not push) so the back
  // stack collapses naturally on Android hardware-back.
  parentRoomPath: string;
  // On mobile the drawer takes over the content column instead of
  // sitting beside the timeline. Layout is otherwise identical so the
  // M5 mobile-stack work doesn't need to fork the component.
  variant: 'desktop' | 'mobile';
  // When true, suppress the drawer's own thread header (the «ТРЕД / в
  // #<room>» caption + ✕ close). The bot conversation surface mounts an
  // outer hero + back-row that owns the chrome, so the channels-oriented
  // header would be foreign there. Defaults to false → channels usage is
  // unchanged. The caller is then responsible for a back affordance
  // (the ✕'s `close()` navigation lives in the header we drop here).
  hideHeader?: boolean;
  // 'assistant' renders a ChatGPT-style transcript instead of the channel chrome: the bot's
  // turns are full-width plain text (no avatar / name / timestamp / hover-menu / reactions),
  // the user's turns are right-aligned bubbles, and a typing-dots indicator shows while the
  // other member (the bot) is typing. Used by the AI bot conversation surface. Defaults to
  // 'default' (the channels Message rendering), so existing callers are unchanged.
  messageStyle?: 'default' | 'assistant';
};

// M4a: thread events render through the shared `<Message>` component
// — same hover/menu/edit/reactions/reply chrome as the main timeline,
// `'channel'` layout (avatar + name + body without bubble). Root and
// replies share the render path so editing/reactions/redact work
// identically for both, matching Slack/Element-web and removing the
// pre-M4a fork between `ThreadEventCard` and `<Message>`.

// Drawer body — handles all the loading / error states the plan calls
// out (null Thread, fetchRoomEvent failure, paginate failure, post-load
// empty). The component is intentionally light on chrome compared to
// the full Message renderer in the timeline: M2 is MVP and rich
// reactions / hover-menus / rail-dot inside the drawer is M9 territory.
export function ThreadDrawer({
  room,
  rootId,
  parentRoomPath,
  variant,
  hideHeader = false,
  messageStyle = 'default',
}: ThreadDrawerProps) {
  const assistantStyle = messageStyle === 'assistant';
  const { t } = useTranslation();
  const mx = useMatrixClient();
  const navigate = useNavigate();
  const editor = useEditor();
  const fileDropContainerRef = useRef<HTMLDivElement>(null);
  const closeBtnRef = useRef<HTMLButtonElement>(null);

  // Desktop-only resizable side pane. Mirrors `ResizablePageNav` in
  // `components/page/Page.tsx`: width persisted to localStorage via
  // `threadDrawerWidthAtom`, clamped [MIN, viewport/3]. Mobile variant
  // doesn't render the wrapper at all so these hooks are effectively
  // no-ops there — they always run (rules-of-hooks) but the ref / DOM
  // is never wired up.
  const isDesktopVariant = variant === 'desktop';
  const resizableRef = useRef<HTMLDivElement>(null);
  const resizeHandleRef = useRef<HTMLDivElement>(null);
  const [savedWidth, setSavedWidth] = useAtom(threadDrawerWidthAtom);
  const [vw, setVw] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1280);
  const [dragging, setDragging] = useState(false);
  // Live width during a drag — kept in component state so we don't
  // flush localStorage on every pointermove (hundreds of sync disk
  // writes per drag). Atom is committed once on pointerup.
  const [liveWidth, setLiveWidth] = useState<number | null>(null);

  useEffect(() => {
    if (!isDesktopVariant) return undefined;
    const onResize = () => setVw(window.innerWidth);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, [isDesktopVariant]);

  const drawerMaxW = Math.max(THREAD_DRAWER_WIDTH_MIN, Math.floor(vw / 3));
  const drawerBaseWidth = dragging && liveWidth !== null ? liveWidth : savedWidth;
  const drawerWidth = clampThreadDrawerWidth(drawerBaseWidth, vw);
  // Viewports too narrow for any meaningful range (max == min) hide the
  // handle entirely — leaving a non-draggable handle reads as broken UI.
  const canResize = isDesktopVariant && drawerMaxW > THREAD_DRAWER_WIDTH_MIN;
  const atMin = dragging && liveWidth !== null && liveWidth <= THREAD_DRAWER_WIDTH_MIN;
  const atMax = dragging && liveWidth !== null && liveWidth >= drawerMaxW;

  // Body-style cleanup if the drag terminates without `pointerup`
  // (component unmount mid-drag — route change, mobile breakpoint flip,
  // Alt-Tab). Without this the page can get stuck with col-resize cursor
  // + userSelect: none.
  useEffect(() => {
    if (!dragging) return undefined;
    return () => {
      document.body.style.cursor = '';
      document.body.style.userSelect = '';
    };
  }, [dragging]);

  const beginDrag = useCallback((pointerId: number) => {
    setDragging(true);
    setLiveWidth(null);
    try {
      resizeHandleRef.current?.setPointerCapture(pointerId);
    } catch {
      /* setPointerCapture is best-effort */
    }
    document.body.style.cursor = 'col-resize';
    document.body.style.userSelect = 'none';
  }, []);

  const endDrag = useCallback(() => {
    setDragging(false);
    document.body.style.cursor = '';
    document.body.style.userSelect = '';
    setLiveWidth((current) => {
      if (current !== null) {
        setSavedWidth(clampThreadDrawerWidth(current, window.innerWidth));
      }
      return null;
    });
  }, [setSavedWidth]);

  const onResizePointerDown = useCallback(
    (e: React.PointerEvent<HTMLDivElement>) => {
      if (e.button !== 0 || !canResize) return;
      e.preventDefault();
      beginDrag(e.pointerId);
    },
    [beginDrag, canResize]
  );

  const onResizePointerMove = useCallback(
    (e: React.PointerEvent<HTMLDivElement>) => {
      if (!dragging || !resizableRef.current) return;
      // Right edge of the wrapper is anchored at the row's right edge
      // (drawer is the last item in Room.tsx's flex row). Width grows
      // as the pointer moves LEFT, shrinks as it moves right — inverse
      // of the page-nav handle.
      const rect = resizableRef.current.getBoundingClientRect();
      setLiveWidth(clampThreadDrawerWidth(rect.right - e.clientX, window.innerWidth));
    },
    [dragging]
  );

  const onResizeStopPointer = useCallback(
    (e: React.PointerEvent<HTMLDivElement>) => {
      try {
        resizeHandleRef.current?.releasePointerCapture(e.pointerId);
      } catch {
        /* releasePointerCapture is best-effort */
      }
      endDrag();
    },
    [endDrag]
  );

  const onResizeKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLDivElement>) => {
      if (!canResize) return;
      const step = e.shiftKey ? 64 : 16;
      let next: number | null = null;
      // Arrow direction is inverted vs the page-nav handle: ArrowLeft
      // grows the drawer (its left edge moves left → width increases).
      if (e.key === 'ArrowLeft') next = savedWidth + step;
      else if (e.key === 'ArrowRight') next = savedWidth - step;
      else if (e.key === 'Home') next = drawerMaxW;
      else if (e.key === 'End') next = THREAD_DRAWER_WIDTH_MIN;
      if (next === null) return;
      e.preventDefault();
      setSavedWidth(clampThreadDrawerWidth(next, vw));
    },
    [savedWidth, vw, drawerMaxW, canResize, setSavedWidth]
  );
  // Sentinel at the bottom of the replies list. The autoscroll effect
  // below scrolls into view on (a) initial reveal, (b) reply count
  // growth while at-bottom, (c) own send. Whether we're at-bottom is
  // tracked via the IntersectionObserver attached by `setBottomSentinel`
  // — using a callback-ref instead of a useEffect so the observer
  // attaches at the moment the DOM node mounts (cold-load renders
  // a Spinner first; the sentinel only appears in the content branch).
  const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
  const bottomObserverRef = useRef<IntersectionObserver | null>(null);
  // Ref to the folds `<Scroll>` host (the actual `overflow:auto`
  // element). Used by the autoscroll layout-effect to set `scrollTop`
  // synchronously before paint on first reveal — `scrollIntoView` from
  // a passive `useEffect` runs AFTER paint, so the user briefly sees
  // the drawer at top-of-content before it «jumps» to the bottom.
  const scrollHostRef = useRef<HTMLDivElement | null>(null);
  const headerId = `thread-drawer-header-${rootId}`;

  const useAuthentication = useMediaAuthentication();
  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
  const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
  // Encrypted rooms never auto-preview URLs (the `encUrlPreview` opt-in was removed).
  const showUrlPreview = room.hasEncryptionStateEvent() ? false : urlPreview;

  const unreadThreadingEnabled = useUnreadThreadingEnabled();

  const mentionClickHandler = useMentionClickHandler(room.roomId);
  const spoilerClickHandler = useSpoilerClickHandler();

  // Same parser pipeline RoomTimeline uses so links / mentions /
  // spoilers behave identically in drawer and main.
  const linkifyOpts = useMemo<LinkifyOpts>(
    () => ({
      ...LINKIFY_OPTS,
      render: factoryRenderLinkifyWithMention((href) =>
        renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
      ),
    }),
    [mx, room, mentionClickHandler]
  );
  const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
    () =>
      getReactCustomHtmlParser(mx, room.roomId, {
        linkifyOpts,
        useAuthentication,
        handleSpoilerClick: spoilerClickHandler,
        handleMentionClick: mentionClickHandler,
      }),
    [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
  );

  const powerLevels = usePowerLevelsContext();
  const creators = useRoomCreators(room);
  const permissions = useRoomPermissions(creators, powerLevels);
  const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
  const canRedact = permissions.action('redact', mx.getSafeUserId());
  const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
  const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
  const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());

  // Power-tag colour wiring — drawer mirrors RoomTimeline so coloured
  // username chips (mods / room creators / power-levelled users) render
  // identically in main timeline and drawer. Bundling these into a
  // shared hook is M9 polish; for now duplicating the call sites keeps
  // the dependency graph explicit.
  const theme = useTheme();
  const creatorsTag = useRoomCreatorsTag();
  const powerLevelTags = usePowerLevelTags(room, powerLevels);
  const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
  const accessiblePowerTagColors = useAccessiblePowerTagColors(
    theme.kind,
    creatorsTag,
    powerLevelTags
  );

  const isOneOnOne = useIsOneOnOne();
  const channelsMode = useChannelsMode();
  const isBridged = channelsMode && isBridgedRoom(room);
  // Assistant-mode typing indicator: in a 1:1 bot DM the only other member is the bot, so any
  // non-self typing receipt means the bot is composing a reply. Drives the typing-dots row.
  const typingReceipts = useRoomTypingMember(room.roomId);
  const botTyping = assistantStyle && typingReceipts.some((r) => r.userId !== mx.getUserId());
  const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');

  const roomToParents = useAtomValue(roomToParentsAtom);
  const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);

  const { spaceIdOrAlias, roomIdOrAlias } = useParams();

  // Reply-draft scope: tuple key `[roomId, rootId]` so the chip writes
  // into the drawer composer's draft slot, not the channel composer's.
  // The `RoomInput` instance below subscribes to the same atom via
  // `draftKey(roomId, threadId)` (see `roomInputDrafts.ts:34-37`) so
  // the chip surfaces inside the drawer composer.
  const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId)));

  // Reply-chip click scrolls within the drawer to the matching
  // `data-message-id` row. If the target lives in the main timeline
  // (e.g. a chip on the root that points to a quoted main-column
  // message) the scroll is a silent no-op — that's an accepted M4a
  // limitation; cross-pane navigation is M9 polish.
  const handleScrollToDrawerEvent = useCallback((evtId: string) => {
    const host = scrollHostRef.current;
    if (!host) return;
    const target = host.querySelector<HTMLElement>(`[data-message-id="${evtId}"]`);
    if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
  }, []);

  const {
    editId,
    handleEdit,
    handleOpenReply,
    handleUserClick,
    handleUsernameClick,
    handleReplyClick,
    handleReactionToggle,
  } = useMessageInteractionHandlers({
    room,
    editor,
    // Drawer composer is mounted alongside the drawer, EXCEPT in the
    // read-only branch where `canMessage === false` and the body
    // renders `<RoomInputPlaceholder>` instead of `<RoomInput>` (see
    // bottom of this component). In that branch the Slate `<Editable>`
    // is never registered in slate-react's `EDITOR_TO_ELEMENT` map, so
    // `ReactEditor.focus(editor)` from the username-click handler
    // would throw. Treating !canMessage as suspended makes the hook
    // bail before touching the unmounted editor.
    composerSuspended: !canMessage,
    setReplyDraft,
    onOpenEvent: handleScrollToDrawerEvent,
    channelsMode,
    isBridged,
    spaceIdOrAlias,
    roomIdOrAlias,
  });

  const [thread, setThread] = useState<Thread | null>(() => room.getThread(rootId));
  const [rootEvent, setRootEvent] = useState<MatrixEvent | null>(
    () => room.getThread(rootId)?.rootEvent ?? room.findEventById(rootId) ?? null
  );
  const [rootError, setRootError] = useState<Error | null>(null);
  const [paginateError, setPaginateError] = useState<Error | null>(null);
  const [paginating, setPaginating] = useState(false);
  // Cold-load relations fetch error state. Separate from `paginateError`
  // because the failure is on initial Thread materialization (before
  // any Thread exists), with a different retry pathway (re-runs the
  // createThread effect via `coldLoadAttempt` increment instead of
  // re-calling paginate).
  const [coldLoadError, setColdLoadError] = useState<Error | null>(null);
  const [coldLoadAttempt, setColdLoadAttempt] = useState(0);
  // True while the cold-load `mx.relations` fetch is in-flight. We
  // can't fall back to `replies.length === 0` to gate the «no replies»
  // empty state because SDK may have pre-created a broken Thread
  // (empty replyCount + null backward token) that we're about to
  // repair via addEvents — until the fetch completes, an empty thread
  // doesn't mean no replies, just «not loaded yet».
  const [coldLoadFetching, setColdLoadFetching] = useState(false);
  const retryColdLoad = useCallback(() => {
    setColdLoadError(null);
    setColdLoadAttempt((n) => n + 1);
  }, []);

  // Lazy-fetch the root event when it isn't already in the room store
  // (e.g. cold-load via shareable thread URL — the room timeline hasn't
  // back-paginated far enough to surface it). Without this we'd render
  // the drawer with a permanent "loading root" placeholder.
  //
  // `getEventMapper()(raw)` wires the event into the room re-emit chain
  // and kicks off decryption for encrypted events automatically (its
  // `decrypt` opt defaults to true). The `MatrixEventEvent.Decrypted`
  // listener attached below then triggers a re-render once decryption
  // completes. `new MatrixEvent(raw)` would skip that wiring and leave
  // an encrypted body permanently empty.
  const loadRootEvent = useCallback(async () => {
    setRootError(null);
    try {
      const raw = await mx.fetchRoomEvent(room.roomId, rootId);
      const evt = mx.getEventMapper()(raw);
      setRootEvent(evt);
    } catch (err) {
      setRootError(err as Error);
    }
  }, [mx, room.roomId, rootId]);

  useEffect(() => {
    if (!rootEvent) {
      loadRootEvent();
    }
    // No cleanup needed: loadRootEvent is idempotent and setState is
    // safely no-op on unmount under React 18 (no warning).
  }, [rootEvent, loadRootEvent]);

  // Subscribe to ThreadEvent.New on the *room* (not the thread — SDK
  // excludes ThreadEvent.New from the thread's own emitter set). We
  // need this for the cold-start case where the user opens the drawer
  // before any reply has been posted: room.getThread() returns null,
  // then the very first reply lands → SDK lazy-creates the Thread and
  // fires ThreadEvent.New on the room.
  //
  // Listener-attach BEFORE the re-check (closes race-window completely
  // — earlier «check then attach» order had a millisecond gap where a
  // synchronous /sync handler could create+emit the Thread between
  // check and attach).
  useEffect(() => {
    if (thread) return undefined;
    const handler = (newThread: Thread) => {
      if (newThread.id === rootId) setThread(newThread);
    };
    room.on(ThreadEvent.New, handler);
    // Re-check now: a /sync delivered between the useState initializer
    // and this effect AND between handler attach and now is now caught
    // either by the handler (if New fires after attach) or by the
    // re-check itself (if New fired before attach).
    const existing = room.getThread(rootId);
    if (existing) {
      setThread(existing);
    }
    return () => {
      room.off(ThreadEvent.New, handler);
    };
  }, [room, rootId, thread]);

  // Cold-load deep-link materialization OR broken-thread recovery.
  //
  // Two paths leave the drawer empty when we deep-link into a thread
  // whose replies aren't in /sync coverage:
  //
  // (a) `room.getThread(rootId) === null`: nothing has materialized
  //     the Thread yet. We need to fetch replies and create it.
  // (b) SDK pre-emptively created an empty Thread via
  //     `client.processThreadRoots` when /sync or /messages pagination
  //     delivered the thread ROOT but no replies.
  //     `room.createThread(threadId, root, [], false)` runs with an
  //     empty events array. The resulting Thread runs
  //     `updateThreadMetadata`, which calls `resetLiveTimeline()` and
  //     then either branches on `replyCount === 0 && rootEvent` to add
  //     just the root with a null backward pagination token, or
  //     auto-paginates. If bundled `m.relations.m.thread.count` is
  //     missing/zero, the shortcut fires and the live timeline is
  //     left with only the root.
  //
  // Critical race: SDK's metadata pass is async and waits on an HTTP
  // fetch (`Thread::fetchRootEvent`). If we add events BEFORE the
  // pass reaches `resetLiveTimeline()`, our additions are wiped and
  // our pagination-token write is overwritten by the shortcut's null
  // token. The pass ends by setting `initialEventsFetched = true` and
  // emitting `ThreadEvent.Update` — both observable, so we serialize
  // after them.
  //
  // Element-web doesn't hit either path because their drawer always
  // opens from a main-timeline click; the root is in cache with
  // bundled aggregations, and `replyCount > 0` flows them through
  // SDK's `paginateEventTimeline` branch which does the right thing.
  //
  // Repair flow:
  //  - thread === null: fetch via `mx.relations`, then `createThread`
  //    with replies pre-loaded. No SDK race because we own the
  //    creation; `addEvents` runs before any auto-fired metadata pass
  //    can reset.
  //  - thread !== null and `initialEventsFetched`: SDK done; safe to
  //    `addEvents` immediately.
  //  - thread !== null and not yet fetched: subscribe to
  //    `ThreadEvent.Update`; on first fire (= pass complete), check
  //    again and `addEvents` if still empty.
  //
  // Why we DON'T pass replies to `createThread([rootEvent], true)`:
  // SDK's metadata pass calls `resetLiveTimeline()` unconditionally
  // before deciding the branch — any events pre-loaded via the
  // constructor get cleared in that step. Adding them afterwards
  // (post-Update) is the only ordering that survives.
  useEffect(() => {
    if (!rootEvent) return undefined;
    if (rootEvent.getId() !== rootId) return undefined;
    // Use `liveTimelineHasReplies` (actual events in timeline) NOT
    // `thread.length` (which is `replyCount + pendingReplyCount`).
    // `replyCount` is set from bundled aggregations BEFORE events
    // land in the timeline, so `length > 0` does not imply the
    // renderer has anything to show.
    if (thread && liveTimelineHasReplies(thread, rootId)) return undefined;

    let cancelled = false;
    let started = false;
    setColdLoadError(null);

    const performRepair = async (target: Thread | null) => {
      if (cancelled || started) return;
      // Last-chance check: SDK may have populated between subscribe
      // and now (e.g. its own auto-paginate branch finished).
      if (target && liveTimelineHasReplies(target, rootId)) return;
      started = true;
      setColdLoadFetching(true);
      try {
        const result = await mx.relations(room.roomId, rootId, RelationType.Thread);
        if (cancelled) return;
        const replies = [...result.events].reverse();
        // Re-resolve the thread once more: SDK may have created one
        // during the await (network slow, /sync arrived).
        const current = target ?? room.getThread(rootId);
        let written: Thread | null = current ?? null;
        if (written) {
          // Same lastchance — don't double-add if SDK already filled.
          if (!liveTimelineHasReplies(written, rootId)) {
            written.addEvents(replies, false);
          }
        } else {
          written = room.createThread(rootId, rootEvent, replies, true);
        }
        if (written) {
          // `result.nextBatch` is the `/relations` continuation
          // marker — feeding it here lets SDK's pagination machinery
          // resume back-pagination from where this fetch left off.
          // null means «no more older events» (server reached the
          // thread start), which correctly disables back-pagination.
          written.liveTimeline.setPaginationToken(result.nextBatch ?? null, Direction.Backward);
          setThread(written);
        }
      } catch (err) {
        if (cancelled) return;
        started = false; // allow retry via coldLoadAttempt
        setColdLoadError(err as Error);
      } finally {
        if (!cancelled) setColdLoadFetching(false);
      }
    };

    // Case 1: no thread yet. We own creation — no SDK race.
    if (!thread) {
      performRepair(null);
      return () => {
        cancelled = true;
      };
    }

    // Case 2: thread exists.
    // 2a: SDK's initial metadata pass already done. Safe to repair.
    if (thread.initialEventsFetched) {
      performRepair(thread);
      return () => {
        cancelled = true;
      };
    }

    // 2b: SDK's pass still in flight. Wait for ThreadEvent.Update
    // (fired at the end of `updateThreadMetadata`) so our `addEvents`
    // / token-write don't get reset out from under us. The handler
    // also re-checks the events to skip if SDK populated via its own
    // paginate branch.
    const onUpdate = () => performRepair(thread);
    thread.on(ThreadEvent.Update, onUpdate);
    return () => {
      cancelled = true;
      thread.off(ThreadEvent.Update, onUpdate);
    };
  }, [thread, rootEvent, room, rootId, mx, coldLoadAttempt]);

  // When a Thread already exists, listen for Update so the drawer
  // re-renders on new replies / edits / redactions inside the thread.
  // `ThreadEvent.Update` covers count + latest_event refresh and is
  // fired both on aggregation changes and on every new reply
  // (`updateThreadMetadata` fires it from `addEvent`). RoomEvent.Timeline
  // re-emit was redundant — both ticked for the same reply, causing
  // back-to-back re-renders + a wasted scrollIntoView frame.
  const [, forceRender] = useState(0);
  useEffect(() => {
    if (!thread) return undefined;
    const tick = () => forceRender((n) => n + 1);
    thread.on(ThreadEvent.Update, tick);
    return () => {
      thread.off(ThreadEvent.Update, tick);
    };
  }, [thread]);

  // Encrypted root cold-load: subscribe to MatrixEventEvent.Decrypted
  // on the root event so the drawer re-renders when decryption finishes
  // (the body is empty until then). Without this listener, an encrypted
  // thread root deep-link shows a permanently empty card. Listener is
  // event-scoped and detaches on unmount or when rootEvent changes.
  useEffect(() => {
    if (!rootEvent || !rootEvent.isEncrypted()) return undefined;
    const tick = () => forceRender((n) => n + 1);
    rootEvent.on(MatrixEventEvent.Decrypted, tick);
    return () => {
      rootEvent.off(MatrixEventEvent.Decrypted, tick);
    };
  }, [rootEvent]);

  // Back-pagination: ONLY incremental on scroll-up via the top
  // sentinel — initial fill is owned by the cold-load repair effect
  // above, which uses `mx.relations` directly to avoid the SDK
  // `Thread.updateThreadMetadata` race. Running an initial paginate
  // here would fire BEFORE the SDK's metadata pass completes (which
  // calls `resetLiveTimeline()` then either auto-paginates or takes
  // the null-token shortcut), and the events we fetched would be
  // wiped when the pass reaches its `resetLiveTimeline()` call.
  //
  // For incremental scroll-up paginate we still gate on
  // `initialEventsFetched`. Once true, SDK won't reset the timeline
  // again, so paginateEventTimeline writes are safe to keep.
  const paginatingRef = useRef(false);
  const canBackPaginateRef = useRef(true);
  const paginate = useCallback(async () => {
    if (!thread) return;
    if (!thread.initialEventsFetched) return;
    if (paginatingRef.current) return;
    if (!canBackPaginateRef.current) return;
    paginatingRef.current = true;
    setPaginating(true);
    setPaginateError(null);
    try {
      const live: EventTimeline = thread.liveTimeline;
      const more = await mx.paginateEventTimeline(live, {
        backwards: true,
        limit: REPLY_PAGE_SIZE,
      });
      if (!more) {
        canBackPaginateRef.current = false;
      }
    } catch (err) {
      setPaginateError(err as Error);
    } finally {
      paginatingRef.current = false;
      setPaginating(false);
    }
  }, [mx, thread]);

  // Reset pagination state when thread identity changes (re-mount via
  // key={`${roomId}/${rootId}`} normally handles this, but guard
  // anyway for safety against future caller patterns).
  useEffect(() => {
    canBackPaginateRef.current = true;
    paginatingRef.current = false;
  }, [thread]);

  // Incremental back-pagination via IntersectionObserver on a top
  // sentinel — Element-web ScrollPanel `onFillRequest` pattern. The
  // sentinel is conditionally rendered (only when `replies.length > 0`)
  // so a useEffect with `[paginate]` deps would never re-run after the
  // sentinel finally mounts: paginate identity is stable once `thread`
  // is set, and the deps don't reflect sentinel-presence. Use a
  // callback-ref so attach/detach happens atomically with node mount.
  // Latest `paginate` is read via a ref so the callback-ref itself can
  // stay stable identity across renders.
  const topSentinelRef = useRef<HTMLDivElement | null>(null);
  const topObserverRef = useRef<IntersectionObserver | null>(null);
  const paginateRef = useRef(paginate);
  paginateRef.current = paginate;
  const setTopSentinel = useCallback((el: HTMLDivElement | null) => {
    topObserverRef.current?.disconnect();
    topObserverRef.current = null;
    topSentinelRef.current = el;
    if (!el) return;
    const observer = new IntersectionObserver(
      (entries) => {
        const entry = entries[0];
        if (entry?.isIntersecting) paginateRef.current();
      },
      { threshold: TOP_SENTINEL_THRESHOLD }
    );
    observer.observe(el);
    topObserverRef.current = observer;
  }, []);

  // Replies — strip aggregations (m.replace edits / m.annotation
  // reactions / redactions) and the root itself so the UI shows just
  // the user-visible reply cards.
  //
  // Compute inline (not via useMemo): the SDK mutates
  // `thread.liveTimeline` in place — the Thread reference and its
  // liveTimeline reference are stable across mutations. A useMemo
  // keyed on `[thread, rootId]` would never invalidate even when
  // forceRender bumps the counter, so newly-arrived replies (incl.
  // own send local-echo) would never reach the rendered list.
  const replies: MatrixEvent[] = thread
    ? thread.liveTimeline.getEvents().filter((evt) => {
        if (evt.getId() === rootId) return false;
        if (reactionOrEditEvent(evt)) return false;
        return true;
      })
    : [];

  // M4a: mount-transition trigger for `<Reactions>` and edited-body
  // refresh. `getEventReactions` and `getEditedEvent` return undefined
  // until the SDK creates a Relations container — at which point the
  // target event emits `MatrixEventEvent.RelationsCreated`
  // (matrix-js-sdk relations.js:375). Without this listener, the FIRST
  // reaction or edit on a thread row never surfaces in the drawer:
  // ThreadEvent.Update doesn't fire for annotations/replaces (Thread
  // returns early at thread.js:332-334 before updateThreadMetadata),
  // and `<Reactions>` only self-subscribes via `useRelations` AFTER
  // it's mounted. Once mounted, subsequent relation deltas flow
  // through useRelations and `getEditedEvent` re-evaluates each
  // render. This effect handles only the empty-→-first transition.
  //
  // Stable string signature (`repliesIds`) keeps the effect from
  // re-running on every `replies` re-allocation; the array reference
  // changes each render by design (see comment above) but content is
  // stable across non-paginate renders.
  const repliesIds = replies.map((r) => r.getId() ?? '').join('|');
  useEffect(() => {
    const handler = () => forceRender((n) => n + 1);
    const subscribed: MatrixEvent[] = [];
    if (rootEvent) {
      rootEvent.on(MatrixEventEvent.RelationsCreated, handler);
      subscribed.push(rootEvent);
    }
    replies.forEach((evt) => {
      evt.on(MatrixEventEvent.RelationsCreated, handler);
      subscribed.push(evt);
    });
    return () => {
      subscribed.forEach((evt) => evt.off(MatrixEventEvent.RelationsCreated, handler));
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [rootEvent, repliesIds]);

  // Read receipts (M4): fired on first reveal at-bottom, on reply
  // growth while at-bottom, and on own send via the layout/effect
  // pair below. The receipt handler in `state/room/roomToUnread.ts`
  // partitions by thread_id (PUT-with-fresh instead of blind DELETE)
  // so a thread receipt no longer wipes main-timeline unread. SDK
  // auto-attaches `thread_id: rootId` via `threadIdForReceipt(event)`.
  // Gated on `unreadThreadingEnabled` so the kill switch falls back
  // to pre-M4 silence (no thread receipts → channel-list badge
  // semantics match the pre-M4 blind-DELETE handler).

  // Track whether the user is parked at the bottom of the replies
  // list via IntersectionObserver on the sentinel. Element-web's
  // ScrollPanel uses the same `stuckAtBottom` flag — gating autoscroll
  // on this prevents pulling a user reading older replies down to the
  // new arrival (matrix-react-sdk PR #9606 «Fix thread list jumping
  // while scrolling»). See `AT_BOTTOM_THRESHOLD` for the «near
  // bottom» tolerance (a few px of overscroll still counts as
  // at-bottom).
  //
  // Callback-ref so the observer attaches when the sentinel actually
  // mounts (cold-load renders Spinner first → sentinel absent on
  // first effect pass; a `useEffect` with `[]` deps would never
  // re-run after the sentinel appears in the content branch).
  const isAtBottomRef = useRef(true);
  const setBottomSentinel = useCallback((el: HTMLDivElement | null) => {
    bottomObserverRef.current?.disconnect();
    bottomObserverRef.current = null;
    bottomSentinelRef.current = el;
    if (!el) return;
    const observer = new IntersectionObserver(
      (entries) => {
        const entry = entries[0];
        if (entry) isAtBottomRef.current = entry.isIntersecting;
      },
      { threshold: AT_BOTTOM_THRESHOLD }
    );
    observer.observe(el);
    bottomObserverRef.current = observer;
  }, []);

  // Auto-scroll to the bottom on (a) initial mount with replies
  // present, (b) reply-count growth when user is at-bottom, (c) own
  // send (regardless of scroll position — own message intent is to
  // follow the conversation, like Slack/Discord). Edits/redactions
  // don't change `repliesCount` so don't trigger.
  //
  // First reveal uses `useLayoutEffect` + direct `scrollTop` write on
  // the scroll host so the DOM is at-bottom BEFORE the browser paints.
  // The previous `useEffect` + `scrollIntoView({behavior: 'auto'})`
  // approach fired after paint, leaving a one-frame top-of-content
  // flash + jump that the user could see. Element-web's ScrollPanel
  // uses the same direct `scrollTop = scrollHeight` pattern
  // (apps/web/src/components/structures/ScrollPanel.tsx:556).
  //
  // Subsequent growth keeps `scrollIntoView({behavior: 'smooth'})` in
  // a regular `useEffect` — for replies arriving during the session
  // an animated scroll is the right cue, and post-paint timing isn't
  // visible because there's no top-of-content frame to flash.
  const repliesCount = replies.length;
  const lastReplyCountRef = useRef(0);
  const myUserId = mx.getUserId();

  // Fire a thread-scoped read receipt when the user lands at-bottom on
  // a thread with replies. SDK auto-routes `thread_id: rootId` via
  // `threadIdForReceipt(event)` (client.js:2655). Skipped when the
  // kill switch (`channels.unreadThreading: false`) is set — the
  // receipt handler in roomToUnread.ts then runs in pre-M4
  // blind-DELETE mode, so a thread receipt would wipe channel-main
  // unread (the M2 reason this was deferred). Also skipped when the
  // tab is backgrounded, mirroring RoomTimeline's `document.hasFocus`
  // gate so push notifications keep firing for unfocused tabs.
  const tryMarkThreadRead = useCallback(() => {
    if (!unreadThreadingEnabled) return;
    if (!thread) return;
    if (!document.hasFocus()) return;
    markAsThreadRead(mx, thread, hideActivity);
  }, [mx, thread, hideActivity, unreadThreadingEnabled]);

  useLayoutEffect(() => {
    const host = scrollHostRef.current;
    if (!host) return;
    const isFirstReveal = lastReplyCountRef.current === 0 && repliesCount > 0;
    if (!isFirstReveal) return;
    host.scrollTop = host.scrollHeight;
    isAtBottomRef.current = true;
    lastReplyCountRef.current = repliesCount;
    tryMarkThreadRead();
  }, [repliesCount, tryMarkThreadRead]);

  useEffect(() => {
    const host = scrollHostRef.current;
    if (!host || !bottomSentinelRef.current) return;
    // First reveal handled by useLayoutEffect above; skip here so we
    // don't double-scroll (the layout-effect bumped the counter).
    if (lastReplyCountRef.current === repliesCount) return;
    const grew = repliesCount > lastReplyCountRef.current;
    lastReplyCountRef.current = repliesCount;
    if (!grew) return;
    const last = replies[replies.length - 1];
    const lastIsOwn = !!myUserId && last?.getSender() === myUserId;
    if (!isAtBottomRef.current && !lastIsOwn) {
      // User is reading older replies — don't yank them down on a
      // new arrival from someone else.
      return;
    }
    // Always instant — never smooth. Cold-load arrives in 1-2 waves
    // (SDK cache + mx.relations resolve), so the first growth pass
    // after initial reveal would otherwise smooth-scroll a few rows
    // and leak a visible animation on drawer-open. Even for
    // steady-state live replies a snap-to-bottom matches Discord /
    // Telegram and beats the «kicks back before settling» feel of a
    // smooth scrollIntoView. Direct scrollTop write is synchronous —
    // browser repaints with the new position in the same frame.
    host.scrollTop = host.scrollHeight;
    isAtBottomRef.current = true;
    tryMarkThreadRead();
    // `replies[last]` is read but the array isn't a dep: the effect is
    // keyed on `repliesCount` so it only fires when the array actually
    // grew, and we always want to inspect the freshest replies snapshot
    // at that moment. Listing `replies` would just refire on every render
    // for the same growth event.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [repliesCount, myUserId, tryMarkThreadRead]);

  // Assistant typing-dots follow. The "bot is typing" row renders INSIDE the scroll, after the
  // last reply. When the bot starts composing while we're parked at the bottom, those dots add
  // height below the fold, the bottom sentinel scrolls out, and the IntersectionObserver flips
  // isAtBottomRef false — which would then make the growth effect suppress the bot's (non-own)
  // reply. So snap to the new bottom when the dots appear (pre-paint, so the sentinel never
  // leaves view). Only when already at-bottom — a user reading older replies is never yanked down.
  useLayoutEffect(() => {
    if (!botTyping) return;
    const host = scrollHostRef.current;
    if (!host || !isAtBottomRef.current) return;
    host.scrollTop = host.scrollHeight;
  }, [botTyping]);

  // Stay at-bottom when the drawer scroll container resizes — Android
  // soft-keyboard open/close shrinks the viewport, and Capacitor's
  // default `windowSoftInputMode=adjustResize` shrinks the WebView
  // so our scroll host's clientHeight changes. Without this, the
  // composer pushes up to roughly mid-replies because `scrollHeight`
  // grows past the new shrunk viewport while `scrollTop` stays put.
  // Mirrors the keyboard-open fix in `RoomTimeline.tsx::useResizeObserver`
  // (line 858+). Skip the first observer callback (initial mount fires
  // synthetic resize) so we don't fight the first-reveal layout-effect.
  useResizeObserver(
    useMemo(() => {
      let mounted = false;
      return (_entries: ResizeObserverEntry[]) => {
        if (!mounted) {
          mounted = true;
          return;
        }
        if (isAtBottomRef.current) {
          requestAnimationFrame(() => {
            const el = scrollHostRef.current;
            if (el) scrollToBottom(el);
          });
        }
      };
    }, []),
    useCallback(() => scrollHostRef.current, [])
  );

  // Autofocus: drop focus on the close button ONCE per drawer
  // lifetime so screen-reader / keyboard users land somewhere
  // predictable. Single-shot via `didFocusRef` — without it, any
  // outer Room.tsx state change (callView/chat flip, screen-size
  // breakpoint cross, useIsOneOnOne flip) would re-key parents and
  // remount the drawer mid-typing, yanking focus from the composer
  // back to the close button. The `key={`${roomId}/${rootId}`}` in
  // Room.tsx already remounts the drawer for thread navigation, so
  // a fresh ref per ThreadDrawer instance is the right granularity.
  // Composer focus on open is intentional non-default — Element/Slack
  // focus the composer; we focus close to avoid stealing focus from
  // the user's current typing context (channel composer was unmounted
  // by the same drawer-open). Trade-off: keyboard users hit Tab to
  // reach the composer.
  const didFocusRef = useRef(false);
  useEffect(() => {
    if (didFocusRef.current) return;
    closeBtnRef.current?.focus({ preventScroll: true });
    didFocusRef.current = true;
  }, []);

  const close = useCallback(() => {
    // Use replace so a second open of the same thread doesn't grow the
    // back-stack. useRoomNavigate uses the same trick for same-path
    // collapses (see comment in hooks/useRoomNavigate.ts).
    navigate(parentRoomPath, { replace: true });
  }, [navigate, parentRoomPath]);

  // ChatGPT-style row: the bot's reply is full-width plain text (no avatar / name / timestamp /
  // hover-menu / reactions); the user's turn is a right-aligned bubble. Body is rendered through
  // the shared RenderMessageContent (markdown/html/media) so links, code, and edits behave like
  // anywhere else — only the surrounding Message chrome is dropped.
  const renderAssistantEvent = (
    mEvent: MatrixEvent,
    eventId: string,
    timelineSet: EventTimelineSet
  ) => {
    const isOwn = mEvent.getSender() === mx.getUserId();
    const senderId = mEvent.getSender() ?? '';
    const senderDisplayName =
      getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
    const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
    const senderAvatarUrl = senderAvatarMxc
      ? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
      : undefined;
    const eventType = mEvent.getType();

    const body = (() => {
      if (mEvent.isRedacted()) {
        return <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content?.reason} />;
      }
      if (eventType === MessageEvent.RoomMessage) {
        const editedEvent = getEditedEvent(eventId, mEvent, timelineSet);
        const getContent = (() =>
          editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
        return (
          <RenderMessageContent
            displayName={senderDisplayName}
            senderId={senderId}
            senderAvatarUrl={senderAvatarUrl}
            hideVoiceAvatar
            msgType={mEvent.getContent().msgtype ?? ''}
            ts={mEvent.getTs()}
            edited={!!editedEvent}
            getContent={getContent}
            mediaAutoLoad={mediaAutoLoad}
            urlPreview={showUrlPreview}
            htmlReactParserOptions={htmlReactParserOptions}
            linkifyOpts={linkifyOpts}
            outlineAttachment
          />
        );
      }
      if (eventType === MessageEvent.RoomMessageEncrypted) {
        return (
          <Text>
            <MessageNotDecryptedContent />
          </Text>
        );
      }
      return (
        <Text>
          <MessageUnsupportedContent />
        </Text>
      );
    })();

    return (
      <div
        key={eventId}
        data-message-id={eventId}
        className={isOwn ? css.AssistantUserRow : css.AssistantBotRow}
      >
        {isOwn ? <div className={css.AssistantUserBubble}>{body}</div> : body}
      </div>
    );
  };

  const renderThreadEvent = (mEvent: MatrixEvent) => {
    const eventId = mEvent.getId();
    if (!eventId) return null;

    // Reactions and edits aggregate into different timelineSets depending
    // on the event's thread partition. Root reactions live on the room's
    // unfiltered timelineSet (root has `shouldLiveInRoom: true`, see
    // matrix-js-sdk room.js:eventShouldLiveIn). Thread-reply reactions
    // live on `thread.timelineSet.relations` —
    // `Thread.addRelatedThreadEvent` (thread.js:367 / 332-334) calls
    // `aggregateChildEvent(event, this.timelineSet)` and returns BEFORE
    // updateThreadMetadata, so they never touch the room's relation
    // cache. Reading from the wrong source means `getEventReactions`
    // and `getEditedEvent` return undefined for thread replies even
    // when the SDK has fully ingested the relation.
    const eventThread = mEvent.getThread();
    const isThreadReply = mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId;
    const timelineSet =
      isThreadReply && eventThread ? eventThread.timelineSet : room.getUnfilteredTimelineSet();
    // Assistant transcript: strip all the channel chrome and render bot = full-width text,
    // user = right-aligned bubble. Reactions/edits-as-aggregations machinery below is skipped.
    if (assistantStyle) {
      return renderAssistantEvent(mEvent, eventId, timelineSet);
    }
    const reactionRelations = getEventReactions(timelineSet, eventId);
    const reactionList = reactionRelations?.getSortedAnnotationsByKey();
    const hasReactions = reactionList && reactionList.length > 0;
    const isEncrypted = mEvent.getType() === MessageEvent.RoomMessageEncrypted;

    const renderMessage = () => {
      const eventType = mEvent.getType();
      const senderId = mEvent.getSender() ?? '';
      const senderDisplayName =
        getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
      const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
      const senderAvatarUrl = senderAvatarMxc
        ? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
        : undefined;
      const { replyEventId, threadRootId } = mEvent;

      // matrix-js-sdk auto-injects a fallback `m.in_reply_to` into every
      // thread reply for non-thread-aware clients (client.js:1819 sets
      // `is_falling_back: !isReply` — true when user just typed in the
      // thread without explicitly quoting). MSC3440 says thread-aware
      // clients SHOULD ignore the fallback chip. Without this gate the
      // drawer renders «↩ test6 <prev body>» on every reply, making it
      // look like the user quoted the previous message every time.
      // Cinny's `RoomInput.tsx:393` flips the flag to `false` only for
      // explicit replies (the chip the user added with the Reply menu),
      // so checking `is_falling_back === false` cleanly distinguishes
      // intentional quotes from auto-fallback chains.
      const wireRelatesTo = mEvent.getWireContent()['m.relates_to'];
      const isFallbackInReplyTo =
        wireRelatesTo?.rel_type === RelationType.Thread && wireRelatesTo?.is_falling_back !== false;
      const showReplyChip = !!replyEventId && !isFallbackInReplyTo;

      return (
        <Message
          key={eventId}
          renderSig={getMessageRenderSig(
            room,
            mEvent,
            getEditedEvent(eventId, mEvent, timelineSet),
            showUrlPreview
          )}
          data-message-id={eventId}
          room={room}
          mEvent={mEvent}
          collapse={false}
          highlight={false}
          edit={editId === eventId}
          canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
          canSendReaction={canSendReaction}
          canPinEvent={canPinEvent}
          imagePackRooms={imagePackRooms}
          relations={hasReactions ? reactionRelations : undefined}
          onUserClick={handleUserClick}
          onUsernameClick={handleUsernameClick}
          onReplyClick={handleReplyClick}
          onReactionToggle={handleReactionToggle}
          onEditId={handleEdit}
          reply={
            showReplyChip && (
              <Reply
                room={room}
                timelineSet={timelineSet}
                replyEventId={replyEventId}
                threadRootId={threadRootId}
                onClick={handleOpenReply}
                getMemberPowerTag={getMemberPowerTag}
                accessibleTagColors={accessiblePowerTagColors}
                legacyUsernameColor={isOneOnOne}
              />
            )
          }
          reactions={
            reactionRelations && (
              <Reactions
                style={{ marginTop: config.space.S200 }}
                room={room}
                relations={reactionRelations}
                mEventId={eventId}
                canSendReaction={canSendReaction}
                onReactionToggle={handleReactionToggle}
              />
            )
          }
          hideReadReceipts={hideActivity}
          showDeveloperTools={showDeveloperTools}
          memberPowerTag={getMemberPowerTag(senderId)}
          accessibleTagColors={accessiblePowerTagColors}
          legacyUsernameColor={isOneOnOne}
          msgType={mEvent.getContent().msgtype ?? ''}
          // We're inside a thread — nested threads aren't a thing in
          // Matrix, hide ThreadPlus on the row (and its menu twin).
          hideThreadReplyAffordance
          // Drawer composer is mounted alongside the row — regular
          // m.in_reply_to reply works in-drawer; setReplyDraft routes
          // to `[roomId, rootId]` so the chip surfaces there.
          hideMainReplyAffordance={false}
          layout="channel"
          channelHeaderInBubble
        >
          {(() => {
            if (mEvent.isRedacted()) {
              return (
                <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content?.reason} />
              );
            }
            if (eventType === MessageEvent.Sticker) {
              return (
                <MSticker
                  content={mEvent.getContent()}
                  renderImageContent={(props) => (
                    <ImageContent
                      {...props}
                      autoPlay={mediaAutoLoad}
                      renderImage={(p) => <Image {...p} loading="lazy" />}
                      renderViewer={(p) => <ImageViewer {...p} />}
                    />
                  )}
                />
              );
            }
            if (eventType === MessageEvent.RoomMessage) {
              const editedEvent = getEditedEvent(eventId, mEvent, timelineSet);
              const getContent = (() =>
                editedEvent?.getContent()['m.new_content'] ??
                mEvent.getContent()) as GetContentCallback;
              return (
                <RenderMessageContent
                  displayName={senderDisplayName}
                  senderId={senderId}
                  senderAvatarUrl={senderAvatarUrl}
                  hideVoiceAvatar
                  msgType={mEvent.getContent().msgtype ?? ''}
                  ts={mEvent.getTs()}
                  edited={!!editedEvent}
                  getContent={getContent}
                  mediaAutoLoad={mediaAutoLoad}
                  urlPreview={showUrlPreview}
                  htmlReactParserOptions={htmlReactParserOptions}
                  linkifyOpts={linkifyOpts}
                  outlineAttachment
                />
              );
            }
            if (eventType === MessageEvent.RoomMessageEncrypted) {
              return (
                <Text>
                  <MessageNotDecryptedContent />
                </Text>
              );
            }
            return (
              <Text>
                <MessageUnsupportedContent />
              </Text>
            );
          })()}
        </Message>
      );
    };

    if (isEncrypted) {
      return (
        <EncryptedContent key={eventId} mEvent={mEvent}>
          {renderMessage}
        </EncryptedContent>
      );
    }
    return renderMessage();
  };

  const renderBody = () => {
    if (rootError) {
      return (
        <div className={css.ThreadErrorState}>
          <Text size="T300" align="Center" priority="400">
            {t('Room.thread_root_error')}
          </Text>
          <Button size="300" variant="Primary" onClick={loadRootEvent}>
            <Text size="B300">{t('Room.thread_retry')}</Text>
          </Button>
        </div>
      );
    }

    if (!rootEvent) {
      return (
        <Box
          grow="Yes"
          alignItems="Center"
          justifyContent="Center"
          style={{ padding: config.space.S400 }}
        >
          <Spinner size="400" />
        </Box>
      );
    }

    return (
      <div className={assistantStyle ? css.ThreadDrawerContentAssistant : css.ThreadDrawerContent}>
        <MessageErrorBoundary resetKey={rootEvent.getId() ?? undefined}>
          {renderThreadEvent(rootEvent)}
        </MessageErrorBoundary>
        {/* Channel-style root↔replies divider / counter. Skipped in assistant mode, which
            renders a continuous ChatGPT-style transcript with no "N replies" affordance. */}
        {!assistantStyle &&
          (() => {
            // Counter prefers the larger of materialized `thread.length`
            // (replyCount + pending) and loaded `replies.length` — covers
            // the cold-load window where the SDK can synthesize an empty
            // Thread (length=0) shortly before the relations fetch lands
            // a populated `replies` list (`??` would have collapsed to 0
            // and silently shown the divider despite present replies).
            const counterCount = Math.max(thread?.length ?? 0, replies.length);
            if (counterCount === 0) return <div className={css.ThreadDivider} />;
            return (
              <div className={css.ThreadCounterRow} aria-hidden>
                <span className={css.ThreadCounterDot} />
                <span className={css.ThreadCounterText}>
                  {t('Room.thread_summary_count', { count: counterCount })}
                </span>
              </div>
            );
          })()}
        {coldLoadError && (
          <div className={css.ThreadErrorState}>
            <Text size="T300" align="Center" priority="400">
              {t('Room.thread_paginate_error')}
            </Text>
            <Button size="300" variant="Primary" onClick={retryColdLoad}>
              <Text size="B300">{t('Room.thread_retry')}</Text>
            </Button>
          </div>
        )}
        {!coldLoadError && (coldLoadFetching || !thread) && (
          <Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S300 }}>
            <Spinner size="300" />
          </Box>
        )}
        {paginating && replies.length === 0 && (
          <Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S300 }}>
            <Spinner size="300" />
          </Box>
        )}
        {paginateError && (
          <div className={css.ThreadErrorState}>
            <Text size="T300" align="Center" priority="400">
              {t('Room.thread_paginate_error')}
            </Text>
            <Button size="300" variant="Primary" onClick={paginate}>
              <Text size="B300">{t('Room.thread_retry')}</Text>
            </Button>
          </div>
        )}
        {!assistantStyle &&
          thread &&
          !coldLoadError &&
          !coldLoadFetching &&
          !paginating &&
          !paginateError &&
          replies.length === 0 && (
            <div className={css.ThreadEmptyState}>
              <Text size="T300" priority="400">
                {t('Room.thread_no_replies')}
              </Text>
            </div>
          )}
        {/* Top sentinel — IntersectionObserver triggers paginate when
            scrolled into view. Element-web onFillRequest pattern. The
            sentinel only renders while back-pagination is possible:
            once SDK reports no more events, we drop it so the observer
            disconnects on next mount. */}
        {replies.length > 0 && <div ref={setTopSentinel} aria-hidden="true" />}
        {paginating && replies.length > 0 && (
          <Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S200 }}>
            <Spinner size="200" />
          </Box>
        )}
        {replies.map((reply) => (
          <MessageErrorBoundary key={reply.getId()} resetKey={reply.getId() ?? undefined}>
            {renderThreadEvent(reply)}
          </MessageErrorBoundary>
        ))}
        {botTyping && (
          <div className={css.AssistantBotRow}>
            <div className={css.TypingDots} aria-label={t('Room.is_typing')}>
              <span className={css.TypingDot} />
              <span className={css.TypingDot} />
              <span className={css.TypingDot} />
            </div>
          </div>
        )}
        <div ref={setBottomSentinel} />
      </div>
    );
  };

  const asideContent = (
    <aside
      ref={fileDropContainerRef}
      className={isDesktopVariant ? css.ThreadDrawer : css.ThreadDrawerMobile}
      role="region"
      aria-labelledby={hideHeader ? undefined : headerId}
    >
      {!hideHeader && (
        <Header className={css.ThreadDrawerHeader} variant="Background" size="600">
          <Box grow="Yes" alignItems="Center" gap="200">
            <Box grow="Yes" direction="Column" id={headerId}>
              <Text className={css.ThreadDrawerHeaderCaption} as="span" size="T200">
                {t('Room.thread_caption')}
              </Text>
              <Text className={css.ThreadDrawerHeaderSubtitle} as="span" size="T200" truncate>
                {t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })}
              </Text>
            </Box>
            <Box shrink="No" alignItems="Center">
              <IconButton
                ref={closeBtnRef}
                variant="Background"
                onClick={close}
                aria-label={t('Room.thread_close')}
              >
                <Icon src={Icons.Cross} />
              </IconButton>
            </Box>
          </Box>
        </Header>
      )}
      <div className={css.ThreadDrawerScroll}>
        <Scroll
          ref={scrollHostRef}
          variant="Background"
          direction="Vertical"
          size="300"
          hideTrack
          visibility="Hover"
        >
          {renderBody()}
        </Scroll>
      </div>
      <div
        className={`${
          assistantStyle ? css.ThreadComposerAssistant : css.ThreadComposer
        } ${ChatComposer}`}
      >
        {canMessage ? (
          <RoomInput
            room={room}
            editor={editor}
            roomId={room.roomId}
            threadId={rootId}
            fileDropContainerRef={fileDropContainerRef}
            // AI-bot conversation: a single-row, text-only composer — just the
            // textarea + send, no +/mic/emoji (matches the bot's new-chat
            // composer so the toolbar doesn't appear only after the first send).
            // Channel threads keep the default full two-row strip.
            singleRow={assistantStyle}
            textOnly={assistantStyle}
          />
        ) : (
          <RoomInputPlaceholder
            style={{ padding: config.space.S200 }}
            alignItems="Center"
            justifyContent="Center"
          >
            <Text align="Center">{t('Room.no_post_permission')}</Text>
          </RoomInputPlaceholder>
        )}
      </div>
    </aside>
  );

  if (!isDesktopVariant) {
    return asideContent;
  }

  return (
    <div ref={resizableRef} className={css.ThreadDrawerResizable} style={{ width: drawerWidth }}>
      {asideContent}
      {canResize && (
        // Canonical WAI-ARIA window-splitter pattern (focusable separator
        // with current/min/max). See Page.tsx for the same justification.
        // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/role-supports-aria-props, jsx-a11y/no-noninteractive-tabindex
        <div
          ref={resizeHandleRef}
          role="separator"
          aria-orientation="vertical"
          aria-valuenow={drawerWidth}
          aria-valuemin={THREAD_DRAWER_WIDTH_MIN}
          aria-valuemax={drawerMaxW}
          aria-label="Resize thread drawer"
          // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
          tabIndex={0}
          className={css.ThreadDrawerResizeHandle}
          data-dragging={dragging || undefined}
          data-at-min={atMin || undefined}
          data-at-max={atMax || undefined}
          onPointerDown={onResizePointerDown}
          onPointerMove={onResizePointerMove}
          onPointerUp={onResizeStopPointer}
          onPointerCancel={onResizeStopPointer}
          onLostPointerCapture={onResizeStopPointer}
          onKeyDown={onResizeKeyDown}
        />
      )}
    </div>
  );
}
