import { AlertCircle, CheckCircle2, Forward, Hash, Image as ImageIcon, MessageCircle, Pencil, Reply, RotateCw, Smile, Trash2, Video, } from "lucide-react"; import { Fragment, useEffect, useMemo, useState } from "react"; import type { MessageRecord } from "../../../entities/message/types.js"; import { parseMetadata } from "../../../shared/lib/utils.js"; import { getMessageById } from "../../../shared/api/client.js"; import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui"; const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g; function renderContentWithCustomEmojis(content: string): React.ReactNode { const parts: React.ReactNode[] = []; const regex = new RegExp(CUSTOM_EMOJI_REGEX.source, "g"); let lastIndex = 0; let match: RegExpExecArray | null; while ((match = regex.exec(content)) !== null) { if (match.index > lastIndex) { parts.push(content.slice(lastIndex, match.index)); } const [, animated, name, id] = match; const ext = animated ? "gif" : "png"; const url = `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`; parts.push( {name}, ); lastIndex = regex.lastIndex; } if (lastIndex < content.length) { parts.push(content.slice(lastIndex)); } if (parts.length === 0) return content; return {parts}; } // ─── Props ─────────────────────────────────────────────────────────────────── interface MessageCardProps { messages: MessageRecord[]; onReanalyze: (id: string) => Promise; } // ─── Helpers ───────────────────────────────────────────────────────────────── function parseStringList(value?: string | null): string[] { if (!value) return []; try { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []; } catch { return value .split(",") .map((item) => item.trim()) .filter(Boolean); } } function severityColor(severity: string) { switch (severity) { case "critical": return "bg-destructive-soft text-destructive border-destructive/20"; case "high": return "bg-warning-soft text-warning border-warning/20"; case "medium": return "bg-warning-soft text-warning border-warning/20"; case "low": return "bg-info-soft text-info border-info/20"; default: return "bg-muted text-muted-foreground border-border"; } } function formatTimeAgo(ts: number): string { const seconds = Math.floor((Date.now() - ts) / 1000); if (seconds < 60) return `${seconds}s ago`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return new Date(ts).toLocaleDateString(); } function formatTime(ts: number): string { return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); } // ─── Single message row inside a group ─────────────────────────────────────── function MessageRow({ message, onReanalyze, }: { message: MessageRecord; onReanalyze: (id: string) => Promise; }) { const metadata = useMemo( () => parseMetadata(message.metadata), [message.metadata], ); const displayContent = message.edited_content ?? message.content; const aiStatus = message.ai_status ?? "pending"; const categories = useMemo(() => { const list = parseStringList( message.ai_categories ?? message.ai_moderation_flags, ); return list.filter((c) => c !== "analysis_incomplete"); }, [message.ai_categories, message.ai_moderation_flags]); const confidence = message.ai_confidence ?? message.ai_moderation_score ?? null; const [isReanalyzing, setIsReanalyzing] = useState(false); // ── Fetch referenced message content for replies if not in metadata ── const referenceMeta = metadata.reference; const [fetchedRefContent, setFetchedRefContent] = useState<{ username: string; content: string; } | null>(null); useEffect(() => { if ( message.is_reply && referenceMeta?.messageId && !referenceMeta?.content && !message.deleted_at ) { getMessageById(referenceMeta.messageId) .then((refMsg) => { if (refMsg) { setFetchedRefContent({ username: refMsg.username, content: refMsg.content, }); } }) .catch(() => { // Referenced message might not exist in our DB }); } }, [message.is_reply, referenceMeta?.messageId, referenceMeta?.content, message.deleted_at]); const analysisSummary = useMemo(() => { const parts: string[] = []; if (categories.length > 0) { parts.push(categories.slice(0, 3).join(", ")); if (categories.length > 3) parts.push(`+${categories.length - 3} more`); } if (message.ai_severity && message.ai_severity !== "none") { parts.push(message.ai_severity); } if (confidence != null) { parts.push(`${Math.round(confidence * 100)}% confidence`); } if (parts.length === 0) return "View AI analysis"; return parts.join(" · "); }, [categories, message.ai_severity, confidence]); const stickers = metadata.stickers ?? []; const attachments = metadata.attachments ?? []; const imageAttachments = attachments.filter( (a) => a.contentType?.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(a.name), ); const videoAttachments = attachments.filter( (a) => a.contentType?.startsWith("video/") || /\.(mp4|webm|mov|mkv|avi)$/i.test(a.name), ); const hasImages = imageAttachments.length > 0; const hasVideos = videoAttachments.length > 0; /** Hide the fallback text ("[Attachment: ...]", "[Sticker: ...]", "[Embed]") when the actual media IS already shown visually. */ const isFallbackText = /^\[(Attachment|Sticker):/i.test(displayContent) || /^\[Embed\]/i.test(displayContent); const shouldShowContent = displayContent && !isFallbackText; const handleReanalyze = async () => { setIsReanalyzing(true); try { await onReanalyze(message.id); } finally { setIsReanalyzing(false); } }; // ── Reference context (reply / forward / crosspost) ───────────────── const renderReferenceIndicator = () => { // Use fetched content if metadata doesn't have it const effectiveRepliedUsername = referenceMeta?.repliedUsername ?? fetchedRefContent?.username ?? null; const effectiveRepliedContent = referenceMeta?.content ?? fetchedRefContent?.content ?? null; if (message.is_reply) { return (
Replying to{" "} {effectiveRepliedUsername ? `@${effectiveRepliedUsername}` : "a message"} {effectiveRepliedContent && ( {effectiveRepliedContent} )}
); } if (message.is_forward) { return (
Forwarded
); } if (message.is_crosspost) { return (
Crossposted
); } return null; }; const referenceIndicator = renderReferenceIndicator(); return (
{/* Row header: time + edit/delete indicators + AI badges */}
{formatTime(message.created_at)} {message.edited_at && ( edited )} {message.deleted_at && ( deleted )}
{aiStatus === "clean" && } {aiStatus === "flagged" && } {aiStatus === "error" && } {message.ai_severity && message.ai_severity !== "none" && ( {message.ai_severity} )} {confidence != null && ( {Math.round(confidence * 100)}% )}
{/* Reference context: reply / forward / crosspost */} {referenceIndicator} {/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */} {shouldShowContent ? (

{renderContentWithCustomEmojis(displayContent)}

) : null} {/* Stickers */} {stickers.length > 0 && (
{stickers.map((sticker) => (
{sticker.url ? ( {sticker.name ) : (
)}
))}
)} {/* Attached images */} {hasImages && (
{imageAttachments.slice(0, 4).map((img) => ( {img.name} ))} {imageAttachments.length > 4 && (
+{imageAttachments.length - 4}
)}
)} {/* Attached videos */} {hasVideos && (
{videoAttachments.slice(0, 4).map((vid) => (
)} {/* Categories */} {categories.length > 0 && (
{categories.map((category) => ( {category} ))}
)} {/* AI Analysis — always expanded */} {message.ai_analysis ? (
{aiStatus === "flagged" ? "🚨" : "ℹ️"}
{analysisSummary}
{message.ai_analysis}
) : null} {/* AI Error */} {message.ai_error ? (
AI error: {message.ai_error}
) : null} {/* Re-analyze button */}
{aiStatus === "error" && ( Click to retry analysis )}
); } // ─── Group card: one card per user group ───────────────────────────────────── export function MessageCard({ messages, onReanalyze }: MessageCardProps) { const firstMsg = messages[0]; const hasMultiple = messages.length > 1; const meta = useMemo( () => parseMetadata(firstMsg.metadata), [firstMsg.metadata], ); const channelMeta = meta.channel; const locationLabel = useMemo(() => { if (channelMeta?.threadName) { return `# ${channelMeta.channelName || "unknown"} › ${channelMeta.threadName}`; } if (channelMeta?.channelName) { return `# ${channelMeta.channelName}`; } return null; }, [channelMeta]); return (
{/* Avatar — only for first message */}
{/* Group header: username + location + timestamp */}
{firstMsg.username || firstMsg.user_id} {locationLabel && ( {locationLabel} )} {formatTimeAgo(firstMsg.created_at)} {hasMultiple && ` · ${messages.length} messages`}
{/* Message rows — divided by separator when multiple */}
{messages.map((msg, idx) => (
0 ? "pt-2.5" : ""} >
))}
); } // ─── Skeleton ──────────────────────────────────────────────────────────────── export function MessageCardSkeleton() { return (
); }