/* eslint-disable react/destructuring-assignment */
import React, {
  Dispatch,
  MouseEventHandler,
  RefObject,
  SetStateAction,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import {
  Direction,
  EventTimeline,
  EventTimelineSet,
  EventTimelineSetHandlerMap,
  IContent,
  MatrixClient,
  MatrixEvent,
  MsgType,
  Room,
  RoomEvent,
  RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import classNames from 'classnames';
import { ReactEditor } from 'slate-react';
import { Editor } from 'slate';
import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai';
import { Badge, Box, Chip, ContainerColor, IconButton, Line, Scroll, Text, as, color, config, toRem } from 'folds';
import { Icon, Icons } from '../../components/icons';
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
import { autoScrollToBottom, editableActiveElement, isScrolledToBottom } from '../../utils/dom';
import {
  DefaultPlaceholder,
  CompactPlaceholder,
  Reply,
  MessageBase,
  MessageUnsupportedContent,
  Time,
  MessageNotDecryptedContent,
  RedactedContent,
  MImage,
  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,
  getMemberDisplayName,
  getReactionContent,
  isMembershipChanged,
  reactionOrEditEvent,
} from '../../utils/room';
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, ScrollToLatestBehavior, settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
import * as customHtmlCss from '../../styles/CustomHtml.css';
import { RoomIntro } from '../../components/room-intro';
import {
  getIntersectionObserverEntry,
  useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import { useMarkAsRead } from '../../hooks/useMarkAsRead';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { activeThreadIdAtomFamily } from '../../state/activeThread';
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 { useInertialHorizontalScroll } from '../../hooks/useInertialHorizontalScroll';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import {
  clearRoomReturnAnchor,
  clearRoomScrollState,
  getRoomReturnAnchor,
  getRoomScrollState,
  saveRoomScrollState,
  setRoomReturnAnchor,
} from '../../state/roomScrollCache';
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 { useIsDirectRoom } from '../../hooks/useRoom';
import { setupCopyHandler } from '../../utils/copyHandler';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
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 {
  useRoomEmbedFilters,
  useRoomWideEmbedFilters,
  combineEmbedFilters,
} from '../../hooks/useRoomEmbedFilters';
import {
  IImageContent,
  PAARROT_CAROUSEL_INDEX_PROPERTY_NAME,
  PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
  PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';

/** Information about a call member event for grouping */
interface CallEventInfo {
  senderId: string;
  senderName: string;
  isJoining: boolean;
  ts: number;
  item: number;
  mEventId: string;
  mEvent: MatrixEvent;
}

interface CarouselEventMetadata {
  uuid: string;
  index: number;
  total: number;
  content: IImageContent;
}

interface CarouselRenderableEvent {
  mEvent: MatrixEvent;
  mEventId: string;
  item: number;
  content: IImageContent;
  index: number;
}

const getCarouselEventMetadata = (mEvent: MatrixEvent): CarouselEventMetadata | undefined => {
  if (mEvent.getType() !== MessageEvent.RoomMessage || mEvent.isRedacted()) {
    return undefined;
  }

  const content = mEvent.getContent() as IImageContent;
  const uuid = content[PAARROT_CAROUSEL_UUID_PROPERTY_NAME];
  const index = content[PAARROT_CAROUSEL_INDEX_PROPERTY_NAME];
  const total = content[PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME];

  if (content.msgtype !== MsgType.Image) return undefined;
  if (typeof uuid !== 'string') return undefined;
  if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) return undefined;
  if (typeof total !== 'number' || !Number.isInteger(total) || total < 1) return undefined;

  return { uuid, index, total, content };
};

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

const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
  ({ variant, children, ...props }, ref) => (
    <Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
      <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
      {children}
      <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
    </Box>
  )
);

type CarouselScrollerProps = {
  children: React.ReactNode;
};

function CarouselScroller({ children }: CarouselScrollerProps) {
  const {
    scrollRef,
    isDragging,
    canScrollBack,
    canScrollForward,
    scrollBack,
    scrollForward,
    scrollProps,
  } = useInertialHorizontalScroll();

  return (
    <Box style={{ position: 'relative' }}>
      <Box
        ref={scrollRef}
        className={classNames(css.CarouselScroller, isDragging && css.CarouselScrollerDragging)}
        {...scrollProps}
      >
        {children}
      </Box>

      {canScrollBack && (
        <>
          <div className={css.CarouselEdgeGradient({ position: 'Left' })} />
          <IconButton
            className={css.CarouselScrollBtn({ position: 'Left' })}
            variant="Secondary"
            radii="Pill"
            size="300"
            outlined
            onClick={scrollBack}
          >
            <Icon size="300" src={Icons.ArrowLeft} />
          </IconButton>
        </>
      )}

      {canScrollForward && (
        <>
          <div className={css.CarouselEdgeGradient({ position: 'Right' })} />
          <IconButton
            className={css.CarouselScrollBtn({ position: 'Right' })}
            variant="Primary"
            radii="Pill"
            size="300"
            outlined
            onClick={scrollForward}
          >
            <Icon size="300" src={Icons.ArrowRight} />
          </IconButton>
        </>
      )}
    </Box>
  );
}

const getCarouselItemWidth = (content: IImageContent): string => {
  const width = content.info?.w;
  const height = content.info?.h;

  if (!width || !height || height <= 0) {
    return 'min(24rem, 72vw)';
  }

  const aspectRatio = width / height;
  const baseHeightRem = 22;
  const computedWidthRem = Math.max(11, Math.min(24, baseHeightRem * aspectRatio));

  return `min(${computedWidthRem}rem, 72vw)`;
};

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

const PAGINATION_LIMIT = 80;

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.getUserId() ?? '');
  if (!readUptoEventId) return undefined;
  const evtTimeline = getEventTimeline(room, readUptoEventId);
  const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
  return {
    readUptoEventId,
    inLiveTimeline: latestTimeline === room.getLiveTimeline(),
    scrollTo,
  };
};

