Files
baya-monorepo/client/src/components/messaging/useThreadScroll.ts
T
2026-07-19 17:13:32 +03:30

78 lines
3.3 KiB
TypeScript

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<HTMLDivElement | null>(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 };
}