ui phase 10
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user