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"; 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 isSameDay(first: number | Date, second: number | Date) { const firstDate = new Date(first); const secondDate = new Date(second); return ( firstDate.getFullYear() === secondDate.getFullYear() && firstDate.getMonth() === secondDate.getMonth() && firstDate.getDate() === secondDate.getDate() ); } function formatMessageDate(sendTime: number) { const date = new Date(sendTime); const today = new Date(); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); if (isSameDay(date, today)) { return "Today"; } if (isSameDay(date, yesterday)) { return "Yesterday"; } const day = String(date.getDate()).padStart(2, "0"); const month = date.toLocaleString([], { month: "long" }); return `${day} ${month} ${date.getFullYear()}`; } function DateSeparator({ label }: { label: string }) { const [visible, setVisible] = useState(false); useEffect(() => { const timeout = window.setTimeout(() => setVisible(true), 100); return () => window.clearTimeout(timeout); }, []); return (
{label}
); } function buildMessageChunks( messages: Array, keyPrefix: string, startOffset = 0, ) { const chunks: { key: string; messages: Array; startIndex: number; }[] = []; 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, ownId, inputBoxRef, error, errorDescription, composerValue, setComposerValue, } = useChat(); const scrollRef = useRef(null); const composerRef = 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 [composerHeight, setComposerHeight] = useState(0); const [editingMessageId, setEditingMessageId] = useState(null); const previousEditingMessageIdRef = useRef(null); const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; const hasChatSecret = chatSecret !== null; useEffect(() => { const previousEditingMessageId = previousEditingMessageIdRef.current; previousEditingMessageIdRef.current = editingMessageId; if (previousEditingMessageId === null || editingMessageId !== null) return; const frame = requestAnimationFrame(() => { inputBoxRef.current ?.querySelector(".cm-content") ?.focus({ preventScroll: true }); }); return () => cancelAnimationFrame(frame); }, [editingMessageId, inputBoxRef]); 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); setEditingMessageId(null); }, [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, paddingStart: composerHeight + 20, }); const totalSize = virtualizer.getTotalSize(); const contentHeight = Math.max(totalSize, viewportHeight); const verticalOffset = Math.max(0, viewportHeight - totalSize); const editLastMessage = useCallback(() => { if (editingMessageId !== null) return; const message = [...messages] .reverse() .find( (candidate) => candidate.SenderId === ownId && candidate.Content.length > 0 && candidate.MessageState !== "awaiting" && !("failed" in candidate && candidate.failed) && !("decryptionFailed" in candidate && candidate.decryptionFailed), ); if (!message) return; const chunkIndex = messageChunks.findIndex((chunk) => chunk.messages.some( (candidate) => candidate.SendTime === message.SendTime, ), ); const chunkIsRendered = virtualizer .getVirtualItems() .some(({ index }) => index === chunkIndex); if (chunkIndex >= 0 && !chunkIsRendered) { virtualizer.scrollToIndex(chunkIndex, { align: "center" }); } setEditingMessageId(message.SendTime); }, [editingMessageId, messageChunks, messages, ownId, virtualizer]); 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(() => { const element = composerRef.current; if (!element || typeof ResizeObserver === "undefined") { return; } const updateComposerHeight = () => { setComposerHeight(element.getBoundingClientRect().height); }; updateComposerHeight(); const observer = new ResizeObserver(updateComposerHeight); 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]); 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 startsNewDay = !lastMessage || !isSameDay(lastMessage.SendTime, message.SendTime); const dateLabel = startsNewDay ? formatMessageDate(message.SendTime) : null; const isGrouped = lastMessage && !startsNewDay && !message.ReplyId && lastMessage.SenderId === message.SenderId && Math.round(lastMessage.SendTime / 10000) === Math.round(message.SendTime / 10000); return ( ( <> {dateLabel && ( )} setEditingMessageId( editing ? message.SendTime : null, ) } user={user} /> )} /> ); })}
); })}
)}
); }