feat: chat
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user