/* eslint-disable react/destructuring-assignment */
import React, {
  Dispatch,
  RefObject,
  SetStateAction,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import {
  Direction,
  EventTimeline,
  EventTimelineSet,
  EventTimelineSetHandlerMap,
  MatrixClient,
  MatrixEvent,
  Room,
  RoomEvent,
  RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import classNames from 'classnames';
import { Editor } from 'slate';
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai';
import { Box, Chip, Icon, Icons, Text, as, config } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
import {
  DefaultPlaceholder,
  Reply,
  MessageBase,
  MessageUnsupportedContent,
  Time,
  MessageNotDecryptedContent,
  RedactedContent,
  MSticker,
  ImageContent,
  EventContent,
} from '../../components/message';
import {
  factoryRenderLinkifyWithMention,
  getReactCustomHtmlParser,
  LINKIFY_OPTS,
  makeMentionCustomProps,
  renderMatrixMention,
} from '../../plugins/react-custom-html-parser';
import {
  canEditEvent,
  decryptAllTimelineEvent,
  getEditedEvent,
  getEventReactions,
  getLatestEditableEvt,
  getMemberAvatarMxc,
  getMemberDisplayName,
  isBridgedRoom,
  isMembershipChanged,
  reactionOrEditEvent,
} from '../../utils/room';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import {
  Reactions,
  Message,
  Event,
  CallMessage,
  CallAggregate,
  SyslineMessage,
  EncryptedContent,
  useMessageInteractionHandlers,
  getMessageRenderSig,
} from './message';
import { ThreadSummaryCard } from './ThreadSummaryCard';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
import * as customHtmlCss from '../../styles/CustomHtml.css';
import { EmptyTimeline } from './EmptyTimeline';
import {
  getIntersectionObserverEntry,
  useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import { markAsRead } from '../../utils/notifications';
import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonYear, today, yesterday } from '../../utils/time';
import { isEmptyEditor } from '../../components/editor';
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { openMessageIdAtom } from '../../state/room/openMessage';
import { VOJO_STICKY_DATE_TOP_PX } from '../../styles/horseshoe';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsOneOnOne } from '../../hooks/useRoom';
import { isNativePlatform } from '../../utils/capacitor';
import { useChannelsMode, useThreadDrawerOpen } from '../../hooks/useChannelsMode';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { RoomTimelineTyping } from './RoomTimelineTyping';
import { MessageErrorBoundary } from './MessageErrorBoundary';

const TimelineFloat = as<'div', css.TimelineFloatVariants>(
  ({ position, className, ...props }, ref) => (
    <Box
      className={classNames(css.TimelineFloat({ position }), className)}
      justifyContent="Center"
      alignItems="Center"
      gap="200"
      {...props}
      ref={ref}
    />
  )
);

export const getLiveTimeline = (room: Room): EventTimeline =>
  room.getUnfilteredTimelineSet().getLiveTimeline();

export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
  const timelineSet = room.getUnfilteredTimelineSet();
  return timelineSet.getTimelineForEvent(eventId) ?? undefined;
};

export const getFirstLinkedTimeline = (
  timeline: EventTimeline,
  direction: Direction
): EventTimeline => {
  const linkedTm = timeline.getNeighbouringTimeline(direction);
  if (!linkedTm) return timeline;
  return getFirstLinkedTimeline(linkedTm, direction);
};

export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
  const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
  const timelines: EventTimeline[] = [];

  for (
    let nextTimeline: EventTimeline | null = firstTimeline;
    nextTimeline;
    nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
  ) {
    timelines.push(nextTimeline);
  }
  return timelines;
};

export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
  const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
    count + timelineToEventsCount(tm);
  return timelines.reduce(timelineEventCountReducer, 0);
};

export const getTimelineAndBaseIndex = (
  timelines: EventTimeline[],
  index: number
): [EventTimeline | undefined, number] => {
  let uptoTimelineLen = 0;
  const timeline = timelines.find((t) => {
    uptoTimelineLen += t.getEvents().length;
    if (index < uptoTimelineLen) return true;
    return false;
  });
  if (!timeline) return [undefined, 0];
  return [timeline, uptoTimelineLen - timeline.getEvents().length];
};

export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
  absoluteIndex - timelineBaseIndex;

export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
  timeline.getEvents()[index];

export const getEventIdAbsoluteIndex = (
  timelines: EventTimeline[],
  eventTimeline: EventTimeline,
  eventId: string
): number | undefined => {
  const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
  if (timelineIndex === -1) return undefined;
  const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
  if (eventIndex === -1) return undefined;
  const baseIndex = timelines
    .slice(0, timelineIndex)
    .reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
  return baseIndex + eventIndex;
};

type RoomTimelineProps = {
  room: Room;
  eventId?: string;
  roomInputRef: RefObject<HTMLElement>;
  editor: Editor;
  // Pixel height of the overlay composer painted by RoomView. The CSS var
  // applied on the chat surface paints the bottom padding visually, but
  // when this number transitions 0 → N at mount the scroll-content grows
  // without a corresponding scrollTop bump — the latest message ends up
  // hidden behind the composer. We use this prop in a layout effect to
  // re-anchor the scroll to the bottom whenever it changes and the user
  // was already at the bottom.
  bottomOverlayHeight?: number;
  // Native-only scroll-aware composer hide. Timeline owns the scroll
  // element, RoomView owns the composer DOM — so direction detection
  // lives here and reports up. Web ignores the callback entirely; the
  // gating happens inside the listener attach effect.
  onComposerHiddenChange?: (hidden: boolean) => void;
};

const PAGINATION_LIMIT = 80;

// Native scroll-aware composer thresholds. Asymmetric hysteresis: easier to
// reveal the composer than to hide it, so a flick up-then-down can't strand
// it in hidden state. NEAR_BOTTOM_PX matches roughly the visible breathing
// room below the last message (timeline paddingBottom + overlay padding) —
// while the user is within this band of the live edge, small scrolls never
// strip the composer.
const COMPOSER_HIDE_DELTA_PX = 8;
const COMPOSER_SHOW_DELTA_PX = 6;
// Tightened so the composer slides away (and the floating date appears) as soon
// as the user nudges up off the live edge, rather than only after a big scroll.
const COMPOSER_NEAR_BOTTOM_PX = 80;

// Jump-to-latest FAB visibility thresholds. Decoupled from the auto-follow
// `atBottom` gate (which carries a 1s debounce designed for live-timeline
// tracking, not visual UI) — FAB needs eager response. Show / hide hysteresis
// avoids flicker at the boundary on tiny scrolls.
const FAB_SHOW_DISTANCE_PX = 150;
const FAB_HIDE_DISTANCE_PX = 50;

// Sticky-date engage thresholds — much tighter than the FAB so stickiness turns
// on the moment the user starts scrolling up (when the composer also slides
// away), and turns back off only right at the live bottom (hysteresis).
const DATE_SHOW_DISTANCE_PX = 36;
const DATE_HIDE_DISTANCE_PX = 6;

// Stable empty set for the channel layout (no minute-grouped timestamps there) —
// shared by the hideTime / timeBelow refs so we don't allocate a fresh Set every
// render and so those terms in each row's renderSig stay constant for channel
// rows (no spurious repaints). Never mutated.
const EMPTY_ITEM_SET: Set<number> = new Set();

type Timeline = {
  linkedTimelines: EventTimeline[];
  range: ItemRange;
};

const useEventTimelineLoader = (
  mx: MatrixClient,
  room: Room,
  onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
  onError: (err: Error | null) => void
) => {
  const loadEventTimeline = useCallback(
    async (eventId: string) => {
      const [err, replyEvtTimeline] = await to(
        mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId)
      );
      if (!replyEvtTimeline) {
        onError(err ?? null);
        return;
      }
      const linkedTimelines = getLinkedTimelines(replyEvtTimeline);
      const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);

      if (absIndex === undefined) {
        onError(err ?? null);
        return;
      }

      onLoad(eventId, linkedTimelines, absIndex);
    },
    [mx, room, onLoad, onError]
  );

  return loadEventTimeline;
};

const useTimelinePagination = (
  mx: MatrixClient,
  timeline: Timeline,
  setTimeline: Dispatch<SetStateAction<Timeline>>,
  limit: number
) => {
  const timelineRef = useRef(timeline);
  timelineRef.current = timeline;
  const alive = useAlive();

  const handleTimelinePagination = useMemo(() => {
    let fetching = false;

    const recalibratePagination = (
      linkedTimelines: EventTimeline[],
      timelinesEventsCount: number[],
      backwards: boolean
    ) => {
      const topTimeline = linkedTimelines[0];
      const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;

      const newLTimelines = getLinkedTimelines(topTimeline);
      const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
      const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);

      const topTmAddedEvt =
        timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
      const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);

      setTimeline((currentTimeline) => ({
        linkedTimelines: newLTimelines,
        range:
          offsetRange > 0
            ? {
                start: currentTimeline.range.start + offsetRange,
                end: currentTimeline.range.end + offsetRange,
              }
            : { ...currentTimeline.range },
      }));
    };

    return async (backwards: boolean) => {
      if (fetching) return;
      const { linkedTimelines: lTimelines } = timelineRef.current;
      const timelinesEventsCount = lTimelines.map(timelineToEventsCount);

      const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
      if (!timelineToPaginate) return;

      const paginationToken = timelineToPaginate.getPaginationToken(
        backwards ? Direction.Backward : Direction.Forward
      );
      if (
        !paginationToken &&
        getTimelinesEventsCount(lTimelines) !==
          getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
      ) {
        recalibratePagination(lTimelines, timelinesEventsCount, backwards);
        return;
      }

      fetching = true;
      const [err] = await to(
        mx.paginateEventTimeline(timelineToPaginate, {
          backwards,
          limit,
        })
      );
      if (err) {
        // TODO: handle pagination error.
        return;
      }
      const fetchedTimeline =
        timelineToPaginate.getNeighbouringTimeline(
          backwards ? Direction.Backward : Direction.Forward
        ) ?? timelineToPaginate;
      // Decrypt all event ahead of render cycle
      const roomId = fetchedTimeline.getRoomId();
      const room = roomId ? mx.getRoom(roomId) : null;

      if (room?.hasEncryptionStateEvent()) {
        await to(decryptAllTimelineEvent(mx, fetchedTimeline));
      }

      fetching = false;
      if (alive()) {
        recalibratePagination(lTimelines, timelinesEventsCount, backwards);
      }
    };
  }, [mx, alive, setTimeline, limit]);
  return handleTimelinePagination;
};

const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => {
  useEffect(() => {
    const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
      mEvent,
      eventRoom,
      toStartOfTimeline,
      removed,
      data
    ) => {
      if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
      onArrive(mEvent);
    };
    const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
      if (eventRoom?.roomId !== room.roomId) return;
      onArrive(mEvent);
    };

    room.on(RoomEvent.Timeline, handleTimelineEvent);
    room.on(RoomEvent.Redaction, handleRedaction);
    return () => {
      room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
      room.removeListener(RoomEvent.Redaction, handleRedaction);
    };
  }, [room, onArrive]);
};

const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
  useEffect(() => {
    const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
      if (r.roomId !== room.roomId) return;
      onRefresh();
    };

    room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh);
    return () => {
      room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh);
    };
  }, [room, onRefresh]);
};

const getInitialTimeline = (room: Room) => {
  const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
  const evLength = getTimelinesEventsCount(linkedTimelines);
  return {
    linkedTimelines,
    range: {
      start: Math.max(evLength - PAGINATION_LIMIT, 0),
      end: evLength,
    },
  };
};