export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
  const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
  const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
  const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
  const direct = useIsDirectRoom();
  const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
  const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
  const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
  const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
  const [personalEmbedFilters] = useRoomEmbedFilters(room);
  const roomWideEmbedFilters = useRoomWideEmbedFilters(room);
  const combinedEmbedFilters = useMemo(
    () => combineEmbedFilters(personalEmbedFilters, roomWideEmbedFilters),
    [personalEmbedFilters, roomWideEmbedFilters]
  );
  const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
  const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
  const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
    useJumboEmojiConfetti(room);

  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
  const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
  const [scrollToLatestBehavior] = useSetting(settingsAtom, 'scrollToLatestBehavior');

  // Track consecutive call member events for collapsing
  const pendingCallEventsRef = useRef<CallEventInfo[]>([]);

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

  const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
  const setActiveThreadId = useSetAtom(activeThreadIdAtomFamily(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 canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
  const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
  const [editId, setEditId] = useState<string>();

  const roomToParents = useAtomValue(roomToParentsAtom);
  const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
  const { navigateRoom } = useRoomNavigate();
  const mentionClickHandler = useMentionClickHandler(room.roomId);
  const spoilerClickHandler = useSpoilerClickHandler();
  const openUserRoomProfile = useOpenUserRoomProfile();
  const space = useSpaceOptionally();
  const markAsRead = useMarkAsRead(mx);
  const location = useLocation();
  const scrollToLatest = (location.state as { scrollToLatest?: boolean } | null)?.scrollToLatest;

  const savedScrollRef = useRef(
    eventId || scrollToLatest ? undefined : getRoomScrollState(room.roomId)
  );

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

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

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

  const [latestUnreadMessage, setLatestUnreadMessage] = useState<{
    sender: string;
    content: string;
  } | null>(null);
  const [returnToEventId, setReturnToEventIdState] = useState<string | null>(
    () => getRoomReturnAnchor(room.roomId) ?? null
  );

  const setReturnToEventId = useCallback(
    (eventId: string | null) => {
      if (eventId) setRoomReturnAnchor(room.roomId, eventId);
      else clearRoomReturnAnchor(room.roomId);
      setReturnToEventIdState(eventId);
    },
    [room.roomId]
  );

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

  const getViewportAnchorEventId = useCallback((): string | undefined => {
    const scrollEl = scrollRef.current;
    if (!scrollEl) return undefined;

    const scrollRect = scrollEl.getBoundingClientRect();
    const centerY = scrollRect.top + scrollRect.height / 2;
    const items = scrollEl.querySelectorAll<HTMLElement>('[data-message-id]');

    let bestId: string | undefined;
    let bestDist = Infinity;

    items.forEach((el) => {
      const rect = el.getBoundingClientRect();
      if (rect.bottom < scrollRect.top || rect.top > scrollRect.bottom) return;
      const mid = (rect.top + rect.bottom) / 2;
      const dist = Math.abs(mid - centerY);
      if (dist < bestDist) {
        bestDist = dist;
        bestId = el.getAttribute('data-message-id') ?? undefined;
      }
    });

    return bestId;
  }, []);

  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>(() => {
    if (eventId) return getEmptyTimeline();
    const initial = getInitialTimeline(room);
    const saved = savedScrollRef.current;
    if (!saved?.range) return initial;

    const evLength = getTimelinesEventsCount(initial.linkedTimelines);
    if (evLength === 0) return initial;

    const start = Math.min(saved.range.start, Math.max(0, evLength - 1));
    const end = Math.min(Math.max(start + 1, saved.range.end), evLength);

    return { ...initial, range: { start, end } };
  });
  const timelineRef = useRef(timeline);
  timelineRef.current = timeline;

  const getRangeCenterEventId = useCallback((): string | undefined => {
    const { linkedTimelines, range } = timelineRef.current;
    if (range.end <= range.start) return undefined;
    const mid = Math.floor((range.start + range.end - 1) / 2);
    const [tm, baseIndex] = getTimelineAndBaseIndex(linkedTimelines, mid);
    if (!tm) return undefined;
    return getTimelineEvent(tm, getTimelineRelativeIndex(mid, baseIndex))?.getId();
  }, []);

  const persistTimelineScroll = useCallback(
    (roomId: string) => {
      if (eventId) return;
      const scrollEl = scrollRef.current;
      if (!scrollEl) return;

      const distanceFromBottom =
        scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
      saveRoomScrollState(roomId, {
        top: scrollEl.scrollTop,
        atBottom: distanceFromBottom <= 50,
        range: timelineRef.current.range,
      });
    },
    [eventId]
  );

  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;

  // Historical / non-live windows are never "at bottom" of the room.
  // When we return to the live end, re-check scroll — IntersectionObserver may
  // not re-fire if the anchor stayed intersecting across the transition.
  useEffect(() => {
    if (!liveTimelineLinked || !rangeAtEnd) {
      setAtBottom(false);
      return;
    }
    const scrollEl = scrollRef.current;
    if (scrollEl && isScrolledToBottom(scrollEl, 100)) {
      setAtBottom(true);
    }
  }, [liveTimelineLinked, rangeAtEnd]);

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

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

  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;
      scrollToBottomRef.current.force = true;
    }, [alive, room])
  );

  useLiveEventArrive(
    room,
    useCallback(
      (mEvt: MatrixEvent) => {
        const isOwnMessage = mEvt.getSender() === mx.getUserId();
        const isBottomPinnedEvent =
          mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker;
        const wasAwayFromBottom = !atBottomRef.current;
        const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent);

        // Always update timeline with new message
        setTimeline((ct) => ({
          ...ct,
          range: {
            start: ct.range.start + 1,
            end: ct.range.end + 1,
          },
        }));

        // Handle own messages and focus
        if (isOwnMessage) {
          setLatestUnreadMessage(null);
          if (document.hasFocus() && (!unreadInfo)) {
            const roomId = mEvt.getRoomId();
            if (roomId) {
              requestAnimationFrame(() => markAsRead(roomId, hideActivity));
            }
          }
          // Sending while scrolled up jumps to latest — offer return to previous spot
          if (wasAwayFromBottom && isBottomPinnedEvent) {
            const anchorEventId =
              eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
            if (anchorEventId) {
              setReturnToEventId(anchorEventId);
            }
          }
        } else if (!document.hasFocus() && !unreadInfo) {
          // Show unread notification for other users' messages when unfocused
          setUnreadInfo(getRoomUnreadInfo(room));
        }

        // Preserve the pre-update bottom state so newly added chat messages keep the view pinned.
        if (shouldStickToBottom) {
          scrollToBottomRef.current.count += 1;
          scrollToBottomRef.current.smooth = !isOwnMessage;
          scrollToBottomRef.current.force = true;
        }

        // Show unread message preview for incoming messages when not at bottom
        if (!isOwnMessage && !atBottomRef.current) {
          const senderName =
            getMemberDisplayName(room, mEvt.getSender() ?? '') ??
            getMxIdLocalPart(mEvt.getSender() ?? '') ??
            'Unknown';
          const content = mEvt.getContent();
          const messageText = content.body ?? content.msgtype ?? 'New message';
          setLatestUnreadMessage({
            sender: senderName,
            content: messageText.length > 100 ? `${messageText.substring(0, 100)}...` : messageText,
          });
        }
      },
      [mx, room, unreadInfo, hideActivity, markAsRead, eventId, getViewportAnchorEventId, getRangeCenterEventId, setReturnToEventId]
    )
  );

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

  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 (restoringScrollRef.current) return;

        if (isScrolledToBottom(scrollElement)) {
          autoScrollToBottom(scrollElement, false, true);
        }
      };
    }, [getScrollElement, roomInputRef]),
    useCallback(() => roomInputRef.current, [roomInputRef])
  );

  useResizeObserver(
    useCallback((entries) => {
      const timelineContent = timelineContentRef.current;
      const scrollElement = getScrollElement();
      if (!timelineContent || !scrollElement) return;

      const timelineEntry = getResizeObserverEntry(timelineContent, entries);
      if (!timelineEntry) return;

      const nextHeight = timelineEntry.contentRect.height;
      const previousHeight = timelineContentHeightRef.current;
      timelineContentHeightRef.current = nextHeight;

      if (restoringScrollRef.current) {
        const saved = savedScrollRef.current;
        if (saved) {
          if (saved.atBottom) {
            scrollElement.scrollTop = scrollElement.scrollHeight;
          } else {
            scrollElement.scrollTop = saved.top;
          }
        }
        return;
      }

      if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) {
        return;
      }

      autoScrollToBottom(scrollElement, false, true);
    }, [getScrollElement]),
    useCallback(() => timelineContentRef.current, [])
  );

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

  useIntersectionObserver(
    useCallback(
      (entries) => {
        const target = atBottomAnchorRef.current;
        if (!target) return;
        const targetEntry = getIntersectionObserverEntry(target, entries);
        if (!targetEntry) return;

        // Leave the live bottom immediately so Jump to Latest stays available.
        // Only intersection clears atBottom here; !atLiveEnd is handled by the
        // liveTimelineLinked/rangeAtEnd effect so we don't stick false when the
        // live end reconnects without a new intersection event.
        if (!targetEntry.isIntersecting) {
          setAtBottom(false);
          return;
        }

        if (!atLiveEndRef.current) return;

        setAtBottom(true);
        setLatestUnreadMessage(null);
        if (document.hasFocus()) {
          tryAutoMarkAsRead();
        }
      },
      [tryAutoMarkAsRead]
    ),
    useCallback(
      () => ({
        root: getScrollElement(),
        rootMargin: '100px',
      }),
      [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) => {
        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;
          setEditId(editableEvtId);
          evt.preventDefault();
        }
      },
      [mx, room, editor]
    )
  );

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

  // Restore return-to-previous when switching rooms (survives remount via cache).
  useEffect(() => {
    setReturnToEventIdState(getRoomReturnAnchor(room.roomId) ?? null);
  }, [room.roomId]);

  // Scroll to latest when clicking on already-selected room
  useEffect(() => {
    if (scrollToLatest) {
      clearRoomScrollState(room.roomId);
      setLatestUnreadMessage(null);
      setUnreadInfo(undefined);
      setTimeline(getInitialTimeline(room));
      scrollToBottomRef.current.count += 1;
      scrollToBottomRef.current.smooth = false;
      scrollToBottomRef.current.force = true;
    }
  }, [scrollToLatest, room]);

  // Remember scroll position when leaving a room (switch DM, nav away, etc.).
  useLayoutEffect(() => {
    const roomId = room.roomId;
    return () => {
      persistTimelineScroll(roomId);
    };
  }, [room.roomId, persistTimelineScroll]);

  // Keep scroll cache updated while reading (useEffect cleanup runs after DOM teardown).
  useEffect(() => {
    const scrollEl = scrollRef.current;
    if (!scrollEl || eventId) return undefined;

    const roomId = room.roomId;
    let raf = 0;
    const onScroll = () => {
      if (restoringScrollRef.current) return;
      window.cancelAnimationFrame(raf);
      raf = window.requestAnimationFrame(() => {
        persistTimelineScroll(roomId);
        // Keep Jump to Latest in sync with real scroll position when on the live end.
        // IntersectionObserver alone can miss transitions and leave atBottom stuck.
        if (atLiveEndRef.current) {
          const at = isScrolledToBottom(scrollEl, 100);
          setAtBottom(at);
          if (at) setLatestUnreadMessage(null);
        }
      });
    };

    scrollEl.addEventListener('scroll', onScroll, { passive: true });
    return () => {
      window.cancelAnimationFrame(raf);
      scrollEl.removeEventListener('scroll', onScroll);
    };
  }, [room.roomId, eventId, persistTimelineScroll]);

  // Restore saved scroll or default to bottom on first open.
  useLayoutEffect(() => {
    const scrollEl = scrollRef.current;
    if (!scrollEl) return undefined;

    if (scrollToLatest) {
      autoScrollToBottom(scrollEl, false, true);
      return undefined;
    }

    const saved = savedScrollRef.current;
    if (!saved) {
      autoScrollToBottom(scrollEl, false, true);
      return undefined;
    }

    restoringScrollRef.current = true;
    setAtBottom(saved.atBottom);

    const restore = () => {
      if (saved.atBottom) {
        scrollEl.scrollTop = scrollEl.scrollHeight;
      } else {
        scrollEl.scrollTop = saved.top;
      }
    };

    restore();
    const raf1 = window.requestAnimationFrame(() => {
      restore();
      window.requestAnimationFrame(() => {
        restore();
        window.setTimeout(() => {
          restore();
          window.setTimeout(() => {
            restoringScrollRef.current = false;
          }, 150);
        }, 50);
      });
    });
    return () => {
      window.cancelAnimationFrame(raf1);
      restoringScrollRef.current = false;
    };
  }, []);

  // 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 && !restoringScrollRef.current) {
      const scrollEl = scrollRef.current;
      if (scrollEl) {
        autoScrollToBottom(
          scrollEl,
          scrollToBottomRef.current.smooth,
          scrollToBottomRef.current.force
        );
        if (atLiveEndRef.current) {
          setAtBottom(true);
        }
      }
    }
  }, [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]);

  // Setup copy handler for better copy formatting (Name - Time: Message)
  useEffect(() => {
    const getMessageData = (eventId: string) => {
      const timelineSet = room.getUnfilteredTimelineSet();
      const evtTimeline = timelineSet.getTimelineForEvent(eventId);
      if (!evtTimeline) return null;
      const mEvent = evtTimeline.getEvents().find((e) => e.getId() === eventId);
      if (!mEvent) return null;
      const sender = getMemberDisplayName(room, mEvent.getSender() ?? '') 
        ?? getMxIdLocalPart(mEvent.getSender() ?? '') 
        ?? mEvent.getSender() 
        ?? 'Unknown';
      const content = mEvent.getContent().body ?? '';
      return {
        sender,
        ts: mEvent.getTs(),
        content,
      };
    };
    return setupCopyHandler(scrollRef, getMessageData, hour24Clock);
  }, [room, hour24Clock]);

  const handleJumpToLatest = () => {
    const anchorEventId =
      eventId ?? getViewportAnchorEventId() ?? getRangeCenterEventId();
    if (anchorEventId) {
      setReturnToEventId(anchorEventId);
    }

    if (eventId) {
      navigateRoom(room.roomId, undefined, { replace: true });
    }
    clearRoomScrollState(room.roomId);
    setLatestUnreadMessage(null);

    // Already on the live timeline: only snap the virtual window to the end.
    // A full getInitialTimeline() rebuild feels like a chat reload when far back.
    if (!eventId && liveTimelineLinked) {
      setTimeline((ct) => {
        const evLength = getTimelinesEventsCount(ct.linkedTimelines);
        return {
          linkedTimelines: ct.linkedTimelines,
          range: {
            start: Math.max(evLength - PAGINATION_LIMIT, 0),
            end: evLength,
          },
        };
      });
    } else {
      setTimeline(getInitialTimeline(room));
    }

    scrollToBottomRef.current.count += 1;
    scrollToBottomRef.current.smooth = false;
    scrollToBottomRef.current.force = true;
  };

  const handleReturnToPrevious = () => {
    if (!returnToEventId) return;
    const targetId = returnToEventId;
    setReturnToEventId(null);
    handleOpenEvent(targetId);
  };

  const handleClearReturnToPrevious = () => {
    setReturnToEventId(null);
  };

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

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

  const handleOpenReply: MouseEventHandler = useCallback(
    async (evt) => {
      const targetId = evt.currentTarget.getAttribute('data-event-id');
      if (!targetId) return;
      handleOpenEvent(targetId);
    },
    [handleOpenEvent]
  );

  const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
    (evt) => {
      evt.preventDefault();
      evt.stopPropagation();
      const userId = evt.currentTarget.getAttribute('data-user-id');
      if (!userId) {
        console.warn('Button should have "data-user-id" attribute!');
        return;
      }
      openUserRoomProfile(
        room.roomId,
        space?.roomId,
        userId,
        evt.currentTarget.getBoundingClientRect()
      );
    },
    [room, space, openUserRoomProfile]
  );
  const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
    (evt) => {
      evt.preventDefault();
      const userId = evt.currentTarget.getAttribute('data-user-id');
      if (!userId) {
        console.warn('Button should have "data-user-id" attribute!');
        return;
      }
      const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
      editor.insertNode(
        createMentionElement(
          userId,
          name.startsWith('@') ? name : `@${name}`,
          userId === mx.getUserId()
        )
      );
      ReactEditor.focus(editor);
      moveCursor(editor);
    },
    [mx, room, editor]
  );

  const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
    (evt, startThread = false) => {
      const replyId = evt.currentTarget.getAttribute('data-event-id');
      if (!replyId) {
        console.warn('Button should have "data-event-id" attribute!');
        return;
      }
      const replyEvt = room.findEventById(replyId);
      if (!replyEvt) return;
      const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
      const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
      const { body, formatted_body: formattedBody } = content;
      const { 'm.relates_to': relation } = startThread
        ? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
        : replyEvt.getWireContent();
      const senderId = replyEvt.getSender();
      if (senderId && typeof body === 'string') {
        setReplyDraft({
          userId: senderId,
          eventId: replyId,
          body,
          formattedBody,
          relation,
        });
        setTimeout(() => ReactEditor.focus(editor), 100);
      }
      if (startThread) {
        setActiveThreadId(replyId);
      }
    },
    [room, setReplyDraft, setActiveThreadId, editor]
  );

  /** Opens the thread view for the given thread root event ID. */
  const handleOpenThread = useCallback(
    (threadRootId: string) => {
      setActiveThreadId(threadRootId);
    },
    [setActiveThreadId]
  );

  const handleReactionToggle = useCallback(
    (targetEventId: string, key: string, shortcode?: string) => {
      const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId);
      const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
      const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
      const reactions = reactionsSet ? Array.from(reactionsSet) : [];
      const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));

      if (myReaction && !!myReaction?.isRelation()) {
        mx.redactEvent(room.roomId, myReaction.getId()!);
        return;
      }
      const rShortcode =
        shortcode ||
        (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
      mx.sendEvent(
        room.roomId,
        MessageEvent.Reaction as any,
        getReactionContent(targetEventId, key, rShortcode)
      );
    },
    [mx, room]
  );
  const handleEdit = useCallback(
    (editEvtId?: string) => {
      if (editEvtId) {
        setEditId(editEvtId);
        return;
      }
      setEditId(undefined);
      ReactEditor.focus(editor);
    },
    [editor]
  );
  const { t } = useTranslation();

  const renderMatrixEvent = useMatrixEventRenderer<
    [string, MatrixEvent, number, EventTimelineSet, boolean]
  >(
    {
      [MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
        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 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;

        return (
          <Message
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            messageSpacing={messageSpacing}
            messageLayout={messageLayout}
            collapse={collapse}
            highlight={highlighted}
            edit={editId === mEventId}
            canDelete={canRedact || 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}
                  onOpenThread={handleOpenThread}
                  getMemberPowerTag={getMemberPowerTag}
                  accessibleTagColors={accessiblePowerTagColors}
                  legacyUsernameColor={legacyUsernameColor || direct}
                />
              )
            }
            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={legacyUsernameColor || direct}
            hour24Clock={hour24Clock}
            dateFormatString={dateFormatString}
          >
            {mEvent.isRedacted() ? (
              <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
            ) : (
              <RenderMessageContent
                displayName={senderDisplayName}
                msgType={mEvent.getContent().msgtype ?? ''}
                ts={mEvent.getTs()}
                edited={!!editedEvent}
                getContent={getContent}
                mediaAutoLoad={mediaAutoLoad}
                urlPreview={showUrlPreview}
                htmlReactParserOptions={htmlReactParserOptions}
                linkifyOpts={linkifyOpts}
                outlineAttachment={messageLayout === MessageLayout.Bubble}
                disabledEmbedPatterns={combinedEmbedFilters}
                targetEventId={mEventId}
                onJumboEmojiClick={handleJumboEmojiClick}
              />
            )}
          </Message>
        );
      },
      [MessageEvent.RoomMessageEncrypted]: (mEventId, mEvent, item, timelineSet, collapse) => {
        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;

        return (
          <Message
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            messageSpacing={messageSpacing}
            messageLayout={messageLayout}
            collapse={collapse}
            highlight={highlighted}
            edit={editId === mEventId}
            canDelete={canRedact || 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}
                  onOpenThread={handleOpenThread}
                  getMemberPowerTag={getMemberPowerTag}
                  accessibleTagColors={accessiblePowerTagColors}
                  legacyUsernameColor={legacyUsernameColor || direct}
                />
              )
            }
            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={legacyUsernameColor || direct}
            hour24Clock={hour24Clock}
            dateFormatString={dateFormatString}
          >
            <EncryptedContent mEvent={mEvent}>
              {() => {
                if (mEvent.isRedacted()) return <RedactedContent />;
                if (mEvent.getType() === MessageEvent.Sticker)
                  return (
                    <MSticker
                      content={mEvent.getContent()}
                      renderImageContent={(props) => (
                        <ImageContent
                          {...props}
                          autoPlay={mediaAutoLoad}
                          renderImage={(p) => <Image {...p} loading="lazy" />}
                          renderViewer={(p) => <ImageViewer {...p} />}
                        />
                      )}
                    />
                  );
                if (mEvent.getType() === 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;
                  return (
                    <RenderMessageContent
                      displayName={senderDisplayName}
                      msgType={mEvent.getContent().msgtype ?? ''}
                      ts={mEvent.getTs()}
                      edited={!!editedEvent}
                      getContent={getContent}
                      mediaAutoLoad={mediaAutoLoad}
                      urlPreview={showUrlPreview}
                      htmlReactParserOptions={htmlReactParserOptions}
                      linkifyOpts={linkifyOpts}
                      outlineAttachment={messageLayout === MessageLayout.Bubble}
                      disabledEmbedPatterns={combinedEmbedFilters}
                      targetEventId={mEventId}
                      onJumboEmojiClick={handleJumboEmojiClick}
                    />
                  );
                }
                if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
                  return (
                    <Text>
                      <MessageNotDecryptedContent />
                    </Text>
                  );
                return (
                  <Text>
                    <MessageUnsupportedContent />
                  </Text>
                );
              }}
            </EncryptedContent>
          </Message>
        );
      },
      [MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => {
        const reactionRelations = getEventReactions(timelineSet, mEventId);
        const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
        const hasReactions = reactions && reactions.length > 0;
        const highlighted = focusItem?.index === item && focusItem.highlight;

        return (
          <Message
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            messageSpacing={messageSpacing}
            messageLayout={messageLayout}
            collapse={collapse}
            highlight={highlighted}
            canDelete={canRedact || 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={legacyUsernameColor || direct}
            hour24Clock={hour24Clock}
            dateFormatString={dateFormatString}
          >
            {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) => {
        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);

        const timeJSX = (
          <Time
            ts={mEvent.getTs()}
            compact={messageLayout === MessageLayout.Compact}
            hour24Clock={hour24Clock}
            dateFormatString={dateFormatString}
          />
        );

        return (
          <Event
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            highlight={highlighted}
            messageSpacing={messageSpacing}
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
            hideReadReceipts={hideActivity}
            showDeveloperTools={showDeveloperTools}
          >
            <EventContent
              messageLayout={messageLayout}
              time={timeJSX}
              iconSrc={parsed.icon}
              content={
                <Box grow="Yes" direction="Column">
                  <Text size="T300" priority="300">
                    {parsed.body}
                  </Text>
                </Box>
              }
            />
          </Event>
        );
      },
      [StateEvent.RoomName]: (mEventId, mEvent, item) => {
        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={messageLayout === MessageLayout.Compact}
            hour24Clock={hour24Clock}
            dateFormatString={dateFormatString}
          />
        );

        return (
          <Event
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            highlight={highlighted}
            messageSpacing={messageSpacing}
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
            hideReadReceipts={hideActivity}
            showDeveloperTools={showDeveloperTools}
          >
            <EventContent
              messageLayout={messageLayout}
              time={timeJSX}
              iconSrc={Icons.Hash}
              content={
                <Box grow="Yes" direction="Column">
                  <Text size="T300" priority="300">
                    <b>{senderName}</b>
                    {t('Organisms.RoomCommon.changed_room_name')}
                  </Text>
                </Box>
              }
            />
          </Event>
        );
      },
      [StateEvent.RoomTopic]: (mEventId, mEvent, item) => {
        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={messageLayout === MessageLayout.Compact}
            hour24Clock={hour24Clock}
            dateFormatString={dateFormatString}
          />
        );

        return (
          <Event
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            highlight={highlighted}
            messageSpacing={messageSpacing}
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
            hideReadReceipts={hideActivity}
            showDeveloperTools={showDeveloperTools}
          >
            <EventContent
              messageLayout={messageLayout}
              time={timeJSX}
              iconSrc={Icons.Hash}
              content={
                <Box grow="Yes" direction="Column">
                  <Text size="T300" priority="300">
                    <b>{senderName}</b>
                    {' changed room topic'}
                  </Text>
                </Box>
              }
            />
          </Event>
        );
      },
      [StateEvent.RoomAvatar]: (mEventId, mEvent, item) => {
        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={messageLayout === MessageLayout.Compact}
            hour24Clock={hour24Clock}
            dateFormatString={dateFormatString}
          />
        );

        return (
          <Event
            key={mEvent.getId()}
            data-message-item={item}
            data-message-id={mEventId}
            room={room}
            mEvent={mEvent}
            highlight={highlighted}
            messageSpacing={messageSpacing}
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
            hideReadReceipts={hideActivity}
            showDeveloperTools={showDeveloperTools}
          >
            <EventContent
              messageLayout={messageLayout}
              time={timeJSX}
              iconSrc={Icons.Hash}
              content={
                <Box grow="Yes" direction="Column">
                  <Text size="T300" priority="300">
                    <b>{senderName}</b>
                    {' changed room avatar'}
                  </Text>
                </Box>
              }
            />
          </Event>
        );
      },
      [StateEvent.CallMember]: (mEventId, mEvent, item) => {
        const senderId = mEvent.getSender() ?? '';
        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
        const content = mEvent.getContent();
        const prevContent = mEvent.getPrevContent();
        
        // Check current content for active devices
        const calls = content['m.calls'];
        const hasDevices = Array.isArray(calls) && calls.some((call: { 'm.devices'?: unknown[] }) => 
          Array.isArray(call['m.devices']) && call['m.devices'].length > 0
        );
        
        // Check previous content for active devices
        const prevCalls = prevContent['m.calls'];
        const hadDevices = Array.isArray(prevCalls) && prevCalls.some((call: { 'm.devices'?: unknown[] }) => 
          Array.isArray(call['m.devices']) && call['m.devices'].length > 0
        );
        
        // Determine if this is a join or leave
        const isJoining = hasDevices && !hadDevices;
        const isLeaving = !hasDevices && hadDevices;
        
        // Skip if no change (refresh events or device updates within same call)
        if (!isJoining && !isLeaving) return null;

        // Add to pending call events for grouping
        // Return a special marker that eventRenderer will recognize
        pendingCallEventsRef.current.push({
          senderId,
          senderName,
          isJoining,
          ts: mEvent.getTs(),
          item,
          mEventId,
          mEvent,
        });
        
        // Return special marker for call event (will be handled by eventRenderer)
        return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
      },
      [MessageEvent.RelayEmojiConfetti]: () => null,
    },
    (mEventId, mEvent, item) => {
      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={messageLayout === MessageLayout.Compact}
          hour24Clock={hour24Clock}
          dateFormatString={dateFormatString}
        />
      );

      return (
        <Event
          key={mEvent.getId()}
          data-message-item={item}
          data-message-id={mEventId}
          room={room}
          mEvent={mEvent}
          highlight={highlighted}
          messageSpacing={messageSpacing}
          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
          hideReadReceipts={hideActivity}
          showDeveloperTools={showDeveloperTools}
        >
          <EventContent
            messageLayout={messageLayout}
            time={timeJSX}
            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) => {
      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={messageLayout === MessageLayout.Compact}
          hour24Clock={hour24Clock}
          dateFormatString={dateFormatString}
        />
      );

      return (
        <Event
          key={mEvent.getId()}
          data-message-item={item}
          data-message-id={mEventId}
          room={room}
          mEvent={mEvent}
          highlight={highlighted}
          messageSpacing={messageSpacing}
          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
          hideReadReceipts={hideActivity}
          showDeveloperTools={showDeveloperTools}
        >
          <EventContent
            messageLayout={messageLayout}
            time={timeJSX}
            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;
  let isPrevRendered = false;
  let newDivider = false;
  let dayDivider = false;
  const groupedCarouselEventIds = new Set<string>();

  // Clear pending call events at the start of each render
  pendingCallEventsRef.current = [];

  /**
   * Renders a summary of accumulated call events
   */
  const renderCallEventSummary = (callEvents: CallEventInfo[]): React.ReactNode => {
    if (callEvents.length === 0) return null;

    // Group by user, tracking the sequence of actions to preserve order
    const userActions = new Map<string, { 
      senderName: string; 
      firstAction: 'join' | 'leave';
      lastAction: 'join' | 'leave';
      joins: number; 
      leaves: number;
    }>();
    
    callEvents.forEach((evt) => {
      const existing = userActions.get(evt.senderId);
      const action = evt.isJoining ? 'join' : 'leave';
      
      if (existing) {
        existing.lastAction = action;
        if (evt.isJoining) {
          existing.joins += 1;
        } else {
          existing.leaves += 1;
        }
      } else {
        userActions.set(evt.senderId, {
          senderName: evt.senderName,
          firstAction: action,
          lastAction: action,
          joins: evt.isJoining ? 1 : 0,
          leaves: evt.isJoining ? 0 : 1,
        });
      }
    });

    // Build summary text
    const summaryParts: string[] = [];
    userActions.forEach((actions) => {
      const { joins, leaves, senderName, firstAction, lastAction } = actions;
      const total = joins + leaves;
      
      if (total === 1) {
        // Single action
        summaryParts.push(`${senderName} ${firstAction === 'join' ? 'joined' : 'left'}`);
      } else if (joins > 0 && leaves > 0) {
        // Mixed joins and leaves - describe based on first action and cycles
        const startedWith = firstAction === 'join' ? 'joined' : 'left';
        const opposite = firstAction === 'join' ? 'left' : 'joined';
        const cycles = Math.min(joins, leaves);
        
        if (firstAction === lastAction) {
          // Ended on same action as started (odd number of same action)
          // e.g., join-leave-join or leave-join-leave
          if (cycles === 1) {
            summaryParts.push(`${senderName} ${startedWith}, ${opposite}, then ${startedWith} again`);
          } else {
            summaryParts.push(`${senderName} ${startedWith} and ${opposite} ${cycles} times, then ${startedWith}`);
          }
        } else {
          // Ended on opposite action (equal joins/leaves or started-opposite pattern)
          if (cycles === 1) {
            summaryParts.push(`${senderName} ${startedWith}, then ${opposite}`);
          } else {
            summaryParts.push(`${senderName} ${startedWith} and ${opposite} ${cycles} times`);
          }
        }
      } else if (joins > 1) {
        summaryParts.push(`${senderName} joined ${joins} times`);
      } else if (leaves > 1) {
        summaryParts.push(`${senderName} left ${leaves} times`);
      }
    });

    // Use the first event for rendering metadata
    const firstEvent = callEvents[0];
    const lastEvent = callEvents[callEvents.length - 1];

    const timeJSX = (
      <Time
        ts={lastEvent.ts}
        compact={messageLayout === MessageLayout.Compact}
        hour24Clock={hour24Clock}
        dateFormatString={dateFormatString}
      />
    );

    return (
      <Event
        key={`call-summary-${firstEvent.mEventId}`}
        data-message-item={firstEvent.item}
        data-message-id={firstEvent.mEventId}
        room={room}
        mEvent={firstEvent.mEvent}
        highlight={false}
        messageSpacing={messageSpacing}
        canDelete={false}
        hideReadReceipts={hideActivity}
        showDeveloperTools={showDeveloperTools}
      >
        <EventContent
          messageLayout={messageLayout}
          time={timeJSX}
          iconSrc={Icons.Phone}
          content={
            <Box grow="Yes" direction="Column">
              <Text size="T300" priority="300">
                {summaryParts.join(', ')}
              </Text>
            </Box>
          }
        />
      </Event>
    );
  };

  const renderImageCarouselMessage = (
    carouselEvents: CarouselRenderableEvent[],
    mEventId: string,
    mEvent: MatrixEvent,
    item: number,
    timelineSet: EventTimelineSet,
    collapse: boolean
  ): React.ReactNode => {
    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 senderId = mEvent.getSender() ?? '';

    const orderedCarouselEvents = [...carouselEvents].sort((a, b) => a.index - b.index);

    return (
      <Message
        key={`carousel-${mEventId}`}
        data-message-item={item}
        data-message-id={mEventId}
        room={room}
        mEvent={mEvent}
        messageSpacing={messageSpacing}
        messageLayout={messageLayout}
        collapse={collapse}
        highlight={highlighted}
        edit={editId === mEventId}
        canDelete={canRedact || 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}
              onOpenThread={handleOpenThread}
              getMemberPowerTag={getMemberPowerTag}
              accessibleTagColors={accessiblePowerTagColors}
              legacyUsernameColor={legacyUsernameColor || direct}
            />
          )
        }
        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={legacyUsernameColor || direct}
        hour24Clock={hour24Clock}
        dateFormatString={dateFormatString}
      >
        {mEvent.isRedacted() ? (
          <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
        ) : (
          <CarouselScroller>
            {orderedCarouselEvents.map((carouselEvent) => (
              <Box
                key={carouselEvent.mEventId}
                className={css.CarouselItem}
                style={{ width: getCarouselItemWidth(carouselEvent.content) }}
              >
                <MImage
                  content={carouselEvent.content}
                  renderImageContent={(props) => (
                    <ImageContent
                      {...props}
                      autoPlay={mediaAutoLoad}
                      renderImage={(p) => <Image {...p} loading="lazy" />}
                      renderViewer={(p) => <ImageViewer {...p} />}
                    />
                  )}
                  outlined={messageLayout === MessageLayout.Bubble}
                />
              </Box>
            ))}
          </CarouselScroller>
        )}
      </Message>
    );
  };

  const eventRenderer = (item: number, index: number, allItems: 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;
    }

    if (groupedCarouselEventIds.has(mEventId)) {
      prevEvent = mEvent;
      isPrevRendered = true;
      return null;
    }

    const collapsed =
      isPrevRendered &&
      !dayDivider &&
      (!newDivider || eventSender === mx.getUserId()) &&
      prevEvent !== undefined &&
      prevEvent.getSender() === eventSender &&
      prevEvent.getType() === mEvent.getType() &&
      minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;

    const getEventByItem = (
      timelineItem: number
    ): { event: MatrixEvent; eventId: string } | undefined => {
      const [tm, bIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, timelineItem);
      if (!tm) return undefined;

      const event = getTimelineEvent(tm, getTimelineRelativeIndex(timelineItem, bIndex));
      const nextEventId = event?.getId();
      if (!event || !nextEventId) return undefined;

      return { event, eventId: nextEventId };
    };

    const carouselMetadata = getCarouselEventMetadata(mEvent);
    const carouselEvents: CarouselRenderableEvent[] = [];
    if (carouselMetadata && eventSender) {
      carouselEvents.push({
        mEvent,
        mEventId,
        item,
        content: carouselMetadata.content,
        index: carouselMetadata.index,
      });

      for (let nextIndex = index + 1; nextIndex < allItems.length; nextIndex += 1) {
        const nextEvt = getEventByItem(allItems[nextIndex]);
        if (!nextEvt) break;

        const nextSender = nextEvt.event.getSender();
        const nextMeta = getCarouselEventMetadata(nextEvt.event);

        if (!nextMeta || nextSender !== eventSender || nextMeta.uuid !== carouselMetadata.uuid) {
          break;
        }

        groupedCarouselEventIds.add(nextEvt.eventId);
        carouselEvents.push({
          mEvent: nextEvt.event,
          mEventId: nextEvt.eventId,
          item: allItems[nextIndex],
          content: nextMeta.content,
          index: nextMeta.index,
        });

        if (carouselEvents.length >= carouselMetadata.total) {
          break;
        }
      }
    }

    let rawEventJSX: React.ReactNode = null;
    if (!reactionOrEditEvent(mEvent)) {
      rawEventJSX =
        carouselEvents.length > 1
          ? renderImageCarouselMessage(carouselEvents, mEventId, mEvent, item, timelineSet, collapsed)
          : renderMatrixEvent(
              mEvent.getType(),
              typeof mEvent.getStateKey() === 'string',
              mEventId,
              mEvent,
              item,
              timelineSet,
              collapsed
            );
    }

    // Check if this is a call event that should be grouped
    const isCallEventPending = rawEventJSX === ('CALL_EVENT_PENDING' as unknown as React.ReactNode);
    
    // Determine if we need to flush pending call events
    // Flush when: we hit a non-call event, or this is the last item
    const isLastItem = index === allItems.length - 1;
    const pendingCallEvents = pendingCallEventsRef.current;
    const shouldFlushCallEvents = pendingCallEvents.length > 0 && (!isCallEventPending || isLastItem);
    
    let callSummaryJSX: React.ReactNode = null;
    if (shouldFlushCallEvents) {
      callSummaryJSX = renderCallEventSummary([...pendingCallEvents]);
      pendingCallEventsRef.current = []; // Clear array
    }
    
    // If this was a call event marker, don't render it directly
    const eventJSX = isCallEventPending ? null : rawEventJSX;
    
    prevEvent = mEvent;
    isPrevRendered = !!eventJSX || !!callSummaryJSX;

    const newDividerJSX =
      newDivider && (eventJSX || callSummaryJSX) && eventSender !== mx.getUserId() ? (
        <MessageBase space={messageSpacing}>
          <TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
            <Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
              <Text size="L400">New Messages</Text>
            </Badge>
          </TimelineDivider>
        </MessageBase>
      ) : null;

    const dayDividerJSX =
      dayDivider && (eventJSX || callSummaryJSX) ? (
        <MessageBase space={messageSpacing}>
          <TimelineDivider variant="Surface">
            <Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
              <Text size="L400">
                {(() => {
                  if (today(mEvent.getTs())) return 'Today';
                  if (yesterday(mEvent.getTs())) return 'Yesterday';
                  return timeDayMonthYear(mEvent.getTs());
                })()}
              </Text>
            </Badge>
          </TimelineDivider>
        </MessageBase>
      ) : null;

    // Handle various combinations of dividers, call summary, and regular events
    const hasContent = eventJSX || callSummaryJSX;
    const hasDividers = newDividerJSX || dayDividerJSX;

    if (hasContent && hasDividers) {
      if (newDividerJSX) newDivider = false;
      if (dayDividerJSX) dayDivider = false;

      return (
        <React.Fragment key={mEventId}>
          {newDividerJSX}
          {dayDividerJSX}
          {callSummaryJSX}
          {eventJSX}
        </React.Fragment>
      );
    }

    if (callSummaryJSX && eventJSX) {
      return (
        <React.Fragment key={mEventId}>
          {callSummaryJSX}
          {eventJSX}
        </React.Fragment>
      );
    }

    return callSummaryJSX || eventJSX;
  };

  const showUnreadTop =
    !!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
  const atLiveBottom = atBottom && liveTimelineLinked && rangeAtEnd;

  return (
    <Box grow="Yes" style={{ position: 'relative' }}>
      <Scroll ref={scrollRef} visibility="Hover" data-room-timeline-scroll="">
        <Box
          ref={timelineContentRef}
          direction="Column"
          justifyContent="End"
          data-chatroll=""
          style={{
            minHeight: '100%',
            padding: `${config.space.S600} 0`,
            userSelect: 'none',
            WebkitUserSelect: 'none',
          }}
        >
          {!canPaginateBack && rangeAtStart && getItems().length > 0 && (
            <div
              style={{
                padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
                  messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
                }`,
              }}
            >
              <RoomIntro room={room} />
            </div>
          )}
          {(canPaginateBack || !rangeAtStart) &&
            (messageLayout === MessageLayout.Compact ? (
              <>
                <MessageBase>
                  <CompactPlaceholder key={getItems().length} />
                </MessageBase>
                <MessageBase>
                  <CompactPlaceholder key={getItems().length} />
                </MessageBase>
                <MessageBase>
                  <CompactPlaceholder key={getItems().length} />
                </MessageBase>
                <MessageBase>
                  <CompactPlaceholder key={getItems().length} />
                </MessageBase>
                <MessageBase ref={observeBackAnchor}>
                  <CompactPlaceholder key={getItems().length} />
                </MessageBase>
              </>
            ) : (
              <>
                <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) &&
            (messageLayout === MessageLayout.Compact ? (
              <>
                <MessageBase ref={observeFrontAnchor}>
                  <CompactPlaceholder key={getItems().length} />
                </MessageBase>
                <MessageBase>
                  <CompactPlaceholder key={getItems().length} />
                </MessageBase>
                <MessageBase>
                  <CompactPlaceholder key={getItems().length} />
                </MessageBase>
                <MessageBase>
                  <CompactPlaceholder key={getItems().length} />
                </MessageBase>
                <MessageBase>
                  <CompactPlaceholder key={getItems().length} />
                </MessageBase>
              </>
            ) : (
              <>
                <MessageBase ref={observeFrontAnchor}>
                  <DefaultPlaceholder key={getItems().length} />
                </MessageBase>
                <MessageBase>
                  <DefaultPlaceholder key={getItems().length} />
                </MessageBase>
                <MessageBase>
                  <DefaultPlaceholder key={getItems().length} />
                </MessageBase>
              </>
            ))}
          {/* 1×1 so IntersectionObserver reliably detects the live bottom */}
          <span
            ref={atBottomAnchorRef}
            aria-hidden
            style={{ display: 'block', width: 1, height: 1 }}
          />
        </Box>
      </Scroll>
      {(showUnreadTop || returnToEventId) && (
        <TimelineFloat position="Top">
          {returnToEventId && (
            <>
              <Chip
                variant="Primary"
                radii="Pill"
                outlined
                before={<Icon size="50" src={Icons.ArrowTop} />}
                onClick={handleReturnToPrevious}
              >
                <Text size="L400">Return to Previous</Text>
              </Chip>
              <IconButton
                title="Dismiss"
                aria-label="Dismiss return to previous"
                size="300"
                radii="Pill"
                variant="SurfaceVariant"
                onClick={handleClearReturnToPrevious}
              >
                <Icon size="50" src={Icons.Cross} />
              </IconButton>
            </>
          )}
          {showUnreadTop && (
            <>
              <Chip
                variant="Primary"
                radii="Pill"
                outlined
                before={<Icon size="50" src={Icons.MessageUnread} />}
                onClick={handleJumpToUnread}
              >
                <Text size="L400">Jump to Unread</Text>
              </Chip>

              <Chip
                variant="SurfaceVariant"
                radii="Pill"
                outlined
                before={<Icon size="50" src={Icons.CheckTwice} />}
                onClick={handleMarkAsRead}
              >
                <Text size="L400">Mark as Read</Text>
              </Chip>
            </>
          )}
        </TimelineFloat>
      )}
      {!atLiveBottom && (
        <TimelineFloat position="Bottom">
          <Box direction="Column" gap="200" alignItems="Center">
            {latestUnreadMessage && (
              <Box
                style={{
                  backgroundColor: color.Surface.Container,
                  padding: `${config.space.S200} ${config.space.S300}`,
                  borderRadius: config.radii.R400,
                  maxWidth: toRem(400),
                  border: `1px solid ${color.Surface.ContainerLine}`,
                }}
              >
                <Text size="T300" priority="300" style={{ 
                  whiteSpace: 'nowrap',
                  overflow: 'hidden',
                  textOverflow: 'ellipsis',
                }}>
                  <b>{latestUnreadMessage.sender}:</b> {latestUnreadMessage.content}
                </Text>
              </Box>
            )}
            <Chip
              variant="SurfaceVariant"
              radii="Pill"
              outlined
              before={<Icon size="50" src={Icons.ArrowBottom} />}
              onClick={handleJumpToLatest}
            >
              <Text size="L400">Jump to Latest</Text>
            </Chip>
          </Box>
        </TimelineFloat>
      )}
      <EmojiConfettiOverlay bursts={emojiConfettiBursts} onBurstComplete={removeEmojiConfettiBurst} />
    </Box>
  );
}
