2026-08-07 10:44:03 +07:00
|
|
|
"use client";
|
|
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
/**
|
|
|
|
|
* Messages scene — the live feed becomes an orbital belt of nodes on the
|
|
|
|
|
* stage; the functional console (picker/search/modes) floats top-left,
|
|
|
|
|
* the stream itself is a translucent dossier column, and a selected
|
|
|
|
|
* message opens its inspection dossier bottom-center.
|
|
|
|
|
*/
|
2026-08-07 10:44:03 +07:00
|
|
|
import {
|
2026-08-15 17:53:48 +07:00
|
|
|
AlertTriangle,
|
2026-08-18 17:43:02 +07:00
|
|
|
Calendar,
|
2026-08-15 17:53:48 +07:00
|
|
|
CheckCircle2,
|
2026-08-15 20:03:55 +07:00
|
|
|
Image as ImageIcon,
|
2026-08-15 17:53:48 +07:00
|
|
|
Loader2,
|
2026-08-15 20:03:55 +07:00
|
|
|
MessageSquare,
|
|
|
|
|
Paperclip,
|
|
|
|
|
Search,
|
|
|
|
|
ShieldAlert,
|
2026-08-15 17:53:48 +07:00
|
|
|
} from "lucide-react";
|
2026-08-16 17:56:39 +07:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
2026-08-18 17:43:02 +07:00
|
|
|
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
2026-08-15 20:03:55 +07:00
|
|
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
2026-08-18 20:45:12 +07:00
|
|
|
import { EditHistory } from "@/components/EditHistory";
|
2026-08-24 15:35:51 +07:00
|
|
|
import { Avatar, Badge, Skeleton } from "@/components/primitives";
|
|
|
|
|
import { EmptyState, ErrorState, SkeletonRows } from "@/components/shared";
|
2026-08-15 20:03:55 +07:00
|
|
|
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
2026-08-24 15:35:51 +07:00
|
|
|
import {
|
|
|
|
|
useSceneFocusSetter,
|
|
|
|
|
useScenePublish,
|
|
|
|
|
} from "@/components/shell/scene-graph-context";
|
2026-08-15 20:03:55 +07:00
|
|
|
import {
|
2026-08-16 16:06:25 +07:00
|
|
|
useLoadMore,
|
2026-08-18 17:43:02 +07:00
|
|
|
useMessageActivity,
|
2026-08-15 20:03:55 +07:00
|
|
|
useMessageDetail,
|
|
|
|
|
useMessageSearch,
|
2026-08-15 17:53:48 +07:00
|
|
|
useMessages,
|
2026-08-16 16:06:25 +07:00
|
|
|
useMessagesHasMore,
|
2026-08-18 10:21:08 +07:00
|
|
|
useMessagesStream,
|
2026-08-15 17:53:48 +07:00
|
|
|
useMessagesWsSync,
|
2026-08-18 20:45:12 +07:00
|
|
|
useRecentEdits,
|
2026-08-18 15:11:01 +07:00
|
|
|
useSemanticSearch,
|
2026-08-15 17:53:48 +07:00
|
|
|
} from "@/hooks";
|
2026-08-18 09:05:12 +07:00
|
|
|
import { aiTone } from "@/lib/ai-status";
|
2026-08-24 15:35:51 +07:00
|
|
|
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
2026-08-15 20:03:55 +07:00
|
|
|
import {
|
|
|
|
|
formatBytes,
|
2026-08-18 09:05:12 +07:00
|
|
|
formatDuration,
|
|
|
|
|
formatRelativeTime,
|
2026-08-15 20:03:55 +07:00
|
|
|
getMessageChannelLabel,
|
|
|
|
|
renderMessageContent,
|
|
|
|
|
safeParseJsonArray,
|
|
|
|
|
} from "@/lib/format";
|
2026-08-18 20:45:12 +07:00
|
|
|
import type {
|
|
|
|
|
AiStatus,
|
|
|
|
|
EditHistoryRow,
|
|
|
|
|
Guild,
|
|
|
|
|
MessageRecord,
|
|
|
|
|
} from "@/lib/types";
|
2026-08-18 11:01:00 +07:00
|
|
|
import { staggerDelay } from "@/lib/utils";
|
2026-08-15 20:03:55 +07:00
|
|
|
import { useWebSocket } from "@/lib/ws/context";
|
2026-08-07 10:44:03 +07:00
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
/** Recent messages → orbital belt (chained edges give the belt shape). */
|
|
|
|
|
function beltGraph(list: MessageRecord[]): ConstellationGraph {
|
|
|
|
|
const recent = list.slice(0, 36);
|
|
|
|
|
const nodes = recent.map((m, i) => ({
|
|
|
|
|
id: `msg:${m.id}`,
|
|
|
|
|
label: m.username,
|
|
|
|
|
kind:
|
|
|
|
|
m.ai_status === "flagged" ? ("flagged" as const) : ("message" as const),
|
|
|
|
|
value: Math.max(0.15, 1 - i / Math.max(1, recent.length)),
|
|
|
|
|
href: undefined,
|
|
|
|
|
}));
|
|
|
|
|
const edges = nodes.slice(0, -1).map((n, i) => ({
|
|
|
|
|
source: n.id,
|
|
|
|
|
target: nodes[i + 1]?.id ?? n.id,
|
|
|
|
|
}));
|
|
|
|
|
return { nodes, edges };
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
export function MessagesView({
|
|
|
|
|
initialGuilds,
|
|
|
|
|
initialGuildId,
|
2026-08-18 10:21:08 +07:00
|
|
|
initialMessages,
|
2026-08-18 20:45:12 +07:00
|
|
|
initialEdits,
|
2026-08-15 17:53:48 +07:00
|
|
|
}: {
|
|
|
|
|
initialGuilds?: Guild[];
|
|
|
|
|
initialGuildId?: string | null;
|
2026-08-18 10:21:08 +07:00
|
|
|
initialMessages?: {
|
|
|
|
|
data: MessageRecord[];
|
|
|
|
|
nextCursor: string | null;
|
|
|
|
|
} | null;
|
2026-08-18 20:45:12 +07:00
|
|
|
initialEdits?: EditHistoryRow[];
|
2026-08-15 17:53:48 +07:00
|
|
|
}) {
|
2026-08-07 10:44:03 +07:00
|
|
|
const ws = useWebSocket();
|
2026-08-15 17:53:48 +07:00
|
|
|
const [guildId, setGuildId] = useState<string | null>(
|
|
|
|
|
initialGuildId ?? initialGuilds?.[0]?.id ?? null,
|
2026-08-07 10:44:03 +07:00
|
|
|
);
|
2026-08-15 17:53:48 +07:00
|
|
|
const [channelId, setChannelId] = useState<string | null>(null);
|
|
|
|
|
const [selected, setSelected] = useState<string | null>(null);
|
|
|
|
|
const [query, setQuery] = useState("");
|
2026-08-18 15:11:01 +07:00
|
|
|
const [semanticMode, setSemanticMode] = useState(false);
|
2026-08-18 17:43:02 +07:00
|
|
|
const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed");
|
2026-08-16 16:21:10 +07:00
|
|
|
const MAX_OLDER_PAGES = 10;
|
|
|
|
|
const [loadedPages, setLoadedPages] = useState(0);
|
2026-08-24 15:35:51 +07:00
|
|
|
const [showIntel, setShowIntel] = useState(false);
|
2026-08-07 10:44:03 +07:00
|
|
|
|
2026-08-15 20:03:55 +07:00
|
|
|
const {
|
|
|
|
|
data: messages,
|
|
|
|
|
isLoading,
|
|
|
|
|
error,
|
2026-08-18 10:21:08 +07:00
|
|
|
} = useMessages(
|
|
|
|
|
guildId ?? "",
|
|
|
|
|
channelId ?? undefined,
|
|
|
|
|
initialMessages ?? undefined,
|
|
|
|
|
);
|
|
|
|
|
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
|
2026-08-16 17:56:39 +07:00
|
|
|
const { data: pageInfo } = useMessagesHasMore(
|
|
|
|
|
guildId ?? "",
|
|
|
|
|
channelId ?? undefined,
|
|
|
|
|
);
|
2026-08-16 16:06:25 +07:00
|
|
|
const nextCursor = pageInfo?.cursor ?? null;
|
|
|
|
|
const hasMore = pageInfo?.hasMore ?? false;
|
|
|
|
|
const loadMore = useLoadMore();
|
2026-08-15 17:53:48 +07:00
|
|
|
useMessagesWsSync(ws, guildId ?? "");
|
2026-08-18 15:11:01 +07:00
|
|
|
const search = useMessageSearch(
|
|
|
|
|
query,
|
|
|
|
|
query.trim().length >= 2 && !semanticMode,
|
|
|
|
|
);
|
|
|
|
|
const semantic = useSemanticSearch(
|
|
|
|
|
query,
|
|
|
|
|
query.trim().length >= 2 && semanticMode,
|
|
|
|
|
);
|
2026-08-18 17:43:02 +07:00
|
|
|
const activity = useMessageActivity(30);
|
2026-08-18 20:45:12 +07:00
|
|
|
const edits = useRecentEdits(50, undefined, initialEdits);
|
2026-08-15 17:53:48 +07:00
|
|
|
const detail = useMessageDetail(selected);
|
|
|
|
|
const ambient = useAmbient();
|
2026-08-24 15:35:51 +07:00
|
|
|
const publish = useScenePublish();
|
|
|
|
|
const setFocus = useSceneFocusSetter();
|
|
|
|
|
|
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const nearBottomRef = useRef(true);
|
2026-08-07 10:44:03 +07:00
|
|
|
|
2026-08-16 16:21:10 +07:00
|
|
|
const loadOlder = useCallback(async () => {
|
|
|
|
|
if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return;
|
2026-08-16 17:56:39 +07:00
|
|
|
const el = scrollRef.current;
|
|
|
|
|
const prevHeight = el ? el.scrollHeight : 0;
|
2026-08-16 16:21:10 +07:00
|
|
|
await loadMore.mutateAsync({
|
|
|
|
|
guildId,
|
|
|
|
|
channelId: channelId ?? undefined,
|
|
|
|
|
cursor: nextCursor ?? "",
|
|
|
|
|
});
|
|
|
|
|
setLoadedPages((n) => n + 1);
|
2026-08-16 17:56:39 +07:00
|
|
|
if (el) {
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
el.scrollTop = el.scrollTop + (el.scrollHeight - prevHeight);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-08-16 16:21:10 +07:00
|
|
|
}, [guildId, channelId, hasMore, loadedPages, nextCursor, loadMore]);
|
|
|
|
|
|
2026-08-07 10:44:03 +07:00
|
|
|
useEffect(() => {
|
2026-08-15 17:53:48 +07:00
|
|
|
ambient.set(query ? "amber" : "signal", 0.3, query ? "search" : "messages");
|
|
|
|
|
}, [query, ambient]);
|
2026-08-07 10:44:03 +07:00
|
|
|
|
2026-08-18 15:11:01 +07:00
|
|
|
const searching = query.trim().length >= 2 && !semanticMode;
|
|
|
|
|
const semanticSearching = query.trim().length >= 2 && semanticMode;
|
2026-08-15 20:03:55 +07:00
|
|
|
const list = searching ? (search.data ?? []) : (messages ?? []);
|
2026-08-16 17:56:39 +07:00
|
|
|
const display = useMemo(() => [...list].reverse(), [list]);
|
|
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
const graph = useMemo(
|
|
|
|
|
() => (searching ? { nodes: [], edges: [] } : beltGraph(list)),
|
|
|
|
|
[list, searching],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
publish({ graph, focus: selected ? `msg:${selected}` : null });
|
|
|
|
|
}, [graph, selected, publish]);
|
|
|
|
|
|
|
|
|
|
useEffect(
|
|
|
|
|
() => () => {
|
|
|
|
|
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
|
|
|
|
setFocus(null);
|
|
|
|
|
},
|
|
|
|
|
[publish, setFocus],
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-18 17:43:02 +07:00
|
|
|
const timelineNodes = useMemo(() => {
|
|
|
|
|
if (viewMode !== "timeline") return null;
|
|
|
|
|
const out: Array<
|
|
|
|
|
| { type: "date"; label: string; iso: string }
|
|
|
|
|
| { type: "msg"; m: (typeof display)[number] }
|
|
|
|
|
> = [];
|
2026-08-24 15:35:51 +07:00
|
|
|
let prevDate = "";
|
2026-08-18 17:43:02 +07:00
|
|
|
for (const m of display) {
|
|
|
|
|
const d = new Date(m.created_at).toLocaleDateString(undefined, {
|
|
|
|
|
weekday: "short",
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
});
|
|
|
|
|
const iso = new Date(m.created_at).toISOString().slice(0, 10);
|
2026-08-24 15:35:51 +07:00
|
|
|
if (d !== prevDate) {
|
2026-08-18 17:43:02 +07:00
|
|
|
out.push({ type: "date", label: d, iso });
|
2026-08-24 15:35:51 +07:00
|
|
|
prevDate = d;
|
2026-08-18 17:43:02 +07:00
|
|
|
}
|
|
|
|
|
out.push({ type: "msg", m });
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}, [display, viewMode]);
|
|
|
|
|
|
2026-08-16 17:56:39 +07:00
|
|
|
const firstLoadRef = useRef(true);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (firstLoadRef.current && display.length > 0) {
|
|
|
|
|
firstLoadRef.current = false;
|
|
|
|
|
const el = scrollRef.current;
|
|
|
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
|
|
|
}
|
|
|
|
|
}, [display.length]);
|
|
|
|
|
|
|
|
|
|
const prevLen = useRef(list.length);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (searching) return;
|
|
|
|
|
const el = scrollRef.current;
|
|
|
|
|
if (!el) return;
|
|
|
|
|
if (list.length > prevLen.current && nearBottomRef.current) {
|
|
|
|
|
el.scrollTop = el.scrollHeight;
|
|
|
|
|
}
|
|
|
|
|
prevLen.current = list.length;
|
|
|
|
|
}, [list.length, searching]);
|
2026-08-07 10:44:03 +07:00
|
|
|
|
|
|
|
|
return (
|
2026-08-24 15:35:51 +07:00
|
|
|
<div className="min-h-full">
|
|
|
|
|
{/* Console whisper — top-left */}
|
|
|
|
|
<section
|
|
|
|
|
className="pointer-events-auto absolute left-5 top-16 z-20 w-[min(22rem,90vw)] space-y-2"
|
|
|
|
|
aria-label="Stream controls"
|
|
|
|
|
>
|
2026-08-15 17:53:48 +07:00
|
|
|
<GuildChannelPicker
|
|
|
|
|
mode="text"
|
|
|
|
|
guildsInitial={initialGuilds}
|
|
|
|
|
guildId={guildId}
|
|
|
|
|
channelId={channelId}
|
|
|
|
|
onChange={(g, c) => {
|
|
|
|
|
setGuildId(g);
|
|
|
|
|
setChannelId(c);
|
|
|
|
|
setSelected(null);
|
2026-08-16 16:21:10 +07:00
|
|
|
setLoadedPages(0);
|
2026-08-16 17:56:39 +07:00
|
|
|
firstLoadRef.current = true;
|
2026-08-15 17:53:48 +07:00
|
|
|
}}
|
2026-08-07 10:44:03 +07:00
|
|
|
/>
|
2026-08-24 15:35:51 +07:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-faint)]" />
|
|
|
|
|
<input
|
|
|
|
|
type="search"
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
|
|
|
placeholder="Search messages…"
|
|
|
|
|
className="h-9 w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 pl-9 pr-3 font-mono text-xs text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]"
|
2026-08-18 15:11:01 +07:00
|
|
|
/>
|
2026-08-24 15:35:51 +07:00
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setSemanticMode((v) => !v)}
|
|
|
|
|
className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
|
|
|
|
|
semanticMode
|
|
|
|
|
? "border-signal/40 bg-signal/10 text-signal"
|
|
|
|
|
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
|
|
|
|
}`}
|
|
|
|
|
title="Toggle semantic (vector) search over the message archive"
|
|
|
|
|
>
|
|
|
|
|
{semanticMode ? "Semantic" : "Exact"}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
2026-08-15 17:53:48 +07:00
|
|
|
}
|
2026-08-24 15:35:51 +07:00
|
|
|
className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
|
|
|
|
|
viewMode === "timeline"
|
|
|
|
|
? "border-signal/40 bg-signal/10 text-signal"
|
|
|
|
|
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
|
|
|
|
}`}
|
|
|
|
|
title="Toggle timeline (date-grouped) view"
|
|
|
|
|
>
|
|
|
|
|
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* Stream dossier — right column */}
|
|
|
|
|
<section
|
|
|
|
|
className="pointer-events-auto absolute bottom-20 right-5 top-28 hidden w-[min(30rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 backdrop-blur-xl md:flex"
|
|
|
|
|
aria-label="Message stream"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
|
|
|
|
<span className="eyebrow">
|
|
|
|
|
{searching ? `“${query}”` : "live stream"}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
|
|
|
|
{list.length} shown
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="min-h-0 flex-1 overflow-hidden p-2">
|
|
|
|
|
{semanticSearching ? (
|
|
|
|
|
<SemanticResults semantic={semantic} />
|
|
|
|
|
) : error && !messages ? (
|
2026-08-15 17:53:48 +07:00
|
|
|
<ErrorState error={error} />
|
|
|
|
|
) : isLoading && !messages ? (
|
2026-08-18 09:21:12 +07:00
|
|
|
<SkeletonRows rows={8} />
|
2026-08-15 17:53:48 +07:00
|
|
|
) : list.length === 0 ? (
|
2026-08-15 20:03:55 +07:00
|
|
|
<EmptyState
|
2026-08-24 15:35:51 +07:00
|
|
|
icon={<MessageSquare className="size-6" />}
|
2026-08-15 20:03:55 +07:00
|
|
|
title="No messages"
|
|
|
|
|
description="Pick a guild to begin, or run a search."
|
|
|
|
|
/>
|
2026-08-15 17:53:48 +07:00
|
|
|
) : (
|
2026-08-24 15:35:51 +07:00
|
|
|
<div className="flex h-full flex-col">
|
2026-08-16 16:06:25 +07:00
|
|
|
{!searching && (
|
2026-08-24 15:35:51 +07:00
|
|
|
<div className="mb-1.5 flex items-center justify-center gap-2">
|
2026-08-16 16:21:10 +07:00
|
|
|
{loadMore.isPending ? (
|
2026-08-24 15:35:51 +07:00
|
|
|
<span className="flex items-center gap-1.5 font-mono text-xs text-[var(--color-ink-soft)]">
|
2026-08-16 16:21:10 +07:00
|
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
|
|
|
Loading older…
|
|
|
|
|
</span>
|
|
|
|
|
) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
|
2026-08-16 16:06:25 +07:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-08-16 16:21:10 +07:00
|
|
|
onClick={loadOlder}
|
2026-08-24 15:35:51 +07:00
|
|
|
className="rounded-full border border-[var(--color-hairline)] px-3 py-1 font-mono text-xs text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]"
|
2026-08-16 16:06:25 +07:00
|
|
|
>
|
2026-08-16 16:21:10 +07:00
|
|
|
↑ Load older messages
|
2026-08-16 16:06:25 +07:00
|
|
|
</button>
|
2026-08-16 16:21:10 +07:00
|
|
|
) : loadedPages >= MAX_OLDER_PAGES ? (
|
2026-08-24 15:35:51 +07:00
|
|
|
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
2026-08-16 17:56:39 +07:00
|
|
|
capped at {MAX_OLDER_PAGES} older pages · use search for
|
|
|
|
|
more
|
2026-08-16 16:21:10 +07:00
|
|
|
</span>
|
2026-08-16 16:06:25 +07:00
|
|
|
) : (
|
|
|
|
|
messages &&
|
|
|
|
|
messages.length > 0 && (
|
2026-08-24 15:35:51 +07:00
|
|
|
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
2026-08-16 16:06:25 +07:00
|
|
|
beginning of history
|
|
|
|
|
</span>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div
|
2026-08-16 17:56:39 +07:00
|
|
|
ref={scrollRef}
|
2026-08-24 15:35:51 +07:00
|
|
|
className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
2026-08-16 16:06:25 +07:00
|
|
|
onScroll={(e) => {
|
2026-08-16 17:56:39 +07:00
|
|
|
const el = e.currentTarget;
|
|
|
|
|
nearBottomRef.current =
|
|
|
|
|
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
2026-08-16 16:06:25 +07:00
|
|
|
if (searching || !hasMore || loadMore.isPending) return;
|
2026-08-16 16:21:10 +07:00
|
|
|
if (loadedPages >= MAX_OLDER_PAGES) return;
|
2026-08-16 17:56:39 +07:00
|
|
|
if (el.scrollTop <= 8) {
|
2026-08-16 16:21:10 +07:00
|
|
|
loadOlder();
|
2026-08-16 16:06:25 +07:00
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-08-18 17:43:02 +07:00
|
|
|
{viewMode === "timeline" && timelineNodes
|
2026-08-24 15:35:51 +07:00
|
|
|
? timelineNodes.map((node) =>
|
2026-08-18 17:43:02 +07:00
|
|
|
node.type === "date" ? (
|
|
|
|
|
<div
|
|
|
|
|
key={`date-${node.iso}`}
|
2026-08-24 15:35:51 +07:00
|
|
|
className="flex items-center gap-2 px-1 font-mono text-[0.65rem] text-[var(--color-ink-faint)]"
|
2026-08-18 17:43:02 +07:00
|
|
|
>
|
|
|
|
|
<Calendar className="size-3" />
|
|
|
|
|
{node.label}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<MessageRow
|
|
|
|
|
key={node.m.id}
|
|
|
|
|
m={node.m}
|
|
|
|
|
selected={selected}
|
|
|
|
|
onSelect={setSelected}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-08-24 15:35:51 +07:00
|
|
|
: display.map((m) => (
|
2026-08-18 17:43:02 +07:00
|
|
|
<MessageRow
|
|
|
|
|
key={m.id}
|
|
|
|
|
m={m}
|
|
|
|
|
selected={selected}
|
|
|
|
|
onSelect={setSelected}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
2026-08-16 17:56:39 +07:00
|
|
|
</div>
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-24 15:35:51 +07:00
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-08-15 17:53:48 +07:00
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
{/* Mobile stream — full-width sheet under the console */}
|
|
|
|
|
<section
|
|
|
|
|
className="pointer-events-auto absolute inset-x-4 bottom-24 top-44 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-2 backdrop-blur-xl md:hidden"
|
|
|
|
|
aria-label="Message stream mobile"
|
|
|
|
|
>
|
|
|
|
|
{error && !messages ? (
|
|
|
|
|
<ErrorState error={error} />
|
|
|
|
|
) : isLoading && !messages ? (
|
|
|
|
|
<SkeletonRows rows={6} />
|
|
|
|
|
) : display.length === 0 ? (
|
|
|
|
|
<EmptyState title="No messages" description="Pick a guild first." />
|
|
|
|
|
) : (
|
|
|
|
|
display.map((m) => (
|
|
|
|
|
<MessageRow
|
|
|
|
|
key={m.id}
|
|
|
|
|
m={m}
|
|
|
|
|
selected={selected}
|
|
|
|
|
onSelect={setSelected}
|
2026-08-15 20:03:55 +07:00
|
|
|
/>
|
2026-08-24 15:35:51 +07:00
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* Inspection dossier — bottom-center */}
|
|
|
|
|
{selected ? (
|
|
|
|
|
<aside
|
|
|
|
|
className="pointer-events-auto absolute bottom-20 left-1/2 z-30 max-h-[52vh] w-[min(34rem,92vw)] -translate-x-1/2 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/85 p-4 backdrop-blur-xl"
|
|
|
|
|
aria-label="Message detail"
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="absolute right-3 top-3 font-mono text-xs text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
|
|
|
|
|
onClick={() => setSelected(null)}
|
|
|
|
|
>
|
|
|
|
|
esc
|
|
|
|
|
</button>
|
|
|
|
|
{detail.loading ? (
|
2026-08-15 17:53:48 +07:00
|
|
|
<div className="space-y-2">
|
2026-08-24 15:35:51 +07:00
|
|
|
<Skeleton className="h-16" />
|
|
|
|
|
<Skeleton className="h-10" />
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
) : detail.message ? (
|
2026-08-15 20:03:55 +07:00
|
|
|
<MessageDetail
|
|
|
|
|
m={detail.message}
|
|
|
|
|
attachments={detail.attachments}
|
|
|
|
|
/>
|
2026-08-15 17:53:48 +07:00
|
|
|
) : (
|
|
|
|
|
<EmptyState title="Not found" />
|
|
|
|
|
)}
|
2026-08-24 15:35:51 +07:00
|
|
|
</aside>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{/* Intel strip — bottom-left */}
|
|
|
|
|
<div className="pointer-events-auto absolute bottom-20 left-5 hidden w-72 lg:block">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setShowIntel((v) => !v)}
|
|
|
|
|
className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
|
|
|
|
|
showIntel
|
|
|
|
|
? "border-signal/40 bg-signal/10 text-signal"
|
|
|
|
|
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
intel {showIntel ? "▲" : "▼"}
|
|
|
|
|
</button>
|
|
|
|
|
{showIntel ? (
|
|
|
|
|
<div className="mt-2 max-h-[38vh] space-y-3 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 p-3 backdrop-blur-xl">
|
|
|
|
|
{activity.data && activity.data.length > 0 ? (
|
|
|
|
|
<ActivityHeatmap buckets={activity.data} />
|
|
|
|
|
) : null}
|
|
|
|
|
{edits.data ? <EditHistory edits={edits.data} /> : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-18 17:43:02 +07:00
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
function SemanticResults({
|
|
|
|
|
semantic,
|
|
|
|
|
}: {
|
|
|
|
|
semantic: ReturnType<typeof useSemanticSearch>;
|
|
|
|
|
}) {
|
|
|
|
|
if (semantic.isLoading) return <SkeletonRows rows={4} />;
|
|
|
|
|
if (!semantic.data || semantic.data.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<EmptyState
|
|
|
|
|
icon={<Search className="size-6" />}
|
|
|
|
|
title="No semantic matches"
|
|
|
|
|
description="Try different wording — semantic search finds meaning, not exact text."
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-full space-y-1.5 overflow-y-auto pr-1">
|
|
|
|
|
{semantic.data.map((r, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={r.message_id ?? i}
|
|
|
|
|
className="animate-stagger rounded-xl border border-[var(--color-hairline)] p-2.5"
|
|
|
|
|
style={staggerDelay(i)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="font-mono text-[0.6rem] text-signal">
|
|
|
|
|
{(r.score * 100).toFixed(0)}%
|
|
|
|
|
</span>
|
|
|
|
|
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
|
|
|
|
{formatRelativeTime(r.created_at)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-0.5 line-clamp-3 text-sm text-[var(--color-ink-soft)]">
|
|
|
|
|
{r.content}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 00:11:00 +07:00
|
|
|
function AiBadge({
|
|
|
|
|
status,
|
|
|
|
|
durationMs,
|
|
|
|
|
}: {
|
|
|
|
|
status?: AiStatus | null;
|
|
|
|
|
durationMs?: number | null;
|
|
|
|
|
}) {
|
2026-08-15 17:53:48 +07:00
|
|
|
if (!status) return null;
|
|
|
|
|
const tone = aiTone(status);
|
|
|
|
|
const icon =
|
2026-08-15 20:03:55 +07:00
|
|
|
status === "clean" ? (
|
|
|
|
|
<CheckCircle2 className="size-3" />
|
|
|
|
|
) : status === "flagged" ? (
|
|
|
|
|
<ShieldAlert className="size-3" />
|
|
|
|
|
) : status === "warn" ? (
|
|
|
|
|
<AlertTriangle className="size-3" />
|
|
|
|
|
) : status === "processing" || status === "pending" ? (
|
|
|
|
|
<Loader2 className="size-3 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<AlertTriangle className="size-3" />
|
|
|
|
|
);
|
2026-08-16 00:11:00 +07:00
|
|
|
const label =
|
|
|
|
|
durationMs && durationMs > 0
|
|
|
|
|
? `${status} · ${formatDuration(durationMs)}`
|
|
|
|
|
: status;
|
|
|
|
|
|
2026-08-15 20:03:55 +07:00
|
|
|
return (
|
|
|
|
|
<Badge tone={tone} dot={status === "processing" || status === "pending"}>
|
|
|
|
|
{icon}
|
2026-08-16 00:11:00 +07:00
|
|
|
{label}
|
2026-08-15 20:03:55 +07:00
|
|
|
</Badge>
|
|
|
|
|
);
|
2026-08-15 17:53:48 +07:00
|
|
|
}
|
|
|
|
|
|
2026-08-15 20:03:55 +07:00
|
|
|
function MessageDetail({
|
|
|
|
|
m,
|
|
|
|
|
attachments,
|
|
|
|
|
}: {
|
|
|
|
|
m: MessageRecord;
|
|
|
|
|
attachments: import("@/lib/types").AttachmentRecord[];
|
|
|
|
|
}) {
|
2026-08-15 17:53:48 +07:00
|
|
|
const flags = safeParseJsonArray(m.ai_moderation_flags);
|
|
|
|
|
const cats = safeParseJsonArray(m.ai_categories);
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3 text-sm">
|
|
|
|
|
<div className="flex items-center gap-3">
|
2026-08-24 15:35:51 +07:00
|
|
|
<Avatar src={m.avatar_url} name={m.username} size={36} />
|
2026-08-15 17:53:48 +07:00
|
|
|
<div>
|
2026-08-24 15:35:51 +07:00
|
|
|
<div className="font-semibold text-[var(--color-ink)]">
|
|
|
|
|
{m.username}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
2026-08-18 09:05:12 +07:00
|
|
|
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
|
2026-08-15 20:03:55 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="ml-auto">
|
2026-08-16 00:11:00 +07:00
|
|
|
<AiBadge
|
|
|
|
|
status={m.ai_status}
|
|
|
|
|
durationMs={m.ai_analysis_duration_ms}
|
|
|
|
|
/>
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
<div className="rounded-xl border border-[var(--color-hairline)] p-3 text-[var(--color-ink-soft)]">
|
2026-08-15 20:03:55 +07:00
|
|
|
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
|
|
|
|
"(no text)"}
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
{m.ai_analysis ? (
|
2026-08-15 17:53:48 +07:00
|
|
|
<div>
|
|
|
|
|
<div className="eyebrow mb-1">AI analysis</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
<div className="rounded-xl border border-[var(--color-hairline)] p-3 text-[var(--color-ink-soft)]">
|
2026-08-15 20:03:55 +07:00
|
|
|
{m.ai_analysis}
|
|
|
|
|
</div>
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
) : null}
|
2026-08-15 17:53:48 +07:00
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
{flags.length > 0 || cats.length > 0 ? (
|
2026-08-15 17:53:48 +07:00
|
|
|
<div className="flex flex-wrap gap-1.5">
|
2026-08-15 20:03:55 +07:00
|
|
|
{flags.map((f) => (
|
|
|
|
|
<Badge key={f} tone="vermilion">
|
|
|
|
|
{f}
|
|
|
|
|
</Badge>
|
|
|
|
|
))}
|
|
|
|
|
{cats.map((c) => (
|
|
|
|
|
<Badge key={c} tone="amber">
|
|
|
|
|
{c}
|
|
|
|
|
</Badge>
|
|
|
|
|
))}
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
) : null}
|
2026-08-15 17:53:48 +07:00
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
{attachments.length > 0 ? (
|
2026-08-15 17:53:48 +07:00
|
|
|
<div>
|
2026-08-15 20:03:55 +07:00
|
|
|
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
|
|
|
|
<Paperclip className="size-3" /> Attachments ({attachments.length})
|
|
|
|
|
</div>
|
2026-08-15 17:53:48 +07:00
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{attachments.map((a) => (
|
2026-08-15 20:03:55 +07:00
|
|
|
<a
|
|
|
|
|
key={a.id}
|
|
|
|
|
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
2026-08-24 15:35:51 +07:00
|
|
|
className="flex items-center gap-2 rounded-xl border border-[var(--color-hairline)] px-3 py-2 font-mono text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
2026-08-15 20:03:55 +07:00
|
|
|
>
|
2026-08-15 17:53:48 +07:00
|
|
|
<ImageIcon className="size-3.5 text-signal" />
|
|
|
|
|
<span className="flex-1 truncate">{a.filename}</span>
|
2026-08-24 15:35:51 +07:00
|
|
|
<span className="text-[var(--color-ink-faint)]">
|
2026-08-15 20:03:55 +07:00
|
|
|
{formatBytes(a.size)}
|
|
|
|
|
</span>
|
2026-08-15 17:53:48 +07:00
|
|
|
</a>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
) : null}
|
2026-08-07 10:44:03 +07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-18 17:43:02 +07:00
|
|
|
|
|
|
|
|
function MessageRow({
|
|
|
|
|
m,
|
|
|
|
|
selected,
|
|
|
|
|
onSelect,
|
|
|
|
|
}: {
|
|
|
|
|
m: MessageRecord;
|
|
|
|
|
selected: string | null;
|
|
|
|
|
onSelect: (id: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onSelect(m.id)}
|
2026-08-24 15:35:51 +07:00
|
|
|
className={`animate-stagger flex w-full items-start gap-3 rounded-xl border p-2.5 text-left transition-colors ${
|
2026-08-18 17:43:02 +07:00
|
|
|
selected === m.id
|
|
|
|
|
? "border-signal/40 bg-signal/8"
|
2026-08-24 15:35:51 +07:00
|
|
|
: "border-[var(--color-hairline)] hover:bg-white/[0.04]"
|
2026-08-18 17:43:02 +07:00
|
|
|
}`}
|
|
|
|
|
>
|
2026-08-24 15:35:51 +07:00
|
|
|
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
2026-08-18 17:43:02 +07:00
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-08-24 15:35:51 +07:00
|
|
|
<span className="truncate text-sm font-semibold text-[var(--color-ink)]">
|
2026-08-18 17:43:02 +07:00
|
|
|
{m.username}
|
|
|
|
|
</span>
|
2026-08-24 15:35:51 +07:00
|
|
|
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
2026-08-18 17:43:02 +07:00
|
|
|
{getMessageChannelLabel(m)}
|
|
|
|
|
</span>
|
2026-08-24 15:35:51 +07:00
|
|
|
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
2026-08-18 17:43:02 +07:00
|
|
|
{formatRelativeTime(m.created_at)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
<div className="mt-0.5 line-clamp-2 text-sm text-[var(--color-ink-soft)]">
|
2026-08-18 17:43:02 +07:00
|
|
|
{renderMessageContent(m.content, m.metadata) || (
|
2026-08-24 15:35:51 +07:00
|
|
|
<span className="italic text-[var(--color-ink-faint)]">
|
|
|
|
|
(empty / embed)
|
|
|
|
|
</span>
|
2026-08-18 17:43:02 +07:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<AiBadge status={m.ai_status} durationMs={m.ai_analysis_duration_ms} />
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|