// Wrapper around the room's chat column that adds the user-profile
// surface on top.
//
// Mobile (≤ 750): the top «horseshoe» rail. Panel and chat header
// live as siblings inside a single `silhouette` wrapper whose own
// `overflow:hidden + border-bottom-radius` cuts the bottom of the
// combined block. There's no separate header-radius / panel-radius
// hand-off mid-drag — the rounding is a property of the silhouette
// itself, so it stays visually anchored to «the bottom of whatever's
// currently last-visible» throughout the gesture without seams or
// blips. Replaces the legacy two-element layout where panel was
// `position: absolute` over a chat column that owned the header in
// flow; each had its own radius animated by the now-deleted
// `horseshoeEmerge` curve.
//
// Tablet + Desktop (> 750): no top horseshoe. The wrapper is a thin
// passthrough; the profile renders as a right-side pane via
// `RoomViewProfileSidePanel`, which `Room.tsx` mounts as a sibling
// of the chat column.

import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { config } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { userRoomProfileAtom } from '../../state/userRoomProfile';
import { useCloseUserRoomProfile, useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMemberAvatarMxc } from '../../utils/room';
import { getMxIdLocalPart, guessDmRoomUserId, mxcUrlToHttp } from '../../utils/matrix';
import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile';
import { stopPropagation } from '../../utils/keyboard';
import colorMXID from '../../../util/colorMXID';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import * as css from './RoomViewProfilePanel.css';

// Rail-height policy: the card sizes itself to its actual content
// height (hero + info rows + optional alerts + safe-top inset + drag
// handle), measured via useLayoutEffect + ResizeObserver below. The
// internal scroll has been removed (user request: «запретил бы драгу
// внутри карточки») so there is no drag-inside-drag — the rail simply
// grows or shrinks with whatever the profile renders.
//
// `MAX_RAIL_FRACTION` is a defensive ceiling for the rare overflow
// case (e.g. 5 stacked moderation alerts) — content beyond it is
// clipped by `panelViewport`'s `overflow: hidden`. 0.85 keeps a
// couple of chat lines peeking at the bottom so the user still has
// context that the chat is behind the card.
//
// `FALLBACK_RAIL_FRACTION` is the size used for one frame before the
// content measurement lands. useLayoutEffect runs synchronously before
// browser paint, so in practice the user only ever sees the measured
// height — but the constant matters during SSR / before the effect
// commits state.
const MAX_RAIL_FRACTION = 0.85;
const FALLBACK_RAIL_FRACTION = 0.42;
// Past this many pixels of drag the gesture commits (open or close).
const COMMIT_THRESHOLD_PX = 80;
const ANIMATION_MS = 250;
// Drag distance over which the horseshoe-radius + chat-gap +
// chat-radius ramp from 0 to their full value during finger-drag.
// Matched to COMMIT_THRESHOLD_PX so the silhouette is fully formed
// exactly when the gesture qualifies to commit — the user sees the
// rounding "earn" itself across the whole gesture instead of
// snapping in immediately. Combined with the easeInOutCubic curve
// below, gives a gentle start (corners barely emerge in first
// ~10px) → fast middle (the bulk of the round-in happens around
// the visual halfway point) → soft settle near commit.
const HORSESHOE_EMERGE_PX = 80;

// Vaul / iOS system curve (asymmetric ease-out — steep accelerate,
// long decelerate). Reads as "spring-like" without the overshoot of
// an actual spring, which is what makes Apple sheet transitions feel
// non-mechanical. Used on the CSS release transitions only — the
// release phase wants instant feedback + soft settle. Vaul itself
// also reserves this curve for release and tracks the finger linearly
// during drag; we intentionally diverge from that during drag (see
// `easeInOutCubic` below) because the user explicitly asked for a
// gentle-start ramp on the rounding, not direct-manipulation.
const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';

// Symmetric cubic in-out S-curve — slow start, fast middle, slow
// finish. NOT a Vaul-curve approximation: Vaul's curve is asymmetric
// (steep accelerate, long decelerate) so it grows fast from the
// first pixel of drag, which would defeat the explicit user ask of
// «не сразу скругление». Symmetric in-out gives the rounding a
// barely-visible start that only blossoms around the halfway point,
// which is the felt-feel the user signed off on. Release animations
// keep the asymmetric Vaul curve in CSS where direct manipulation
// is over and the auto-animation can take centre stage.
const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);

