import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import InputComponent from "./components/input"; import Message from "./components/message"; import { useChat } from "./context"; import { PAGE_SIZE, FALLBACK_MESSAGE_HEIGHT, MESSAGES_PER_VIRTUAL_ROW, type LiveMessage, type RawMessage, } from "./values"; import Wrapper from "@tensamin/user/wrapper"; import { Skeleton } from "@tensamin/ui"; type MessageChunk = { key: string; messages: Array; startIndex: number; }; function shouldFetchPreviousPage({ entry, hasNextPage, isFetchingNextPage, userScrolledUp, }: { entry: IntersectionObserverEntry | undefined; hasNextPage: boolean; isFetchingNextPage: boolean; userScrolledUp: boolean; }) { return [ entry?.isIntersecting === true, userScrolledUp, hasNextPage, !isFetchingNextPage, ].every(Boolean); } function getMessageRenderKey(message: RawMessage | LiveMessage) { return "localId" in message ? message.localId : String(message.SendTime); } function buildMessageChunks( messages: Array, keyPrefix: string, startOffset = 0, ) { const chunks: MessageChunk[] = []; for (let end = messages.length; end > 0; end -= MESSAGES_PER_VIRTUAL_ROW) { const start = Math.max(0, end - MESSAGES_PER_VIRTUAL_ROW); const chunkMessages = messages.slice(start, end); const firstMessage = chunkMessages[0]; if (firstMessage) { chunks.push({ key: `${keyPrefix}-${getMessageRenderKey(firstMessage)}`, messages: chunkMessages, startIndex: startOffset + start, }); } } return chunks; } /** * Renders the chat screen with virtualized history and live message updates. * @returns Chat screen JSX. */ export default function Screen() { const { getMessages, liveMessages, clearLiveMessages, userId, chatSecret, error, errorDescription, } = useChat(); const scrollRef = useRef(null); const topSentinelRef = useRef(null); const didInitialScrollRef = useRef(false); const userScrolledUpRef = useRef(false); const isAtBottomRef = useRef(true); const smoothScrollFrameRef = useRef(null); const smoothScrollTargetRef = useRef(0); const hasNextPageRef = useRef(false); const isFetchingNextPageRef = useRef(false); const fetchNextPageRef = useRef<(() => void) | null>(null); const [lastLiveMessageCount, setLastLiveMessageCount] = useState(0); const [didInitialScroll, setDidInitialScroll] = useState(false); const [viewportHeight, setViewportHeight] = useState(0); const [value, setValue] = useState(""); const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; const hasChatSecret = chatSecret !== null; const messagesQuery = useInfiniteQuery({ queryKey: ["chat-messages", String(userId), hasChatSecret], initialPageParam: 0, queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)), enabled: hasValidChatUser && hasChatSecret, getNextPageParam: (lastPage, allPages) => { if (lastPage.length < PAGE_SIZE) { return undefined; } return allPages.length * PAGE_SIZE; }, }); const { fetchNextPage: fetchMessagesNextPage, hasNextPage: hasMessagesNextPage, isFetchingNextPage: isFetchingMessagesNextPage, } = messagesQuery; useEffect(() => { hasNextPageRef.current = hasMessagesNextPage; isFetchingNextPageRef.current = isFetchingMessagesNextPage; fetchNextPageRef.current = () => { void fetchMessagesNextPage(); }; }, [fetchMessagesNextPage, hasMessagesNextPage, isFetchingMessagesNextPage]); useEffect(() => { clearLiveMessages(); didInitialScrollRef.current = false; userScrolledUpRef.current = false; isAtBottomRef.current = true; setDidInitialScroll(false); setLastLiveMessageCount(0); }, [clearLiveMessages, userId]); const historicalMessages = useMemo(() => { const pages = messagesQuery.data?.pages ?? []; const seenSendTimes = new Set(); const dedupedMessages: RawMessage[] = []; for (const message of [...pages].reverse().flat()) { if (seenSendTimes.has(message.SendTime)) { continue; } seenSendTimes.add(message.SendTime); dedupedMessages.push(message); } return dedupedMessages; }, [messagesQuery.data]); const liveMessagesSnapshot = liveMessages(); const liveWithoutDuplicates = useMemo(() => { const historicalSendTimes = new Set( historicalMessages.map((message) => message.SendTime), ); return liveMessagesSnapshot.filter( (message) => !historicalSendTimes.has(message.SendTime), ); }, [historicalMessages, liveMessagesSnapshot]); const messages = useMemo(() => { return [...historicalMessages, ...liveWithoutDuplicates]; }, [historicalMessages, liveWithoutDuplicates]); const historicalMessageChunks = useMemo(() => { return buildMessageChunks(historicalMessages, "history"); }, [historicalMessages]); const liveMessageChunks = useMemo(() => { return buildMessageChunks( liveWithoutDuplicates, "live", historicalMessages.length, ); }, [historicalMessages.length, liveWithoutDuplicates]); const messageChunks = useMemo(() => { return [...liveMessageChunks, ...historicalMessageChunks]; }, [historicalMessageChunks, liveMessageChunks]); const virtualRowCount = messageChunks.length; const getItemKey = useCallback( (index: number) => messageChunks[index]?.key ?? index, [messageChunks], ); const estimateSize = useCallback(() => FALLBACK_MESSAGE_HEIGHT, []); // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count: virtualRowCount, getScrollElement: () => scrollRef.current, getItemKey, estimateSize, overscan: 2, }); const totalSize = virtualizer.getTotalSize(); const contentHeight = Math.max(totalSize, viewportHeight); const verticalOffset = Math.max(0, viewportHeight - totalSize); useLayoutEffect(() => { const element = scrollRef.current; if (!element || typeof ResizeObserver === "undefined") { return; } const updateViewportHeight = () => { setViewportHeight(element.clientHeight); }; updateViewportHeight(); const observer = new ResizeObserver(updateViewportHeight); observer.observe(element); return () => { observer.disconnect(); }; }, []); useLayoutEffect(() => { if (didInitialScrollRef.current || virtualRowCount === 0) { return; } requestAnimationFrame(() => { if (scrollRef.current) { scrollRef.current.scrollTop = 0; } requestAnimationFrame(() => { didInitialScrollRef.current = true; setDidInitialScroll(true); }); }); }, [virtualizer, virtualRowCount]); useLayoutEffect(() => { const count = liveMessagesSnapshot.length; if (count > lastLiveMessageCount && isAtBottomRef.current) { requestAnimationFrame(() => { if (scrollRef.current) { scrollRef.current.scrollTop = 0; } }); } setLastLiveMessageCount(count); }, [ lastLiveMessageCount, liveMessagesSnapshot.length, virtualRowCount, virtualizer, ]); useEffect(() => { if ( viewportHeight === 0 || totalSize > viewportHeight || messagesQuery.isFetchingNextPage || !messagesQuery.hasNextPage ) { return; } void messagesQuery.fetchNextPage(); }, [didInitialScroll, messagesQuery, totalSize, viewportHeight]); useLayoutEffect(() => { if ( !didInitialScroll || userScrolledUpRef.current || virtualRowCount === 0 ) { return; } requestAnimationFrame(() => { if (scrollRef.current) { scrollRef.current.scrollTop = 0; } }); }, [didInitialScroll, virtualRowCount]); useEffect(() => { const root = scrollRef.current; const sentinel = topSentinelRef.current; if (!root || !sentinel || !didInitialScroll) { return; } const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if ( shouldFetchPreviousPage({ entry, hasNextPage: messagesQuery.hasNextPage, isFetchingNextPage: messagesQuery.isFetchingNextPage, userScrolledUp: userScrolledUpRef.current, }) ) { void messagesQuery.fetchNextPage(); } }, { root, rootMargin: "240px 0px 0px 0px" }, ); observer.observe(sentinel); return () => { observer.disconnect(); }; }, [didInitialScroll, messagesQuery, virtualRowCount]); const handleContainerScroll = useCallback(() => { if (!scrollRef.current) { return; } isAtBottomRef.current = scrollRef.current.scrollTop <= 140; if (didInitialScrollRef.current && scrollRef.current.scrollTop > 140) { userScrolledUpRef.current = true; } }, []); useEffect(() => { const element = scrollRef.current; if (!element) { return; } smoothScrollTargetRef.current = element.scrollTop; const animateScroll = () => { const distance = smoothScrollTargetRef.current - element.scrollTop; if (Math.abs(distance) < 0.5) { element.scrollTop = smoothScrollTargetRef.current; smoothScrollFrameRef.current = null; handleContainerScroll(); return; } element.scrollTop += distance * 0.35; handleContainerScroll(); smoothScrollFrameRef.current = requestAnimationFrame(animateScroll); }; const fetchNextPageNearTop = (scrollTop: number, maxScrollTop: number) => { if (didInitialScrollRef.current && scrollTop > 140) { userScrolledUpRef.current = true; } if ( scrollTop >= maxScrollTop - 240 && userScrolledUpRef.current && hasNextPageRef.current && !isFetchingNextPageRef.current ) { fetchNextPageRef.current?.(); } }; const handleWheel = (event: WheelEvent) => { event.preventDefault(); event.stopPropagation(); if (smoothScrollFrameRef.current === null) { smoothScrollTargetRef.current = element.scrollTop; } const maxScrollTop = Math.max( 0, element.scrollHeight - element.clientHeight, ); smoothScrollTargetRef.current = Math.min( Math.max(0, smoothScrollTargetRef.current - event.deltaY), maxScrollTop, ); fetchNextPageNearTop(smoothScrollTargetRef.current, maxScrollTop); if (smoothScrollFrameRef.current === null) { smoothScrollFrameRef.current = requestAnimationFrame(animateScroll); } }; element.addEventListener("wheel", handleWheel, { passive: false }); return () => { element.removeEventListener("wheel", handleWheel); if (smoothScrollFrameRef.current !== null) { cancelAnimationFrame(smoothScrollFrameRef.current); smoothScrollFrameRef.current = null; } }; }, [handleContainerScroll]); if (!hasValidChatUser) { return (

Invalid User

); } return (
{error !== "" && errorDescription !== "" ? (

{error}

{errorDescription}

) : ( <>
{virtualizer.getVirtualItems().map((virtualRow) => { const chunkIndex = virtualRow.index; const chunk = messageChunks[chunkIndex]; if (!chunk) { return null; } return (
{chunk.messages.map((message, chunkMessageIndex) => { const messageIndex = chunk.startIndex + chunkMessageIndex; const lastMessage = messages[messageIndex - 1]; const isGrouped = lastMessage && lastMessage.SenderId === message.SenderId && Math.round(lastMessage.SendTime / 10000) === Math.round(message.SendTime / 10000); return ( } component={(user) => ( )} /> ); })}
); })}
)}
); }