"use client"; /** * The scrolling thread: date separators, grouped bubbles, and infinite scroll * upwards into older history. * * Two scroll behaviours matter and are easy to get wrong: * * 1. Loading older messages PREPENDS content, which would otherwise shove the * reader down the page. We record scrollHeight before the prepend and add * the difference back afterwards, so the message they were reading stays * under their eyes. * * 2. A new message should only auto-scroll if they were already at the * bottom. Yanking someone away from history they are reading is worse than * making them tap to catch up. */ import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { groupMessages } from "@/lib/chat-grouping"; import { MessageGroup } from "@/components/chat/message-group"; import type { ChatMessage, ChatPeer, Receipts } from "@/lib/chat"; /** Distance from the bottom still considered "at the bottom". */ const AT_BOTTOM_PX = 120; /** Distance from the top that triggers loading older history. */ const LOAD_MORE_PX = 200; function dayLabel(iso: string): string { const d = new Date(iso); const today = new Date(); const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString(); if (sameDay(d, today)) return "Today"; if (sameDay(d, yesterday)) return "Yesterday"; return d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: d.getFullYear() === today.getFullYear() ? undefined : "numeric", }); } export function MessageList({ messages, selfId, senderFor, receipts, isGroup, hasMore, loadingMore, onLoadMore, onReply, onMenu, starredIds, onRetry, nameFor, }: { /** Ascending by seq — oldest first. */ messages: ChatMessage[]; selfId: string; /** Resolves a message's sender for their avatar and name. */ senderFor: (senderId: string | null | undefined) => ChatPeer | undefined; receipts: Receipts | null; isGroup: boolean; hasMore: boolean; loadingMore: boolean; onLoadMore: () => void; onReply: (message: ChatMessage) => void; onMenu: (message: ChatMessage, point: { x: number; y: number }) => void; starredIds: Set; onRetry: (message: ChatMessage) => void; nameFor: (senderId: string | null | undefined) => string; }) { const scrollRef = useRef(null); const atBottomRef = useRef(true); // Height captured just before older messages are prepended. const prependAnchorRef = useRef(null); const prevOldestRef = useRef(null); const prevNewestRef = useRef(null); const oldest = messages[0]?.id ?? null; const newest = messages[messages.length - 1]?.id ?? null; const onScroll = useCallback(() => { const el = scrollRef.current; if (!el) return; atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < AT_BOTTOM_PX; if (el.scrollTop < LOAD_MORE_PX && hasMore && !loadingMore) { // Capture the anchor before the parent prepends, so the layout effect // below can restore the reading position. prependAnchorRef.current = el.scrollHeight; onLoadMore(); } }, [hasMore, loadingMore, onLoadMore]); // Runs before paint, so the correction is never visible as a jump. useLayoutEffect(() => { const el = scrollRef.current; if (!el) return; const prependedOlder = prevOldestRef.current !== null && oldest !== prevOldestRef.current; const appendedNewer = prevNewestRef.current !== null && newest !== prevNewestRef.current; const firstRender = prevNewestRef.current === null && newest !== null; if (prependedOlder && prependAnchorRef.current !== null) { el.scrollTop += el.scrollHeight - prependAnchorRef.current; prependAnchorRef.current = null; } else if (firstRender) { el.scrollTop = el.scrollHeight; // open at the newest message } else if (appendedNewer && atBottomRef.current) { el.scrollTop = el.scrollHeight; } prevOldestRef.current = oldest; prevNewestRef.current = newest; }, [oldest, newest, messages.length]); // Keep pulling while the thread is still shorter than its viewport — // otherwise a short first page leaves nothing to scroll and history stalls. useEffect(() => { const el = scrollRef.current; if (!el || loadingMore || !hasMore) return; if (el.scrollHeight <= el.clientHeight) onLoadMore(); }, [messages.length, loadingMore, hasMore, onLoadMore]); const rows = groupMessages(messages); return (
{loadingMore && (

Loading earlier messages…

)}
{rows.map((row) => row.kind === "date" ? (
{dayLabel(row.at)}
) : row.messages[0]?.kind === "system" ? (
{row.messages[0].body}
) : ( ), )}
); }