"use client"; import { useQuery } from "@tanstack/react-query"; import { ExternalLink, Flag, Hash, Loader2, MessageSquare, RefreshCw, Search, Sparkles, } from "lucide-react"; import Image from "next/image"; import { useCallback, useEffect, useMemo, useState } from "react"; import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared"; import { GuildSelector } from "@/components/shared/guild-selector"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useImages, useLoadMore, useMessageDetail, useMessages, useMessagesHasMore, useMessagesWsSync, useReanalyze, useReanalyzeBatch, useReview, useTextChannels, } from "@/hooks"; import { messagesApi } from "@/lib/api"; import { formatBytes, safeParseJsonArray } from "@/lib/format"; import type { MessageRecord } from "@/lib/types"; import { cn } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; export default function MessagesPage() { const [guildId, setGuildId] = useState(""); const [selectedChannel, setSelectedChannel] = useState(""); const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all"); const [searchQuery, setSearchQuery] = useState(""); const [detailId, setDetailId] = useState(null); const ws = useWebSocket(); const { data: channels = [] } = useTextChannels(guildId); const { data: messages, isLoading, error, refetch, } = useMessages(guildId, selectedChannel || undefined); const { data: cursorData, refetch: refetchCursor } = useMessagesHasMore( guildId, selectedChannel || undefined, ); const loadMoreMut = useLoadMore(); const { data: images } = useImages(guildId); const { data: reviews } = useReview(selectedChannel || undefined); const reanalyzeMut = useReanalyze(); const reanalyzeBatchMut = useReanalyzeBatch(); // Sync WS events into the TanStack Query cache useMessagesWsSync(ws, guildId); // Detail dialog const { message: detailMessage, attachments: detailAttachments, loading: detailLoading, } = useMessageDetail(detailId); // Images fetch is managed by the query hook (enabled when guildId is set) // Review fetch is managed by the query hook // Search query (manual trigger) const [searchEnabled, setSearchEnabled] = useState(false); const { data: searchResults, isFetching: searching } = useQuery< MessageRecord[] >({ queryKey: ["messages-search", guildId, searchQuery], queryFn: async () => { const result = await messagesApi.search(searchQuery, 50); return result.results; }, enabled: searchEnabled && !!searchQuery && !!guildId, }); const handleSearch = useCallback(() => { if (!searchQuery.trim()) return; setSearchEnabled(true); }, [searchQuery]); const handleLoadMore = useCallback(() => { if (!cursorData?.cursor || loadMoreMut.isPending) return; loadMoreMut.mutate({ guildId, channelId: selectedChannel || undefined, cursor: cursorData.cursor, }); }, [cursorData, loadMoreMut, guildId, selectedChannel]); const displayMessages = searchResults ?? messages ?? []; const hasMore = cursorData?.hasMore ?? false; const isEmpty = !isLoading && displayMessages.length === 0; if (error) { return (
); } return (
setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSearch()} className="pl-9 h-9" />
{channels.length > 0 && ( )}
setViewTab(v as typeof viewTab)} > All ({(searchResults ?? messages)?.length ?? 0}) Images ({images?.length ?? 0}) Review ({reviews?.length ?? 0}) {searchResults && (

Found {searchResults.length} result {searchResults.length !== 1 ? "s" : ""}

)} {/* ── ALL tab ── */} {viewTab === "all" && (
{isLoading ? ( ) : isEmpty ? (

{searchResults ? "No messages found matching your search." : "No captures yet."}

) : ( <> {displayMessages.map((msg) => ( reanalyzeMut.mutate(id)} /> ))} {hasMore && (
)} )}
)} {/* ── IMAGES tab ── */} {viewTab === "images" && (
{!images || images.length === 0 ? (

No images yet.

) : ( images.map((msg) => { const imgUrl = extractImage(msg.metadata); return ( setDetailId(msg.id)} >
{imgUrl ? ( {msg.content ) : (
No image
)} {msg.content && (

{msg.username}: {msg.content}

)}
); }) )}
)} {/* ── REVIEW tab ── */} {viewTab === "review" && (
{!reviews || reviews.length === 0 ? (

No flagged messages to review.

) : ( reviews.map((msg) => ( reanalyzeMut.mutate(id)} /> )) )}
)} {/* Detail dialog */} !o && setDetailId(null)} > Message Detail {detailLoading ? (
) : detailMessage ? ( ) : null}
); } // ── Message Card ──────────────────────────────────────────────── function MessageCard({ message: msg, onClick, onReanalyze, }: { message: MessageRecord; onClick: (id: string) => void; onReanalyze: (id: string) => void; }) { const severity = ( { low: "border-l-sky-400", medium: "border-l-yellow-400", high: "border-l-orange-400", critical: "border-l-red-500", } as Record )[msg.ai_severity ?? ""]; return ( onClick(msg.id)} >
{msg.username.charAt(0).toUpperCase()}
{msg.username} {new Date(msg.created_at).toLocaleString()} {msg.channel_id.slice(0, 8)} {msg.ai_severity && msg.ai_severity !== "none" && ( {msg.ai_severity} )} {msg.type === "deleted" && ( deleted )} {msg.type === "edited" && ( edited )}

{msg.content}

{msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && (
{safeParseJsonArray(msg.ai_moderation_flags).map((f) => ( {f} ))}
)} {msg.ai_analysis && (

{msg.ai_analysis}

)} {msg.ai_confidence != null && (
{(msg.ai_confidence * 100).toFixed(0)}%
)}
); } function AiStatusBadge({ status }: { status?: string | null }) { const c = ( { clean: "bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/20", warn: "bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20", flagged: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20", error: "bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20", pending: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20", processing: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20", } as Record )[status ?? ""]; if (!c) return null; return ( {status} ); } // ── Detail View ────────────────────────────────────────────────── function DetailView({ message, attachments, }: { message: MessageRecord; attachments: { id: string; filename: string; type: string; size: number; uploaded_url?: string | null; discord_url?: string | null; }[]; }) { return (
{message.username.charAt(0).toUpperCase()}
{message.username} {new Date(message.created_at).toLocaleString()} {message.type === "deleted" && ( deleted )} {message.type === "edited" && ( edited )}

{message.content}

{message.ai_analysis && (

AI Analysis

{message.ai_analysis}

)} {message.ai_moderation_flags && message.ai_moderation_flags !== "[]" && (

Moderation Flags

{safeParseJsonArray(message.ai_moderation_flags).map((f) => ( {f} ))}
)}
{message.ai_status && ( )} {message.ai_severity && message.ai_severity !== "none" && ( )} {message.ai_confidence != null && ( )} {message.ai_recommended_action && message.ai_recommended_action !== "none" && ( )}
{attachments.length > 0 && (

Attachments ({attachments.length})

)}
); } function MiniStat({ label, value, destructive, capitalize, }: { label: string; value: string; destructive?: boolean; capitalize?: boolean; }) { return (

{label}

{value}

); } // ── Helpers ────────────────────────────────────────────────────── function extractImage(metadata: string | null | undefined): string | null { if (!metadata) return null; try { const m = JSON.parse(metadata); const atts: Array<{ url: string; contentType?: string }> = m.attachments ?? []; return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null; } catch { return null; } } function ImageIcon({ className }: { className?: string }) { return ( Image ); }