"use client"; import { useState, useEffect, useCallback } from "react"; import { messagesApi, voiceApi } from "@/lib/api"; import type { MessageRecord, Channel, AttachmentRecord } from "@/lib/types"; import { useWebSocket } from "@/lib/ws/context"; import { Search, RefreshCw, Loader2, AlertCircle, Flag, X, Download, ExternalLink, } from "lucide-react"; import { useAppConfig } from "@/lib/hooks/use-config"; export function MessagesPanel() { const { config } = useAppConfig(); const guildId = config?.monitorGuildId ?? ""; const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [cursor, setCursor] = useState(null); const [hasMore, setHasMore] = useState(true); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); const [searching, setSearching] = useState(false); const [viewTab, setViewTab] = useState<"all" | "images" | "review">("all"); const [imageMessages, setImageMessages] = useState([]); const [reviewMessages, setReviewMessages] = useState([]); const [channels, setChannels] = useState([]); const [detailMessage, setDetailMessage] = useState(null); const [detailAttachments, setDetailAttachments] = useState([]); const [detailLoading, setDetailLoading] = useState(false); const [selectedChannel, setSelectedChannel] = useState(""); const ws = useWebSocket(); // Fetch available text channels for filtering useEffect(() => { if (!guildId) return; voiceApi.getTextChannels(guildId).then(setChannels).catch(() => {}); }, [guildId]); // Fetch initial messages const fetchMessages = useCallback(async () => { setLoading(true); setError(null); try { const result = await messagesApi.list(guildId, 50, selectedChannel || undefined); setMessages(result.data); setCursor(result.nextCursor); setHasMore(result.nextCursor !== null); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load messages"); } finally { setLoading(false); } }, [guildId, selectedChannel]); // Fetch image messages const fetchImages = useCallback(async () => { try { const result = await messagesApi.getImages(guildId, 50); setImageMessages(result.data); } catch { // silently fail } }, [guildId]); // Fetch review (flagged) messages const fetchReview = useCallback(async () => { try { const result = await messagesApi.getReview(50, selectedChannel || undefined); setReviewMessages(result.results); } catch { // silently fail } }, [selectedChannel]); useEffect(() => { fetchMessages(); fetchImages(); }, [fetchMessages, fetchImages]); useEffect(() => { if (viewTab === "review") fetchReview(); }, [viewTab, fetchReview]); // WS subscription for real-time message updates useEffect(() => { const unsubCreated = ws.on("message_created", (msg) => { setMessages((prev) => [msg as MessageRecord, ...prev]); }); const unsubUpdated = ws.on("message_updated", (msg) => { setMessages((prev) => prev.map((m) => (msg as MessageRecord).id === m.id ? (msg as MessageRecord) : m, ), ); }); const unsubDeleted = ws.on("message_deleted", (id) => { setMessages((prev) => prev.filter((m) => m.id !== (id as unknown as string)), ); }); const unsubAnalyzed = ws.on("message_analyzed", (msg) => { setMessages((prev) => prev.map((m) => (msg as MessageRecord).id === m.id ? (msg as MessageRecord) : m, ), ); }); return () => { unsubCreated(); unsubUpdated(); unsubDeleted(); unsubAnalyzed(); }; }, [ws]); // Search handler const handleSearch = useCallback(async () => { if (!searchQuery.trim()) { setSearchResults(null); return; } setSearching(true); try { const result = await messagesApi.search(searchQuery, 50); setSearchResults(result.results); } catch { setSearchResults([]); } finally { setSearching(false); } }, [searchQuery]); // Load more (cursor pagination) const handleLoadMore = useCallback(async () => { if (!cursor || loadingMore) return; setLoadingMore(true); try { const result = await messagesApi.list(guildId, 50, selectedChannel || undefined, cursor); setMessages((prev) => [...prev, ...result.data]); setCursor(result.nextCursor); setHasMore(result.nextCursor !== null); } catch { // ignore } finally { setLoadingMore(false); } }, [cursor, loadingMore, guildId, selectedChannel]); const handleMessageClick = useCallback(async (id: string) => { setDetailLoading(true); setDetailAttachments([]); try { const detail = await messagesApi.getDetail(id); setDetailMessage(detail); // Try to fetch attachments too if (detail.channel_id && id) { messagesApi .getAttachments(detail.channel_id, 10) .then((res) => setDetailAttachments(res.data)) .catch(() => {}); } } catch { setDetailMessage(null); } finally { setDetailLoading(false); } }, []); const handleReanalyze = useCallback(async (id: string) => { try { await messagesApi.reanalyze(id); } catch { // ignore } }, []); const handleReanalyzeBatch = useCallback(async () => { try { await messagesApi.reanalyzeBatch(); } catch { // ignore } }, []); const displayMessages = searchResults ?? messages; const isEmpty = !loading && displayMessages.length === 0; // Render if (error) { return (

