Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29baba3a72 | ||
|
|
217ecc1aa1 | ||
|
|
0cb0b82fb1 |
No files matched your search
@@ -172,6 +172,71 @@ export class MessagesRepository {
|
|||||||
return { data, nextCursor };
|
return { data, nextCursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async generator that yields messages ONE AT A TIME for WS streaming.
|
||||||
|
* Each `.next()` runs its own bounded DB query (limit+1) advancing on the
|
||||||
|
* `created_at` cursor, so memory stays flat and the caller can emit one WS
|
||||||
|
* frame per message (no 50-row batch). Stops when a page returns < limit.
|
||||||
|
*/
|
||||||
|
async *streamMany(
|
||||||
|
query: MessageQuery,
|
||||||
|
pageSize = 50,
|
||||||
|
): AsyncGenerator<ReturnType<typeof mapMessageRow>, void, unknown> {
|
||||||
|
const conditions: SQL[] = [];
|
||||||
|
|
||||||
|
if (query.guildId) {
|
||||||
|
conditions.push(eq(pgMessagesTable.guild_id, query.guildId));
|
||||||
|
}
|
||||||
|
if (query.channelId) {
|
||||||
|
conditions.push(eq(pgMessagesTable.channel_id, query.channelId));
|
||||||
|
}
|
||||||
|
if (query.userId) {
|
||||||
|
conditions.push(eq(pgMessagesTable.user_id, query.userId));
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
conditions.push(eq(pgMessagesTable.ai_status, query.status));
|
||||||
|
}
|
||||||
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||||
|
const excludeThreads = or(
|
||||||
|
isNull(pgMessagesTable.thread_id),
|
||||||
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
|
);
|
||||||
|
if (excludeThreads) conditions.push(excludeThreads);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
let cursor: string | undefined = query.cursor;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const pageConditions = where ? [where] : [];
|
||||||
|
if (cursor) {
|
||||||
|
pageConditions.push(lt(pgMessagesTable.created_at, Number(cursor)));
|
||||||
|
}
|
||||||
|
const pageWhere =
|
||||||
|
pageConditions.length > 0 ? and(...pageConditions) : undefined;
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(pageWhere)
|
||||||
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(pageSize + 1);
|
||||||
|
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
const hasMore = rows.length > pageSize;
|
||||||
|
const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
|
||||||
|
|
||||||
|
for (const r of pageRows) {
|
||||||
|
yield mapMessageRow(r as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMore) return;
|
||||||
|
cursor = String(rows[pageSize - 1].created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: MessageCreate) {
|
async create(data: MessageCreate) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export class MessagesService {
|
|||||||
return messagesRepository.findMany(query);
|
return messagesRepository.findMany(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream messages one at a time (no 50-row batch). The WS handler iterates
|
||||||
|
* this generator and emits one `message_snapshot` frame per message.
|
||||||
|
*/
|
||||||
|
streamMessages(query: MessageQuery, pageSize = 50) {
|
||||||
|
return messagesRepository.streamMany(query, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
async getMessagesByChannel(channelId: string, query: MessageQuery) {
|
async getMessagesByChannel(channelId: string, query: MessageQuery) {
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
throw new ValidationError("channelId is required");
|
throw new ValidationError("channelId is required");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { IncomingMessage, Server } from "node:http";
|
import type { IncomingMessage, Server } from "node:http";
|
||||||
import type { Duplex } from "node:stream";
|
import type { Duplex } from "node:stream";
|
||||||
import { WebSocket, WebSocketServer } from "ws";
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
|
import { messagesService } from "../modules/messages/messages.service.js";
|
||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
|
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
|
||||||
import { createChildLogger } from "../shared/logger/index.js";
|
import { createChildLogger } from "../shared/logger/index.js";
|
||||||
@@ -140,6 +141,73 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stream historical messages one-by-one over WS (no 50-row batch).
|
||||||
|
// The frontend requests it once per channel switch; the backend emits one
|
||||||
|
// `message_snapshot` frame per message so the UI renders progressively.
|
||||||
|
jsonHandlers.set("stream_messages", async (ws, message) => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const payload = (message.payload ?? {}) as {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
const guildId = payload.guildId;
|
||||||
|
const channelId = payload.channelId;
|
||||||
|
if (!guildId && !channelId) {
|
||||||
|
logger.warn({ payload }, "stream_messages requires guildId or channelId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = 50; // internal DB page size; still emitted one frame at a time
|
||||||
|
const maxFrames = Math.min(payload.limit ?? 200, 500);
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let nextCursor: string | null = null;
|
||||||
|
try {
|
||||||
|
for await (const msg of messagesService.streamMessages(
|
||||||
|
{
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
cursor: payload.cursor,
|
||||||
|
} as never,
|
||||||
|
pageSize,
|
||||||
|
)) {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) break;
|
||||||
|
// Streamed DESC (newest first); the oldest emitted carries the smallest
|
||||||
|
// created_at, which is exactly the next-page cursor for "load older".
|
||||||
|
const createdAt = (msg as { created_at?: number }).created_at;
|
||||||
|
if (createdAt !== undefined) nextCursor = String(createdAt);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "message_snapshot",
|
||||||
|
data: msg,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sent++;
|
||||||
|
if (sent >= maxFrames) break;
|
||||||
|
}
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "message_snapshot_end",
|
||||||
|
data: { sent, nextCursor },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "stream_messages failed");
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "message_snapshot_end",
|
||||||
|
data: { sent, nextCursor, error: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
wss.on("connection", (ws: WebSocket, req) => {
|
wss.on("connection", (ws: WebSocket, req) => {
|
||||||
// Parse auth token from query string
|
// Parse auth token from query string
|
||||||
const rawUrl = req.url ?? "/";
|
const rawUrl = req.url ?? "/";
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock the contract that the WS `stream_messages` handler + frontend
|
||||||
|
* `useMessagesStream` depend on.
|
||||||
|
*
|
||||||
|
* Real behavior (src/modules/messages/messages.repository.ts → streamMany, and
|
||||||
|
* src/ws/server.ts stream_messages handler):
|
||||||
|
* - ONE `stream_messages` request streams the WHOLE history for the scope,
|
||||||
|
* internally paging `limit+1` at a time (cursor = oldest created_at of the
|
||||||
|
* page) until exhausted or maxFrames is hit.
|
||||||
|
* - Messages are emitted ONE AT A TIME, DESC (newest first).
|
||||||
|
* - The final `message_snapshot_end` carries `nextCursor` = the OLDEST emitted
|
||||||
|
* row's `created_at`, so the FE's next "load older" request pages forward.
|
||||||
|
*
|
||||||
|
* We replicate streamMany's pagination algorithm over an in-memory array so the
|
||||||
|
* test needs no DB.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Row = { id: string; created_at: number; guild_id: string };
|
||||||
|
|
||||||
|
function makeStream(
|
||||||
|
rows: Row[],
|
||||||
|
query: { guildId?: string; channelId?: string; cursor?: string },
|
||||||
|
pageSize = 50,
|
||||||
|
): () => Generator<Row, void, unknown> {
|
||||||
|
return function* () {
|
||||||
|
let cursor = query.cursor;
|
||||||
|
while (true) {
|
||||||
|
const page = rows
|
||||||
|
.filter((r) => (query.guildId ? r.guild_id === query.guildId : true))
|
||||||
|
.filter((r) => (cursor ? r.created_at < Number(cursor) : true))
|
||||||
|
.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
.slice(0, pageSize + 1);
|
||||||
|
|
||||||
|
if (page.length === 0) return;
|
||||||
|
const hasMore = page.length > pageSize;
|
||||||
|
const pageRows = hasMore ? page.slice(0, pageSize) : page;
|
||||||
|
for (const r of pageRows) yield r;
|
||||||
|
if (!hasMore) return;
|
||||||
|
cursor = String(page[pageSize - 1].created_at);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamAll(
|
||||||
|
rows: Row[],
|
||||||
|
query: { guildId?: string; channelId?: string; cursor?: string },
|
||||||
|
pageSize = 50,
|
||||||
|
maxFrames = Infinity,
|
||||||
|
): { data: Row[]; nextCursor: string | null } {
|
||||||
|
const data: Row[] = [];
|
||||||
|
let nextCursor: string | null = null;
|
||||||
|
for (const r of makeStream(rows, query, pageSize)()) {
|
||||||
|
nextCursor = String(r.created_at);
|
||||||
|
data.push(r);
|
||||||
|
if (data.length >= maxFrames) break;
|
||||||
|
}
|
||||||
|
return { data, nextCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mk = (id: string, created_at: number, guild_id = "g1"): Row => ({
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
guild_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("messages.streamMany contract", () => {
|
||||||
|
it("emits newest-first and sets nextCursor to oldest created_at", () => {
|
||||||
|
const rows = [mk("a", 300), mk("b", 200), mk("c", 100)];
|
||||||
|
const { data, nextCursor } = streamAll(rows, { guildId: "g1" });
|
||||||
|
expect(data.map((r) => r.id)).toEqual(["a", "b", "c"]);
|
||||||
|
expect(nextCursor).toBe("100"); // oldest emitted
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams the entire history in one request, one frame at a time", () => {
|
||||||
|
// 120 rows; one request must yield all 120 (no 50-row batch boundary).
|
||||||
|
const rows = Array.from({ length: 120 }, (_, i) => mk(`m${i}`, 1000 - i));
|
||||||
|
const { data, nextCursor } = streamAll(rows, { guildId: "g1" }, 50);
|
||||||
|
expect(data).toHaveLength(120);
|
||||||
|
expect(data[0].id).toBe("m0"); // newest first
|
||||||
|
expect(nextCursor).toBe("881"); // oldest = m119 (1000-119)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a frame cap and leaves nextCursor mid-history", () => {
|
||||||
|
const rows = Array.from({ length: 120 }, (_, i) => mk(`m${i}`, 1000 - i));
|
||||||
|
const { data, nextCursor } = streamAll(rows, { guildId: "g1" }, 50, 60);
|
||||||
|
expect(data).toHaveLength(60);
|
||||||
|
// nextCursor = 60th oldest = m59 (1000-59=941)
|
||||||
|
expect(nextCursor).toBe("941");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paginates correctly across subsequent load-older requests", () => {
|
||||||
|
const rows = Array.from({ length: 120 }, (_, i) => mk(`m${i}`, 1000 - i));
|
||||||
|
const first = streamAll(rows, { guildId: "g1" }, 50, 50);
|
||||||
|
expect(first.data).toHaveLength(50);
|
||||||
|
expect(first.nextCursor).toBe("951"); // 50th oldest = m49
|
||||||
|
|
||||||
|
const older = streamAll(
|
||||||
|
rows,
|
||||||
|
{ guildId: "g1", cursor: first.nextCursor ?? undefined },
|
||||||
|
50,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
expect(older.data[0].id).toBe("m50"); // continues right after m49
|
||||||
|
expect(older.nextCursor).toBe("901"); // 100th oldest
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by guild", () => {
|
||||||
|
const rows = [mk("x", 500, "g1"), mk("y", 400, "g2")];
|
||||||
|
const { data } = streamAll(rows, { guildId: "g2" });
|
||||||
|
expect(data.map((r) => r.id)).toEqual(["y"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
import type { DashboardStats } from "@/lib/types";
|
import type { DashboardStats } from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
|
|
||||||
function deriveSignal(stats?: DashboardStats) {
|
function deriveSignal(stats?: DashboardStats) {
|
||||||
if (!stats) return { tone: "signal" as const, label: "nominal" };
|
if (!stats) return { tone: "signal" as const, label: "nominal" };
|
||||||
@@ -113,23 +114,31 @@ export function DashboardView({
|
|||||||
value={formatNumber(s.total_messages)}
|
value={formatNumber(s.total_messages)}
|
||||||
tone="signal"
|
tone="signal"
|
||||||
icon={<MessageSquare className="size-3.5" />}
|
icon={<MessageSquare className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(0)}
|
||||||
/>
|
/>
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Flagged"
|
label="Flagged"
|
||||||
value={formatNumber(s.total_flagged)}
|
value={formatNumber(s.total_flagged)}
|
||||||
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
|
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
|
||||||
hint={`${s.today_flagged} today`}
|
hint={`${s.today_flagged} today`}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(1)}
|
||||||
/>
|
/>
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Active 24h"
|
label="Active 24h"
|
||||||
value={formatNumber(s.active_users_24h)}
|
value={formatNumber(s.active_users_24h)}
|
||||||
tone="signal"
|
tone="signal"
|
||||||
icon={<Users className="size-3.5" />}
|
icon={<Users className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(2)}
|
||||||
/>
|
/>
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Voice clips"
|
label="Voice clips"
|
||||||
value={formatNumber(s.total_voice_recordings)}
|
value={formatNumber(s.total_voice_recordings)}
|
||||||
icon={<Mic className="size-3.5" />}
|
icon={<Mic className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(3)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { formatDuration } from "@/lib/format";
|
import { formatDuration } from "@/lib/format";
|
||||||
import type { MediaState } from "@/lib/types";
|
import type { MediaState } from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||||
@@ -205,7 +206,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
{queueList.map((item, i) => (
|
{queueList.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.source}-${i}`}
|
key={`${item.source}-${i}`}
|
||||||
className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
|
className="animate-stagger flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
|
||||||
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PageTransition } from "@/components/shared";
|
import { PageTransition } from "@/components/shared";
|
||||||
import { getConfig, getGuilds } from "@/lib/api/server";
|
import { getConfig, getGuilds, getMessages } from "@/lib/api/server";
|
||||||
import { MessagesView } from "./view";
|
import { MessagesView } from "./view";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -7,8 +7,16 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function MessagesPage() {
|
export default async function MessagesPage() {
|
||||||
let config: import("@/lib/types/guild").AppConfig | undefined;
|
let config: import("@/lib/types/guild").AppConfig | undefined;
|
||||||
let guilds: import("@/lib/types").Guild[] | undefined;
|
let guilds: import("@/lib/types").Guild[] | undefined;
|
||||||
|
let initialMessages: {
|
||||||
|
data: import("@/lib/types").MessageRecord[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
} | null = null;
|
||||||
try {
|
try {
|
||||||
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
|
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
|
||||||
|
const gid = config?.monitorGuildId;
|
||||||
|
if (gid) {
|
||||||
|
initialMessages = await getMessages(gid, undefined, 50);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* client hooks surface errors */
|
/* client hooks surface errors */
|
||||||
}
|
}
|
||||||
@@ -17,6 +25,7 @@ export default async function MessagesPage() {
|
|||||||
<MessagesView
|
<MessagesView
|
||||||
initialGuilds={guilds}
|
initialGuilds={guilds}
|
||||||
initialGuildId={config?.monitorGuildId ?? null}
|
initialGuildId={config?.monitorGuildId ?? null}
|
||||||
|
initialMessages={initialMessages}
|
||||||
/>
|
/>
|
||||||
</PageTransition>
|
</PageTransition>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
useMessageSearch,
|
useMessageSearch,
|
||||||
useMessages,
|
useMessages,
|
||||||
useMessagesHasMore,
|
useMessagesHasMore,
|
||||||
|
useMessagesStream,
|
||||||
useMessagesWsSync,
|
useMessagesWsSync,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
@@ -44,14 +45,20 @@ import {
|
|||||||
safeParseJsonArray,
|
safeParseJsonArray,
|
||||||
} from "@/lib/format";
|
} from "@/lib/format";
|
||||||
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
|
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
export function MessagesView({
|
export function MessagesView({
|
||||||
initialGuilds,
|
initialGuilds,
|
||||||
initialGuildId,
|
initialGuildId,
|
||||||
|
initialMessages,
|
||||||
}: {
|
}: {
|
||||||
initialGuilds?: Guild[];
|
initialGuilds?: Guild[];
|
||||||
initialGuildId?: string | null;
|
initialGuildId?: string | null;
|
||||||
|
initialMessages?: {
|
||||||
|
data: MessageRecord[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
} | null;
|
||||||
}) {
|
}) {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const [guildId, setGuildId] = useState<string | null>(
|
const [guildId, setGuildId] = useState<string | null>(
|
||||||
@@ -69,7 +76,15 @@ export function MessagesView({
|
|||||||
data: messages,
|
data: messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
} = useMessages(guildId ?? "", channelId ?? undefined);
|
} = useMessages(
|
||||||
|
guildId ?? "",
|
||||||
|
channelId ?? undefined,
|
||||||
|
initialMessages ?? undefined,
|
||||||
|
);
|
||||||
|
// Stream history one message per WS frame (replaces the 50-row batched fetch).
|
||||||
|
// Drives snapshots into the SWR list above as they arrive; falls back to the
|
||||||
|
// SSR `initialMessages` seed if WS is unavailable.
|
||||||
|
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
|
||||||
// Cursor to the next (older) page + whether more history exists.
|
// Cursor to the next (older) page + whether more history exists.
|
||||||
const { data: pageInfo } = useMessagesHasMore(
|
const { data: pageInfo } = useMessagesHasMore(
|
||||||
guildId ?? "",
|
guildId ?? "",
|
||||||
@@ -240,16 +255,17 @@ export function MessagesView({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{display.map((m) => (
|
{display.map((m, i) => (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelected(m.id)}
|
onClick={() => setSelected(m.id)}
|
||||||
className={`flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||||
selected === m.id
|
selected === m.id
|
||||||
? "border-signal/40 bg-signal/8"
|
? "border-signal/40 bg-signal/8"
|
||||||
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
ModerationActionType,
|
ModerationActionType,
|
||||||
ModerationStats,
|
ModerationStats,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
|
|
||||||
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
||||||
delete_message: <Trash2 className="size-3.5" />,
|
delete_message: <Trash2 className="size-3.5" />,
|
||||||
@@ -217,8 +218,8 @@ export function ModerationView({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||||
{(actions ?? []).map((a) => (
|
{(actions ?? []).map((a, i) => (
|
||||||
<ActionRow key={a.id} a={a} />
|
<ActionRow key={a.id} a={a} index={i} />
|
||||||
))}
|
))}
|
||||||
{(actions ?? []).length === 0 && (
|
{(actions ?? []).length === 0 && (
|
||||||
<div className="py-10 text-center text-xs text-ink-faint">
|
<div className="py-10 text-center text-xs text-ink-faint">
|
||||||
@@ -232,7 +233,7 @@ export function ModerationView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionRow({ a }: { a: ModerationAction }) {
|
function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
||||||
const tone =
|
const tone =
|
||||||
a.status === "executed"
|
a.status === "executed"
|
||||||
? "signal"
|
? "signal"
|
||||||
@@ -243,7 +244,10 @@ function ActionRow({ a }: { a: ModerationAction }) {
|
|||||||
<AlertTriangle className="size-3.5" />
|
<AlertTriangle className="size-3.5" />
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3">
|
<div
|
||||||
|
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||||
|
style={staggerDelay(index)}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
||||||
import type { VoiceRecording } from "@/lib/types";
|
import type { VoiceRecording } from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
export function RecordingsView({
|
export function RecordingsView({
|
||||||
@@ -94,12 +95,13 @@ export function RecordingsView({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{(items ?? []).map((r) => {
|
{(items ?? []).map((r, i) => {
|
||||||
const up = uploadStatus(r);
|
const up = uploadStatus(r);
|
||||||
return (
|
return (
|
||||||
<GlassCard
|
<GlassCard
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className="flex flex-col gap-3 transition-colors hover:bg-white/[0.06]"
|
className="animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06]"
|
||||||
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
||||||
|
|||||||
@@ -280,6 +280,25 @@
|
|||||||
.animate-fade-up {
|
.animate-fade-up {
|
||||||
animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
}
|
}
|
||||||
|
/* List stagger: each child rises in sequence. Pair with inline
|
||||||
|
style={{ animationDelay }} set via staggerDelay(). */
|
||||||
|
.animate-stagger {
|
||||||
|
animation: fade-up 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
/* Toast: slide up + settle from the dock edge. */
|
||||||
|
.animate-toast-in {
|
||||||
|
animation: toast-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes toast-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.scan-line::after,
|
.scan-line::after,
|
||||||
@@ -289,7 +308,9 @@
|
|||||||
.animate-pulse-ring,
|
.animate-pulse-ring,
|
||||||
.animate-shimmer,
|
.animate-shimmer,
|
||||||
.animate-breathe,
|
.animate-breathe,
|
||||||
.animate-fade-up {
|
.animate-fade-up,
|
||||||
|
.animate-stagger,
|
||||||
|
.animate-toast-in {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
.scan-line {
|
.scan-line {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const Button = ({
|
|||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap transition-all duration-150 select-none",
|
"inline-flex items-center justify-center whitespace-nowrap transition-all duration-150 select-none active:scale-[0.97]",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal/60 disabled:opacity-40 disabled:pointer-events-none",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal/60 disabled:opacity-40 disabled:pointer-events-none",
|
||||||
variants[variant],
|
variants[variant],
|
||||||
sizes[size],
|
sizes[size],
|
||||||
|
|||||||
@@ -74,8 +74,11 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className="glass pointer-events-auto flex items-start gap-3 p-3.5"
|
className={cn(
|
||||||
style={{ animation: "fade-up 0.18s ease" }}
|
"glass animate-toast-in pointer-events-auto flex items-start gap-3 p-3.5 transition-colors",
|
||||||
|
t.tone === "signal" && "border-signal/30",
|
||||||
|
t.tone === "vermilion" && "border-vermilion/30",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -95,11 +98,12 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Dismiss"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
store = store.filter((x) => x.id !== t.id);
|
store = store.filter((x) => x.id !== t.id);
|
||||||
emit();
|
emit();
|
||||||
}}
|
}}
|
||||||
className="text-ink-faint hover:text-ink"
|
className="rounded-md p-1 text-ink-faint transition-colors hover:bg-white/8 hover:text-ink"
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function MetricTile({
|
|||||||
spark,
|
spark,
|
||||||
icon,
|
icon,
|
||||||
className,
|
className,
|
||||||
|
style,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: React.ReactNode;
|
value: React.ReactNode;
|
||||||
@@ -45,6 +46,7 @@ export function MetricTile({
|
|||||||
spark?: number[];
|
spark?: number[];
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}) {
|
}) {
|
||||||
const toneColor =
|
const toneColor =
|
||||||
tone === "vermilion"
|
tone === "vermilion"
|
||||||
@@ -60,6 +62,7 @@ export function MetricTile({
|
|||||||
"glass p-4 ring-focus transition-transform duration-200 hover:-translate-y-0.5",
|
"glass p-4 ring-focus transition-transform duration-200 hover:-translate-y-0.5",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="eyebrow">{label}</div>
|
<div className="eyebrow">{label}</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export {
|
|||||||
useMessageSearch,
|
useMessageSearch,
|
||||||
useMessages,
|
useMessages,
|
||||||
useMessagesHasMore,
|
useMessagesHasMore,
|
||||||
|
useMessagesStream,
|
||||||
useMessagesWsSync,
|
useMessagesWsSync,
|
||||||
useReview,
|
useReview,
|
||||||
useTextChannels,
|
useTextChannels,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import useSWR, { useSWRConfig } from "swr";
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
import { useAction } from "@/hooks/use-action";
|
import { useAction } from "@/hooks/use-action";
|
||||||
import { messagesApi, voiceApi } from "@/lib/api";
|
import { messagesApi, voiceApi } from "@/lib/api";
|
||||||
@@ -275,3 +275,82 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
|
|||||||
};
|
};
|
||||||
}, [ws, guildId, mutate]);
|
}, [ws, guildId, mutate]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a channel/guild history ONE message per WS frame (no 50-row batch).
|
||||||
|
* Calls the backend `stream_messages` handler and accumulates each incoming
|
||||||
|
* `message_snapshot` into the SWR list as it arrives, so the UI renders
|
||||||
|
* progressively. Falls back to the batched `messagesApi.list` if WS is down.
|
||||||
|
*
|
||||||
|
* Returns: { streaming, streamed, error }.
|
||||||
|
*/
|
||||||
|
export function useMessagesStream(
|
||||||
|
ws: WsHook,
|
||||||
|
guildId: string | null,
|
||||||
|
channelId?: string | null,
|
||||||
|
) {
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
const [streaming, setStreaming] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!guildId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const key = msgKeys.list(guildId, channelId ?? undefined);
|
||||||
|
const unsubSnap = ws.on("message_snapshot", (data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const msg = data as MessageRecord;
|
||||||
|
if (channelId && msg.channel_id !== channelId) return;
|
||||||
|
if (!channelId && msg.guild_id && msg.guild_id !== guildId) return;
|
||||||
|
void mutate(
|
||||||
|
key,
|
||||||
|
(old: MessagePage | undefined): MessagePage => {
|
||||||
|
const data2 = old?.data ?? [];
|
||||||
|
if (data2.some((m) => m.id === msg.id))
|
||||||
|
return old ?? { data: [], nextCursor: null };
|
||||||
|
return { data: [msg, ...data2], nextCursor: old?.nextCursor ?? null };
|
||||||
|
},
|
||||||
|
{ revalidate: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const unsubEnd = ws.on("message_snapshot_end", (data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const end = data as {
|
||||||
|
sent: number;
|
||||||
|
nextCursor: string | null;
|
||||||
|
error?: boolean;
|
||||||
|
};
|
||||||
|
setStreaming(false);
|
||||||
|
setError(Boolean(end.error));
|
||||||
|
// Persist the next-page cursor so "load older" still works after streaming.
|
||||||
|
if (end.nextCursor) {
|
||||||
|
void mutate(
|
||||||
|
key,
|
||||||
|
(old: MessagePage | undefined): MessagePage =>
|
||||||
|
old
|
||||||
|
? { ...old, nextCursor: end.nextCursor }
|
||||||
|
: { data: [], nextCursor: end.nextCursor },
|
||||||
|
{ revalidate: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setStreaming(true);
|
||||||
|
setError(false);
|
||||||
|
ws.sendText(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "stream_messages",
|
||||||
|
payload: { guildId, channelId: channelId ?? undefined, limit: 200 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubSnap();
|
||||||
|
unsubEnd();
|
||||||
|
};
|
||||||
|
}, [ws, guildId, channelId, mutate]);
|
||||||
|
|
||||||
|
return { streaming, error };
|
||||||
|
}
|
||||||
@@ -99,3 +99,26 @@ export async function getRecordings(limit = 50): Promise<PaginatedRecordings> {
|
|||||||
limit,
|
limit,
|
||||||
}) as unknown as Promise<PaginatedRecordings>;
|
}) as unknown as Promise<PaginatedRecordings>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Messages (SSR seed for the streaming view) ----
|
||||||
|
// Used to seed the first paint so the feed isn't blank before the WS stream
|
||||||
|
// arrives. The client then takes over and streams the rest one frame at a time.
|
||||||
|
export async function getMessages(
|
||||||
|
guildId: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 50,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<{
|
||||||
|
data: import("@/lib/types").MessageRecord[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
}> {
|
||||||
|
return serverOrpc().messages.list({
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
limit,
|
||||||
|
cursor,
|
||||||
|
}) as unknown as Promise<{
|
||||||
|
data: import("@/lib/types").MessageRecord[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -4,3 +4,16 @@ import { twMerge } from "tailwind-merge";
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an inline style that staggers a list item's entrance animation.
|
||||||
|
* Pair with the `animate-stagger` class. Caps the delay so long lists still
|
||||||
|
* appear promptly.
|
||||||
|
*/
|
||||||
|
export function staggerDelay(
|
||||||
|
index: number,
|
||||||
|
step = 45,
|
||||||
|
max = 600,
|
||||||
|
): React.CSSProperties {
|
||||||
|
return { animationDelay: `${Math.min(index * step, max)}ms` };
|
||||||
|
}
|
||||||
@@ -5,4 +5,5 @@ export type WsHook = {
|
|||||||
eventType: E,
|
eventType: E,
|
||||||
handler: (data: unknown) => void,
|
handler: (data: unknown) => void,
|
||||||
) => () => void;
|
) => () => void;
|
||||||
|
sendText: (text: string) => void;
|
||||||
};
|
};
|
||||||
@@ -36,6 +36,13 @@ export interface WsEventMap {
|
|||||||
/** Gateway emits { id, deleted_at } — NOT a bare string */
|
/** Gateway emits { id, deleted_at } — NOT a bare string */
|
||||||
message_deleted: { id: string; deleted_at?: number };
|
message_deleted: { id: string; deleted_at?: number };
|
||||||
message_analyzed: MessageRecord;
|
message_analyzed: MessageRecord;
|
||||||
|
/**
|
||||||
|
* Streamed history frame — one MessageRecord per WS message (replaces the old
|
||||||
|
* 50-row batched `messages.list` fetch on the client). The view accumulates
|
||||||
|
* these into the SWR list as they arrive. `message_snapshot_end` signals done.
|
||||||
|
*/
|
||||||
|
message_snapshot: MessageRecord;
|
||||||
|
message_snapshot_end: { sent: number; error?: boolean };
|
||||||
attachment_created: unknown;
|
attachment_created: unknown;
|
||||||
attachment_uploaded: unknown;
|
attachment_uploaded: unknown;
|
||||||
voice_recording_started: unknown;
|
voice_recording_started: unknown;
|
||||||
|
|||||||
Reference in new issue
Block a user