const getEmptyTimeline = () => ({
  range: { start: 0, end: 0 },
  linkedTimelines: [],
});

const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
  const readUptoEventId = room.getEventReadUpTo(room.client.getSafeUserId());
  if (!readUptoEventId) return undefined;
  const evtTimeline = getEventTimeline(room, readUptoEventId);
  const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
  return {
    readUptoEventId,
    inLiveTimeline: latestTimeline === room.getLiveTimeline(),
    scrollTo,
  };
};

// Channels-mode visibility filter. Single source of truth for the
// /channels/* surface — called from BOTH the rail-endpoint scan
// (`isRenderableTimelineEvent`) AND the renderer null-gate so the two never
// disagree on which events count as visible.
//
// Thread-relation hiding delegates to SDK's `room.eventShouldLiveIn`
// (matrix-js-sdk room.ts:891) — same predicate Element-web uses in
// `MessagePanel.filterEventsThatShouldLiveInThisTimeline`. Canonical
// because: (a) handles thread-root visible-in-both-timelines correctly;
// (b) handles orphan thread relations whose root never landed; (c)
// classifies redactions via target's thread; (d) classifies pending
// local-echoes the same way the SDK does — kills the «message flashes
// in main timeline before drawer» blink that hand-rolled relType check
// missed because the SDK adds the thread relation to the local-echo
// content asynchronously vs the predicate's `getRelation()` read.
//
// Bridged-room exception: SDK still partitions thread events into the
// thread, but on bridged rooms (Telegram puppets etc.) we want the
// thread events visible in main timeline because the bridge has no
// thread surface. Override returns false (don't hide).
//
// Service events (call.*, rtc.*, poll.*) stay hand-filtered — these
// are not thread-related and the SDK predicate has no opinion on them.
const isChannelsModeHidden = (room: Room, event: MatrixEvent, isBridged: boolean): boolean => {
  const partition = room.eventShouldLiveIn(event);
  if (!partition.shouldLiveInRoom) {
    if (isBridged) return false;
    return true;
  }
  const eventType = event.getType();
  if (eventType.startsWith('m.call.')) return true;
  if (eventType === 'm.rtc.notification') return true;
  if (eventType === 'm.rtc.member') return true;
  if (eventType === 'org.matrix.msc4143.rtc.member') return true;
  if (eventType === 'm.poll.start') return true;
  if (eventType === 'm.poll.response') return true;
  if (eventType === 'm.poll.end') return true;
  if (eventType === 'org.matrix.msc3381.poll.start') return true;
  if (eventType === 'org.matrix.msc3381.poll.response') return true;
  if (eventType === 'org.matrix.msc3381.poll.end') return true;
  // m.reference relations (e.g. polls' votes) — partition reports
  // shouldLiveInRoom: true for these but we never render them in the
  // channel center column. SDK has no special predicate; explicit drop.
  if (event.getRelation()?.rel_type === 'm.reference') return true;
  return false;
};

