import { useEffect, useRef, useState } from 'react'; /** How close to the bottom edge (in px) still counts as "already at the bottom" (§3.2). */ const NEAR_BOTTOM_THRESHOLD_PX = 120; export interface UseThreadScrollResult { /** Attach to a zero-height sentinel element rendered after the last message. */ bottomRef: (node: HTMLDivElement | null) => void; /** A floating «پیام جدید ↓» pill should render while this is true. */ showNewMessagePill: boolean; /** Scrolls to the newest message and dismisses the pill (the pill's own tap handler). */ scrollToNewest: () => void; } /** * Chat scroll orchestration (§3.2), reusable across any message-list surface (this thread now; phase 11's * admin thread scrollbox next — it has the inverse bug, opening scrolled to the top). A thread opens * scrolled to the **newest** message; sending always scrolls to the new bubble; receiving auto-scrolls only * when the viewer is already near the bottom, otherwise shows a dismissible "new message" pill. * * Uses an `IntersectionObserver` on a bottom sentinel (not a tracked scroll container) so it works whether * the *page* scrolls or an inner box does — the observer's root is the viewport by default, which correctly * accounts for whichever ancestor actually scrolls. Guarded for environments without * `IntersectionObserver` (e.g. jsdom in tests): it degrades to "always treat as near the bottom". */ export function useThreadScroll(messageCount: number, lastMessageIsMine: boolean): UseThreadScrollResult { const [showNewMessagePill, setShowNewMessagePill] = useState(false); const sentinelRef = useRef(null); const isNearBottomRef = useRef(true); const didInitialScrollRef = useRef(false); const prevCountRef = useRef(0); const scrollToNewest = () => { sentinelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); setShowNewMessagePill(false); }; const bottomRef = (node: HTMLDivElement | null) => { sentinelRef.current = node; }; useEffect(() => { const node = sentinelRef.current; if (!node || typeof IntersectionObserver === 'undefined') return undefined; const observer = new IntersectionObserver( ([entry]) => { isNearBottomRef.current = entry.isIntersecting; if (entry.isIntersecting) setShowNewMessagePill(false); }, { rootMargin: `0px 0px ${NEAR_BOTTOM_THRESHOLD_PX}px 0px` }, ); observer.observe(node); return () => observer.disconnect(); }, []); useEffect(() => { if (messageCount === 0) return; if (!didInitialScrollRef.current) { // Opens at the newest message (§3.2) — instant, no animation, on first load. sentinelRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' }); didInitialScrollRef.current = true; prevCountRef.current = messageCount; return; } if (messageCount > prevCountRef.current) { if (lastMessageIsMine || isNearBottomRef.current) { sentinelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); setShowNewMessagePill(false); } else { setShowNewMessagePill(true); } } prevCountRef.current = messageCount; }, [messageCount, lastMessageIsMine]); return { bottomRef, showNewMessagePill, scrollToNewest }; }