{error}

); } return (
{/* Search + toolbar */}
setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSearch()} className="w-full h-9 rounded-lg border border-input bg-background pl-9 pr-3 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
{/* Channel filter */} {channels.length > 0 && ( )}
{/* Tab bar */}
{/* Search results count */} {searchResults !== null && (

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

)} {/* Messages feed */} {viewTab === "all" ? (
{loading ? (
{Array.from({ length: 8 }).map((_, i) => (
))}
) : isEmpty ? (

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

) : ( <> {displayMessages.map((msg) => ( ))} {/* Load more */} {hasMore && searchResults === null && (
)} )}
) : viewTab === "images" ? (
{imageMessages.map((msg) => (
{msg.content && (
{msg.username}: {msg.content}
)}
))}
) : ( /* Review tab */
{reviewMessages.length === 0 ? (

No flagged messages to review.

) : ( reviewMessages.map((msg) => ( )) )}
)} {/* Message Detail Modal */} {detailMessage && (
{/* Header */}

Message Detail

{/* Content */}
{detailLoading ? (
) : ( <> {/* Message info */}
{detailMessage.avatar_url ? ( ) : ( detailMessage.username.charAt(0).toUpperCase() )}
{detailMessage.username} {new Date(detailMessage.created_at).toLocaleString()} {detailMessage.type === "deleted" && ( deleted )}

{detailMessage.content}

{/* AI Analysis section */} {detailMessage.ai_analysis && (

AI Analysis

{detailMessage.ai_analysis}

)} {/* AI flags */} {detailMessage.ai_moderation_flags && detailMessage.ai_moderation_flags !== "[]" && (

Moderation Flags

{safeParseJsonArray( detailMessage.ai_moderation_flags, ).map((flag) => ( {flag} ))}
)} {/* AI Scores */}
{detailMessage.ai_status && (

Status

{detailMessage.ai_status}

)} {detailMessage.ai_severity && detailMessage.ai_severity !== "none" && (

Severity

{detailMessage.ai_severity}

)} {detailMessage.ai_confidence != null && (

Confidence

{(detailMessage.ai_confidence * 100).toFixed(0)}%

)} {detailMessage.ai_recommended_action && detailMessage.ai_recommended_action !== "none" && (

Action

{detailMessage.ai_recommended_action}

)}
{/* Attachments */} {detailAttachments.length > 0 && (

Attachments ({detailAttachments.length})

{detailAttachments.map((att) => (

{att.filename}

{att.type} · {formatBytes(att.size)}

))}
)} {/* Raw metadata */} {detailMessage.metadata && detailMessage.metadata !== "{}" && (

Metadata (raw)

                          {JSON.stringify(
                            safeParseJsonObject(detailMessage.metadata),
                            null,
                            2,
                          )}
                        
)} )}
)}
); } // ── Message Card ────────────────────────────────────────── function MessageCard({ message: msg, onClick, onReanalyze, }: { message: MessageRecord; onClick: (id: string) => void; onReanalyze: (id: string) => void; }) { const aiStatusColor: Record = { clean: "bg-green-500/15 text-green-600 dark:text-green-400", warn: "bg-yellow-500/15 text-yellow-600 dark:text-yellow-400", flagged: "bg-red-500/15 text-red-600 dark:text-red-400", error: "bg-gray-500/15 text-gray-600 dark:text-gray-400", pending: "bg-blue-500/15 text-blue-600 dark:text-blue-400", processing: "bg-blue-500/15 text-blue-600 dark:text-blue-400", }; const severityColor: Record = { none: "", low: "border-l-green-400", medium: "border-l-yellow-400", high: "border-l-orange-400", critical: "border-l-red-500", }; const date = new Date(msg.created_at); const timeStr = date.toLocaleString(); return (
onClick(msg.id)} onKeyDown={(e) => e.key === "Enter" && onClick(msg.id)} className={`rounded-lg border p-4 space-y-2 transition-colors cursor-pointer hover:bg-muted/50 ${ msg.ai_severity ? severityColor[msg.ai_severity] ?? "" : "" } ${msg.ai_severity && msg.ai_severity !== "none" ? "border-l-2" : ""}`} > {/* Header */}
{/* Avatar */}
{msg.avatar_url ? ( ) : ( msg.username.charAt(0).toUpperCase() )}
{/* Username + time + badges */}
{msg.username} {timeStr} #{msg.channel_id.slice(0, 8)} {/* AI Status badge */} {msg.ai_status && aiStatusColor[msg.ai_status] && ( {msg.ai_status} )} {/* Severity badge */} {msg.ai_severity && msg.ai_severity !== "none" && ( {msg.ai_severity} )} {/* Message type badge */} {msg.type === "deleted" && ( deleted )} {msg.type === "edited" && ( edited )}
{/* Content */}

{msg.type === "deleted" ? ( {msg.content} ) : ( msg.content )}

{/* AI Details */} {msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && (
{safeParseJsonArray(msg.ai_moderation_flags).map( (flag) => ( {flag} ), )}
)} {msg.ai_analysis && (

{msg.ai_analysis}

)} {/* Confidence score */} {msg.ai_confidence !== undefined && msg.ai_confidence !== null && (
{(msg.ai_confidence * 100).toFixed(0)}%
)} {/* Actions */}
); } // ── Helpers ─────────────────────────────────────────────── function safeParseJsonObject( value: string | null | undefined, ): Record { if (!value) return {}; try { const parsed = JSON.parse(value); if (typeof parsed === "object" && parsed !== null) return parsed; return {}; } catch { return {}; } } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function safeParseJsonArray( value: string | null | undefined, ): string[] { if (!value) return []; try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) return parsed; return []; } catch { return []; } }