export function RoomTimeline({
  room,
  eventId,
  roomInputRef,
  editor,
  bottomOverlayHeight = 0,
  onComposerHiddenChange,
}: RoomTimelineProps) {
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
  // After P3c every room renders Stream. The DM-vs-group split that drove the
  // membership-sysline gate flips to a 1:1 vs N>2 check via `useIsOneOnOne`,
  // not the persisted `m.direct` flag — see plan §6.8.
  const isOneOnOne = useIsOneOnOne();
  const channelsMode = useChannelsMode();
  // bridged-room check is cheap and stable for a route lifetime; recompute on
  // each render is fine — `isBridgedRoom` walks the room state-event index,
  // not the timeline.
  const isBridged = channelsMode && isBridgedRoom(room);
  // M2: «Reply in Thread» affordance is channels-mode-only. Bridged
  // channels (Telegram puppets) get nothing because the bridge has no
  // thread semantic — clicking would create a draft the bridge drops
  // on send. Outside channels (DM/Bots/legacy /<space>/) the surface
  // has no drawer and no thread UI per plan §6.7, so the button has
  // nowhere to lead. Pre-M2 the button existed in those surfaces but
  // never produced a visible thread.
  const hideThreadReplyAffordance = !channelsMode || isBridged;
  // M3a: thread summary pill rides a tighter gate — non-bridged channels
  // are the only surface where threads are first-class. The card itself
  // returns null when the root has no replies, so mounting on every
  // visible row in channels-mode is safe (`useVirtualPaginator` keeps
  // the rendered window bounded; SDK Room maxListeners is 100).
  const showThreadSummary = channelsMode && !isBridged;
  // Avatar-first ChannelLayout is route-agnostic for any non-1:1 room:
  // group DMs in /direct/, group child rooms under /space/, and every
  // channel under /channels/. 1:1 DMs and 1:1 bot control rooms keep
  // the Stream rail — the dot/time chrome only carries meaning when there
  // are exactly two participants. Channels-mode-specific behaviour
  // (thread surfacing, RTC/edit hiding) stays gated on `channelsMode` —
  // this flag is purely visual.
  const channelStyleLayout = channelsMode || !isOneOnOne;
  const messageLayout: 'stream' | 'channel' = channelStyleLayout ? 'channel' : 'stream';
  // Discord-style channel rows: avatar + the username + time on a header line
  // ABOVE the message body (not inside the bubble). `false` puts the header
  // above and lets ChannelLayout bubble only the peer body (own = plain text);
  // the thread drawer passes `true` separately for its compact in-bubble look.
  const channelHeaderInBubble = false;
  // M2: when the thread drawer is open, the channel composer is
  // unmounted by RoomView (single-Slate-at-a-time pattern). Clicking
  // the channel timeline's «Reply» menu would write a reply chip into
  // an atom no surface reads, then fire `ReactEditor.focus(editor)`
  // on a Slate editor that's no longer mounted (silent no-op) — dead
  // click from the user's perspective. Hide the regular Reply
  // affordance in this state. ThreadPlus stays gated separately by
  // `hideThreadReplyAffordance` and remains active for re-opening the
  // drawer to a different thread root.
  const threadDrawerOpen = useThreadDrawerOpen();
  const hideMainReplyAffordance = threadDrawerOpen;
  // Captured for the channels-mode «Reply in Thread» branch inside
  // useMessageInteractionHandlers — re-encoded via generatePath so
  // passing the raw (already URL-encoded) values from useParams is fine.
  const { spaceIdOrAlias, roomIdOrAlias } = useParams();
  const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
  const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
  // Encrypted rooms never auto-preview URLs (privacy-safe default; the
  // `encUrlPreview` opt-in setting was removed). Plaintext rooms honour urlPreview.
  const showUrlPreview = room.hasEncryptionStateEvent() ? false : urlPreview;
  // Hidden/redacted "dev view" of events — was an orphan setting with no UI,
  // permanently off. Pinned false; the hidden-event renderers below stay inert.
  const showHiddenEvents = false;
  const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');

  const ignoredUsersList = useIgnoredUsers();
  const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);

  // Reply-draft setter targets the MAIN composer of this room (channel
  // / DM / legacy timeline). Drawer composer manages its own per-thread
  // reply draft via DraftKey([roomId, rootId]) inside RoomInput.
  const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId)));
  const powerLevels = usePowerLevelsContext();
  const creators = useRoomCreators(room);

  const creatorsTag = useRoomCreatorsTag();
  const powerLevelTags = usePowerLevelTags(room, powerLevels);
  const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);

  const theme = useTheme();
  const accessiblePowerTagColors = useAccessiblePowerTagColors(
    theme.kind,
    creatorsTag,
    powerLevelTags
  );

  const permissions = useRoomPermissions(creators, powerLevels);

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

  const roomToParents = useAtomValue(roomToParentsAtom);
  const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
  const { navigateRoom } = useRoomNavigate();

  // Bubble (1:1 DM) tap-rail: reset the open-message on room change, and close
  // it on any click outside a message row (single-open is owned by the atom; the
  // per-row toggle handles taps ON a message — see Message.tsx).
  const setOpenMessageId = useSetAtom(openMessageIdAtom);
  useEffect(() => {
    setOpenMessageId(null);
    const onPointerDown = (e: PointerEvent) => {
      const target = e.target as Element | null;
      if (target?.closest('[data-message-id]')) return;
      setOpenMessageId(null);
    };
    document.addEventListener('pointerdown', onPointerDown);
    return () => {
      document.removeEventListener('pointerdown', onPointerDown);
      setOpenMessageId(null);
    };
  }, [room.roomId, setOpenMessageId]);
  const mentionClickHandler = useMentionClickHandler(room.roomId);
  const spoilerClickHandler = useSpoilerClickHandler();

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

  const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
  const readUptoEventIdRef = useRef<string>();
  if (unreadInfo) {
    readUptoEventIdRef.current = unreadInfo.readUptoEventId;
  }

  const atBottomAnchorRef = useRef<HTMLElement>(null);
  const [atBottom, setAtBottom] = useState<boolean>(true);
  const atBottomRef = useRef(atBottom);
  atBottomRef.current = atBottom;

  // Jump-to-latest FAB visibility — driven by distance from the live edge,
  // not by the debounced auto-follow `atBottom`. Initial `true` because the
  // common open-room state is "scrolled to bottom"; deep-links to a specific
  // event will fire scroll events that correct the state.
  const [fabHidden, setFabHidden] = useState(true);
  const fabHiddenRef = useRef(fabHidden);
  fabHiddenRef.current = fabHidden;

  // Pulse counter — bumped on each fresh live message while the FAB is
  // showing. Drives the `key` of an icon-wrap span so React remounts it and
  // the CSS keyframes restart.
  const [pulseCount, setPulseCount] = useState(0);

  const scrollRef = useRef<HTMLDivElement>(null);
  const scrollToBottomRef = useRef({
    count: 0,
    smooth: true,
  });

  const [focusItem, setFocusItem] = useState<
    | {
        index: number;
        scrollTo: boolean;
        highlight: boolean;
      }
    | undefined
  >();
  const alive = useAlive();

  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 parseMemberEvent = useMemberEventParser();

  const [timeline, setTimeline] = useState<Timeline>(() =>
    eventId ? getEmptyTimeline() : getInitialTimeline(room)
  );
  const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
  const liveTimelineLinked =
    timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
  const canPaginateBack =
    typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
  const rangeAtStart = timeline.range.start === 0;
  const rangeAtEnd = timeline.range.end === eventsLength;
  const atLiveEndRef = useRef(liveTimelineLinked && rangeAtEnd);
  atLiveEndRef.current = liveTimelineLinked && rangeAtEnd;

  const handleTimelinePagination = useTimelinePagination(
    mx,
    timeline,
    setTimeline,
    PAGINATION_LIMIT
  );

  const getScrollElement = useCallback(() => scrollRef.current, []);

  // Bubble (1:1 DM) tap-rail floats at a fixed viewport point, so close it on
  // timeline scroll rather than let it hang detached over the moving messages
  // (standard cursor-menu UX). Separate from the outside-tap close above so it
  // can reach the scroll element (defined here, after scrollRef).
  useEffect(() => {
    const scrollEl = getScrollElement();
    if (!scrollEl) return undefined;
    const onScroll = () => setOpenMessageId(null);
    scrollEl.addEventListener('scroll', onScroll, { passive: true });
    return () => scrollEl.removeEventListener('scroll', onScroll);
  }, [getScrollElement, setOpenMessageId]);

  const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
    useVirtualPaginator({
      count: eventsLength,
      limit: PAGINATION_LIMIT,
      range: timeline.range,
      onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
      getScrollElement,
      getItemElement: useCallback(
        (index: number) =>
          (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
          undefined,
        []
      ),
      onEnd: handleTimelinePagination,
    });

  const loadEventTimeline = useEventTimelineLoader(
    mx,
    room,
    useCallback(
      (evtId, lTimelines, evtAbsIndex) => {
        if (!alive()) return;
        const evLength = getTimelinesEventsCount(lTimelines);

        setFocusItem({
          index: evtAbsIndex,
          scrollTo: true,
          highlight: evtId !== readUptoEventIdRef.current,
        });
        setTimeline({
          linkedTimelines: lTimelines,
          range: {
            start: Math.max(evtAbsIndex - PAGINATION_LIMIT, 0),
            end: Math.min(evtAbsIndex + PAGINATION_LIMIT, evLength),
          },
        });
      },
      [alive]
    ),
    useCallback(() => {
      if (!alive()) return;
      setTimeline(getInitialTimeline(room));
      scrollToBottomRef.current.count += 1;
      scrollToBottomRef.current.smooth = false;
    }, [alive, room])
  );

  // M2 channels-mode: hidden events (thread replies, RTC, polls, etc.)
  // are appended to the SDK timelineSet and bump `getTimelinesEventsCount`,
  // but the live handler early-returns without mutating state. The
  // virtual `range` is indexed against the FULL SDK event-pool (see
  // `getInitialTimeline` — `range.end = evLength`), so a +1 advance on
  // the next visible event would lag behind by N hidden events and
  // miss the visible one in the rendered window. Track skipped count
  // here; the next visible event consumes `skipped + 1` as its delta.
  const skippedHiddenLiveEventsRef = useRef(0);
  // Counter is meaningful only against the CURRENT linkedTimelines —
  // after a full rebase (loadEventTimeline / refresh / jump-to-latest /
  // jump-to-unread), range is recomputed against the fresh evLength,
  // so any pre-rebase skipped count would over-shoot range on the next
  // visible event. Track `linkedTimelines` reference identity (replaced
  // on every rebase) and zero the counter alongside.
  useEffect(() => {
    skippedHiddenLiveEventsRef.current = 0;
  }, [timeline.linkedTimelines]);
  useLiveEventArrive(
    room,
    useCallback(
      (mEvt: MatrixEvent) => {
        // Hidden events: skip render-state mutation (no blink), but
        // record the count so the next visible event can advance the
        // range by the correct delta.
        if (channelsMode && isChannelsModeHidden(room, mEvt, isBridged)) {
          skippedHiddenLiveEventsRef.current += 1;
          return;
        }

        // Visible event arrival — snapshot + reset the skipped counter.
        // `liveDelta` covers all SDK-appended events since the last
        // visible one (skipped hidden + this one) so the virtual range
        // stays aligned with `getTimelinesEventsCount(linkedTimelines)`.
        const liveDelta = skippedHiddenLiveEventsRef.current + 1;
        skippedHiddenLiveEventsRef.current = 0;

        // «Sending while scrolled up jumps the timeline to live» — Telegram /
        // WhatsApp / Slack pattern. After P3c every room renders Stream, so
        // the own-message follow-to-bottom guard is universal.
        const isOwnLiveStreamMessage =
          mEvt.getSender() === mx.getUserId() &&
          !reactionOrEditEvent(mEvt) &&
          (mEvt.getType() === MessageEvent.RoomMessage ||
            mEvt.getType() === MessageEvent.RoomMessageEncrypted ||
            mEvt.getType() === MessageEvent.Sticker);

        // if user is at bottom of timeline
        // keep paginating timeline and conditionally mark as read
        // otherwise we update timeline without paginating
        // so timeline can be updated with evt like: edits, reactions etc
        if (atBottomRef.current || isOwnLiveStreamMessage) {
          if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
            // Check if the document is in focus (user is actively viewing the app),
            // and either there are no unread messages or the latest message is from the current user.
            // If either condition is met, trigger the markAsRead function to send a read receipt.
            const evtRoomId = mEvt.getRoomId();
            if (evtRoomId) {
              requestAnimationFrame(() => markAsRead(mx, evtRoomId, hideActivity));
            }
          }

          if (!document.hasFocus() && !unreadInfo) {
            setUnreadInfo(getRoomUnreadInfo(room));
          }

          scrollToBottomRef.current.count += 1;
          // Always instant — never smooth on incoming. Smooth animation
          // briefly takes the at-bottom anchor out of the
          // IntersectionObserver's intersection box during the
          // animation frame, which the 1s debounce sometimes turns
          // into a false-positive «user scrolled away» → atBottom flips
          // false → next message stops auto-following. Direct snap
          // matches Discord / Telegram and the drawer (M4 fix in
          // ThreadDrawer.tsx) — predictable, no animation surface for
          // the observer to misinterpret.
          scrollToBottomRef.current.smooth = false;
          if (isOwnLiveStreamMessage) {
            setAtBottom(true);
            if (!atLiveEndRef.current) {
              // Sending while viewing an older event intentionally jumps the DM back
              // to live; clear stale deep-link focus before replacing the timeline.
              setFocusItem(undefined);
            }
          }

          setTimeline((ct) => {
            if (!atLiveEndRef.current && isOwnLiveStreamMessage) {
              return getInitialTimeline(room);
            }
            return {
              ...ct,
              range: {
                start: ct.range.start + liveDelta,
                end: ct.range.end + liveDelta,
              },
            };
          });
          return;
        }
        setTimeline((ct) => ({ ...ct }));
        if (!unreadInfo) {
          setUnreadInfo(getRoomUnreadInfo(room));
        }
      },
      [mx, room, unreadInfo, hideActivity, channelsMode, isBridged]
    )
  );

  // Pulse the FAB when a fresh message from another user lands on the live
  // timeline while the user is scrolled away. Edits / reactions / own
  // messages are filtered so the cue fires only for genuinely new content
  // worth jumping back for.
  useLiveEventArrive(
    room,
    useCallback(
      (mEvt: MatrixEvent) => {
        if (fabHiddenRef.current) return;
        if (mEvt.getSender() === mx.getUserId()) return;
        if (reactionOrEditEvent(mEvt)) return;
        const type = mEvt.getType();
        if (
          type !== MessageEvent.RoomMessage &&
          type !== MessageEvent.RoomMessageEncrypted &&
          type !== MessageEvent.Sticker
        ) {
          return;
        }
        setPulseCount((c) => c + 1);
      },
      [mx]
    )
  );

  const handleOpenEvent = useCallback(
    async (
      evtId: string,
      highlight = true,
      onScroll: ((scrolled: boolean) => void) | undefined = undefined
    ) => {
      const evtTimeline = getEventTimeline(room, evtId);
      const absoluteIndex =
        evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId);

      if (typeof absoluteIndex === 'number') {
        const scrolled = scrollToItem(absoluteIndex, {
          behavior: 'smooth',
          align: 'center',
          stopInView: true,
        });
        if (onScroll) onScroll(scrolled);
        setFocusItem({
          index: absoluteIndex,
          scrollTo: false,
          highlight,
        });
      } else {
        setTimeline(getEmptyTimeline());
        loadEventTimeline(evtId);
      }
    },
    [room, timeline, scrollToItem, loadEventTimeline]
  );

  const {
    editId,
    handleEdit,
    handleOpenReply,
    handleUserClick,
    handleUsernameClick,
    handleReplyClick,
    handleReactionToggle,
  } = useMessageInteractionHandlers({
    room,
    editor,
    composerSuspended: threadDrawerOpen,
    setReplyDraft,
    onOpenEvent: handleOpenEvent,
    channelsMode,
    isBridged,
    spaceIdOrAlias,
    roomIdOrAlias,
  });

  useLiveTimelineRefresh(
    room,
    useCallback(() => {
      if (liveTimelineLinked) {
        setTimeline(getInitialTimeline(room));
      }
    }, [room, liveTimelineLinked])
  );

  // Stay at bottom when room editor resize
  useResizeObserver(
    useMemo(() => {
      let mounted = false;
      return (entries) => {
        if (!mounted) {
          // skip initial mounting call
          mounted = true;
          return;
        }
        if (!roomInputRef.current) return;
        const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
        const scrollElement = getScrollElement();
        if (!editorBaseEntry || !scrollElement) return;

        if (atBottomRef.current) {
          scrollToBottom(scrollElement);
        }
      };
    }, [getScrollElement, roomInputRef]),
    useCallback(() => roomInputRef.current, [roomInputRef])
  );

  // Re-anchor to bottom when the overlay-composer height changes. At mount
  // the composer measures async (RoomView's `useEffect` → `setComposerHeight`
  // → CSS var → our paddingBottom grows), which adds N pixels to scrollHeight
  // without bumping scrollTop, leaving the latest message hidden behind the
  // composer. A layout effect on the prop catches every transition (initial
  // 0 → N and subsequent multi-line/reply-preview growth) deterministically,
  // unlike a ResizeObserver dispatch which can coalesce the mount and the
  // resize into a single skipped first firing.
  useLayoutEffect(() => {
    if (!atBottomRef.current) return;
    const el = getScrollElement();
    if (el) scrollToBottom(el);
  }, [bottomOverlayHeight, getScrollElement]);

  // Single scroll listener covering two concerns:
  //   1. FAB visibility (all platforms) — eager distance gate with show /
  //      hide hysteresis, decoupled from `atBottom`'s 1s debounce.
  //   2. Composer slide/fade (Capacitor native only) — direction-tracked
  //      accumulator with asymmetric hysteresis; hide only fires past the
  //      near-bottom band so the timeline's bottom breathing room can be
  //      revealed by small scrolls without stripping the composer.
  // The atBottom force-show effect below corrects composer state on
  // programmatic jumps to bottom (jump-to-latest, send, layout re-anchor).
  useEffect(() => {
    const scrollEl = getScrollElement();
    if (!scrollEl) return undefined;
    const native = isNativePlatform();

    let lastTop = scrollEl.scrollTop;
    let accumulator = 0;
    let lastDir: 1 | -1 | 0 = 0;

    const handler = () => {
      const top = scrollEl.scrollTop;
      const distFromBottom = scrollEl.scrollHeight - top - scrollEl.clientHeight;

      setFabHidden((prev) => {
        if (distFromBottom > FAB_SHOW_DISTANCE_PX) return false;
        if (distFromBottom <= FAB_HIDE_DISTANCE_PX) return true;
        return prev;
      });

      if (!native || !onComposerHiddenChange) return;
      const delta = top - lastTop;
      lastTop = top;
      if (delta === 0) return;
      const dir: 1 | -1 = delta > 0 ? 1 : -1;
      if (dir !== lastDir) {
        accumulator = delta;
        lastDir = dir;
      } else {
        accumulator += delta;
      }
      if (accumulator <= -COMPOSER_HIDE_DELTA_PX) {
        if (distFromBottom > COMPOSER_NEAR_BOTTOM_PX) {
          onComposerHiddenChange(true);
        }
        accumulator = 0;
      } else if (accumulator >= COMPOSER_SHOW_DELTA_PX) {
        onComposerHiddenChange(false);
        accumulator = 0;
      }
    };
    scrollEl.addEventListener('scroll', handler, { passive: true });
    return () => scrollEl.removeEventListener('scroll', handler);
  }, [getScrollElement, onComposerHiddenChange]);

  useEffect(() => {
    if (!isNativePlatform()) return;
    if (atBottom && onComposerHiddenChange) onComposerHiddenChange(false);
  }, [atBottom, onComposerHiddenChange]);

  // Stay at bottom when scroll container resizes (e.g. Android keyboard open/close)
  useResizeObserver(
    useMemo(() => {
      let mounted = false;
      return (_entries: ResizeObserverEntry[]) => {
        if (!mounted) {
          mounted = true;
          return;
        }
        if (atBottomRef.current) {
          requestAnimationFrame(() => {
            const el = getScrollElement();
            if (el) scrollToBottom(el);
          });
        }
      };
    }, [getScrollElement]),
    getScrollElement
  );

  const tryAutoMarkAsRead = useCallback(() => {
    const readUptoEventId = readUptoEventIdRef.current;
    if (!readUptoEventId) {
      requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
      return;
    }
    const evtTimeline = getEventTimeline(room, readUptoEventId);
    const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
    if (latestTimeline === room.getLiveTimeline()) {
      requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
    }
  }, [mx, room, hideActivity]);

  const debounceSetAtBottom = useDebounce(
    useCallback((entry: IntersectionObserverEntry) => {
      if (!entry.isIntersecting) setAtBottom(false);
    }, []),
    { wait: 1000 }
  );
  useIntersectionObserver(
    useCallback(
      (entries) => {
        const target = atBottomAnchorRef.current;
        if (!target) return;
        const targetEntry = getIntersectionObserverEntry(target, entries);
        if (targetEntry) debounceSetAtBottom(targetEntry);
        if (targetEntry?.isIntersecting && atLiveEndRef.current) {
          setAtBottom(true);
          if (document.hasFocus()) {
            tryAutoMarkAsRead();
          }
        }
      },
      [debounceSetAtBottom, tryAutoMarkAsRead]
    ),
    useCallback(
      () => ({
        root: getScrollElement(),
        // Forgiving threshold: 100px was too tight — single tall
        // message + minor touch-scroll inertia easily took users out
        // of «at-bottom» state, after which incoming events stopped
        // auto-following. 400px gives roughly two-three Stream rows
        // of slack before treating the scroll as a deliberate detach,
        // matching Discord / Slack auto-follow generosity.
        rootMargin: '400px',
      }),
      [getScrollElement]
    ),
    useCallback(() => atBottomAnchorRef.current, [])
  );

  useDocumentFocusChange(
    useCallback(
      (inFocus) => {
        if (inFocus && atBottomRef.current) {
          if (unreadInfo?.inLiveTimeline) {
            handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => {
              // the unread event is already in view
              // so, try mark as read;
              if (!scrolled) {
                tryAutoMarkAsRead();
              }
            });
            return;
          }
          tryAutoMarkAsRead();
        }
      },
      [tryAutoMarkAsRead, unreadInfo, handleOpenEvent]
    )
  );

  // Handle up arrow edit
  useKeyDown(
    window,
    useCallback(
      (evt) => {
        // Drawer-active: the focused `<Editable>` may be the drawer
        // composer (also tagged `data-editable-name="RoomInput"`), so
        // this selector alone can't distinguish main vs thread. Without
        // this guard, ArrowUp in an empty drawer composer would put the
        // CHANNEL timeline's latest event into edit mode — visible on
        // desktop, where main timeline stays mounted next to the drawer.
        if (threadDrawerOpen) return;
        if (
          isKeyHotkey('arrowup', evt) &&
          editableActiveElement() &&
          document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' &&
          isEmptyEditor(editor)
        ) {
          const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) =>
            canEditEvent(mx, mEvt)
          );
          const editableEvtId = editableEvt?.getId();
          if (!editableEvtId) return;
          handleEdit(editableEvtId);
          evt.preventDefault();
        }
      },
      [mx, room, editor, threadDrawerOpen, handleEdit]
    )
  );

  useEffect(() => {
    if (eventId) {
      setTimeline(getEmptyTimeline());
      loadEventTimeline(eventId);
    }
  }, [eventId, loadEventTimeline]);

  // Scroll to bottom on initial timeline load
  useLayoutEffect(() => {
    const scrollEl = scrollRef.current;
    if (scrollEl) {
      scrollToBottom(scrollEl);
    }
  }, []);

  // if live timeline is linked and unreadInfo change
  // Scroll to last read message
  useLayoutEffect(() => {
    const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
    if (readUptoEventId && inLiveTimeline && scrollTo) {
      const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
      const evtTimeline = getEventTimeline(room, readUptoEventId);
      const absoluteIndex =
        evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
      if (absoluteIndex) {
        scrollToItem(absoluteIndex, {
          behavior: 'instant',
          align: 'start',
          stopInView: true,
        });
      }
    }
  }, [room, unreadInfo, scrollToItem]);

  // scroll to focused message
  useLayoutEffect(() => {
    if (focusItem && focusItem.scrollTo) {
      scrollToItem(focusItem.index, {
        behavior: 'instant',
        align: 'center',
        stopInView: true,
      });
    }

    setTimeout(() => {
      if (!alive()) return;
      setFocusItem((currentItem) => {
        if (currentItem === focusItem) return undefined;
        return currentItem;
      });
    }, 2000);
  }, [alive, focusItem, scrollToItem]);

  // scroll to bottom of timeline
  const scrollToBottomCount = scrollToBottomRef.current.count;
  useLayoutEffect(() => {
    if (scrollToBottomCount > 0) {
      const scrollEl = scrollRef.current;
      if (scrollEl)
        scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
    }
  }, [scrollToBottomCount]);

  // Remove unreadInfo on mark as read
  useEffect(() => {
    if (!unread) {
      setUnreadInfo(undefined);
    }
  }, [unread]);

  // scroll out of view msg editor in view.
  useEffect(() => {
    if (editId) {
      const editMsgElement =
        (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
        undefined;
      if (editMsgElement) {
        scrollToElement(editMsgElement, {
          align: 'center',
          behavior: 'smooth',
          stopInView: true,
        });
      }
    }
  }, [scrollToElement, editId]);

  const handleJumpToLatest = () => {
    if (eventId) {
      navigateRoom(room.roomId, undefined, { replace: true });
    }
    setTimeline(getInitialTimeline(room));
    scrollToBottomRef.current.count += 1;
    scrollToBottomRef.current.smooth = false;
  };

  const handleJumpToUnread = () => {
    if (unreadInfo?.readUptoEventId) {
      setTimeline(getEmptyTimeline());
      loadEventTimeline(unreadInfo.readUptoEventId);
    }
  };

  const handleMarkAsRead = () => {
    markAsRead(mx, room.roomId, hideActivity);
  };

  const { t } = useTranslation();

  // Sticky day capsules (every room class — 1:1 bubble, group, channel). Each
  // day boundary renders a REAL
  // capsule that is a CSS `position: sticky` element (see RoomTimeline.css
  // `[data-sticky-dates='on'] BubbleDayCapsuleRow`). The browser pins it on the
  // compositor while you scroll through a day, then lets it settle back into its
  // empty slot when you reach that day's start — so it NEVER jitters (the old
  // JS-transform emulation lagged the scroll by a frame, which is what «дёргается»
  // was). This effect does only two cheap, lag-tolerant things:
  //   1. Engage/disengage stickiness via a container attribute — ON while scrolled
  //      up, OFF at the live bottom (so no date sits stuck at the top while the
  //      composer is up). Hysteresis between SHOW/HIDE distances.
  //   2. Flat siblings all pin at the same `top`, so older days pile up behind the
  //      current one. We hide every piled-up capsule except the front (newest
  //      stuck) one, so a narrower pill never lets a wider older pill peek out.
  // Both are binary toggles, so a one-frame scroll-event delay is invisible.
  // `offsetTop` is the in-flow position (sticky doesn't change it), so the
  // front detection and the virtual paginator's offsetTop maths stay in agreement.
  useEffect(() => {
    const scrollEl = getScrollElement();
    if (!scrollEl) return undefined;

    let raf = 0;
    let engaged = false;
    const showAll = () => {
      const rows = scrollEl.querySelectorAll<HTMLElement>('[data-day-divider]');
      for (let i = 0; i < rows.length; i += 1) rows[i].style.visibility = '';
    };
    const recompute = () => {
      raf = 0;
      const distFromBottom = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
      if (distFromBottom > DATE_SHOW_DISTANCE_PX) engaged = true;
      else if (distFromBottom <= DATE_HIDE_DISTANCE_PX) engaged = false;

      if (!engaged) {
        if (scrollEl.dataset.stickyDates === 'on') scrollEl.dataset.stickyDates = 'off';
        showAll();
        return;
      }
      if (scrollEl.dataset.stickyDates !== 'on') scrollEl.dataset.stickyDates = 'on';

      // Front = the last (newest, lowest in the chat) capsule whose natural slot
      // has scrolled up to/past the pin — that's the one the browser shows pinned.
      const rows = scrollEl.querySelectorAll<HTMLElement>('[data-day-divider]');
      const { offsetTop: sOffsetTop, scrollTop } = scrollEl;
      let front = -1;
      const stuck: boolean[] = [];
      for (let i = 0; i < rows.length; i += 1) {
        const naturalTop = rows[i].offsetTop - sOffsetTop - scrollTop;
        stuck[i] = naturalTop <= VOJO_STICKY_DATE_TOP_PX + 0.5;
        if (stuck[i]) front = i;
      }
      for (let i = 0; i < rows.length; i += 1) {
        rows[i].style.visibility = stuck[i] && i !== front ? 'hidden' : '';
      }
    };

    const handler = () => {
      if (!raf) raf = requestAnimationFrame(recompute);
    };

    recompute();
    scrollEl.addEventListener('scroll', handler, { passive: true });
    return () => {
      scrollEl.removeEventListener('scroll', handler);
      if (raf) cancelAnimationFrame(raf);
      delete scrollEl.dataset.stickyDates;
      showAll();
    };
  }, [getScrollElement]);

  // Group `m.call.member` (StateEvent.GroupCallMemberPrefix) events into one
  // aggregate bubble per CALL SESSION. Each session is delimited by «joined
  // count went from 0 → ≥1, then back to 0». A session's anchor is its
  // earliest event; the sender of that anchor is the call initiator and
  // decides bubble side (own/peer).
  //
  // Why per-session and not just per-`call_id`: legacy MSC3401 room-scoped
  // calls use an empty `call_id` (see matrix-js-sdk MembershipManager
  // `INFO_SLOT_ID_LEGACY_CASE`), so every historical call in the same DM
  // room shares the same slot id. Splitting on the «count returned to 0»
  // boundary keeps each call's metrics — connectedAt / endedAt / duration —
  // scoped to its own session, otherwise the bubble would smear the entire
  // call history into one super-aggregate.
  //
  // Forward chronological walk per linked timeline tracks: (1) joined count
  // per session so we can pin the conversation-duration window to «both
  // joined» → «one left» and exclude the ringing prelude; (2) each
  // currently-joined membership's absolute expiry so a crashed peer
  // (join with no leave) doesn't keep the bubble in «Ongoing» forever —
  // once the SDK's DEFAULT_EXPIRE_DURATION elapses past
  // created_ts + expires, we treat the stale join as gone. Linear in
  // #call-member events (rare); matches the inline `streamRenderableItemHasBefore`
  // IIFE pattern just below.
  // This rebuilds the call-membership state machine by scanning EVERY event in
  // EVERY linked timeline (grows unbounded with scrollback). Previously it ran
  // as a per-render IIFE, so every fab/atBottom/focus/unread/pulse state change
  // re-scanned the whole room. Memoized on the `timeline` state object — the
  // only reactive input — so it recomputes only when timeline content changes.
  // Date.now() (used below for «ongoing») reads fresh on each actual recompute.
  const callAnchors: Map<string, CallAggregate> = useMemo(() => {
    type CallScan = {
      slotId: string;
      anchorEventId: string;
      anchorSenderId: string;
      startTs: number;
      endTs: number;
      // TS when joined count first reached 2 (both sides connected). Used as
      // the duration window start; falls back to startTs on pagination edge.
      connectedAt: number | null;
      // TS when joined count dropped from 2 → 1 for the last time. Null
      // while the conversation is still live (or never began).
      endedAt: number | null;
      // Running tally of currently-joined memberships (per stateKey).
      joinedCount: number;
      participants: Set<string>;
      // state_key (mxid + device) -> is currently joined
      memberStates: Map<string, boolean>;
      // state_key -> absolute expiry timestamp of the active join (ms since
      // epoch). Cleared on leave. Used to detect crashed clients.
      joinedAbsoluteExpiries: Map<string, number>;
      // At least one join observed in this session — guards session close
      // and filters leave-only pagination fragments.
      everJoined: boolean;
      // Session terminated (count returned to 0 after everJoined). New
      // events for this slot open a fresh session instead of appending.
      closed: boolean;
    };
    // Per-slot ordered list of sessions. Newest session is `last(sessions)`;
    // earlier entries are closed (count returned to 0).
    const slotSessions = new Map<string, CallScan[]>();
    // Hot data-pipeline path: nested for-of with early `continue` is the
    // clearest expression of the state-machine. Array iteration helpers would
    // require restructuring the dual-key membership ledger.
    /* eslint-disable no-restricted-syntax, no-continue */
    for (const tl of timeline.linkedTimelines) {
      const events = tl.getEvents();
      for (const ev of events) {
        if (ev.getType() !== StateEvent.GroupCallMemberPrefix) continue;
        const content = ev.getContent<SessionMembershipData>();
        const prevContent = ev.getPrevContent() as Partial<SessionMembershipData>;
        let slotId: string | null = null;
        if (typeof content.call_id === 'string') {
          slotId = content.call_id;
        } else if (typeof prevContent.call_id === 'string') {
          slotId = prevContent.call_id;
        }
        if (slotId == null) continue;
        // `anchorEventId` is the React key for the merged call bubble; an
        // empty fallback would collide across multiple eventless rows.
        const evId = ev.getId();
        if (!evId) continue;
        const ts = ev.getTs();
        const isJoin = !!content.application;
        const wasPreviouslyJoined = !!prevContent.application;
        const sender = ev.getSender() ?? '';
        const stateKey = ev.getStateKey() ?? '';

        let sessions = slotSessions.get(slotId);
        if (!sessions) {
          sessions = [];
          slotSessions.set(slotId, sessions);
        }
        let scan = sessions[sessions.length - 1];
        if (!scan || scan.closed) {
          // Don't open a session for a stray leave event with no prior join
          // context (its session paginated out — there is nothing to render
          // and absorbing it would corrupt the next real session's counts).
          if (!isJoin && !wasPreviouslyJoined) continue;
          scan = {
            slotId,
            anchorEventId: evId,
            anchorSenderId: sender,
            startTs: ts,
            endTs: ts,
            connectedAt: null,
            endedAt: null,
            joinedCount: 0,
            participants: new Set(),
            memberStates: new Map(),
            joinedAbsoluteExpiries: new Map(),
            everJoined: false,
            closed: false,
          };
          sessions.push(scan);
        }

        if (ts < scan.startTs) {
          scan.startTs = ts;
          scan.anchorEventId = evId;
          scan.anchorSenderId = sender;
        }
        if (ts > scan.endTs) scan.endTs = ts;

        const prevJoined = scan.memberStates.get(stateKey) ?? false;
        if (isJoin && !prevJoined) {
          scan.joinedCount += 1;
          scan.everJoined = true;
          if (scan.joinedCount === 2 && scan.connectedAt === null) {
            scan.connectedAt = ts;
          }
        } else if (!isJoin && prevJoined) {
          if (scan.joinedCount === 2) {
            scan.endedAt = ts;
          }
          scan.joinedCount = Math.max(0, scan.joinedCount - 1);
        }
        scan.memberStates.set(stateKey, isJoin);
        if (isJoin) {
          const createdTs = typeof content.created_ts === 'number' ? content.created_ts : ts;
          // `expires === 0` (or any non-positive value) is a misbehaving
          // client emitting an instantly-stale membership — refuse and fall
          // back to the SDK default rather than declaring the join expired
          // on arrival.
          const expiresDelta =
            typeof content.expires === 'number' && content.expires > 0
              ? content.expires
              : DEFAULT_EXPIRE_DURATION;
          scan.joinedAbsoluteExpiries.set(stateKey, createdTs + expiresDelta);
        } else {
          scan.joinedAbsoluteExpiries.delete(stateKey);
        }

        // A leave with `prev_content.application` proves this sender was
        // previously joined even if their join event paginated out of view.
        // Counting it keeps `wasAnswered` correct when the user scrolls
        // into the middle of a call.
        if (sender && (isJoin || wasPreviouslyJoined)) scan.participants.add(sender);

        // Close the session once everyone has left. The next event for this
        // slot opens a fresh session — this is what separates back-to-back
        // calls that share the same legacy room-scoped slot id.
        if (scan.joinedCount === 0 && scan.everJoined) {
          scan.closed = true;
        }
      }
    }
    /* eslint-enable no-restricted-syntax, no-continue */
    const now = Date.now();
    // Consecutive unsuccessful sessions from the same caller within this
    // window collapse into one bubble (WhatsApp/iOS Recents-style anti-spam).
    // Answered/ongoing calls always stand alone.
    const MERGE_WINDOW_MS = 60 * 60 * 1000;
    type SessionDisplay = {
      scan: CallScan;
      ongoing: boolean;
      wasAnswered: boolean;
    };
    type DisplayUnit = {
      // Anchor scan — its `anchorEventId` is the event the merged bubble
      // attaches to in the timeline. We pick the LATEST scan in the group
      // so the bubble shows at the most recent attempt's position, while
      // earlier attempts disappear from view (the spam we want to hide).
      anchor: SessionDisplay;
      mergedCount: number;
      // false once any answered/ongoing scan lands in the unit — sealed
      // units never absorb later scans.
      mergeable: boolean;
      // Used by the merge predicate: the latest scan's endTs decides the
      // gap to the next candidate.
      lastEndTs: number;
    };
    const anchors = new Map<string, CallAggregate>();
    // Second pass: collapse same-sender unanswered scans into merge units.
    // Same justification as the upstream loop — pipeline expression with
    // early `continue` is clearer than reduce+filter chains.
    /* eslint-disable no-restricted-syntax, no-continue */
    for (const sessions of slotSessions.values()) {
      const units: DisplayUnit[] = [];
      for (const scan of sessions) {
        if (!scan.everJoined && scan.participants.size === 0) continue;
        const ongoing = Array.from(scan.joinedAbsoluteExpiries.values()).some((exp) => exp > now);
        const wasAnswered = scan.connectedAt !== null || scan.participants.size >= 2;
        const display: SessionDisplay = { scan, ongoing, wasAnswered };
        const mergeable = !ongoing && !wasAnswered;
        const last = units[units.length - 1];
        if (
          mergeable &&
          last !== undefined &&
          last.mergeable &&
          last.anchor.scan.anchorSenderId === scan.anchorSenderId &&
          scan.startTs - last.lastEndTs <= MERGE_WINDOW_MS
        ) {
          last.anchor = display;
          last.mergedCount += 1;
          last.lastEndTs = scan.endTs;
        } else {
          units.push({
            anchor: display,
            mergedCount: 1,
            mergeable,
            lastEndTs: scan.endTs,
          });
        }
      }
      for (const unit of units) {
        const { scan, ongoing, wasAnswered } = unit.anchor;
        const conversationStart = wasAnswered ? scan.connectedAt ?? scan.startTs : null;
        const conversationEnd = wasAnswered && !ongoing ? scan.endedAt ?? scan.endTs : null;
        anchors.set(scan.anchorEventId, {
          callId: scan.slotId,
          startTs: scan.startTs,
          endTs: ongoing ? null : scan.endTs,
          conversationStart,
          conversationEnd,
          ongoing,
          wasAnswered,
          participants: Array.from(scan.participants),
          anchorSenderId: scan.anchorSenderId,
          mergedCount: unit.mergedCount,
        });
      }
    }
    /* eslint-enable no-restricted-syntax, no-continue */
    return anchors;
  }, [timeline]);

  // Item index of the user's last own message at the live bottom (bubble layout),
  // or undefined. Set from the pre-scan below; read inside the message renderers
  // to mount the read-status line. A ref so these renderer closures — created
  // here but run after the pre-scan assigns it — always see the fresh value.
  const latestOwnItemRef = useRef<number | undefined>(undefined);
  // Items whose per-message timestamp is hidden because the next renderable row
  // is the same sender in the same minute — so a same-minute burst collapses to
  // ONE timestamp (on the last message of the minute group), Telegram-style.
  // Bubble layout only; set from the pre-scan, read in the message renderers.
  const hideTimeRef = useRef<Set<number>>(new Set());
  // Items whose (shown) timestamp drops BELOW the bubble rather than to the side:
  // the members of a same-minute series (the shown one is its last message). A
  // standalone message keeps its timestamp on the side. Bubble layout only.
  const timeBelowRef = useRef<Set<number>>(new Set());

  const renderMatrixEvent = useMatrixEventRenderer<
    // (mEventId, mEvent, item, timelineSet, collapse, railStart, railEnd, railHidden)
    [string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean, boolean]
  >(
    {
      // Suppress DM-call service events from the timeline. In encrypted
      // DMs this takes effect after per-event decryption re-render via
      // EncryptedContent; the first render shows the "not decrypted" placeholder.
      // Hardcoded strings — migrate to EventType.RTCNotification/RTCDecline
      // when MSC4075/MSC4310 stabilize.
      'org.matrix.msc4075.rtc.notification': () => null,
      'org.matrix.msc4310.rtc.decline': () => null,
      [MessageEvent.RoomMessage]: (
        mEventId,
        mEvent,
        item,
        timelineSet,
        collapse,
        streamRailStart,
        streamRailEnd,
        railHidden
      ) => {
        const reactionRelations = getEventReactions(timelineSet, mEventId);
        const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
        const hasReactions = reactions && reactions.length > 0;
        const { replyEventId, threadRootId } = mEvent;
        const highlighted = focusItem?.index === item && focusItem.highlight;
        const isLatestOwn = item === latestOwnItemRef.current;
        const showTime = !hideTimeRef.current.has(item);
        const timeBelow = timeBelowRef.current.has(item);

        const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
        const getContent = (() =>
          editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;

        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;

        return (
          <Message
            key={mEvent.getId()}
            renderSig={getMessageRenderSig(
              room,
              mEvent,
              editedEvent,
              showUrlPreview,
              isLatestOwn,
              showTime,
              timeBelow
            )}
            isLatestOwn={isLatestOwn}
            showTime={showTime}
            timeBelow={timeBelow}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            collapse={collapse}
            railHidden={railHidden}
            highlight={highlighted}
            edit={editId === mEventId}
            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={
              replyEventId && (
                <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={mEventId}
                  canSendReaction={canSendReaction}
                  onReactionToggle={handleReactionToggle}
                />
              )
            }
            hideReadReceipts={hideActivity}
            showDeveloperTools={showDeveloperTools}
            memberPowerTag={getMemberPowerTag(senderId)}
            accessibleTagColors={accessiblePowerTagColors}
            legacyUsernameColor={isOneOnOne}
            streamRailStart={streamRailStart}
            streamRailEnd={streamRailEnd}
            msgType={mEvent.getContent().msgtype ?? ''}
            hideThreadReplyAffordance={hideThreadReplyAffordance}
            hideMainReplyAffordance={hideMainReplyAffordance}
            threadSummary={
              showThreadSummary ? <ThreadSummaryCard room={room} rootEvent={mEvent} /> : undefined
            }
            layout={messageLayout}
            channelHeaderInBubble={channelHeaderInBubble}
          >
            {mEvent.isRedacted() ? (
              <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
            ) : (
              <RenderMessageContent
                displayName={senderDisplayName}
                senderId={senderId}
                senderAvatarUrl={senderAvatarUrl}
                // Voice notes never draw their own avatar in the timeline: the
                // bubble layout already places own → right / peer → left and
                // colours the card per side, so an avatar is redundant. (Channel
                // rows draw the per-message avatar themselves.)
                hideVoiceAvatar
                msgType={mEvent.getContent().msgtype ?? ''}
                ts={mEvent.getTs()}
                edited={!!editedEvent}
                getContent={getContent}
                mediaAutoLoad={mediaAutoLoad}
                urlPreview={showUrlPreview}
                htmlReactParserOptions={htmlReactParserOptions}
                linkifyOpts={linkifyOpts}
                outlineAttachment
                eventId={mEvent.getId()}
              />
            )}
          </Message>
        );
      },
      [MessageEvent.RoomMessageEncrypted]: (
        mEventId,
        mEvent,
        item,
        timelineSet,
        collapse,
        streamRailStart,
        streamRailEnd,
        railHidden
      ) => {
        const reactionRelations = getEventReactions(timelineSet, mEventId);
        const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
        const hasReactions = reactions && reactions.length > 0;
        const { replyEventId, threadRootId } = mEvent;
        const highlighted = focusItem?.index === item && focusItem.highlight;
        const isLatestOwn = item === latestOwnItemRef.current;
        const showTime = !hideTimeRef.current.has(item);
        const timeBelow = timeBelowRef.current.has(item);

        return (
          <EncryptedContent mEvent={mEvent}>
            {() => {
              // After decrypt, DM-call service events still route through
              // this branch (outer typeToRenderer dispatched on the pre-decrypt
              // 'm.room.encrypted' type). Drop the whole row instead of falling
              // through to MessageUnsupportedContent. Keys mirror the hardcoded
              // literals in the outer filter — migrate together.
              const decryptedType = mEvent.getType();
              if (decryptedType === 'org.matrix.msc4075.rtc.notification') return null;
              if (decryptedType === 'org.matrix.msc4310.rtc.decline') return null;

              return (
                <Message
                  key={mEvent.getId()}
                  renderSig={getMessageRenderSig(
                    room,
                    mEvent,
                    getEditedEvent(mEventId, mEvent, timelineSet),
                    showUrlPreview,
                    isLatestOwn,
                    showTime,
                    timeBelow
                  )}
                  isLatestOwn={isLatestOwn}
                  showTime={showTime}
                  timeBelow={timeBelow}
                  data-message-item={item}
                  data-message-id={mEventId}
                  room={room}
                  mEvent={mEvent}
                  collapse={collapse}
                  railHidden={railHidden}
                  highlight={highlighted}
                  edit={editId === mEventId}
                  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={
                    replyEventId && (
                      <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={mEventId}
                        canSendReaction={canSendReaction}
                        onReactionToggle={handleReactionToggle}
                      />
                    )
                  }
                  hideReadReceipts={hideActivity}
                  showDeveloperTools={showDeveloperTools}
                  memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
                  accessibleTagColors={accessiblePowerTagColors}
                  legacyUsernameColor={isOneOnOne}
                  streamRailStart={streamRailStart}
                  streamRailEnd={streamRailEnd}
                  msgType={mEvent.getContent().msgtype ?? ''}
                  hideThreadReplyAffordance={hideThreadReplyAffordance}
                  hideMainReplyAffordance={hideMainReplyAffordance}
                  threadSummary={
                    showThreadSummary ? (
                      <ThreadSummaryCard room={room} rootEvent={mEvent} />
                    ) : undefined
                  }
                  layout={messageLayout}
                  channelHeaderInBubble={channelHeaderInBubble}
                >
                  {(() => {
                    if (mEvent.isRedacted()) return <RedactedContent />;
                    if (decryptedType === MessageEvent.Sticker)
                      return (
                        <MSticker
                          content={mEvent.getContent()}
                          renderImageContent={(props) => (
                            <ImageContent
                              {...props}
                              autoPlay={mediaAutoLoad}
                              renderImage={(p) => <Image {...p} loading="lazy" />}
                              renderViewer={(p) => <ImageViewer {...p} />}
                            />
                          )}
                        />
                      );
                    if (decryptedType === MessageEvent.RoomMessage) {
                      const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
                      const getContent = (() =>
                        editedEvent?.getContent()['m.new_content'] ??
                        mEvent.getContent()) as GetContentCallback;

                      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;
                      return (
                        <RenderMessageContent
                          displayName={senderDisplayName}
                          senderId={senderId}
                          senderAvatarUrl={senderAvatarUrl}
                          // Hide the voice avatar in every layout — same as the
                          // cleartext branch above. (Encrypted DMs are the default,
                          // so this branch is the main 1:1 voice path.) The bubble
                          // places own/peer by side+colour; channel rows draw their
                          // own per-message avatar.
                          hideVoiceAvatar
                          msgType={mEvent.getContent().msgtype ?? ''}
                          ts={mEvent.getTs()}
                          edited={!!editedEvent}
                          getContent={getContent}
                          mediaAutoLoad={mediaAutoLoad}
                          urlPreview={showUrlPreview}
                          htmlReactParserOptions={htmlReactParserOptions}
                          linkifyOpts={linkifyOpts}
                          outlineAttachment
                          eventId={mEvent.getId()}
                        />
                      );
                    }
                    if (decryptedType === MessageEvent.RoomMessageEncrypted)
                      return (
                        <Text>
                          <MessageNotDecryptedContent />
                        </Text>
                      );
                    return (
                      <Text>
                        <MessageUnsupportedContent />
                      </Text>
                    );
                  })()}
                </Message>
              );
            }}
          </EncryptedContent>
        );
      },
      [MessageEvent.Sticker]: (
        mEventId,
        mEvent,
        item,
        timelineSet,
        collapse,
        streamRailStart,
        streamRailEnd,
        railHidden
      ) => {
        const reactionRelations = getEventReactions(timelineSet, mEventId);
        const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
        const hasReactions = reactions && reactions.length > 0;
        const highlighted = focusItem?.index === item && focusItem.highlight;
        const isLatestOwn = item === latestOwnItemRef.current;
        const showTime = !hideTimeRef.current.has(item);
        const timeBelow = timeBelowRef.current.has(item);

        return (
          <Message
            key={mEvent.getId()}
            renderSig={getMessageRenderSig(
              room,
              mEvent,
              undefined,
              showUrlPreview,
              isLatestOwn,
              showTime,
              timeBelow
            )}
            isLatestOwn={isLatestOwn}
            showTime={showTime}
            timeBelow={timeBelow}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            collapse={collapse}
            railHidden={railHidden}
            highlight={highlighted}
            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}
            reactions={
              reactionRelations && (
                <Reactions
                  style={{ marginTop: config.space.S200 }}
                  room={room}
                  relations={reactionRelations}
                  mEventId={mEventId}
                  canSendReaction={canSendReaction}
                  onReactionToggle={handleReactionToggle}
                />
              )
            }
            hideReadReceipts={hideActivity}
            showDeveloperTools={showDeveloperTools}
            memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
            accessibleTagColors={accessiblePowerTagColors}
            legacyUsernameColor={isOneOnOne}
            streamRailStart={streamRailStart}
            streamRailEnd={streamRailEnd}
            hideThreadReplyAffordance={hideThreadReplyAffordance}
            hideMainReplyAffordance={hideMainReplyAffordance}
            threadSummary={
              showThreadSummary ? <ThreadSummaryCard room={room} rootEvent={mEvent} /> : undefined
            }
            layout={messageLayout}
            channelHeaderInBubble={channelHeaderInBubble}
          >
            {mEvent.isRedacted() ? (
              <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
            ) : (
              <MSticker
                content={mEvent.getContent()}
                renderImageContent={(props) => (
                  <ImageContent
                    {...props}
                    autoPlay={mediaAutoLoad}
                    renderImage={(p) => <Image {...p} loading="lazy" />}
                    renderViewer={(p) => <ImageViewer {...p} />}
                  />
                )}
              />
            )}
          </Message>
        );
      },
      [StateEvent.RoomMember]: (
        mEventId,
        mEvent,
        item,
        _timelineSet,
        _collapse,
        streamRailStart,
        streamRailEnd
      ) => {
        // 1:1 rooms always hide membership/nick/avatar syslines — they are
        // pure noise in DMs. Group rooms (3+) respect the per-user settings.
        if (isOneOnOne) return null;
        const membershipChanged = isMembershipChanged(mEvent);
        if (membershipChanged && hideMembershipEvents) return null;
        if (!membershipChanged && hideNickAvatarEvents) return null;

        const highlighted = focusItem?.index === item && focusItem.highlight;
        const parsed = parseMemberEvent(mEvent);
        return (
          <SyslineMessage
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            body={parsed.body}
            highlight={highlighted}
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
            hideReadReceipts={hideActivity}
            showDeveloperTools={showDeveloperTools}
            streamRailStart={streamRailStart}
            streamRailEnd={streamRailEnd}
            layout={messageLayout}
            channelHeaderInBubble={channelHeaderInBubble}
          />
        );
      },
      [StateEvent.RoomName]: (
        mEventId,
        mEvent,
        item,
        _timelineSet,
        _collapse,
        streamRailStart,
        streamRailEnd
      ) => {
        const highlighted = focusItem?.index === item && focusItem.highlight;
        const senderId = mEvent.getSender() ?? '';
        const senderName =
          getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
        return (
          <SyslineMessage
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            body={
              <>
                <b>{senderName}</b>
                {t('Organisms.RoomCommon.changed_room_name')}
              </>
            }
            highlight={highlighted}
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
            hideReadReceipts={hideActivity}
            showDeveloperTools={showDeveloperTools}
            streamRailStart={streamRailStart}
            streamRailEnd={streamRailEnd}
            layout={messageLayout}
            channelHeaderInBubble={channelHeaderInBubble}
          />
        );
      },
      [StateEvent.RoomTopic]: (
        mEventId,
        mEvent,
        item,
        _timelineSet,
        _collapse,
        streamRailStart,
        streamRailEnd
      ) => {
        const highlighted = focusItem?.index === item && focusItem.highlight;
        const senderId = mEvent.getSender() ?? '';
        const senderName =
          getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
        return (
          <SyslineMessage
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            body={
              <>
                <b>{senderName}</b>
                {' changed room topic'}
              </>
            }
            highlight={highlighted}
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
            hideReadReceipts={hideActivity}
            showDeveloperTools={showDeveloperTools}
            streamRailStart={streamRailStart}
            streamRailEnd={streamRailEnd}
            layout={messageLayout}
            channelHeaderInBubble={channelHeaderInBubble}
          />
        );
      },
      [StateEvent.RoomAvatar]: (
        mEventId,
        mEvent,
        item,
        _timelineSet,
        _collapse,
        streamRailStart,
        streamRailEnd
      ) => {
        const highlighted = focusItem?.index === item && focusItem.highlight;
        const senderId = mEvent.getSender() ?? '';
        const senderName =
          getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
        return (
          <SyslineMessage
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            body={
              <>
                <b>{senderName}</b>
                {' changed room avatar'}
              </>
            }
            highlight={highlighted}
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
            hideReadReceipts={hideActivity}
            showDeveloperTools={showDeveloperTools}
            streamRailStart={streamRailStart}
            streamRailEnd={streamRailEnd}
            layout={messageLayout}
            channelHeaderInBubble={channelHeaderInBubble}
          />
        );
      },
      [StateEvent.GroupCallMemberPrefix]: (
        mEventId,
        mEvent,
        item,
        _timelineSet,
        _collapse,
        streamRailStart,
        streamRailEnd
      ) => {
        // One aggregate bubble per call — only the call's anchor event
        // renders, all other join/leave events for the same call_id are
        // collapsed (null). `callAnchors` is computed above and mirrored
        // in `isRenderableTimelineEvent` so rail-endpoints agree.
        const aggregate = callAnchors.get(mEventId);
        if (!aggregate) return null;
        const highlighted = focusItem?.index === item && focusItem.highlight;
        return (
          <CallMessage
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            aggregate={aggregate}
            highlight={highlighted}
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
            hideReadReceipts={hideActivity}
            showDeveloperTools={showDeveloperTools}
            streamRailStart={streamRailStart}
            streamRailEnd={streamRailEnd}
            layout={messageLayout}
            channelHeaderInBubble={channelHeaderInBubble}
            memberPowerTag={getMemberPowerTag(aggregate.anchorSenderId)}
            accessibleTagColors={accessiblePowerTagColors}
            legacyUsernameColor={isOneOnOne}
          />
        );
      },
    },
    (mEventId, mEvent, item, _timelineSet, _collapse, streamRailStart, streamRailEnd) => {
      if (!showHiddenEvents) return null;
      const highlighted = focusItem?.index === item && focusItem.highlight;
      const senderId = mEvent.getSender() ?? '';
      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);

      const timeJSX = <Time ts={mEvent.getTs()} compact />;

      return (
        <Event
          key={mEvent.getId()}
          data-message-item={item}
          data-message-id={mEventId}
          room={room}
          mEvent={mEvent}
          highlight={highlighted}
          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
          hideReadReceipts={hideActivity}
          showDeveloperTools={showDeveloperTools}
        >
          <EventContent
            time={timeJSX}
            railStart={streamRailStart}
            railEnd={streamRailEnd}
            layout={messageLayout}
            iconSrc={Icons.Code}
            content={
              <Box grow="Yes" direction="Column">
                <Text size="T300" priority="300">
                  <b>{senderName}</b>
                  {' sent '}
                  <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
                  {' state event'}
                </Text>
              </Box>
            }
          />
        </Event>
      );
    },
    (mEventId, mEvent, item, _timelineSet, _collapse, streamRailStart, streamRailEnd) => {
      if (!showHiddenEvents) return null;
      if (Object.keys(mEvent.getContent()).length === 0) return null;
      if (mEvent.getRelation()) return null;
      if (mEvent.isRedaction()) return null;

      const highlighted = focusItem?.index === item && focusItem.highlight;
      const senderId = mEvent.getSender() ?? '';
      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);

      const timeJSX = <Time ts={mEvent.getTs()} compact />;

      return (
        <Event
          key={mEvent.getId()}
          data-message-item={item}
          data-message-id={mEventId}
          room={room}
          mEvent={mEvent}
          highlight={highlighted}
          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
          hideReadReceipts={hideActivity}
          showDeveloperTools={showDeveloperTools}
        >
          <EventContent
            time={timeJSX}
            railStart={streamRailStart}
            railEnd={streamRailEnd}
            layout={messageLayout}
            iconSrc={Icons.Code}
            content={
              <Box grow="Yes" direction="Column">
                <Text size="T300" priority="300">
                  <b>{senderName}</b>
                  {' sent '}
                  <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
                  {' event'}
                </Text>
              </Box>
            }
          />
        </Event>
      );
    }
  );

  let prevEvent: MatrixEvent | undefined;
  // The last event that actually RENDERED (skips hidden membership / reaction /
  // edit / redaction / RTC rows). `sameSenderRun` compares against this so an
  // intervening hidden event doesn't break a same-sender run.
  let prevRenderedEvent: MatrixEvent | undefined;
  let newDivider = false;
  let dayDivider = false;

  // Keep this in sync with renderMatrixEvent early `return null` branches above:
  // Stream rail endpoints (start/end) use this predicate to skip hidden
  // service / reaction / edit events when deciding whether a visible row is
  // the first or last timeline dot.
  const isRenderableTimelineEvent = (event: MatrixEvent): boolean => {
    const eventIdForRender = event.getId();
    if (!eventIdForRender) return false;

    const senderId = event.getSender();
    if (senderId && ignoredUsersSet.has(senderId)) return false;
    if (event.isRedacted() && !showHiddenEvents) return false;
    if (reactionOrEditEvent(event)) return false;

    // RTC service events (notifications / declines) — once decrypted, the
    // SDK's getType() returns the inner type, so this filter catches them.
    // While still encrypted+pending-decryption, the event passes the
    // RoomMessageEncrypted branch below as «renderable»; once decryption
    // completes, the timeline emits a fresh render that re-evaluates this
    // predicate with the inner type and falls through to false. The
    // transient one-frame mismatch on rail endpoint computation self-heals
    // on the very next pass — no code path needed.
    const eventType = event.getType();
    if (eventType === 'org.matrix.msc4075.rtc.notification') return false;
    if (eventType === 'org.matrix.msc4310.rtc.decline') return false;

    // Channels-mode filter — see `isChannelsModeHidden` above for the rule
    // set and rationale. Same helper used by the renderer null-gate so the
    // rail-endpoint scan and the actual render always agree on visibility.
    if (channelsMode && isChannelsModeHidden(room, event, isBridged)) return false;

    if (eventType === StateEvent.RoomMember) {
      // Mirror the membership-sysline gate from the renderer above so the
      // rail-endpoint scan and the actual render agree on visibility.
      if (isOneOnOne) return false;
      const membershipChanged = isMembershipChanged(event);
      if (membershipChanged && hideMembershipEvents) return false;
      if (!membershipChanged && hideNickAvatarEvents) return false;
      return true;
    }

    if (eventType === StateEvent.GroupCallMemberPrefix) {
      // Mirror the renderer: only the per-call anchor event is renderable;
      // all other joins/leaves for the same call_id collapse into the
      // aggregate bubble. Rail-endpoint scan must agree with the renderer.
      return callAnchors.has(event.getId() ?? '');
    }

    if (
      eventType === MessageEvent.RoomMessage ||
      eventType === MessageEvent.RoomMessageEncrypted ||
      eventType === MessageEvent.Sticker ||
      eventType === StateEvent.RoomName ||
      eventType === StateEvent.RoomTopic ||
      eventType === StateEvent.RoomAvatar
    ) {
      return true;
    }

    if (typeof event.getStateKey() === 'string') return showHiddenEvents;
    if (!showHiddenEvents) return false;
    if (Object.keys(event.getContent()).length === 0) return false;
    if (event.getRelation()) return false;
    // Note: redactions are intentionally NOT filtered here. The catch-all
    // renderer still renders them when showHiddenEvents=true (dev tools),
    // so the predicate must agree — otherwise rail endpoints would treat a
    // visible redaction event as invisible and miscount «is there a renderable
    // event before/after this row» on the dev-tools path.

    return true;
  };

  const getTimelineItemEvent = (item: number): MatrixEvent | undefined => {
    const [itemTimeline, itemBaseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
    if (!itemTimeline) return undefined;
    return getTimelineEvent(itemTimeline, getTimelineRelativeIndex(item, itemBaseIndex));
  };

  // Whether `cur` continues `prev` as the same Stream «run» (so `cur` is a
  // dot-less continuation, not a rail head). Mirrors the render loop's
  // `sameSenderRun` for the STREAM layout (no minute split): same sender +
  // type, same day, and not across the unread boundary (unless it's our own
  // message). Used only to place the rail endpoints — the channel layout has
  // no rail, so the absence of its 2-minute split here doesn't matter.
  const isStreamRunContinuation = (prev: MatrixEvent, cur: MatrixEvent): boolean =>
    prev.getSender() === cur.getSender() &&
    prev.getType() === cur.getType() &&
    inSameDay(prev.getTs(), cur.getTs()) &&
    (prev.getId() !== readUptoEventIdRef.current || cur.getSender() === mx.getUserId());

  // Single forward pass that records, for each visible item, whether there is
  // any RENDERABLE event before it (→ Stream rail-start = no renderable before)
  // and finds the LAST run head (last dot). Looks at renderability — skipping
  // the reaction / edit / hidden service events that render as nothing — so a
  // hidden row between two messages doesn't reset rail-start mid-thread. The
  // rail must stop at the last dot, so the rail-end caps on the last head and
  // the trailing dot-less continuations below it suppress their rail (see
  // `streamRailHidden`).
  const {
    before: streamRenderableItemHasBefore,
    hasRenderable: hasRenderableEvent,
    lastHead: streamLastHeadItem,
    lastOwn: latestOwnItem,
    hideTime: hideTimeItems,
    timeBelow: timeBelowItems,
  } = (() => {
    const before = new Map<number, boolean>();

    const items = getItems();
    const renderableFlags = items.map((item) => {
      const ev = getTimelineItemEvent(item);
      return !!ev && isRenderableTimelineEvent(ev);
    });

    // A renderable row is a «head» (renders a dot) when it does NOT continue
    // the previous renderable row's run; track the last one for the rail-end.
    // We also track the LAST renderable event so the bubble read-status line can
    // attach to it when it's the user's own message, and the items whose
    // timestamp collapses into the next same-sender same-minute row.
    let seenBefore = false;
    let prevRenderable: MatrixEvent | undefined;
    let prevRenderableItem: number | undefined;
    let lastHead: number | undefined;
    let lastRenderableItem: number | undefined;
    let lastRenderableEv: MatrixEvent | undefined;
    const hideTime = new Set<number>();
    // Items whose timestamp goes BELOW the bubble instead of on the side: the
    // members of a same-sender same-minute series. Only the LAST of a series
    // actually shows its time (the rest are in `hideTime`), and that last one is
    // a continuation, so it lands here → below. A standalone message is in
    // neither set → its time stays on the side.
    const timeBelow = new Set<number>();
    for (let index = 0; index < items.length; index += 1) {
      before.set(items[index], seenBefore);
      if (renderableFlags[index]) {
        seenBefore = true;
        const ev = getTimelineItemEvent(items[index]);
        if (ev) {
          // Same-sender continuation in the same calendar minute → the PREVIOUS
          // row's timestamp is redundant; hide it so the minute group shows one
          // stamp (on its last message). floor(ts/60000) buckets by wall-clock
          // minute identically in every timezone. The CURRENT row is a series
          // member, so its (possibly shown) stamp drops below.
          if (
            prevRenderable !== undefined &&
            prevRenderableItem !== undefined &&
            isStreamRunContinuation(prevRenderable, ev) &&
            Math.floor(prevRenderable.getTs() / 60000) === Math.floor(ev.getTs() / 60000)
          ) {
            hideTime.add(prevRenderableItem);
            timeBelow.add(items[index]);
          }
          lastRenderableItem = items[index];
          lastRenderableEv = ev;
          const isHead =
            prevRenderable === undefined || !isStreamRunContinuation(prevRenderable, ev);
          if (isHead) lastHead = items[index];
          prevRenderable = ev;
          prevRenderableItem = items[index];
        }
      }
    }

    const myId = mx.getUserId();
    const lastType = lastRenderableEv?.getType();
    const lastOwn =
      lastRenderableEv !== undefined &&
      lastRenderableItem !== undefined &&
      lastRenderableEv.getSender() === myId &&
      // A redacted own message (visible only with the dev show-hidden-events
      // toggle) carries no read state — don't hang a status line off a deleted bubble.
      !lastRenderableEv.isRedacted() &&
      (lastType === MessageEvent.RoomMessage ||
        lastType === MessageEvent.RoomMessageEncrypted ||
        lastType === MessageEvent.Sticker)
        ? lastRenderableItem
        : undefined;

    return {
      before,
      hasRenderable: renderableFlags.some(Boolean),
      lastHead,
      lastOwn,
      hideTime,
      timeBelow,
    };
  })();

  // Read-status line shows only at the live bottom of a 1:1 (bubble) timeline,
  // under the user's genuine last message — never in channel rooms or while
  // scrolled up into history.
  latestOwnItemRef.current =
    messageLayout !== 'channel' && liveTimelineLinked && rangeAtEnd ? latestOwnItem : undefined;
  // Minute-grouped timestamps (hide / drop-below) are a bubble-layout affordance only.
  hideTimeRef.current = messageLayout !== 'channel' ? hideTimeItems : EMPTY_ITEM_SET;
  timeBelowRef.current = messageLayout !== 'channel' ? timeBelowItems : EMPTY_ITEM_SET;

  const eventRenderer = (item: number) => {
    const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
    if (!eventTimeline) return null;
    const timelineSet = eventTimeline?.getTimelineSet();
    const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
    const mEventId = mEvent?.getId();

    if (!mEvent || !mEventId) return null;

    const eventSender = mEvent.getSender();
    if (eventSender && ignoredUsersSet.has(eventSender)) {
      return null;
    }
    if (mEvent.isRedacted() && !showHiddenEvents) {
      return null;
    }

    if (!newDivider && readUptoEventIdRef.current) {
      newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
    }
    if (!dayDivider) {
      dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
    }

    // Same-sender «run»: the previous rendered row is the same author + type,
    // not across a day/unread divider. Only the FIRST message of a run is a
    // rail «head» (dot + timestamp + nick); every later message is a collapsed
    // continuation (a bare body, new paragraph) until the other side replies
    // or a day/unread divider breaks the run.
    // Compare against the last RENDERED message, not the immediately preceding
    // timeline item: in a 1:1 several event types render as nothing (hidden
    // membership / profile changes, reactions, edits, redactions, RTC service
    // events), and any of them sitting between two of the peer's messages must
    // NOT break the run. `isStreamRunContinuation` is the same predicate the
    // rail pre-scan uses, so the dots and the collapse stay in agreement.
    const sameSenderRun =
      prevRenderedEvent !== undefined && isStreamRunContinuation(prevRenderedEvent, mEvent);

    // Stream collapses the WHOLE run (drop dot + time + nick, stack the body
    // tight) regardless of minute — the series ends only when the sender
    // changes. The channel layout keeps its looser ~90s avatar/header grouping,
    // so it still gates on a 2-minute gap.
    const collapsed =
      sameSenderRun &&
      (messageLayout !== 'channel' ||
        (prevRenderedEvent !== undefined &&
          minuteDifference(prevRenderedEvent.getTs(), mEvent.getTs()) < 2));

    // streamRailStart looks at the precomputed «is there a renderable event
    // before me in the visible window» — not at the immediately preceding
    // item, which is non-rendered right after a reaction / edit / hidden
    // service event and would otherwise restart the rail mid-conversation.
    // Symmetric with
    // streamRailEnd: only declare a row to be the rail's first dot when the
    // visible window is sitting at the genuine timeline start AND no further
    // back-pagination is possible — otherwise the «origin» dot would be a
    // lie about an earlier untouched history.
    const streamRailStart =
      rangeAtStart && !canPaginateBack && streamRenderableItemHasBefore.get(item) === false;
    // The rail stops at the LAST dot: cap the rail-end on the last run head
    // (not the last renderable row) and suppress the rail on the trailing
    // dot-less continuations below it. Only meaningful at the live end; when
    // more history sits below the window the rail extends as before.
    const atLiveEnd = liveTimelineLinked && rangeAtEnd;
    const streamRailEnd =
      atLiveEnd && streamLastHeadItem !== undefined && item === streamLastHeadItem;
    const streamRailHidden =
      atLiveEnd && streamLastHeadItem !== undefined && item > streamLastHeadItem;

    // Channels-mode renderer gate — same helper as the predicate above so
    // the rail-endpoint scan and the renderer never disagree on visibility.
    const channelsModeHidden = channelsMode && isChannelsModeHidden(room, mEvent, isBridged);

    const eventJSX =
      channelsModeHidden || reactionOrEditEvent(mEvent)
        ? null
        : renderMatrixEvent(
            mEvent.getType(),
            typeof mEvent.getStateKey() === 'string',
            mEventId,
            mEvent,
            item,
            timelineSet,
            collapsed,
            streamRailStart,
            streamRailEnd,
            streamRailHidden
          );
    prevEvent = mEvent;
    if (eventJSX) prevRenderedEvent = mEvent;

    if (newDivider && eventJSX) {
      // TODO(P3c-followup): replace the legacy full-width unread divider with
      // a Stream-native first-unread affordance — dot ring/pulse/brightness on
      // the rail. The «Jump to unread» chip stays functional in the meantime.
      newDivider = false;
    }

    const dayLabel = (() => {
      if (today(mEvent.getTs())) return t('Room.today');
      if (yesterday(mEvent.getTs())) return t('Room.yesterday');
      return timeDayMonYear(mEvent.getTs());
    })();

    const renderDayDivider = () => (
      // Every room class (1:1 bubble, group, channel) draws the same centred,
      // single dark-blue date pill. The row is the real `position: sticky`
      // element — `data-day-divider` is the hook the scroll effect uses to engage
      // stickiness and pick the front pill when several pile up. No MessageBase
      // wrapper: the row must be a direct child of the timeline column so its
      // sticky containing block is the whole day, not a one-row box.
      <div className={css.BubbleDayCapsuleRow} data-day-divider="true">
        <span className={css.BubbleDayCapsule} role="separator">
          {dayLabel}
        </span>
      </div>
    );

    const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;

    if (eventJSX && dayDividerJSX) {
      dayDivider = false;
      return (
        <React.Fragment key={mEventId}>
          {dayDividerJSX}
          <MessageErrorBoundary resetKey={mEventId}>{eventJSX}</MessageErrorBoundary>
        </React.Fragment>
      );
    }

    if (eventJSX) {
      return (
        <MessageErrorBoundary key={mEventId} resetKey={mEventId}>
          {eventJSX}
        </MessageErrorBoundary>
      );
    }

    return eventJSX;
  };

  const unreadFloatShown = !!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;

  return (
    <Box grow="Yes" style={{ position: 'relative' }}>
      {/* Day dates are the inline capsules themselves (every room class), made
          sticky via real CSS `position: sticky` (engaged by the effect above) —
          no separate floating pill. */}
      {unreadFloatShown && (
        <TimelineFloat position="Top">
          <Chip
            variant="Primary"
            radii="Pill"
            outlined
            before={<Icon size="50" src={Icons.MessageUnread} />}
            onClick={handleJumpToUnread}
          >
            <Text size="L400">{t('Room.jump_to_unread')}</Text>
          </Chip>

          <Chip
            variant="SurfaceVariant"
            radii="Pill"
            outlined
            before={<Icon size="50" src={Icons.CheckTwice} />}
            onClick={handleMarkAsRead}
          >
            <Text size="L400">{t('Room.mark_as_read')}</Text>
          </Chip>
        </TimelineFloat>
      )}
      <div ref={scrollRef} className={css.TimelineScroll}>
        <Box
          direction="Column"
          justifyContent="End"
          // Every room class centres its message column in the AI-chat band.
          className={css.BubbleTimelineBand}
          style={{
            minHeight: '100%',
            // Bottom padding reserves room for the overlay composer painted
            // by RoomView. Driven by the same number passed in via
            // `bottomOverlayHeight` to keep React state and CSS layout in
            // sync — a layout effect (`bottomOverlayHeight` dep, above)
            // re-anchors the scroll on every transition so the latest
            // message stays flush above the composer.
            paddingTop: config.space.S600,
            paddingBottom: `calc(${config.space.S600} + ${bottomOverlayHeight}px)`,
          }}
        >
          {/* No real message anywhere in the (fully-loaded) timeline — a
              brand-new room has only hidden membership/creation events, so we
              gate on renderable messages, not raw event count. Suppressed the
              moment any actual message exists. */}
          {!canPaginateBack &&
            rangeAtStart &&
            liveTimelineLinked &&
            rangeAtEnd &&
            !hasRenderableEvent && <EmptyTimeline room={room} roomId={room.roomId} />}
          {(canPaginateBack || !rangeAtStart) && (
            <>
              <MessageBase>
                <DefaultPlaceholder key={getItems().length} />
              </MessageBase>
              <MessageBase>
                <DefaultPlaceholder key={getItems().length} />
              </MessageBase>
              <MessageBase ref={observeBackAnchor}>
                <DefaultPlaceholder key={getItems().length} />
              </MessageBase>
            </>
          )}

          {getItems().map(eventRenderer)}

          {(!liveTimelineLinked || !rangeAtEnd) && (
            <>
              <MessageBase ref={observeFrontAnchor}>
                <DefaultPlaceholder key={getItems().length} />
              </MessageBase>
              <MessageBase>
                <DefaultPlaceholder key={getItems().length} />
              </MessageBase>
              <MessageBase>
                <DefaultPlaceholder key={getItems().length} />
              </MessageBase>
            </>
          )}
          {liveTimelineLinked && rangeAtEnd && <RoomTimelineTyping room={room} />}
          <span ref={atBottomAnchorRef} />
        </Box>
      </div>
      <button
        type="button"
        className={css.JumpToLatestFab}
        onClick={handleJumpToLatest}
        aria-label={t('Room.jump_to_latest')}
        aria-hidden={fabHidden}
        tabIndex={fabHidden ? -1 : 0}
        data-hidden={fabHidden ? 'true' : 'false'}
        style={{
          // Sit S400 above the overlay; tracks composer growth (reply
          // preview, multi-line typing) via the live composer height.
          bottom: `calc(${bottomOverlayHeight}px + ${config.space.S400})`,
        }}
      >
        <span
          // Remounting on `pulseCount` change restarts the keyframes
          // animation. pulseCount=0 mounts without the class so the FAB
          // doesn't auto-pulse on first reveal.
          key={pulseCount}
          className={pulseCount === 0 ? undefined : css.FabIconPulseSlot}
          style={pulseCount === 0 ? { display: 'flex' } : undefined}
        >
          <Icon size="300" src={Icons.ChevronBottom} />
        </span>
      </button>
    </Box>
  );
}
