402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
import {
|
||
AlertCircle,
|
||
CheckCircle2,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Image as ImageIcon,
|
||
Pencil,
|
||
RotateCw,
|
||
Smile,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import { Fragment, useMemo, useState } from "react";
|
||
import { parseMetadata } from "../../../entities/message/types";
|
||
import type { MessageRecord } from "../../../shared/api/client";
|
||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||
|
||
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
||
|
||
/**
|
||
* Renders message content with Discord custom emojis displayed as images
|
||
* instead of raw text like `<:name:id>`.
|
||
*/
|
||
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) {
|
||
// Text before the emoji
|
||
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(
|
||
<img
|
||
key={`${id}-${match.index}`}
|
||
src={url}
|
||
alt={name}
|
||
className="inline-block h-[22px] w-[22px] align-middle object-contain"
|
||
loading="lazy"
|
||
draggable={false}
|
||
title={`:${name}:`}
|
||
/>,
|
||
);
|
||
|
||
lastIndex = regex.lastIndex;
|
||
}
|
||
|
||
// Remaining text after last emoji
|
||
if (lastIndex < content.length) {
|
||
parts.push(content.slice(lastIndex));
|
||
}
|
||
|
||
// If no emojis were found, just return the raw content
|
||
if (parts.length === 0) {
|
||
return content;
|
||
}
|
||
|
||
return <Fragment>{parts}</Fragment>;
|
||
}
|
||
|
||
interface MessageCardProps {
|
||
message: MessageRecord;
|
||
onReanalyze: (id: string) => Promise<void>;
|
||
compact?: boolean;
|
||
}
|
||
|
||
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 aiVariant(status: string) {
|
||
if (status === "clean") return "success";
|
||
if (status === "flagged" || status === "error") return "destructive";
|
||
return "secondary";
|
||
}
|
||
|
||
function severityColor(severity: string) {
|
||
switch (severity) {
|
||
case "critical":
|
||
return "bg-red-100 text-red-700 border-red-200";
|
||
case "high":
|
||
return "bg-orange-100 text-orange-700 border-orange-200";
|
||
case "medium":
|
||
return "bg-yellow-100 text-yellow-700 border-yellow-200";
|
||
case "low":
|
||
return "bg-blue-100 text-blue-700 border-blue-200";
|
||
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();
|
||
}
|
||
|
||
export function MessageCard({
|
||
message,
|
||
onReanalyze,
|
||
compact,
|
||
}: MessageCardProps) {
|
||
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);
|
||
const [showAnalysis, setShowAnalysis] = useState(aiStatus === "flagged");
|
||
|
||
// Build a human-readable analysis summary from categories + confidence + severity
|
||
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 hasImages = imageAttachments.length > 0;
|
||
|
||
const handleReanalyze = async () => {
|
||
setIsReanalyzing(true);
|
||
try {
|
||
await onReanalyze(message.id);
|
||
} finally {
|
||
setIsReanalyzing(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<article
|
||
className={`group rounded-2xl border bg-white shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
||
compact ? "px-4 py-1.5" : "p-4"
|
||
} ${
|
||
message.deleted_at ? "border-red-200 opacity-60" : "border-primary/20"
|
||
}`}
|
||
>
|
||
<div className={`flex ${compact ? "gap-2" : "gap-3"}`}>
|
||
{!compact && (
|
||
<img
|
||
src={
|
||
message.avatar_url ??
|
||
"https://cdn.discordapp.com/embed/avatars/0.png"
|
||
}
|
||
alt=""
|
||
className="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30"
|
||
/>
|
||
)}
|
||
<div
|
||
className={`min-w-0 flex-1 ${compact ? "space-y-1" : "space-y-2.5"}`}
|
||
>
|
||
{!compact && (
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||
<span className="font-semibold text-foreground">
|
||
{message.username || message.user_id}
|
||
</span>
|
||
<span
|
||
className="text-xs text-muted-foreground"
|
||
title={new Date(message.created_at).toLocaleString()}
|
||
>
|
||
{formatTimeAgo(message.created_at)}
|
||
</span>
|
||
{message.edited_at && (
|
||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||
<Pencil className="h-3 w-3" /> edited
|
||
</span>
|
||
)}
|
||
{message.deleted_at && (
|
||
<span className="flex items-center gap-1 text-xs text-destructive">
|
||
<Trash2 className="h-3 w-3" /> deleted
|
||
</span>
|
||
)}
|
||
<div className="ml-auto flex items-center gap-1.5">
|
||
<Badge
|
||
variant={aiVariant(aiStatus)}
|
||
className="flex items-center gap-1 text-xs"
|
||
>
|
||
{aiStatus === "clean" && (
|
||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||
)}
|
||
{aiStatus === "flagged" && (
|
||
<AlertCircle className="h-3.5 w-3.5" />
|
||
)}
|
||
{aiStatus === "error" && (
|
||
<AlertCircle className="h-3.5 w-3.5" />
|
||
)}
|
||
{aiStatus}
|
||
</Badge>
|
||
{message.ai_severity && message.ai_severity !== "none" && (
|
||
<Badge
|
||
className={`text-xs ${severityColor(message.ai_severity)}`}
|
||
>
|
||
{message.ai_severity}
|
||
</Badge>
|
||
)}
|
||
{confidence != null && (
|
||
<Badge variant="outline" className="text-xs tabular-nums">
|
||
{Math.round(confidence * 100)}%
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{displayContent ? (
|
||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
||
{renderContentWithCustomEmojis(displayContent)}
|
||
</p>
|
||
) : null}
|
||
|
||
{stickers.length > 0 && (
|
||
<div className="flex flex-wrap gap-3">
|
||
{stickers.map((sticker) => (
|
||
<div
|
||
key={sticker.name || sticker.url}
|
||
className="flex items-center gap-2"
|
||
>
|
||
{sticker.url ? (
|
||
<img
|
||
src={sticker.url}
|
||
alt={sticker.name || "sticker"}
|
||
className="h-16 w-16 rounded-xl border border-border object-contain bg-muted/50"
|
||
loading="lazy"
|
||
/>
|
||
) : (
|
||
<div className="flex h-16 w-16 items-center justify-center rounded-xl border border-border bg-muted/50">
|
||
<Smile className="h-8 w-8 text-muted-foreground" />
|
||
</div>
|
||
)}
|
||
<span
|
||
className="text-xs text-muted-foreground max-w-[120px] truncate"
|
||
title={sticker.name}
|
||
>
|
||
{sticker.name}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{hasImages && (
|
||
<div className="flex gap-2 overflow-x-auto">
|
||
{imageAttachments.slice(0, 4).map((img) => (
|
||
<a
|
||
key={img.url}
|
||
href={img.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="shrink-0 overflow-hidden rounded-xl border border-border"
|
||
>
|
||
<img
|
||
src={img.url}
|
||
alt={img.name}
|
||
className="h-20 w-20 object-cover transition-transform hover:scale-105"
|
||
loading="lazy"
|
||
/>
|
||
</a>
|
||
))}
|
||
{imageAttachments.length > 4 && (
|
||
<div className="flex h-20 w-20 items-center justify-center rounded-xl border border-border bg-muted text-xs text-muted-foreground">
|
||
+{imageAttachments.length - 4}{" "}
|
||
<ImageIcon className="ml-1 h-3 w-3" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{categories.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{categories.map((category) => (
|
||
<Badge key={category} variant="secondary" className="text-xs">
|
||
{category}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{message.ai_analysis ? (
|
||
<div
|
||
className={`rounded-xl border-l-4 p-3 ${
|
||
aiStatus === "flagged"
|
||
? "border-l-pink-400 bg-pink-50/50"
|
||
: "border-l-emerald-400 bg-emerald-50/50"
|
||
}`}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowAnalysis(!showAnalysis)}
|
||
className="flex w-full items-center justify-between gap-2 text-left text-xs"
|
||
>
|
||
<span className="font-medium text-foreground/80">
|
||
{aiStatus === "flagged" ? "🚨 " : "ℹ️ "}
|
||
{analysisSummary}
|
||
</span>
|
||
{showAnalysis ? (
|
||
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
) : (
|
||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
)}
|
||
</button>
|
||
{showAnalysis && (
|
||
<div className="mt-2 border-t border-border pt-2 text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||
{message.ai_analysis}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{message.ai_error ? (
|
||
<div className="rounded-xl bg-pink-50/50 p-3 text-sm text-pink-700">
|
||
AI error: {message.ai_error}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<Button
|
||
size="sm"
|
||
variant={aiStatus === "error" ? "destructive" : "outline"}
|
||
onClick={handleReanalyze}
|
||
disabled={aiStatus === "pending" || isReanalyzing}
|
||
className="text-xs"
|
||
>
|
||
<RotateCw
|
||
className={`h-3.5 w-3.5 ${isReanalyzing ? "animate-spin" : ""}`}
|
||
/>
|
||
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
||
</Button>
|
||
{aiStatus === "error" && (
|
||
<span className="text-xs text-pink-600/80">
|
||
Click to retry analysis
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
export function MessageCardSkeleton() {
|
||
return (
|
||
<article className="rounded-2xl border border-primary/20 bg-white p-4 shadow-sm">
|
||
<div className="flex gap-3">
|
||
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
|
||
<div className="min-w-0 flex-1 space-y-3">
|
||
<Skeleton className="h-5 w-48" />
|
||
<Skeleton className="h-4 w-full" />
|
||
<Skeleton className="h-4 w-3/4" />
|
||
<div className="flex gap-2">
|
||
<Skeleton className="h-6 w-16 rounded-full" />
|
||
<Skeleton className="h-6 w-20 rounded-full" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|