"use client"; import { AlertTriangle, Calendar, CheckCircle2, Image as ImageIcon, Loader2, MessageSquare, Paperclip, Search, ShieldAlert, Sparkles, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityHeatmap } from "@/components/ActivityHeatmap"; import { useAmbient } from "@/components/ambient/ambient-context"; import { EditHistory } from "@/components/EditHistory"; 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, useMessageActivity, useMessageDetail, useMessageSearch, useMessages, useMessagesHasMore, useMessagesStream, useMessagesWsSync, useRecentEdits, useReviewWsSync, useSemanticSearch, } from "@/hooks"; import { useStaggerReveal } from "@/hooks/use-gsap-animation"; import { aiTone } from "@/lib/ai-status"; import { formatBytes, formatDuration, formatRelativeTime, getMessageChannelLabel, renderMessageContent, safeParseJsonArray, } from "@/lib/format"; import type { AiStatus, EditHistoryRow, Guild, MessageMetadata, MessageRecord, } from "@/lib/types"; import { useWebSocket } from "@/lib/ws/context"; export function MessagesView({ initialGuilds, initialGuildId, initialMessages, initialEdits, }: { initialGuilds?: Guild[]; initialGuildId?: string | null; initialMessages?: { data: MessageRecord[]; nextCursor: string | null; } | null; initialEdits?: EditHistoryRow[]; }) { 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(""); // Search mode: "exact" (substring match over captured messages) or // "semantic" (vector similarity over the persistent Qdrant archive). const [semanticMode, setSemanticMode] = useState(false); // feed | timeline: "timeline" groups messages into date-grouped cards. const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed"); // 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, refetch, } = useMessages( guildId ?? "", channelId ?? undefined, initialMessages ?? undefined, ); // Stream history over WS. // Drives snapshots into the SWR list above buffered by rAF; falls back to the // SSR `initialMessages` seed if WS is unavailable. const { streaming } = 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 ?? ""); useReviewWsSync(ws); const search = useMessageSearch( query, query.trim().length >= 2 && !semanticMode, ); const semantic = useSemanticSearch( query, query.trim().length >= 2 && semanticMode, ); const activity = useMessageActivity(30); const edits = useRecentEdits(50, undefined, initialEdits); 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 && !semanticMode; const semanticSearching = query.trim().length >= 2 && semanticMode; 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]); // Timeline mode: inject date-separator headers above the first message of // each day. Messages are sorted oldest→newest (display is reversed), so a // date change means a new group. Produces an array of either "date" or "msg" // nodes so the render loop can switch easily. const timelineNodes = useMemo(() => { if (viewMode !== "timeline") return null; const out: Array< | { type: "date"; label: string; iso: string } | { type: "msg"; m: (typeof display)[number] } > = []; let prev = ""; for (const m of display) { const d = new Date(m.created_at).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", }); const iso = new Date(m.created_at).toISOString().slice(0, 10); if (d !== prev) { out.push({ type: "date", label: d, iso }); prev = d; } out.push({ type: "msg", m }); } return out; }, [display, viewMode]); // 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]); // Depend on viewMode and initial load flag, NOT display.length, so streaming 200 items doesn't thrash animation const streamRef = useStaggerReveal(".msg-feed-card", { stagger: 0.02, y: 6, dependencies: [viewMode], }); return (
{/* Tactical HUD Header Bar */}

Chat Log Stream · Ingestion Stream

{streaming && ( STREAMING )}
MODE: {searching ? "SEARCH_ACTIVE" : viewMode.toUpperCase()}
{/* Filter and Mode Bar */} { setGuildId(g); setChannelId(c); setSelected(null); setLoadedPages(0); firstLoadRef.current = true; }} />
setQuery(e.target.value)} />
{semanticSearching && ( {semantic.data?.length ?? 0} matches } /> {semantic.isLoading ? ( ) : semantic.data && semantic.data.length > 0 ? (
{semantic.data.map((r, i) => (
{(r.score * 100).toFixed(0)}% RELEVANCE {formatRelativeTime(r.created_at)}
{r.content}
))}
) : ( } title="No semantic matches" description="Try different phrasing — vector search inspects contextual semantics." /> )}
)} {/* Message Stream Deck */} {list.length} messages } /> {error && !messages ? ( refetch()} /> ) : isLoading && !messages ? ( ) : list.length === 0 ? ( } title="No messages captured" description="Select a channel or verify discord bridge is active." /> ) : (
{!searching && (
{loadMore.isPending ? ( FETCHING EARLIER PACKETS... ) : hasMore && loadedPages < MAX_OLDER_PAGES ? ( ) : ( {loadedPages >= MAX_OLDER_PAGES ? `CAPPED AT ${MAX_OLDER_PAGES} PAGES` : "STREAM ROOT REACHED"} )}
)}
{ const el = e.currentTarget; 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(); } }} >
{viewMode === "timeline" && timelineNodes ? timelineNodes.map((node) => node.type === "date" ? (
{node.label}
) : ( ), ) : display.map((m) => ( ))}
)}
{/* Message Inspector Detail Panel */} {!selected ? ( ) : detail.loading ? (
) : detail.message ? ( ) : ( )}
{activity.data && activity.data.length > 0 && ( )} {edits.data && }
); } 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 content)"}
{m.ai_analysis && (
AI heuristic reasoning
{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)} ))}
)}
); } /** Parse metadata JSON safely — returns null on malformed / missing data. */ function parseMeta(raw: string | null | undefined): MessageMetadata | null { if (!raw) return null; try { return JSON.parse(raw) as MessageMetadata; } catch { return null; } } /** True when an attachment content-type looks like an image we can inline. */ function isImageType(ct?: string | null): boolean { if (!ct) return false; return ct.startsWith("image/"); } /** Single message card used by both the live feed and the date-grouped timeline. */ function MessageRow({ m, selected, onSelect, }: { m: MessageRecord; selected: string | null; onSelect: (id: string) => void; }) { const meta = parseMeta(m.metadata); const attachments = meta?.attachments ?? []; const embeds = meta?.embeds ?? []; const stickers = meta?.stickers ?? []; const imageAttachments = attachments.filter((a) => isImageType(a.contentType), ); const fileAttachments = attachments.filter( (a) => !isImageType(a.contentType), ); // First embed image or sticker url for visual preview const embedImage = embeds.find((e) => e.image?.url)?.image?.url ?? embeds.find((e) => e.thumbnail?.url)?.thumbnail?.url ?? null; const stickerUrl = stickers.find((s) => s.url)?.url ?? null; return ( ); }