"use client"; import { AlertTriangle, CheckCircle2, Image as ImageIcon, Loader2, MessageSquare, Paperclip, Search, ShieldAlert, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; import { Avatar, Badge, GlassPanel, Input, Skeleton, } from "@/components/primitives"; import { EmptyState, ErrorState, SectionHeader, SkeletonRows, } from "@/components/shared"; import { GuildChannelPicker } from "@/components/shared/guild-picker"; import { useLoadMore, useMessageDetail, useMessageSearch, useMessages, useMessagesHasMore, useMessagesStream, useMessagesWsSync, } from "@/hooks"; import { aiTone } from "@/lib/ai-status"; import { formatBytes, formatDuration, formatRelativeTime, getMessageChannelLabel, renderMessageContent, safeParseJsonArray, } from "@/lib/format"; import type { AiStatus, Guild, MessageRecord } from "@/lib/types"; import { staggerDelay } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; export function MessagesView({ initialGuilds, initialGuildId, initialMessages, }: { initialGuilds?: Guild[]; initialGuildId?: string | null; initialMessages?: { data: MessageRecord[]; nextCursor: string | null; } | null; }) { const ws = useWebSocket(); const [guildId, setGuildId] = useState( initialGuildId ?? initialGuilds?.[0]?.id ?? null, ); const [channelId, setChannelId] = useState(null); const [selected, setSelected] = useState(null); const [query, setQuery] = useState(""); // Guard against loading the entire history on a long scroll: cap how many // older pages we append. Each page is 50 messages (backend limit default). const MAX_OLDER_PAGES = 10; const [loadedPages, setLoadedPages] = useState(0); const { data: messages, isLoading, error, } = useMessages( guildId ?? "", channelId ?? undefined, initialMessages ?? undefined, ); // Stream history one message per WS frame (replaces the 50-row batched fetch). // Drives snapshots into the SWR list above as they arrive; falls back to the // SSR `initialMessages` seed if WS is unavailable. useMessagesStream(ws, guildId ?? "", channelId ?? undefined); // Cursor to the next (older) page + whether more history exists. const { data: pageInfo } = useMessagesHasMore( guildId ?? "", channelId ?? undefined, ); const nextCursor = pageInfo?.cursor ?? null; const hasMore = pageInfo?.hasMore ?? false; const loadMore = useLoadMore(); useMessagesWsSync(ws, guildId ?? ""); const search = useMessageSearch(query, query.trim().length >= 2); const detail = useMessageDetail(selected); const ambient = useAmbient(); // Fetch the next (older) page via cursor and bump the loaded-page counter. // Older messages prepend at the top, so preserve the viewport by offsetting // scrollTop by the height added above (Discord keeps your place while loading). const loadOlder = useCallback(async () => { if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return; const el = scrollRef.current; const prevHeight = el ? el.scrollHeight : 0; await loadMore.mutateAsync({ guildId, channelId: channelId ?? undefined, cursor: nextCursor ?? "", }); setLoadedPages((n) => n + 1); if (el) { requestAnimationFrame(() => { el.scrollTop = el.scrollTop + (el.scrollHeight - prevHeight); }); } }, [guildId, channelId, hasMore, loadedPages, nextCursor, loadMore]); useEffect(() => { ambient.set(query ? "amber" : "signal", 0.3, query ? "search" : "messages"); }, [query, ambient]); const searching = query.trim().length >= 2; const list = searching ? (search.data ?? []) : (messages ?? []); // Discord-style order: oldest at the top, newest at the bottom. The backend // returns DESC (newest first); reverse so the feed reads top→bottom like DC. const display = useMemo(() => [...list].reverse(), [list]); // Ref to the scroll container so we can manage scroll position like Discord: // open at the bottom (newest), keep the viewport stable when prepending older // messages at the top, and follow new live messages only when already near // the bottom. const scrollRef = useRef(null); const nearBottomRef = useRef(true); // Scroll to the bottom on the first load / when switching guild-channel, so // the newest messages are visible (Discord behaviour). const firstLoadRef = useRef(true); useEffect(() => { if (firstLoadRef.current && display.length > 0) { firstLoadRef.current = false; const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; } }, [display.length]); // When a new live message lands (list grows, still searching off), follow it // to the bottom only if the user was already near the bottom. const prevLen = useRef(list.length); useEffect(() => { if (searching) return; const el = scrollRef.current; if (!el) return; if (list.length > prevLen.current && nearBottomRef.current) { el.scrollTop = el.scrollHeight; } prevLen.current = list.length; }, [list.length, searching]); return (
{ setGuildId(g); setChannelId(c); setSelected(null); setLoadedPages(0); firstLoadRef.current = true; }} />
setQuery(e.target.value)} />
{list.length} shown } /> {error && !messages ? ( ) : isLoading && !messages ? ( ) : list.length === 0 ? ( } title="No messages" description="Pick a guild to begin, or run a search." /> ) : (
{!searching && (
{loadMore.isPending ? ( Loading older… ) : hasMore && loadedPages < MAX_OLDER_PAGES ? ( ) : loadedPages >= MAX_OLDER_PAGES ? ( capped at {MAX_OLDER_PAGES} older pages · use search for more ) : ( messages && messages.length > 0 && ( beginning of history ) )}
)}
{ const el = e.currentTarget; // Track whether the user is near the bottom (to follow live // messages) and auto-load older messages when scrolled to top. nearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120; if (searching || !hasMore || loadMore.isPending) return; if (loadedPages >= MAX_OLDER_PAGES) return; if (el.scrollTop <= 8) { loadOlder(); } }} > {display.map((m, i) => ( ))}
)}
{!selected ? ( ) : detail.loading ? (
) : detail.message ? ( ) : ( )}
); } function AiBadge({ status, durationMs, }: { status?: AiStatus | null; durationMs?: number | null; }) { if (!status) return null; const tone = aiTone(status); const icon = status === "clean" ? ( ) : status === "flagged" ? ( ) : status === "warn" ? ( ) : status === "processing" || status === "pending" ? ( ) : ( ); const label = durationMs && durationMs > 0 ? `${status} · ${formatDuration(durationMs)}` : status; return ( {icon} {label} ); } function MessageDetail({ m, attachments, }: { m: MessageRecord; attachments: import("@/lib/types").AttachmentRecord[]; }) { const flags = safeParseJsonArray(m.ai_moderation_flags); const cats = safeParseJsonArray(m.ai_categories); return (
{m.username}
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
{renderMessageContent(m.edited_content ?? m.content, m.metadata) || "(no text)"}
{m.ai_analysis && (
AI analysis
{m.ai_analysis}
)} {(flags.length > 0 || cats.length > 0) && (
{flags.map((f) => ( {f} ))} {cats.map((c) => ( {c} ))}
)} {attachments.length > 0 && (
Attachments ({attachments.length})
{attachments.map((a) => ( {a.filename} {formatBytes(a.size)} ))}
)}
); }