78 lines
3.3 KiB
TypeScript
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 };
|
|
}
|