Files
GMW/services/frontend/src/features/messages/components/MessageCard.tsx
T
asepharyana 81a004d250 feat(frontend): IMPHNEN design deep integration — full 5-layer redesign
Layer 0 — Foundation:
- Dark mode palette (30+ CSS var pairs in [data-theme='dark'])
- CSS-first theme engine in styles.css (@theme + CSS vars)
- UseTheme hook with light/dark/system support + localStorage persistence
- Fixed animation keyframes (shimmer 1.5s canonical, glowPulse moved to CSS)
- Smooth theme switch transitions via .theme-transitioning class

Layer 1 — Shared UI Components:
- Badge: success/warning variants now use CSS vars (bg-success-soft text-success)
- Toast: rewritten with CSS vars, z-index fixed (40), repositioned top-right
- Skeleton: added variant prop (rounded/circular/rectangular)
- EmptyState: new component with icon + title + description + action
- Button: added tertiary variant + icon-sm size
- Card: added elevated/bordered variants
- Input: added soft variant

Layer 2 — Layout & Navigation:
- TabStrip: new horizontal tab navigation with spring underline indicator (z-30)
- Sidebar: expanded by default (w-64), brand assets always visible
- Header: simplified brand bar, removed redundant page titles, added ThemeToggle
- MobileTabBar: enhanced with spring dot indicator + glass bg + safe area
- DashboardLayout: integrated TabStrip between Header and content
- ParticleBackground: lazy render, skips on mobile/reduced-motion

Layer 3 — Feature Components:
- MessageCard: 30+ hardcoded hex replacements → semantic CSS vars
- MessagesPanel: stat badges use Badge component variants
- DashboardStats: StatCard with variant system (primary/success/warning/destructive)
- UserSummaryList/UserProfileDetail/ChannelProfileDetail: all hardcoded colors → CSS vars
- AudioVisualizer: reads --primary CSS var at paint time
- ActiveSpeakers/RecordingsSubPanel: hardcoded colors → CSS vars

Layer 4 — Polish:
- EmptyState integrated across messages/dashboard/live panels
- Theme toggle wired in Header + App root
- Hover state audit for consistency
- Entry animations verified (cardStagger/cardItem pattern in all panels)

Resolves DESIGN_TOKENS.md §13.x issues: hardcoded colors, shimmer mismatch,
glow-pulse fragmentation, z-index collisions, toast positioning.
2026-07-02 05:58:36 +07:00

