chatbot-template/hooks/use-scroll-to-bottom.tsx
dmitry.galkin 3e21c2334c Initial commit: EGBE chatbot template
Stripped from vercel/chatbot (Apache 2.0):
- Dropped @vercel/* packages and AI Gateway
- Removed artifacts feature (code/text/sheet/image side panel)
- Switched AI provider to @ai-sdk/openai-compatible -> EGBE LiteLLM
- Replaced Vercel Blob upload with data URLs
- Dropped Redis resumable streams and rate limiter (in-memory now)
- Added Dockerfile (Next.js standalone) + entrypoint that runs migrations
- Wired DATABASE_URL, EGBE_AI_API_URL/KEY, NEXT_PUBLIC_BASE_URL for app-deploy.sh
2026-05-25 14:54:04 +04:00

124 lines
3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
export function useScrollToBottom() {
const containerRef = useRef<HTMLDivElement>(null);
const endRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const isAtBottomRef = useRef(true);
const isUserScrollingRef = useRef(false);
useEffect(() => {
isAtBottomRef.current = isAtBottom;
}, [isAtBottom]);
const checkIfAtBottom = useCallback(() => {
if (!containerRef.current) {
return true;
}
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
return scrollTop + clientHeight >= scrollHeight - 100;
}, []);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
if (!containerRef.current) {
return;
}
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
behavior,
});
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
let scrollTimeout: ReturnType<typeof setTimeout>;
const handleScroll = () => {
isUserScrollingRef.current = true;
clearTimeout(scrollTimeout);
const atBottom = checkIfAtBottom();
setIsAtBottom(atBottom);
isAtBottomRef.current = atBottom;
scrollTimeout = setTimeout(() => {
isUserScrollingRef.current = false;
}, 150);
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
clearTimeout(scrollTimeout);
};
}, [checkIfAtBottom]);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const scrollIfNeeded = () => {
if (isAtBottomRef.current && !isUserScrollingRef.current) {
requestAnimationFrame(() => {
container.scrollTo({
top: container.scrollHeight,
behavior: "instant",
});
setIsAtBottom(true);
isAtBottomRef.current = true;
});
}
};
const mutationObserver = new MutationObserver(scrollIfNeeded);
mutationObserver.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
const resizeObserver = new ResizeObserver(scrollIfNeeded);
resizeObserver.observe(container);
for (const child of container.children) {
resizeObserver.observe(child);
}
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
};
}, []);
function onViewportEnter() {
setIsAtBottom(true);
isAtBottomRef.current = true;
}
function onViewportLeave() {
setIsAtBottom(false);
isAtBottomRef.current = false;
}
const reset = useCallback(() => {
setIsAtBottom(true);
isAtBottomRef.current = true;
isUserScrollingRef.current = false;
}, []);
return {
containerRef,
endRef,
isAtBottom,
scrollToBottom,
onViewportEnter,
onViewportLeave,
reset,
};
}