Corp/frontend/src/hooks/useChatScroll.ts

200 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState } from "react";
import { formatDayDivider } from "@/lib/messages/messageUtils";
import type { ScrollableMessageListInner } from "@/components/ScrollableMessageList";
/* ─── helpers ─── */
function formatDateInputValue(date: Date): string {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, "0");
const day = `${date.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function shiftDateInputValue(value: string, deltaDays: number): string {
const baseDate = value ? new Date(`${value}T00:00:00`) : new Date();
if (Number.isNaN(baseDate.getTime())) return formatDateInputValue(new Date());
baseDate.setDate(baseDate.getDate() + deltaDays);
return formatDateInputValue(baseDate);
}
/* ─── types ─── */
export interface UseChatScrollOptions {
chatId: number;
/** Number of rendered messages, used to recalculate floating date after reload/jump */
messageCount?: number;
/** Ref to the scroll container component */
viewportRef: React.RefObject<ScrollableMessageListInner | null>;
/** Whether there are more newer messages to load */
hasMoreNewerRef: React.MutableRefObject<boolean>;
/** Ref to unread-below message ids */
unreadBelowIdsRef: React.MutableRefObject<Set<number>>;
/** Whether we are in history navigation mode */
historyNavigationMode: boolean;
}
export interface UseChatScrollReturn {
/** Whether the scroll-to-bottom button should be visible */
showScrollToBottom: boolean;
setShowScrollToBottom: React.Dispatch<React.SetStateAction<boolean>>;
/** Floating date display */
floatingDate: string | null;
floatingDateValue: string | null;
showFloatingDate: boolean;
/** Date navigator state */
isDateNavigatorOpen: boolean;
setIsDateNavigatorOpen: React.Dispatch<React.SetStateAction<boolean>>;
selectedHistoryDate: string;
setSelectedHistoryDate: React.Dispatch<React.SetStateAction<string>>;
/** Check if user is near bottom */
isNearBottom: () => boolean;
/** Scroll to bottom */
scrollToBottom: (smooth?: boolean) => void;
/** Synchronize the scroll-to-bottom button visibility. Returns true if at bottom. */
syncScrollToBottomState: () => boolean;
/** Open date navigator, optionally with a preset date */
openDateNavigator: (dateValue?: string | null) => void;
}
/* ─── hook ─── */
export function useChatScroll({
chatId,
messageCount = 0,
viewportRef,
hasMoreNewerRef,
unreadBelowIdsRef,
historyNavigationMode,
}: UseChatScrollOptions): UseChatScrollReturn {
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [floatingDate, setFloatingDate] = useState<string | null>(null);
const [floatingDateValue, setFloatingDateValue] = useState<string | null>(null);
const [showFloatingDate, setShowFloatingDate] = useState(false);
const [isDateNavigatorOpen, setIsDateNavigatorOpen] = useState(false);
const [selectedHistoryDate, setSelectedHistoryDate] = useState("");
const floatingDateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Cleanup
useEffect(() => () => {
if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current);
}, []);
const isNearBottom = useCallback(() => {
const c = viewportRef.current;
if (!c?.containerRef?.current) return false;
const v = c.containerRef.current;
return v.scrollHeight - v.scrollTop - v.clientHeight <= 150;
}, [viewportRef]);
const scrollToBottom = useCallback((smooth = false) => {
viewportRef.current?.scrollToBottom(smooth ? "smooth" : "auto");
}, [viewportRef]);
const syncScrollToBottomState = useCallback(() => {
const atBottom = isNearBottom();
const hasBufferedNewer = hasMoreNewerRef.current || unreadBelowIdsRef.current.size > 0;
if (atBottom && !hasMoreNewerRef.current) {
setShowScrollToBottom(false);
return true;
}
setShowScrollToBottom(!atBottom || hasBufferedNewer || historyNavigationMode);
return atBottom;
}, [hasMoreNewerRef, historyNavigationMode, isNearBottom, unreadBelowIdsRef]);
const openDateNavigator = useCallback((dateValue?: string | null) => {
setSelectedHistoryDate(dateValue || floatingDateValue || formatDateInputValue(new Date()));
setShowFloatingDate(true);
setIsDateNavigatorOpen(true);
}, [floatingDateValue]);
/** Update floating date from scroll position – call inside onScroll handler */
const updateFloatingDate = useCallback((viewport: HTMLElement) => {
const messageEls = viewport.querySelectorAll<HTMLElement>("[data-message-date]");
const viewportTop = viewport.getBoundingClientRect().top;
let topDate: string | null = null;
let topDateVal: string | null = null;
for (let i = 0; i < messageEls.length; i += 1) {
const el = messageEls[i];
if (el.getBoundingClientRect().bottom > viewportTop + 8) {
const iso = el.getAttribute("data-message-date");
if (iso) {
const d = new Date(iso);
if (!Number.isNaN(d.getTime())) {
topDate = formatDayDivider(d);
topDateVal = formatDateInputValue(d);
}
}
break;
}
}
if (topDate) {
setFloatingDate(topDate);
setFloatingDateValue(topDateVal);
setShowFloatingDate(true);
if (topDateVal && !isDateNavigatorOpen) {
setSelectedHistoryDate(topDateVal);
}
if (floatingDateTimeoutRef.current) clearTimeout(floatingDateTimeoutRef.current);
floatingDateTimeoutRef.current = setTimeout(() => setShowFloatingDate(false), 1100);
return;
}
setFloatingDate(null);
setFloatingDateValue(null);
setShowFloatingDate(false);
}, [isDateNavigatorOpen]);
// Expose updateFloatingDate as a stable ref so the scroll handler can call it
const updateFloatingDateRef = useRef(updateFloatingDate);
useEffect(() => { updateFloatingDateRef.current = updateFloatingDate; }, [updateFloatingDate]);
// Attach scroll handler after the message list actually exists in the DOM.
// `ref.current` changes do not trigger effects, so we re-run when messageCount changes.
useEffect(() => {
const c = viewportRef.current;
if (!c?.containerRef?.current) return;
const viewport = c.containerRef.current;
let frameId: number | null = null;
const updateFromScrollPosition = () => {
frameId = null;
updateFloatingDateRef.current(viewport);
};
const scheduleUpdate = () => {
if (frameId === null) {
frameId = window.requestAnimationFrame(updateFromScrollPosition);
}
};
scheduleUpdate();
viewport.addEventListener("scroll", scheduleUpdate, { passive: true });
return () => {
if (frameId !== null) window.cancelAnimationFrame(frameId);
viewport.removeEventListener("scroll", scheduleUpdate);
};
}, [chatId, messageCount, viewportRef]);
return {
showScrollToBottom,
setShowScrollToBottom,
floatingDate,
floatingDateValue,
showFloatingDate,
isDateNavigatorOpen,
setIsDateNavigatorOpen,
selectedHistoryDate,
setSelectedHistoryDate,
isNearBottom,
scrollToBottom,
syncScrollToBottomState,
openDateNavigator,
};
}