Files
spsays-training/components/chat/message-list.tsx
T
2026-08-29 03:49:58 +05:30

179 lines
5.8 KiB
TypeScript

"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,
peer,
receipts,
isGroup,
hasMore,
loadingMore,
onLoadMore,
onReply,
onMenu,
starredIds,
onRetry,
nameFor,
}: {
/** Ascending by seq — oldest first. */
messages: ChatMessage[];
selfId: string;
peer?: ChatPeer | null;
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<string>;
onRetry: (message: ChatMessage) => void;
nameFor: (senderId: string | null | undefined) => string;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const atBottomRef = useRef(true);
// Height captured just before older messages are prepended.
const prependAnchorRef = useRef<number | null>(null);
const prevOldestRef = useRef<string | null>(null);
const prevNewestRef = useRef<string | null>(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 (
<div
ref={scrollRef}
onScroll={onScroll}
className="flex-1 overflow-y-auto overscroll-contain px-4 py-4"
>
{loadingMore && (
<p className="pb-3 text-center text-xs text-muted-foreground">
Loading earlier messages…
</p>
)}
<div className="flex flex-col gap-3">
{rows.map((row) =>
row.kind === "date" ? (
<div key={row.key} className="flex justify-center py-1">
<span className="rounded-full bg-muted px-3 py-1 text-[11px] font-medium text-muted-foreground">
{dayLabel(row.at)}
</span>
</div>
) : row.messages[0]?.kind === "system" ? (
<div key={row.key} className="flex justify-center">
<span className="max-w-[80%] text-center text-[11px] text-muted-foreground">
{row.messages[0].body}
</span>
</div>
) : (
<MessageGroup
key={row.key}
messages={row.messages}
mine={row.senderId === selfId}
peer={peer}
receipts={receipts}
showAvatar={isGroup}
selfId={selfId}
onReply={onReply}
onMenu={onMenu}
starredIds={starredIds}
onRetry={onRetry}
nameFor={nameFor}
/>
),
)}
</div>
</div>
);
}