feat(messages): enhance message and edit history with channel names and content diffs
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
@@ -548,6 +549,10 @@ function MessageDetail({
|
||||
}) {
|
||||
const flags = safeParseJsonArray(m.ai_moderation_flags);
|
||||
const cats = safeParseJsonArray(m.ai_categories);
|
||||
const editHistory =
|
||||
(m as { edit_history?: Array<{ old_content: string; edited_at: number }> })
|
||||
.edit_history ?? [];
|
||||
const editCount = (m as { edit_count?: number }).edit_count ?? 0;
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -595,6 +600,44 @@ function MessageDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit history — before / after diff */}
|
||||
{editCount > 0 && editHistory.length > 0 && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1.5 flex items-center gap-1.5">
|
||||
<History className="size-3" /> Edit history ({editCount})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{editHistory.map((e, i) => (
|
||||
<div
|
||||
key={`${m.id}-edit-${i}`}
|
||||
className="grid grid-cols-2 gap-1.5 rounded-[6px] border border-hairline bg-surface-2/50 text-[10px] leading-relaxed"
|
||||
>
|
||||
<div className="overflow-hidden rounded-l-[5px] border-r border-hairline">
|
||||
<div className="border-b border-hairline bg-vermilion/5 px-2 py-0.5 font-mono text-[8px] font-semibold uppercase tracking-wider text-vermilion">
|
||||
Before
|
||||
</div>
|
||||
<div className="max-h-20 overflow-y-auto p-1.5">
|
||||
<pre className="whitespace-pre-wrap break-words text-ink-faint/80">
|
||||
{e.old_content || <em>(empty)</em>}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-r-[5px]">
|
||||
<div className="border-b border-hairline bg-success/5 px-2 py-0.5 font-mono text-[8px] font-semibold uppercase tracking-wider text-success">
|
||||
After
|
||||
</div>
|
||||
<div className="max-h-20 overflow-y-auto p-1.5">
|
||||
<pre className="whitespace-pre-wrap break-words text-ink-soft">
|
||||
{m.content || <em>(empty)</em>}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
||||
|
||||
@@ -1,35 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import type { MessageActivityBucket } from "@/lib/types";
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
/** Neutral palette: dark slate → signal teal. No brand color for quiet data. */
|
||||
function heatColor(t: number): string {
|
||||
// t in [0,1] → signal gradient (dark → bright).
|
||||
if (t <= 0) return "var(--color-hairline)";
|
||||
return `rgba(45, 212, 191, ${0.15 + 0.85 * t})`;
|
||||
if (t <= 0) return "var(--color-surface-2)";
|
||||
// 4 stops: subtle → medium → bright → full
|
||||
if (t < 0.25) return "rgba(45, 212, 191, 0.10)";
|
||||
if (t < 0.5) return "rgba(45, 212, 191, 0.25)";
|
||||
if (t < 0.75) return "rgba(45, 212, 191, 0.50)";
|
||||
return "rgba(45, 212, 191, 0.85)";
|
||||
}
|
||||
|
||||
const HOUR_MARKS = [0, 4, 8, 12, 16, 20, 23];
|
||||
|
||||
export function ActivityHeatmap({
|
||||
buckets,
|
||||
}: {
|
||||
buckets: MessageActivityBucket[];
|
||||
}) {
|
||||
// Group by channel, find max count for normalization.
|
||||
const channels = Array.from(new Set(buckets.map((b) => b.channelId)));
|
||||
const byKey = new Map<string, number>();
|
||||
let max = 0;
|
||||
for (const b of buckets) {
|
||||
const k = `${b.channelId}:${b.hour}`;
|
||||
byKey.set(k, (byKey.get(k) ?? 0) + b.count);
|
||||
if ((byKey.get(k) ?? 0) > max) max = byKey.get(k) ?? 0;
|
||||
}
|
||||
// Group by channel, normalise per-channel for better contrast.
|
||||
const { rows, globalMax } = useMemo(() => {
|
||||
const byKey = new Map<string, number>();
|
||||
const channelNames = new Map<string, string>();
|
||||
let gMax = 0;
|
||||
|
||||
if (buckets.length === 0) {
|
||||
for (const b of buckets) {
|
||||
const k = `${b.channelId}:${b.hour}`;
|
||||
const next = (byKey.get(k) ?? 0) + b.count;
|
||||
byKey.set(k, next);
|
||||
channelNames.set(b.channelId, b.channelName);
|
||||
if (next > gMax) gMax = next;
|
||||
}
|
||||
|
||||
const rows = Array.from(channelNames.entries())
|
||||
.map(([id, name]) => ({
|
||||
id,
|
||||
name,
|
||||
cells: HOURS.map((h) => byKey.get(`${id}:${h}`) ?? 0),
|
||||
}))
|
||||
// Sort by total descending so busiest channel is on top
|
||||
.sort((a, b) => {
|
||||
const sumA = a.cells.reduce((s, v) => s + v, 0);
|
||||
const sumB = b.cells.reduce((s, v) => s + v, 0);
|
||||
return sumB - sumA;
|
||||
});
|
||||
|
||||
return { rows, globalMax: gMax };
|
||||
}, [buckets]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="insight" title="Activity Heatmap" />
|
||||
<p className="py-6 text-center text-xs text-ink-faint">
|
||||
No message activity recorded yet.
|
||||
@@ -39,49 +66,81 @@ export function ActivityHeatmap({
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-5">
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="insight"
|
||||
title="Activity Heatmap"
|
||||
action={
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{channels.length} channels · messages/hour
|
||||
<span className="mono text-[10px] text-ink-faint">
|
||||
{rows.length} channels · {globalMax} peak msgs/hr
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[640px] space-y-1">
|
||||
{channels.map((ch) => (
|
||||
<div key={ch} className="flex items-center gap-2">
|
||||
<span className="mono w-24 shrink-0 truncate text-[0.6rem] text-ink-faint">
|
||||
{ch.slice(-6)}
|
||||
</span>
|
||||
<div className="flex flex-1 gap-0.5">
|
||||
{HOURS.map((h) => {
|
||||
const c = byKey.get(`${ch}:${h}`) ?? 0;
|
||||
const t = max > 0 ? c / max : 0;
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
title={`${ch} · ${String(h).padStart(2, "0")}:00 — ${c} msgs`}
|
||||
className="h-4 flex-1 rounded-[2px]"
|
||||
style={{ background: heatColor(t) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className="w-24 shrink-0" />
|
||||
<div className="flex flex-1 justify-between">
|
||||
{[0, 6, 12, 18, 23].map((h) => (
|
||||
<span key={h} className="mono text-[0.55rem] text-ink-faint">
|
||||
{String(h).padStart(2, "0")}h
|
||||
<div className="min-w-[600px]">
|
||||
{/* Column headers — hour labels */}
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="w-28 shrink-0" />
|
||||
<div className="flex flex-1 justify-between px-px">
|
||||
{HOUR_MARKS.map((h) => (
|
||||
<span
|
||||
key={h}
|
||||
className="mono text-[9px] text-ink-faint tabular-nums"
|
||||
>
|
||||
{String(h).padStart(2, "0")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="space-y-0.5">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-28 shrink-0 truncate text-[10px] text-ink-muted"
|
||||
title={row.name}
|
||||
>
|
||||
{row.name}
|
||||
</span>
|
||||
<div className="flex flex-1 gap-px">
|
||||
{row.cells.map((count, h) => {
|
||||
// Per-channel normalisation for better local contrast
|
||||
const chMax = Math.max(...row.cells, 1);
|
||||
const t = count / chMax;
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
title={`${row.name} · ${String(h).padStart(2, "0")}:00 — ${count} msgs`}
|
||||
className="h-5 flex-1 rounded-[2px] transition-colors hover:ring-1 hover:ring-signal/40"
|
||||
style={{ background: heatColor(t) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Row total */}
|
||||
<span className="w-10 shrink-0 text-right font-mono text-[9px] text-ink-faint tabular-nums">
|
||||
{row.cells.reduce((s, v) => s + v, 0)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="w-28 shrink-0" />
|
||||
<div className="flex items-center gap-1.5 text-[9px] text-ink-faint">
|
||||
<span>Less</span>
|
||||
{[0, 0.15, 0.35, 0.6, 0.9].map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="h-3 w-3 rounded-[2px]"
|
||||
style={{ background: heatColor(t) }}
|
||||
/>
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
@@ -1,12 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Download, History } from "lucide-react";
|
||||
import { ArrowRight, Download, History } from "lucide-react";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import { downloadCsv } from "@/lib/csv";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { EditHistoryRow } from "@/lib/types";
|
||||
|
||||
function DiffBlock({ oldText, newText }: { oldText: string; newText: string }) {
|
||||
const oldLines = oldText.split("\n");
|
||||
const newLines = newText.split("\n");
|
||||
const maxLen = Math.max(oldLines.length, newLines.length);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 rounded-[6px] border border-hairline bg-surface-2/50 text-[11px] leading-relaxed">
|
||||
{/* Before */}
|
||||
<div className="min-w-0 overflow-hidden rounded-l-[5px] border-r border-hairline">
|
||||
<div className="flex items-center gap-1.5 border-b border-hairline bg-vermilion/5 px-2.5 py-1">
|
||||
<span className="font-mono text-[9px] font-semibold uppercase tracking-wider text-vermilion">
|
||||
Before
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-24 overflow-y-auto p-2">
|
||||
{oldLines.length === 0 || (oldLines.length === 1 && !oldLines[0]) ? (
|
||||
<span className="italic text-ink-faint">(empty)</span>
|
||||
) : (
|
||||
oldLines.map((line, i) => (
|
||||
<div
|
||||
key={`o-${i}`}
|
||||
className="whitespace-pre-wrap break-words text-ink-faint/80"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* After */}
|
||||
<div className="min-w-0 overflow-hidden rounded-r-[5px]">
|
||||
<div className="flex items-center gap-1.5 border-b border-hairline bg-success/5 px-2.5 py-1">
|
||||
<span className="font-mono text-[9px] font-semibold uppercase tracking-wider text-success">
|
||||
After
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-24 overflow-y-auto p-2">
|
||||
{newLines.length === 0 || (newLines.length === 1 && !newLines[0]) ? (
|
||||
<span className="italic text-ink-faint">(empty)</span>
|
||||
) : (
|
||||
newLines.map((line, i) => (
|
||||
<div
|
||||
key={`n-${i}`}
|
||||
className="whitespace-pre-wrap break-words text-ink-soft"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditHistory({ edits }: { edits: EditHistoryRow[] }) {
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-4">
|
||||
@@ -24,6 +80,7 @@ export function EditHistory({ edits }: { edits: EditHistoryRow[] }) {
|
||||
author: e.username ?? "",
|
||||
channel: e.channel_name ?? "",
|
||||
old_content: e.old_content,
|
||||
new_content: e.new_content,
|
||||
edited_at: e.edited_at,
|
||||
})),
|
||||
)
|
||||
@@ -41,24 +98,25 @@ export function EditHistory({ edits }: { edits: EditHistoryRow[] }) {
|
||||
No edited messages recorded recently.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{edits.map((e) => (
|
||||
<div key={e.id} className="text-sm">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="font-medium text-ink">
|
||||
<div key={e.id} className="space-y-2">
|
||||
{/* Header row */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold text-ink">
|
||||
{e.username ?? "unknown"}
|
||||
</span>
|
||||
<span className="text-xs text-ink-faint">
|
||||
edited {formatRelativeTime(e.edited_at)} ·{" "}
|
||||
{e.channel_name ?? e.channel_id ?? "unknown channel"}
|
||||
<span className="flex items-center gap-1 text-[10px] text-ink-muted">
|
||||
<History className="size-3 text-ink-faint/50" />
|
||||
edited {formatRelativeTime(e.edited_at)}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] text-ink-faint">
|
||||
{e.channel_name ?? e.channel_id ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-start gap-1.5">
|
||||
<History className="mt-0.5 size-3.5 shrink-0 text-ink-faint/50" />
|
||||
<pre className="line-clamp-2 whitespace-pre-wrap break-words text-ink-faint/80">
|
||||
{e.old_content || <em>(content not available)</em>}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Before / After diff */}
|
||||
<DiffBlock oldText={e.old_content} newText={e.new_content} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface EditHistoryRow {
|
||||
id: string;
|
||||
message_id: string;
|
||||
old_content: string;
|
||||
new_content: string;
|
||||
edited_at: number;
|
||||
channel_id: string | null;
|
||||
channel_name: string | null;
|
||||
|
||||
@@ -175,6 +175,7 @@ export interface SemanticSearchResult {
|
||||
|
||||
export interface MessageActivityBucket {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
hour: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user