type RoomViewProfilePanelProps = {
  header: ReactNode;
  children: ReactNode;
};

type DragState = {
  source: 'header' | 'panel';
  // Origin tag — `touch` for finger drags, `pointer` for mouse/pen.
  // Each path filters by this so an in-flight drag started by one
  // input device can never be advanced or ended by the other. The
  // failure mode it prevents: hybrid laptop (touchscreen + mouse)
  // where a touchstart fires but its matching touchend gets dropped
  // by the OS / browser, leaving `dragRef.current` populated; if the
  // user then moves the mouse, the pointer-path handlers would
  // otherwise apply mouse clientY against the touch's startY and
  // commit a bogus close. Tagging makes each path self-isolating.
  inputType: 'touch' | 'pointer';
  startY: number;
  deltaY: number;
};

// Mobile-only top horseshoe. Slides down from above on drag-down on
// the chat header (1:1 DM only); slides up to close on drag-up
// anywhere on the panel. Panel and chat header share a single
// `silhouette` wrapper whose `overflow:hidden + border-bottom-radius`
// owns the visible rounded bottom — see file header for the
// architectural rationale.
function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) {
  const { t } = useTranslation();
  const profileState = useAtomValue(userRoomProfileAtom);
  const close = useCloseUserRoomProfile();
  const openProfile = useOpenUserRoomProfile();
  const room = useRoom();
  const space = useSpaceOptionally();
  const mx = useMatrixClient();
  const useAuthentication = useMediaAuthentication();
  const isOneOnOne = useIsOneOnOne();

  const myUserId = mx.getSafeUserId();
  const peerCandidate = isOneOnOne ? guessDmRoomUserId(room, myUserId) : undefined;
  const headerDragPeer = peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined;
  const headerDragEnabled = !!headerDragPeer;

  const [drag, setDrag] = useState<DragState | null>(null);
  const [avatarMode, setAvatarMode] = useState(false);

  const headerRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const headerInnerRef = useRef<HTMLDivElement>(null);
  // Measured natural height of the chat-header inner block (incl. its
  // `padding-top: var(--vojo-safe-top)`). Drives the explicit `height`
  // animation on `headerViewport` — replaces the legacy `grid-template-
  // rows: 1fr → 0fr` trick because Folds `<Header size="600">` enforces
  // a `min-height` that prevents the grid track from collapsing fully
  // to 0, leaving a light-blue strip at the bottom of the user card
  // when the rail is fully open. Measured via `useLayoutEffect` so the
  // first paint already uses the right value (no flicker on cold
  // mount) and re-measured by ResizeObserver whenever the chat-header
  // content reflows (e.g. online tag appears, env-inset changes on
  // rotation).
  const [headerNaturalHeight, setHeaderNaturalHeight] = useState(0);

  // Close profile when the room changes — atom is global state and
  // would otherwise carry the previous room's userId into this room.
  useEffect(() => () => close(), [room.roomId, close]);

  // Reset avatar-zoom mode whenever the rendered user changes.
  useEffect(() => {
    setAvatarMode(false);
  }, [profileState?.userId]);

  const [viewportHeight, setViewportHeight] = useState(() => {
    if (typeof window === 'undefined') return 800;
    return window.innerHeight;
  });
  useEffect(() => {
    const onResize = () => setViewportHeight(window.innerHeight);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  // Measured natural content height of the user card (S400-padded
  // wrapper around `UserRoomProfile`) plus the panelContent's safe-top
  // padding and the panelHandle. Drives `railHeightPx` so the rail
  // exactly contains the content — no internal scroll in the fit case.
  // Re-measures when the profile reflows (alerts mount, encryption
  // detected, presence label changes, etc.).
  const [contentNaturalHeight, setContentNaturalHeight] = useState(0);
  const panelContentRef = useRef<HTMLDivElement>(null);
  const panelMeasureRef = useRef<HTMLDivElement>(null);
  useLayoutEffect(() => {
    const measureEl = panelMeasureRef.current;
    const contentEl = panelContentRef.current;
    if (!measureEl || !contentEl) return undefined;
    const measure = () => {
      const innerH = measureEl.scrollHeight;
      if (innerH <= 0) return;
      const padTop = parseFloat(getComputedStyle(contentEl).paddingTop) || 0;
      // 20 px must stay in sync with `panelHandle.height` in css.ts.
      setContentNaturalHeight(innerH + padTop + 20);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(measureEl);
    return () => ro.disconnect();
  }, []);

  const maxRailPx = Math.round(viewportHeight * MAX_RAIL_FRACTION);
  const railHeightPx =
    contentNaturalHeight > 0
      ? Math.min(maxRailPx, contentNaturalHeight)
      : Math.round(viewportHeight * FALLBACK_RAIL_FRACTION);
  // Internal scroll is enabled ONLY when the rail hit the safety cap
  // (very rare — multiple stacked moderation alerts). In the common
  // case the rail is exactly content-sized and there is nothing to
  // scroll — `overflow: hidden` then prevents any drag-inside-drag.
  const contentOverflows = contentNaturalHeight > 0 && contentNaturalHeight > maxRailPx;

  // Measure the chat header's natural height (incl. its safe-top padding).
  // `scrollHeight` returns the content size even while the outer is
  // constrained by our animated `height` — so we keep getting the right
  // natural value across reflows. `useLayoutEffect` does the first
  // measurement synchronously after DOM mutation, before paint, so the
  // first frame already uses the measured value.
  useLayoutEffect(() => {
    const el = headerInnerRef.current;
    if (!el) return undefined;
    const measure = () => {
      const next = el.scrollHeight;
      if (next > 0) setHeaderNaturalHeight(next);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const open = !!profileState;
  const baseExpanded = open ? railHeightPx : 0;
  const expandedPx = drag
    ? Math.max(0, Math.min(railHeightPx, baseExpanded + drag.deltaY))
    : baseExpanded;
  const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
  const isDragging = drag !== null;
  const horseshoeActive = expandedPx > 0;

  const liveUserId =
    profileState?.userId ?? (drag?.source === 'header' ? headerDragPeer : undefined);
  const lastUserIdRef = useRef<string | undefined>(undefined);
  if (liveUserId) lastUserIdRef.current = liveUserId;
  const renderUserId = liveUserId ?? lastUserIdRef.current;

  // Drop width/height — the silhouette-filling fullview on a 3x DPR
  // phone needs ~1200 native px wide, so a 720 thumbnail gets both
  // recompressed by Synapse AND upscaled by the browser, visibly
  // pixelating the avatar («шакалит»). Matches `MediaViewerBody` —
  // the other Vojo fullscreen surface fetches the original.
  const renderUserAvatarMxc = renderUserId ? getMemberAvatarMxc(room, renderUserId) : undefined;
  const renderUserAvatarUrl =
    (renderUserAvatarMxc && mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication)) ?? undefined;

  const dragRef = useRef<DragState | null>(null);
  dragRef.current = drag;
  const profileStateRef = useRef(profileState);
  profileStateRef.current = profileState;
  const openProfileRef = useRef(openProfile);
  openProfileRef.current = openProfile;
  const closeRef = useRef(close);
  closeRef.current = close;
  const roomIdRef = useRef(room.roomId);
  roomIdRef.current = room.roomId;
  const spaceIdRef = useRef(space?.roomId);
  spaceIdRef.current = space?.roomId;

  useEffect(() => {
    const headerEl = headerRef.current;
    const panelEl = panelRef.current;

    const dragOpenCords = (): { x: number; y: number; width: number; height: number } => {
      const rect = headerRef.current?.getBoundingClientRect();
      if (rect) return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
      return { x: 0, y: 0, width: 0, height: 0 };
    };

    // Shared commit + cancel logic between touch and pointer paths.
    const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => {
      const d = dragRef.current;
      if (!d) return;
      const rawDelta = clientY - d.startY;
      let nextDelta = rawDelta;
      if (d.source === 'header') {
        nextDelta = Math.max(0, rawDelta);
      } else {
        if (rawDelta > 0) return;
        nextDelta = rawDelta;
      }
      if (e.cancelable) e.preventDefault();
      setDrag({ ...d, deltaY: nextDelta });
    };

    const applyEnd = () => {
      const d = dragRef.current;
      if (!d) return;
      if (d.source === 'header' && d.deltaY > COMMIT_THRESHOLD_PX) {
        if (headerDragPeer) {
          openProfileRef.current(
            roomIdRef.current,
            spaceIdRef.current,
            headerDragPeer,
            dragOpenCords()
          );
        }
      } else if (d.source === 'panel' && -d.deltaY > COMMIT_THRESHOLD_PX) {
        closeRef.current();
      }
      setDrag(null);
    };

    // === Touch path === Unchanged from the touch-only implementation.
    // Touch events have implicit pointer-capture semantics (touchmove
    // keeps firing on the touchstart element until touchend) so we
    // don't need to escalate to document-level listeners the way the
    // mouse path below does. Kept separate from the pointer path so
    // touch UX is bit-identical to the prior version.
    const onHeaderTouchStart = (e: TouchEvent) => {
      if (dragRef.current) return;
      if (profileStateRef.current) return;
      if (!headerDragEnabled) return;
      const touch = e.touches[0];
      setDrag({ source: 'header', inputType: 'touch', startY: touch.clientY, deltaY: 0 });
    };

    const onPanelTouchStart = (e: TouchEvent) => {
      if (dragRef.current) return;
      if (!profileStateRef.current) return;
      const touch = e.touches[0];
      setDrag({ source: 'panel', inputType: 'touch', startY: touch.clientY, deltaY: 0 });
    };

    const onTouchMove = (e: TouchEvent) => {
      const d = dragRef.current;
      if (!d || d.inputType !== 'touch') return;
      applyMove(e.touches[0].clientY, e);
    };
    const onTouchEnd = () => {
      const d = dragRef.current;
      if (!d || d.inputType !== 'touch') return;
      applyEnd();
    };

    // === Mouse / pen path === Pointer events gated on
    // `pointerType !== 'touch'` so touch devices stay on the touch
    // path above and never double-fire. pointermove + pointerup are
    // attached to `document` (not the trigger element) so the drag
    // keeps tracking when the mouse cursor wanders past the element
    // bounds — common when the user yanks the rail up fast and
    // overshoots the panel. The alternative (setPointerCapture on the
    // trigger) would steal pointerup from inner interactive children
    // (kebab popout button inside the user card, mention links) and
    // break their click handlers — document-level listeners avoid
    // that hijack entirely.
    const onHeaderPointerDown = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      if (dragRef.current) return;
      if (profileStateRef.current) return;
      if (!headerDragEnabled) return;
      if (e.button !== 0) return;
      setDrag({ source: 'header', inputType: 'pointer', startY: e.clientY, deltaY: 0 });
    };

    const onPanelPointerDown = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      if (dragRef.current) return;
      if (!profileStateRef.current) return;
      if (e.button !== 0) return;
      setDrag({ source: 'panel', inputType: 'pointer', startY: e.clientY, deltaY: 0 });
    };

    const onDocumentPointerMove = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      const d = dragRef.current;
      if (!d || d.inputType !== 'pointer') return;
      applyMove(e.clientY, e);
    };

    const onDocumentPointerEnd = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      const d = dragRef.current;
      if (!d || d.inputType !== 'pointer') return;
      applyEnd();
    };

    if (headerEl) {
      headerEl.addEventListener('touchstart', onHeaderTouchStart, { passive: true });
      headerEl.addEventListener('touchmove', onTouchMove, { passive: false });
      headerEl.addEventListener('touchend', onTouchEnd, { passive: true });
      headerEl.addEventListener('touchcancel', onTouchEnd, { passive: true });
      headerEl.addEventListener('pointerdown', onHeaderPointerDown);
    }
    if (panelEl) {
      panelEl.addEventListener('touchstart', onPanelTouchStart, { passive: true });
      panelEl.addEventListener('touchmove', onTouchMove, { passive: false });
      panelEl.addEventListener('touchend', onTouchEnd, { passive: true });
      panelEl.addEventListener('touchcancel', onTouchEnd, { passive: true });
      panelEl.addEventListener('pointerdown', onPanelPointerDown);
    }
    document.addEventListener('pointermove', onDocumentPointerMove, { passive: false });
    document.addEventListener('pointerup', onDocumentPointerEnd, { passive: true });
    document.addEventListener('pointercancel', onDocumentPointerEnd, { passive: true });

    return () => {
      if (headerEl) {
        headerEl.removeEventListener('touchstart', onHeaderTouchStart);
        headerEl.removeEventListener('touchmove', onTouchMove);
        headerEl.removeEventListener('touchend', onTouchEnd);
        headerEl.removeEventListener('touchcancel', onTouchEnd);
        headerEl.removeEventListener('pointerdown', onHeaderPointerDown);
      }
      if (panelEl) {
        panelEl.removeEventListener('touchstart', onPanelTouchStart);
        panelEl.removeEventListener('touchmove', onTouchMove);
        panelEl.removeEventListener('touchend', onTouchEnd);
        panelEl.removeEventListener('touchcancel', onTouchEnd);
        panelEl.removeEventListener('pointerdown', onPanelPointerDown);
      }
      document.removeEventListener('pointermove', onDocumentPointerMove);
      document.removeEventListener('pointerup', onDocumentPointerEnd);
      document.removeEventListener('pointercancel', onDocumentPointerEnd);
    };
  }, [headerDragEnabled, headerDragPeer]);

  // Horseshoe geometry — single ramp factor drives the silhouette
  // bottom radius, the chat top radius, AND the chat margin-top gap.
  //
  //   • Finger-drag: live formula over the first HORSESHOE_EMERGE_PX
  //     (80px = COMMIT_THRESHOLD_PX) of drag, so the rounding and
  //     gap visibly emerge across the whole commit gesture and finish
  //     exactly when the gesture qualifies to commit.
  //   • Release animation: binary on/off, paired with a CSS
  //     transition so the values decay smoothly in lockstep with the
  //     250ms panel-height / header-collapse animations.
  //
  // The same ramp factor feeds all three properties so they always
  // appear and disappear together — no per-element timing skew that
  // could create a perceived «blip».
  let horseshoeRamp: number;
  if (isDragging) {
    horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
  } else if (horseshoeActive) {
    horseshoeRamp = 1;
  } else {
    horseshoeRamp = 0;
  }

  const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
  const chatRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
  const chatGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;

  const panelViewportTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
  const headerViewportTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
  const silhouetteTransition = isDragging
    ? 'none'
    : `border-bottom-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-bottom-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
  const chatBodyTransition = isDragging
    ? 'none'
    : `margin-top ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;

  const containerStyle: React.CSSProperties = {
    backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
  };

  return (
    <div className={css.container} style={containerStyle}>
      <div
        className={css.silhouette}
        style={{
          borderBottomLeftRadius: `${silhouetteRadiusPx}px`,
          borderBottomRightRadius: `${silhouetteRadiusPx}px`,
          transition: silhouetteTransition,
        }}
      >
        <FocusTrap
          focusTrapOptions={{
            initialFocus: false,
            // Mobile: only swipe-up dismisses the rail. Click on chat /
            // scroll on chat must NOT close it AND must remain
            // interactive.
            clickOutsideDeactivates: false,
            allowOutsideClick: () => true,
            escapeDeactivates: stopPropagation,
            onDeactivate: () => {
              if (profileStateRef.current) close();
            },
            checkCanFocusTrap: () => Promise.resolve(),
          }}
          // Don't toggle `active` on drag — flipping it to false makes
          // focus-trap-react fire `onDeactivate` synthetically, so a
          // plain panel tap (deltaY=0) closes the rail. Trap stays
          // active for the entire open lifetime.
          active={open}
        >
          <div
            ref={panelRef}
            className={css.panelViewport}
            style={{
              height: `${expandedPx}px`,
              transition: panelViewportTransition,
              visibility: expandedPx > 0 ? 'visible' : 'hidden',
            }}
          >
            <div
              ref={panelContentRef}
              className={css.panelContent}
              style={{ height: `${railHeightPx}px` }}
            >
              {avatarMode ? (
                <button
                  type="button"
                  className={css.avatarFullView}
                  onClick={() => setAvatarMode(false)}
                  aria-label={t('Room.collapse_avatar')}
                >
                  {renderUserAvatarUrl ? (
                    <img
                      className={css.avatarFullImage}
                      src={renderUserAvatarUrl}
                      alt={renderUserId ?? ''}
                      draggable={false}
                    />
                  ) : (
                    <div
                      className={css.avatarFullFallback}
                      style={{ backgroundColor: colorMXID(renderUserId ?? '') }}
                    >
                      {(renderUserId && getMxIdLocalPart(renderUserId)?.[0]) ?? '?'}
                    </div>
                  )}
                </button>
              ) : (
                <div className={css.panelInner}>
                  {/* The rail is sized to the measured content height
                      (see `contentNaturalHeight` above), capped at
                      `MAX_RAIL_FRACTION × viewport`. In the common
                      fit case `overflow: hidden` prevents any
                      drag-inside-drag — there's literally nothing to
                      scroll. Only when the cap clips real content
                      (rare — many stacked moderation alerts) do we
                      switch to `auto` so the user can reach the
                      hidden tail. Scrollbar chrome stays suppressed
                      via the css class either way. */}
                  <div
                    className={css.panelScroll}
                    style={{ overflowY: contentOverflows ? 'auto' : 'hidden' }}
                  >
                    <div ref={panelMeasureRef} style={{ padding: config.space.S400 }}>
                      {renderUserId && (
                        <UserRoomProfile
                          userId={renderUserId}
                          onAvatarClick={() => setAvatarMode(true)}
                        />
                      )}
                    </div>
                  </div>
                  <div className={css.panelHandle} aria-label={t('Room.drag_to_close')}>
                    <div className={css.panelHandleBar} />
                  </div>
                </div>
              )}
            </div>
          </div>
        </FocusTrap>

        <div
          ref={headerRef}
          className={css.headerViewport}
          style={{
            // Animate the chat header's height from its measured natural
            // size down to 0 as the user-card panel expands. Linear scale
            // by `(1 - expandedFraction)` so the panel and the header
            // grow / shrink in lockstep. Falls back to `auto` until the
            // first measurement lands (cold mount before useLayoutEffect
            // runs) so the closed-state header isn't briefly squashed to
            // 0. Replaces the legacy `grid-template-rows: 1fr → 0fr`
            // trick because Folds `<Header size="600">` enforces a
            // `min-height` that prevented the grid track from collapsing
            // to 0, leaving a light-blue strip at the bottom of the
            // user card. With explicit `height` + `overflow: hidden` on
            // the viewport (see css), `height: 0` is forced honest.
            height:
              headerNaturalHeight > 0
                ? `${(1 - expandedFraction) * headerNaturalHeight}px`
                : 'auto',
            transition: headerViewportTransition,
            // While the rail is mostly open the (collapsed) header
            // shouldn't intercept drag-back-to-open gestures —
            // `pan-x` lets horizontal Android-back-edge gestures
            // still work.
            touchAction: headerDragEnabled ? 'pan-x' : undefined,
          }}
        >
          <div ref={headerInnerRef} className={css.headerViewportInner}>
            {header}
          </div>
        </div>
      </div>

      <div
        className={css.chatBody}
        style={{
          marginTop: `${chatGapPx}px`,
          borderTopLeftRadius: chatRadiusPx ? `${chatRadiusPx}px` : undefined,
          borderTopRightRadius: chatRadiusPx ? `${chatRadiusPx}px` : undefined,
          // Clip chat content to the rounded top corners. Folds
          // popouts use Portals → escape this clip; the timeline
          // keeps its own scroll container.
          overflow: chatRadiusPx > 0 ? 'hidden' : undefined,
          transition: chatBodyTransition,
          // Suppress browser pull-to-refresh / scroll-chaining so a
          // header-drag-down doesn't fire pull-to-refresh briefly
          // before the touchmove handler intercepts.
          overscrollBehaviorY: 'contain',
        }}
      >
        {children}
      </div>
    </div>
  );
}

// Top-level router. On non-mobile we pass through — the side-pane
// surface is rendered by `Room.tsx` as a sibling, so the wrapper just
// hands `header` and `children` back to the parent. The mobile branch
// owns its own state machine in a sub-component so we don't run
// drag/avatar hooks on desktop renders.
export function RoomViewProfilePanel({ header, children }: RoomViewProfilePanelProps) {
  const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
  if (!isMobile) {
    return (
      <>
        {header}
        {children}
      </>
    );
  }
  return <MobileProfileHorseshoe header={header}>{children}</MobileProfileHorseshoe>;
}
