179 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|