562 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(
<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;
}
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
if (parts.length === 0) return content;
return <Fragment>{parts}</Fragment>;
}
// ─── Props ───────────────────────────────────────────────────────────────────
interface MessageCardProps {
messages: MessageRecord[];
onReanalyze: (id: string) => Promise<void>;
}
// ─── 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<void>;
}) {
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 (
<div className="flex items-start gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-muted-foreground/20 pl-2.5 py-1 hover:border-primary/40 transition-colors">
<Reply className="h-3 w-3 mt-0.5 shrink-0" />
<span className="min-w-0">
<span className="font-medium text-foreground/60">
Replying to{" "}
{effectiveRepliedUsername
? `@${effectiveRepliedUsername}`
: "a message"}
</span>
{effectiveRepliedContent && (
<span className="block truncate max-w-[400px] text-ellipsis text-[11px] text-muted-foreground/50 mt-0.5">
{effectiveRepliedContent}
</span>
)}
</span>
</div>
);
}
if (message.is_forward) {
return (
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-warning/40 pl-2.5 py-1">
<Forward className="h-3 w-3 shrink-0 text-warning" />
<span className="font-medium text-warning/70">Forwarded</span>
</div>
);
}
if (message.is_crosspost) {
return (
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-info/40 pl-2.5 py-1">
<MessageCircle className="h-3 w-3 shrink-0 text-info" />
<span className="font-medium text-info/70">Crossposted</span>
</div>
);
}
return null;
};
const referenceIndicator = renderReferenceIndicator();
return (
<div className="space-y-2">
{/* Row header: time + edit/delete indicators + AI badges */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span
className="text-[11px] text-muted-foreground/70"
title={new Date(message.created_at).toLocaleString()}
>
{formatTime(message.created_at)}
</span>
{message.edited_at && (
<span className="flex items-center gap-0.5 text-[11px] text-muted-foreground/70">
<Pencil className="h-2.5 w-2.5" /> edited
</span>
)}
{message.deleted_at && (
<span className="flex items-center gap-0.5 text-[11px] text-destructive/70">
<Trash2 className="h-2.5 w-2.5" /> deleted
</span>
)}
<div className="ml-auto flex items-center gap-1">
<StatusBadge status={aiStatus} className="text-[10px] px-1.5 py-0">
{aiStatus === "clean" && <CheckCircle2 className="h-3 w-3" />}
{aiStatus === "flagged" && <AlertCircle className="h-3 w-3" />}
{aiStatus === "error" && <AlertCircle className="h-3 w-3" />}
</StatusBadge>
{message.ai_severity && message.ai_severity !== "none" && (
<Badge
className={`text-[10px] px-1.5 py-0 ${severityColor(message.ai_severity)}`}
>
{message.ai_severity}
</Badge>
)}
{confidence != null && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 tabular-nums"
>
{Math.round(confidence * 100)}%
</Badge>
)}
</div>
</div>
{/* Reference context: reply / forward / crosspost */}
{referenceIndicator}
{/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */}
{shouldShowContent ? (
<p
className={`whitespace-pre-wrap break-words text-sm leading-6 ${
message.deleted_at
? "text-muted-foreground/60"
: "text-foreground/90"
}`}
>
{renderContentWithCustomEmojis(displayContent)}
</p>
) : null}
{/* Stickers */}
{stickers.length > 0 && (
<div className="flex flex-wrap gap-2">
{stickers.map((sticker) => (
<div
key={sticker.name || sticker.url}
className="flex items-center gap-1.5"
>
{sticker.url ? (
<img
src={sticker.url}
alt={sticker.name || "sticker"}
className="h-12 w-12 rounded-lg border border-border object-contain bg-muted/50"
loading="lazy"
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
<Smile className="h-6 w-6 text-muted-foreground" />
</div>
)}
</div>
))}
</div>
)}
{/* Attached images */}
{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-lg border border-border"
>
<img
src={img.url}
alt={img.name}
className="h-16 w-16 object-cover transition-transform hover:scale-105"
loading="lazy"
/>
</a>
))}
{imageAttachments.length > 4 && (
<div className="flex h-16 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
+{imageAttachments.length - 4}
<ImageIcon className="ml-0.5 h-3 w-3" />
</div>
)}
</div>
)}
{/* Attached videos */}
{hasVideos && (
<div className="flex gap-2 overflow-x-auto">
{videoAttachments.slice(0, 4).map((vid) => (
<video
key={vid.url}
src={vid.url}
controls
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black"
preload="metadata"
/>
))}
{videoAttachments.length > 4 && (
<div className="flex h-28 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
+{videoAttachments.length - 4}
<Video className="ml-0.5 h-3 w-3" />
</div>
)}
</div>
)}
{/* Categories */}
{categories.length > 0 && (
<div className="flex flex-wrap gap-1">
{categories.map((category) => (
<Badge key={category} variant="secondary" className="text-[10px]">
{category}
</Badge>
))}
</div>
)}
{/* AI Analysis — always expanded */}
{message.ai_analysis ? (
<div
className={`rounded-lg border-l-[3px] px-3 py-2 ${
aiStatus === "flagged"
? "border-l-tertiary bg-tertiary/5"
: "border-l-success bg-success-soft"
}`}
>
<div className="flex items-start gap-2 text-[11px]">
<span className="mt-0.5 shrink-0">
{aiStatus === "flagged" ? "🚨" : "️"}
</span>
<div className="min-w-0 flex-1">
<span className="block font-medium text-foreground/70 mb-1">
{analysisSummary}
</span>
<div className="text-[12px] text-muted-foreground leading-relaxed whitespace-pre-wrap">
{message.ai_analysis}
</div>
</div>
</div>
</div>
) : null}
{/* AI Error */}
{message.ai_error ? (
<div className="rounded-lg bg-tertiary/5 px-3 py-2 text-[12px] text-tertiary">
AI error: {message.ai_error}
</div>
) : null}
{/* Re-analyze button */}
<div className="flex items-center gap-2">
<Button
size="sm"
variant={aiStatus === "error" ? "destructive" : "outline"}
onClick={handleReanalyze}
disabled={aiStatus === "pending" || isReanalyzing}
className="text-[11px] h-7 px-2.5"
>
<RotateCw
className={`h-3 w-3 ${isReanalyzing ? "animate-spin" : ""}`}
/>
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
</Button>
{aiStatus === "error" && (
<span className="text-[11px] text-tertiary/70">
Click to retry analysis
</span>
)}
</div>
</div>
);
}
// ─── 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 (
<article
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
firstMsg.deleted_at ? "border-destructive/20 opacity-60" : "border-border"
}`}
>
<div className="flex gap-3 p-4">
{/* Avatar — only for first message */}
<img
src={
firstMsg.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">
{/* Group header: username + location + timestamp */}
<div className="flex items-baseline gap-2 mb-2">
<span className="font-semibold text-sm text-foreground">
{firstMsg.username || firstMsg.user_id}
</span>
{locationLabel && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded-full">
<Hash className="h-2.5 w-2.5" />
{locationLabel}
</span>
)}
<span
className="text-[11px] text-muted-foreground/60"
title={new Date(firstMsg.created_at).toLocaleString()}
>
{formatTimeAgo(firstMsg.created_at)}
{hasMultiple && ` · ${messages.length} messages`}
</span>
</div>
{/* Message rows — divided by separator when multiple */}
<div
className={
hasMultiple ? "divide-y divide-border/30 space-y-2.5" : ""
}
>
{messages.map((msg, idx) => (
<div
key={msg.id}
className={hasMultiple && idx > 0 ? "pt-2.5" : ""}
>
<MessageRow message={msg} onReanalyze={onReanalyze} />
</div>
))}
</div>
</div>
</div>
</article>
);
}
// ─── Skeleton ────────────────────────────────────────────────────────────────
export function MessageCardSkeleton() {
return (
<article className="rounded-xl border border-border bg-card 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>
);
}