diff --git a/flake.nix b/flake.nix index b614589..845483c 100644 --- a/flake.nix +++ b/flake.nix @@ -223,7 +223,7 @@ WRAPPER }; }; - # ---- Frontend (Next.js static export) ---- + # ---- Frontend (Next.js SSR standalone) ---- frontend = pkgs.stdenv.mkDerivation { pname = "gmw-frontend"; version = "1.0.0"; @@ -233,29 +233,40 @@ WRAPPER nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ]; buildPhase = pnpmInstall + '' - echo "=== Building Next.js static export ===" - # Build args are provided as env vars + echo "=== Building Next.js SSR (standalone) ===" export NEXT_TELEMETRY_DISABLED=1 + export GMW_BACKEND_URL=http://127.0.0.1:4001 npx next build 2>&1 ''; installPhase = '' - mkdir -p $out/share/gmw-frontend - cp -r out $out/share/gmw-frontend/out 2>/dev/null || \ - cp -r dist $out/share/gmw-frontend/dist 2>/dev/null || \ - cp -r .next $out/share/gmw-frontend/.next 2>/dev/null || true + echo "=== Packaging standalone server ===" + mkdir -p $out/lib/gmw-frontend/standalone + # The standalone server bundles its own minimal node_modules but + # needs the build assets + public copied INSIDE its tree. + cp -r .next/standalone/. $out/lib/gmw-frontend/standalone/ + mkdir -p $out/lib/gmw-frontend/standalone/.next + cp -r .next/static $out/lib/gmw-frontend/standalone/.next/static + cp -r public $out/lib/gmw-frontend/standalone/public 2>/dev/null || true - # Copy node_modules for standalone mode if it exists - cp -r node_modules $out/share/gmw-frontend/ 2>/dev/null || true + mkdir -p $out/bin + cat > $out/bin/gmw-frontend << WRAPPER +#!${pkgs.runtimeShell} +cd $out/lib/gmw-frontend/standalone +export PORT=''${GMW_FRONTEND_PORT:-4017} +export HOSTNAME=127.0.0.1 +exec ${nodejs}/bin/node server.js +WRAPPER + chmod +x $out/bin/gmw-frontend ''; meta = { - description = "GMW Frontend — Next.js static dashboard"; + description = "GMW Frontend — Next.js SSR dashboard"; platforms = pkgs.lib.platforms.linux; }; }; - # ---- Proxy (nginx serving frontend) ---- + # ---- Proxy (nginx: / -> Next SSR, /api + /ws -> backend) ---- proxy = pkgs.stdenv.mkDerivation { pname = "gmw-proxy"; version = "1.0.0"; @@ -270,11 +281,10 @@ WRAPPER mkdir -p $out/bin $out/etc $out/share # Substitute placeholders in nginx template - sed \ - -e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \ - -e "s|@FRONTEND_ROOT@|${frontend}/share/gmw-frontend/out|g" \ - ${./infra/nix/nginx.conf.template} \ - > $out/etc/nginx.conf + sed -e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \ + -e "s|@NEXT_PORT@|4017|g" \ + ${./infra/nix/nginx.conf.template} \ + > $out/etc/nginx.conf cat > $out/bin/gmw-proxy << WRAPPER #!${pkgs.runtimeShell} @@ -284,7 +294,7 @@ WRAPPER ''; meta = { - description = "GMW Proxy — nginx serving frontend"; + description = "GMW Proxy — nginx -> Next.js + backend"; platforms = pkgs.lib.platforms.linux; }; }; diff --git a/infra/nix/nginx.conf.template b/infra/nix/nginx.conf.template index c157bf6..19e1379 100644 --- a/infra/nix/nginx.conf.template +++ b/infra/nix/nginx.conf.template @@ -11,28 +11,44 @@ http { '' close; } + # Next.js standalone SSR server (backend-fetching on every render). + # Not for hand-editing: @NEXT_PORT@ is substituted at build time. + upstream gmw_next { + server 127.0.0.1:@NEXT_PORT@; + keepalive 16; + } + + upstream gmw_backend { + server 127.0.0.1:4001; + keepalive 16; + } + server { listen 4009; server_name _; # Use relative redirects (Location: /dashboard/) instead of absolute - # URLs that leak the internal listen port (4009) through Traefik. + # URLs that leak the internal listen port (4009) through the reverse proxy. absolute_redirect off; gzip on; gzip_types text/plain text/css application/json application/javascript application/wasm image/svg+xml; gzip_min_length 256; + # ── Backend REST ─────────────────────────────────────────────── location ^~ /api { - proxy_pass http://127.0.0.1:4001$uri$is_args$args; + proxy_pass http://gmw_backend$uri$is_args$args; + proxy_http_version 1.1; + proxy_set_header Connection ""; # keepalive to backend proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + # ── Backend WebSocket (realtime shared state + voice PCM) ────── location ^~ /ws { - proxy_pass http://127.0.0.1:4001$uri$is_args$args; + proxy_pass http://gmw_backend$uri$is_args$args; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; @@ -45,16 +61,30 @@ http { proxy_send_timeout 86400s; } - location /assets/ { - root @FRONTEND_ROOT@; + # ── Next.js build assets — immutable, edge/shareable ─────────── + location ^~ /_next/static/ { + proxy_pass http://gmw_next$uri$is_args$args; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; expires 1y; add_header Cache-Control "public, immutable"; } + # ── Everything else → Next.js server (SSR) ── location / { - root @FRONTEND_ROOT@; - index index.html; - try_files $uri $uri/ /index.html; + proxy_pass http://gmw_next$uri$is_args$args; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Next-Prefetch $http_x_next_prefetch; + proxy_buffering off; + proxy_read_timeout 30s; } } -} +} \ No newline at end of file diff --git a/services/backend/src/modules/voice/live-speaker.ts b/services/backend/src/modules/voice/live-speaker.ts new file mode 100644 index 0000000..6cc7206 --- /dev/null +++ b/services/backend/src/modules/voice/live-speaker.ts @@ -0,0 +1,83 @@ +/** + * Authoritative live-voice store. + * + * Single source of truth for who is present / speaking in voice. The backend + * WebSocket server is the one relay every frontend client connects to, so it + * is the correct place to aggregate the gateway's `voice_active_user` deltas + * into a shared snapshot. A late-joining browser must be able to see the same + * state as everyone else — this store makes that possible (seeded into the WS + * initial states and served via GET /api/voice/status). + */ + +export interface LiveSpeaker { + userId: string; + username: string; + avatar?: string | null; + speaking: boolean; + /** Epoch ms of the most recent activity (start OR end of speech). */ + lastActiveAt: number; +} + +const speakers = new Map(); + +const MAX_SPEAKERS = 200; + +/** + * Record a voice_active_user event. `speaking: true` upserts the speaker as + * active; `speaking: false` marks them inactive while keeping them for the + * activity timeline. + */ +/** + * recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true` + * upserts the speaker as ACTIVE; `speaking: false` marks them inactive while + * keeping them for the activity timeline. + */ +export function recordSpeaker(data: { + userId: string; + username?: string; + avatar?: string | null; + speaking: boolean; +}): void { + const { userId, speaking } = data; + const existing = speakers.get(userId); + const speaker: LiveSpeaker = { + userId, + username: data.username ?? existing?.username ?? "Unknown", + avatar: data.avatar ?? existing?.avatar ?? null, + speaking, + lastActiveAt: Date.now(), + }; + + if (speakers.size >= MAX_SPEAKERS && !existing) { + // Drop the least-recently-active non-speaking speaker to stay bounded. + let oldestId: string | null = null; + let oldestTs = Infinity; + for (const [id, s] of speakers) { + if (!s.speaking && s.lastActiveAt < oldestTs) { + oldestTs = s.lastActiveAt; + oldestId = id; + } + } + if (oldestId) speakers.delete(oldestId); + else return; + } + + speakers.set(userId, speaker); +} + +/** All known speakers, most recently active first. */ +export function getActiveSpeakers(): LiveSpeaker[] { + return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt); +} + +/** Only speakers currently flagged as speaking. */ +export function getSpeakingSpeakers(): LiveSpeaker[] { + return [...speakers.values()] + .filter((s) => s.speaking) + .sort((a, b) => b.lastActiveAt - a.lastActiveAt); +} + +/** Drop all tracked speakers (used on backend restart). */ +export function resetLiveSpeakers(): void { + speakers.clear(); +} \ No newline at end of file diff --git a/services/backend/src/modules/voice/voice.service.ts b/services/backend/src/modules/voice/voice.service.ts index 8c0e258..3c3e592 100644 --- a/services/backend/src/modules/voice/voice.service.ts +++ b/services/backend/src/modules/voice/voice.service.ts @@ -15,6 +15,10 @@ import { VOICE_STATUS_KEY, } from "../../shared/index.js"; import { publishCommand, readRedisStatus } from "../../shared/redis/index.js"; +import { + getActiveSpeakers, + type LiveSpeaker, +} from "./live-speaker.js"; const logger = createChildLogger("voice.service"); @@ -45,6 +49,12 @@ export interface VoiceStatus { activeChannelId: string | null; activeChannelName: string | null; connections: GuildVoiceEntry[]; + /** + * Authoritative shared voice snapshot — who is present / speaking right + * now, aggregated server-side from the gateway's `voice_active_user` + * deltas. All browsers converge on this same list. + */ + activeSpeakers: LiveSpeaker[]; } export const DEFAULT_VOICE_STATUS: VoiceStatus = { @@ -53,8 +63,16 @@ export const DEFAULT_VOICE_STATUS: VoiceStatus = { activeChannelId: null, activeChannelName: null, connections: [], + activeSpeakers: [], }; +/** Attach the live speaker snapshot to any voice status payload. */ +function withActiveSpeakers>( + status: T, +): T & { activeSpeakers: LiveSpeaker[] } { + return { ...status, activeSpeakers: getActiveSpeakers() }; +} + /** * Wraps tryCommandThenFallback with a cleaner signature for use within this module. * Attempts a Redis command first; on failure, falls back to the provided function. @@ -68,8 +86,10 @@ async function withFallback( } function readVoiceStatusFallback(): Promise { - return readRedisStatus(VOICE_STATUS_KEY).then( - (cached) => (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS, + return readRedisStatus(VOICE_STATUS_KEY).then((cached) => + withActiveSpeakers( + (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS, + ), ); } @@ -139,7 +159,9 @@ export async function getVoiceChannels(guildId: string): Promise { export async function getVoiceStatus(): Promise { logger.debug("getVoiceStatus called"); const cached = await readRedisStatus(VOICE_STATUS_KEY); - return (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS; + return withActiveSpeakers( + (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS, + ); } /** diff --git a/services/backend/src/ws/redis-bridge.ts b/services/backend/src/ws/redis-bridge.ts index b21e63c..e503f63 100644 --- a/services/backend/src/ws/redis-bridge.ts +++ b/services/backend/src/ws/redis-bridge.ts @@ -2,9 +2,11 @@ import Redis from "ioredis"; import { config } from "../shared/config/index.js"; import { DISCORD_CHANNEL_TO_WS_EVENT, + DISCORD_VOICE_ACTIVE_USER, DISCORD_VOICE_PCM, } from "../shared/index.js"; import { createChildLogger } from "../shared/logger/index.js"; +import { recordSpeaker } from "../modules/voice/live-speaker.js"; import { broadcastBinary, broadcastEvent } from "./broadcast.js"; const logger = createChildLogger("ws.redis-bridge"); @@ -62,6 +64,26 @@ function handleSubscriptionMessage(channel: string, message: string): void { } } + // Aggregate live-voice state authoritatively BEFORE broadcasting. + // Every browser hears the same `voice_active_user` deltas, so the backend + // can maintain the single shared snapshot for late-joining clients. + if (channel === DISCORD_VOICE_ACTIVE_USER) { + const speaker = data as { + userId?: string; + username?: string; + avatar?: string | null; + speaking?: boolean; + }; + if (speaker?.userId) { + recordSpeaker({ + userId: speaker.userId, + username: speaker.username, + avatar: speaker.avatar, + speaking: Boolean(speaker.speaking), + }); + } + } + logger.debug({ channel, eventType }, "Broadcasting Redis event"); broadcastEvent(eventType, data); } diff --git a/services/backend/src/ws/server.ts b/services/backend/src/ws/server.ts index eb96649..6e45023 100644 --- a/services/backend/src/ws/server.ts +++ b/services/backend/src/ws/server.ts @@ -66,6 +66,22 @@ async function sendInitialStates(ws: WebSocket): Promise { } catch (err) { logger.warn({ err }, "Failed to send initial media_state"); } + + // Send initial live-voice snapshot (shared authoritative state — a browser + // joining mid-call sees the same speakers as everyone else, not an empty DB). + try { + const { getActiveSpeakers } = await import( + "../modules/voice/live-speaker.js" + ); + ws.send( + JSON.stringify({ + type: "voice_state", + state: { activeSpeakers: getActiveSpeakers() }, + }), + ); + } catch (err) { + logger.warn({ err }, "Failed to send initial voice_state"); + } } export function closeWebSocketServer(): void { diff --git a/services/frontend/AGENTS.md b/services/frontend/AGENTS.md index c604a01..c1aeb43 100644 --- a/services/frontend/AGENTS.md +++ b/services/frontend/AGENTS.md @@ -3,26 +3,41 @@ Next.js 16 (App Router), React 19, TypeScript strict, Tailwind v4, shadcn/ui, base-ui. Key points: -- **All pages** are `"use client"` — the dashboard is fully client-rendered -- **API client** at `src/lib/api/` — fetch-based, covers all 30+ backend endpoints -- **WebSocket** at `src/lib/ws/` — auto-reconnecting client with typed event subscriptions -- **Static export**: `output: "export"` in next.config.ts, served via nginx +- **Server-side rendered (SSR)** — `output: "standalone"` in next.config.ts; pages + are React Server Components that fetch initial data from the backend at + render-time, then hydrate interactive client components (no blank-spinner-first-load). +- **Server data layer** at `src/lib/api/server.ts` — server-only fetchers that + call the backend directly via `GMW_BACKEND_URL` (default `http://127.0.0.1:4001`). + Never import from a client component. +- **API client** at `src/lib/api/client.ts` — browser-side fetch for live ops, + same-origin through the reverse proxy. +- **WebSocket** at `src/lib/ws/` — auto-reconnecting client with typed event + subscriptions. Realtime state (voice, media, messages) stays client-side. +- **Shared realtime state is server-authoritative**: the backend aggregates the + gateway's `voice_active_user` deltas into a live speaker snapshot + (`GET /api/voice/status` → `activeSpeakers`, plus WS `voice_state` sent on + connect). Every browser converges on the same voice state; `useSpeakers` + seeds from the server snapshot instead of accumulating per-tab. - **No authentication**: all endpoints are public ## Data flow (match these — do not invent endpoints) ``` -Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→ frontend - ↑ REST /api/* (same-origin) - └ WS /ws (events + PCM binary) +Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→ Next.js SSR + ↑ REST /api/* (server: GMW_BACKEND_URL + └ WS /ws (events + PCM binary) 127.0.0.1:4001) + ↑ browser WS (same-origin /ws) ``` -- **Base URL**: API + WS default to same-origin. `gmw-proxy` nginx (:4009) - proxies `/api` and `/ws` to the backend on :4001. Public host: - `imphnen.asepharyana.my.id` (Caddy reverse proxy → :4009). -- Local dev overrides: `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_WS_URL` - (e.g. https://imphnen.asepharyana.my.id). -- **Never hardcode a host** in api/ws clients — same-origin or env override only. +- **Rendering**: `gmw-proxy` nginx (:4009) proxies `/` → Next standalone server + (:4017, `node .next/standalone/server.js`), and `/api` + `/ws` → backend :4001. + Public host: `imphnen.asepharyana.my.id` (Caddy reverse proxy → :4009). +- **SSR seed pattern**: each `page.tsx` is a server component that fetches via + `src/lib/api/server.ts` and passes typed data to a `view.tsx` client + component; the hooks take `initialData` as SWR `fallbackData`. +- Local dev overrides: `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_WS_URL` for the + browser; `GMW_BACKEND_URL` for the server. +- **Never hardcode a host** in api/ws clients — same-origin/env or GMW_BACKEND_URL only. ## Backend response shapes that bite @@ -34,3 +49,5 @@ Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→ - Dashboard endpoints: `/api/dashboard/stats|users|channels` (+ `/:id` details). - Channel/guild names live inside `message.metadata` JSON (`channel.channelName`), not top-level. +- `GET /api/voice/status` now includes `activeSpeakers` (authoritative shared + snapshot from `src/modules/voice/live-speaker.ts` on the backend). diff --git a/services/frontend/next.config.ts b/services/frontend/next.config.ts index bba8180..43abafc 100644 --- a/services/frontend/next.config.ts +++ b/services/frontend/next.config.ts @@ -2,9 +2,12 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactCompiler: true, - output: "export", + // SSR — pages render on the server; realtime stays client-side via WS. + // No static export: shared state (voice, media, moderation) is served + // from the backend at render-time on the server. + output: "standalone", trailingSlash: true, images: { unoptimized: true }, }; -export default nextConfig; +export default nextConfig; \ No newline at end of file diff --git a/services/frontend/src/app/(dashboard)/dashboard/page.tsx b/services/frontend/src/app/(dashboard)/dashboard/page.tsx index 090796f..9df5ce0 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -1,182 +1,25 @@ -"use client"; +/** + * Dashboard page — Server Component. + * + * Fetches y the initial stats + activity on the server (no client round-trip + * for first paint) and hands them to the hydrated client view. This is the + * "data on the server" leg of the reworked data flow. + */ +import { getActivity, getDashboardStats } from "@/lib/api/server"; +import DashboardView from "./view"; -import { - AlertCircle, - Clock, - Hash, - Heart, - Shield, - Sparkles, - Users, -} from "lucide-react"; -import { useState } from "react"; -import { ActivityChart } from "@/components/dashboard/activity-chart"; -import { ChannelsSection } from "@/components/dashboard/channels-section"; -import { HourlyActivityChart } from "@/components/dashboard/hourly-activity-chart"; -import { ModerationDonut } from "@/components/dashboard/moderation-donut"; -import { ReactionsSection } from "@/components/dashboard/reactions-section"; -import { StatCard } from "@/components/dashboard/stat-card"; -import { TopChannelsChart } from "@/components/dashboard/top-channels-chart"; -import { UsersSection } from "@/components/dashboard/users-section"; -import { SubNav } from "@/components/layout/sub-nav"; -import { ErrorState, LoadingSkeleton } from "@/components/shared"; -import { useActivity, useStats } from "@/hooks"; -import { cn } from "@/lib/utils"; - -type DashboardTab = "stats" | "users" | "channels" | "reactions"; - -const DAY_RANGES = [7, 14, 30] as const; - -const MODERATION_COLORS: Record = { - Clean: "oklch(0.72 0.16 155)", - Flagged: "oklch(0.62 0.19 25)", - Warned: "oklch(0.78 0.15 80)", - Error: "oklch(0.55 0.02 245)", -}; - -export default function DashboardPage() { - const [tab, setTab] = useState("stats"); - const [days, setDays] = useState(14); - const { data: stats, isLoading, error, mutate: refetch } = useStats(); - const { data: activity, isLoading: activityLoading } = useActivity(days); - - const subNavTabs = [ - { id: "stats", label: "Stats", icon: }, - { id: "users", label: "Users", icon: }, - { id: "channels", label: "Channels", icon: }, - { id: "reactions", label: "Reactions", icon: }, - ]; - - const moderationData = stats - ? [ - { - name: "Clean", - value: stats.total_clean, - color: MODERATION_COLORS.Clean, - }, - { - name: "Flagged", - value: stats.total_flagged, - color: MODERATION_COLORS.Flagged, - }, - { - name: "Warned", - value: stats.total_warned, - color: MODERATION_COLORS.Warned, - }, - { - name: "Error", - value: stats.total_error, - color: MODERATION_COLORS.Error, - }, - ].filter((d) => d.value > 0) - : []; +export default async function DashboardPage() { + const [stats, activity] = await Promise.allSettled([ + getDashboardStats(), + getActivity(14), + ]); return ( -
- setTab(t as DashboardTab)} - /> - - {tab === "stats" && ( -
- {error ? ( - - ) : isLoading || !stats ? ( - - ) : ( - <> -
- - - - - - -
- -
- {DAY_RANGES.map((range) => ( - - ))} -
- -
-
- {activityLoading ? ( - - ) : ( - - )} -
- -
- -
-
- {activityLoading ? ( - - ) : ( - - )} -
- ({ - name: c.channel_name ?? c.channel_id, - count: c.message_count, - }))} - /> -
- - )} -
- )} - - {tab === "users" && } - - {tab === "channels" && } - - {tab === "reactions" && } -
+ ); } diff --git a/services/frontend/src/app/(dashboard)/dashboard/view.tsx b/services/frontend/src/app/(dashboard)/dashboard/view.tsx new file mode 100644 index 0000000..9e999ad --- /dev/null +++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { + AlertCircle, + Clock, + Hash, + Heart, + Shield, + Sparkles, + Users, +} from "lucide-react"; +import { useState } from "react"; +import { ActivityChart } from "@/components/dashboard/activity-chart"; +import { ChannelsSection } from "@/components/dashboard/channels-section"; +import { HourlyActivityChart } from "@/components/dashboard/hourly-activity-chart"; +import { ModerationDonut } from "@/components/dashboard/moderation-donut"; +import { ReactionsSection } from "@/components/dashboard/reactions-section"; +import { StatCard } from "@/components/dashboard/stat-card"; +import { TopChannelsChart } from "@/components/dashboard/top-channels-chart"; +import { UsersSection } from "@/components/dashboard/users-section"; +import { SubNav } from "@/components/layout/sub-nav"; +import { ErrorState, LoadingSkeleton } from "@/components/shared"; +import { useActivity, useStats } from "@/hooks"; +import type { DashboardActivity, DashboardStats } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +type DashboardTab = "stats" | "users" | "channels" | "reactions"; + +const DAY_RANGES = [7, 14, 30] as const; + +const MODERATION_COLORS: Record = { + Clean: "oklch(0.72 0.16 155)", + Flagged: "oklch(0.62 0.19 25)", + Warned: "oklch(0.78 0.15 80)", + Error: "oklch(0.55 0.02 245)", +}; + +/** + * Dashboard view — hydrated on the client but seeded with server-rendered + * initial data. SWR takes over for revalidation after first paint. + */ +export default function DashboardView({ + initialStats, + initialActivity, +}: { + initialStats?: DashboardStats; + initialActivity?: DashboardActivity; +}) { + const [tab, setTab] = useState("stats"); + const [days, setDays] = useState(14); + const { data: stats, error, mutate: refetch } = useStats(initialStats); + const { data: activity } = useActivity( + days, + days === 14 ? initialActivity : undefined, + ); + + const subNavTabs = [ + { id: "stats", label: "Stats", icon: }, + { id: "users", label: "Users", icon: }, + { id: "channels", label: "Channels", icon: }, + { id: "reactions", label: "Reactions", icon: }, + ]; + + const moderationData = stats + ? [ + { + name: "Clean", + value: stats.total_clean, + color: MODERATION_COLORS.Clean, + }, + { + name: "Flagged", + value: stats.total_flagged, + color: MODERATION_COLORS.Flagged, + }, + { + name: "Warned", + value: stats.total_warned, + color: MODERATION_COLORS.Warned, + }, + { + name: "Error", + value: stats.total_error, + color: MODERATION_COLORS.Error, + }, + ].filter((d) => d.value > 0) + : []; + + return ( +
+ setTab(t as DashboardTab)} + /> + + {tab === "stats" && ( +
+ {error ? ( + + ) : !stats ? ( + + ) : ( + <> +
+ + + + + + +
+ +
+ {DAY_RANGES.map((range) => ( + + ))} +
+ +
+
+ {activity && } +
+ +
+ +
+
+ {activity && } +
+ ({ + name: c.channel_name ?? c.channel_id, + count: c.message_count, + }))} + /> +
+ + )} +
+ )} + + {tab === "users" && } + + {tab === "channels" && } + + {tab === "reactions" && } +
+ ); +} diff --git a/services/frontend/src/app/(dashboard)/media/page.tsx b/services/frontend/src/app/(dashboard)/media/page.tsx index bd362ce..3c72a65 100644 --- a/services/frontend/src/app/(dashboard)/media/page.tsx +++ b/services/frontend/src/app/(dashboard)/media/page.tsx @@ -1,14 +1,13 @@ -"use client"; +/** + * Media page — Server Component. Seeds the music player with the shared media + * state fetched on the server (same state every user sees), then live-updates + * over WS. + */ +import { getMediaStatus } from "@/lib/api/server"; +import MediaView from "./view"; -import { MusicPlayer } from "@/components/media/music-player"; -import { useWebSocket } from "@/lib/ws/context"; +export default async function MediaPage() { + const status = await getMediaStatus().catch(() => undefined); -export default function MediaPage() { - const ws = useWebSocket(); - - return ( -
- -
- ); + return ; } diff --git a/services/frontend/src/app/(dashboard)/media/view.tsx b/services/frontend/src/app/(dashboard)/media/view.tsx new file mode 100644 index 0000000..41daa67 --- /dev/null +++ b/services/frontend/src/app/(dashboard)/media/view.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { MusicPlayer } from "@/components/media/music-player"; +import type { MediaState } from "@/lib/types"; +import { useWebSocket } from "@/lib/ws/context"; + +export default function MediaView({ + initialStatus, +}: { + initialStatus?: MediaState; +}) { + const ws = useWebSocket(); + + return ( +
+ +
+ ); +} diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index d3babda..e94d6f3 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -1,348 +1,41 @@ -"use client"; +/** + * Messages page — Server Component. + * + * Reads the URL (guild/channel/tab/selected) on the server and, when a guild + * is already selected, fetches the first message page server-side so the + * initial list is server-rendered, not a client round-trip. + */ +import { getMessages, type MessagePageResult } from "@/lib/api/server"; +import MessagesView from "./view"; -import { Flag, Image, Loader2, Search } from "lucide-react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; -import { GlassCard } from "@/components/glass/card"; -import { GlassPanel } from "@/components/glass/panel"; -import { SubNav } from "@/components/layout/sub-nav"; -import { Lightbox } from "@/components/messages/lightbox"; -import { extractFirstImage } from "@/components/messages/message-card"; -import { MessageDetailView } from "@/components/messages/message-detail-view"; -import { MessageList } from "@/components/messages/message-list"; -import { SearchOverlay } from "@/components/messages/search-overlay"; -import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared"; -import { GuildSelector } from "@/components/shared/guild-selector"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - useImages, - useLoadMore, - useMessageDetail, - useMessages, - useMessagesHasMore, - useMessagesWsSync, - useReview, - useTextChannels, -} from "@/hooks"; -import { renderMessageContent } from "@/lib/format"; -import type { MessageRecord } from "@/lib/types"; -import { cn } from "@/lib/utils"; -import { useWebSocket } from "@/lib/ws/context"; - -type MessagesTab = "all" | "images" | "review"; - -export default function MessagesPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const [guildId, setGuildId] = useState(searchParams.get("guild") || ""); - const [selectedChannel, setSelectedChannel] = useState( - searchParams.get("channel") || "", - ); - const [detailId, setDetailId] = useState( - searchParams.get("selected"), - ); - const [tab, setTab] = useState( - (searchParams.get("tab") as MessagesTab) || "all", - ); - const [searchOpen, setSearchOpen] = useState(false); - const [lightbox, setLightbox] = useState<{ - images: Array<{ src: string; alt?: string }>; - index: number; - } | null>(null); - - const ws = useWebSocket(); - const { data: channels = [] } = useTextChannels(guildId); - const { - data: messages, - isLoading, - error, - refetch, - } = useMessages(guildId, selectedChannel || undefined); - const { data: cursorData } = useMessagesHasMore( - guildId, - selectedChannel || undefined, - ); - const loadMoreMut = useLoadMore(); - const { data: images } = useImages(guildId); - const { data: reviews } = useReview(selectedChannel || undefined); - - const { - message: detailMessage, - attachments: detailAttachments, - loading: detailLoading, - } = useMessageDetail(detailId); - - useMessagesWsSync(ws, guildId); - - // Sync state to URL - useEffect(() => { - const params = new URLSearchParams(); - if (guildId) params.set("guild", guildId); - if (selectedChannel) params.set("channel", selectedChannel); - if (detailId) params.set("selected", detailId); - if (tab !== "all") params.set("tab", tab); - router.replace(`/messages?${params.toString()}`, { scroll: false }); - }, [guildId, selectedChannel, detailId, tab, router]); - - // Global Cmd+K search trigger - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - setSearchOpen(true); - } - }; - document.addEventListener("keydown", handleKey); - return () => document.removeEventListener("keydown", handleKey); - }, []); - - const handleLoadMore = useCallback(() => { - if (!cursorData?.cursor || loadMoreMut.isPending) return; - loadMoreMut.mutate({ - guildId, - channelId: selectedChannel || undefined, - cursor: cursorData.cursor, - }); - }, [cursorData, loadMoreMut, guildId, selectedChannel]); - - const handleGuildChange = useCallback((g: string) => { - setGuildId(g); - setSelectedChannel(""); - setDetailId(null); - }, []); - - const subNavTabs = [ - { id: "all", label: "All", icon: null }, - { id: "images", label: "Images", icon: }, - { id: "review", label: "Review", icon: }, - ]; - - const currentMessages = messages ?? []; - - return ( -
- {/* ── Controls bar ── */} -
- - {channels.length > 0 && ( - - )} - -
- - {/* ── Sub navigation ── */} - setTab(t as MessagesTab)} - /> - - {/* ── Split pane ── */} - {error ? ( - - ) : isLoading ? ( - - ) : ( -
- {/* Left pane */} -
- {tab === "all" && ( - - )} - {tab === "images" && ( - - )} - {tab === "review" && ( - - )} -
- - {/* Right pane — message detail */} - {detailId && ( -
- {detailLoading ? ( - - - - ) : detailMessage ? ( -
- - { - const imgs = (detailAttachments ?? []) - .filter((a) => a.type?.startsWith("image/")) - .map((a) => ({ - src: a.uploaded_url || a.discord_url, - alt: a.filename, - })); - if (imgs.length > 0) { - setLightbox({ images: imgs, index }); - } - }} - /> -
- ) : null} -
- )} -
- )} - - {/* ── Search overlay ── */} - setSearchOpen(false)} - onSelect={(id) => { - setDetailId(id); - setTab("all"); - }} - /> - - {/* ── Lightbox ── */} - {lightbox && ( - setLightbox(null)} - /> - )} -
- ); -} - -// ── Inline ImageGrid (glass-styled) ──────────────── - -function ImageGrid({ - items, - onSelect, +export default async function MessagesPage({ + searchParams, }: { - items: MessageRecord[]; - onSelect: (id: string) => void; + searchParams: Promise>; }) { + const sp = await searchParams; + const guild = typeof sp.guild === "string" ? sp.guild : ""; + const channel = typeof sp.channel === "string" ? sp.channel : ""; + const selected = typeof sp.selected === "string" ? sp.selected : null; + const tab = + typeof sp.tab === "string" && ["all", "images", "review"].includes(sp.tab) + ? (sp.tab as "all" | "images" | "review") + : "all"; + + let initialPage: MessagePageResult | undefined; + if (guild) { + initialPage = await getMessages(guild, channel || undefined).catch( + () => undefined, + ); + } + return ( -
- {items.map((item) => { - const imgUrl = extractFirstImage(item.metadata); - return ( - - ); - })} - {items.length === 0 && ( - - )} -
- ); -} - -// ── Inline ReviewList (glass-styled) ──────────────── - -function ReviewList({ - items, - onSelect, -}: { - items: MessageRecord[]; - onSelect: (id: string) => void; -}) { - return ( -
- {items.map((item) => ( - onSelect(item.id)} - > -
- -
-

- {renderMessageContent(item.content, item.metadata) || item.id} -

-
-
-
- ))} - {items.length === 0 && ( - - )} -
+ ); } diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx new file mode 100644 index 0000000..1d4efa9 --- /dev/null +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -0,0 +1,365 @@ +"use client"; + +import { Flag, Image, Loader2, Search } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { GlassCard } from "@/components/glass/card"; +import { GlassPanel } from "@/components/glass/panel"; +import { SubNav } from "@/components/layout/sub-nav"; +import { Lightbox } from "@/components/messages/lightbox"; +import { extractFirstImage } from "@/components/messages/message-card"; +import { MessageDetailView } from "@/components/messages/message-detail-view"; +import { MessageList } from "@/components/messages/message-list"; +import { SearchOverlay } from "@/components/messages/search-overlay"; +import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared"; +import { GuildSelector } from "@/components/shared/guild-selector"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + useImages, + useLoadMore, + useMessageDetail, + useMessages, + useMessagesHasMore, + useMessagesWsSync, + useReview, + useTextChannels, +} from "@/hooks"; +import { renderMessageContent } from "@/lib/format"; +import type { MessageRecord } from "@/lib/types"; +import { cn } from "@/lib/utils"; +import { useWebSocket } from "@/lib/ws/context"; + +type MessagesTab = "all" | "images" | "review"; + +interface MessagesViewProps { + initialGuild?: string; + initialChannel?: string; + initialDetailId?: string | null; + initialTab?: MessagesTab; + initialMessagePage?: { data: MessageRecord[]; nextCursor: string | null }; +} + +/** + * Messages view — hydrated on the client. Initial guild/channel/detail/tab + * come from the URL (server-read on first SSR), and the first message page is + * seeded from the server when a guild is already selected. + */ +export default function MessagesView({ + initialGuild = "", + initialChannel = "", + initialDetailId = null, + initialTab = "all", + initialMessagePage, +}: MessagesViewProps) { + const router = useRouter(); + const [guildId, setGuildId] = useState(initialGuild); + const [selectedChannel, setSelectedChannel] = useState(initialChannel); + const [detailId, setDetailId] = useState(initialDetailId); + const [tab, setTab] = useState(initialTab); + const [searchOpen, setSearchOpen] = useState(false); + const [lightbox, setLightbox] = useState<{ + images: Array<{ src: string; alt?: string }>; + index: number; + } | null>(null); + + const ws = useWebSocket(); + const { data: channels = [] } = useTextChannels(guildId); + const { + data: messages, + error, + refetch, + } = useMessages( + guildId, + selectedChannel || undefined, + guildId === initialGuild && selectedChannel === initialChannel + ? initialMessagePage + : undefined, + ); + const { data: cursorData } = useMessagesHasMore( + guildId, + selectedChannel || undefined, + ); + const loadMoreMut = useLoadMore(); + const { data: images } = useImages(guildId); + const { data: reviews } = useReview(selectedChannel || undefined); + + const { + message: detailMessage, + attachments: detailAttachments, + loading: detailLoading, + } = useMessageDetail(detailId); + + useMessagesWsSync(ws, guildId); + + // Sync state to URL + useEffect(() => { + const params = new URLSearchParams(); + if (guildId) params.set("guild", guildId); + if (selectedChannel) params.set("channel", selectedChannel); + if (detailId) params.set("selected", detailId); + if (tab !== "all") params.set("tab", tab); + router.replace(`/messages?${params.toString()}`, { scroll: false }); + }, [guildId, selectedChannel, detailId, tab, router]); + + // Global Cmd+K search trigger + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setSearchOpen(true); + } + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, []); + + const handleLoadMore = useCallback(() => { + if (!cursorData?.cursor || loadMoreMut.isPending) return; + loadMoreMut.mutate({ + guildId, + channelId: selectedChannel || undefined, + cursor: cursorData.cursor, + }); + }, [cursorData, loadMoreMut, guildId, selectedChannel]); + + const handleGuildChange = useCallback((g: string) => { + setGuildId(g); + setSelectedChannel(""); + setDetailId(null); + }, []); + + const subNavTabs = [ + { id: "all", label: "All", icon: null }, + { id: "images", label: "Images", icon: }, + { id: "review", label: "Review", icon: }, + ]; + + const currentMessages = messages ?? []; + + return ( +
+ {/* ── Controls bar ── */} +
+ + {channels.length > 0 && ( + + )} + +
+ + {/* ── Sub navigation ── */} + setTab(t as MessagesTab)} + /> + + {/* ── Split pane ── */} + {error ? ( + + ) : !messages ? ( + + ) : ( +
+ {/* Left pane */} +
+ {tab === "all" && ( + + )} + {tab === "images" && ( + + )} + {tab === "review" && ( + + )} +
+ + {/* Right pane — message detail */} + {detailId && ( +
+ {detailLoading ? ( + + + + ) : detailMessage ? ( +
+ + { + const imgs = (detailAttachments ?? []) + .filter((a) => a.type?.startsWith("image/")) + .map((a) => ({ + src: a.uploaded_url || a.discord_url, + alt: a.filename, + })); + if (imgs.length > 0) { + setLightbox({ images: imgs, index }); + } + }} + /> +
+ ) : null} +
+ )} +
+ )} + + {/* ── Search overlay ── */} + setSearchOpen(false)} + onSelect={(id) => { + setDetailId(id); + setTab("all"); + }} + /> + + {/* ── Lightbox ── */} + {lightbox && ( + setLightbox(null)} + /> + )} +
+ ); +} + +// ── Inline ImageGrid (glass-styled) ──────────────── + +function ImageGrid({ + items, + onSelect, +}: { + items: MessageRecord[]; + onSelect: (id: string) => void; +}) { + return ( +
+ {items.map((item) => { + const imgUrl = extractFirstImage(item.metadata); + return ( + + ); + })} + {items.length === 0 && ( + + )} +
+ ); +} + +// ── Inline ReviewList (glass-styled) ──────────────── + +function ReviewList({ + items, + onSelect, +}: { + items: MessageRecord[]; + onSelect: (id: string) => void; +}) { + return ( +
+ {items.map((item) => ( + onSelect(item.id)} + > +
+ +
+

+ {renderMessageContent(item.content, item.metadata) || item.id} +

+
+
+
+ ))} + {items.length === 0 && ( + + )} +
+ ); +} diff --git a/services/frontend/src/app/(dashboard)/moderation/page.tsx b/services/frontend/src/app/(dashboard)/moderation/page.tsx index d4f1c51..4a78db9 100644 --- a/services/frontend/src/app/(dashboard)/moderation/page.tsx +++ b/services/frontend/src/app/(dashboard)/moderation/page.tsx @@ -1,11 +1,24 @@ -"use client"; - +/** + * Moderation page — Server Component. Seeds summary + action log from + * server-fetched moderation state (shared across all users). + */ import { ModerationSection } from "@/components/moderation/moderation-section"; +import { getModerationActions, getModerationStats } from "@/lib/api/server"; + +export default async function ModerationPage() { + const [stats, actions] = await Promise.allSettled([ + getModerationStats(), + getModerationActions(100), + ]); -export default function ModerationPage() { return (
- +
); } diff --git a/services/frontend/src/app/(dashboard)/recordings/page.tsx b/services/frontend/src/app/(dashboard)/recordings/page.tsx index b03667b..cb14eff 100644 --- a/services/frontend/src/app/(dashboard)/recordings/page.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/page.tsx @@ -1,186 +1,12 @@ -"use client"; +/** + * Recordings page — Server Component. Seeds the library from server-fetched + * recordings; live `voice_recording_uploaded` events keep it fresh over WS. + */ +import { getRecordings } from "@/lib/api/server"; +import RecordingsView from "./view"; -import { Clock, Database, Mic, Users } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; -import { StatCard } from "@/components/dashboard/stat-card"; -import { SubNav } from "@/components/layout/sub-nav"; -import { RecordingCard } from "@/components/recordings/recording-card"; -import { RecordingPlayer } from "@/components/recordings/recording-player"; -import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared"; -import { useRecordings, useRecordingsWsSync } from "@/hooks"; -import { formatBytes } from "@/lib/format"; -import type { VoiceRecording } from "@/lib/types"; -import { useWebSocket } from "@/lib/ws/context"; +export default async function RecordingsPage() { + const data = await getRecordings(50).catch(() => undefined); -type RecordingsTab = "library" | "stats"; - -export default function RecordingsPage() { - const { - data: recordings, - isLoading, - error, - mutate: refetch, - } = useRecordings(); - const [playingId, setPlayingId] = useState(null); - const [isPlaying, setIsPlaying] = useState(false); - const [isLoadingAudio, setIsLoadingAudio] = useState(false); - const [tab, setTab] = useState("library"); - const ws = useWebSocket(); - const audioRef = useRef(null); - - // Live-update the library when the gateway publishes voice_recording_uploaded - useRecordingsWsSync(ws); - - const currentTrack = - playingId && recordings - ? recordings.find((r: VoiceRecording) => r.id === playingId) - : null; - - const togglePlay = (id: string) => { - if (playingId !== id) { - setPlayingId(id); // RecordingPlayer picks up the new url + autoplays - } else { - const audio = audioRef.current; - if (!audio) return; - if (audio.paused) audio.play().catch(() => {}); - else audio.pause(); - } - }; - - const stats = useMemo(() => { - const list = recordings ?? []; - const totalSize = list.reduce((sum, r) => sum + (r.size_bytes ?? 0), 0); - const byUser = new Map< - string, - { name: string; count: number; size: number } - >(); - for (const rec of list) { - const key = rec.user_id ?? rec.username; - const cur = byUser.get(key) ?? { name: rec.username, count: 0, size: 0 }; - cur.count += 1; - cur.size += rec.size_bytes ?? 0; - byUser.set(key, cur); - } - const topUsers = [...byUser.values()] - .sort((a, b) => b.count - a.count) - .slice(0, 8); - return { - total: list.length, - totalSize, - uniqueUsers: byUser.size, - topUsers, - }; - }, [recordings]); - - return ( -
- setTab(t as RecordingsTab)} - /> - - {tab === "library" && - (error ? ( - - ) : isLoading ? ( - - ) : ( -
- {(recordings ?? []).map((rec: VoiceRecording) => ( - - ))} - {(recordings ?? []).length === 0 && ( - - )} -
- ))} - - {tab === "stats" && - (isLoading ? ( - - ) : stats.total === 0 ? ( - - ) : ( -
-
- - formatBytes(v)} - /> - -
- - {stats.topUsers.length > 0 && ( -
-

- Top Speakers -

-
- {stats.topUsers.map((u) => ( -
- - {u.count} - - - {u.name} - - - {formatBytes(u.size)} - -
- ))} -
-
- )} -
- ))} - - togglePlay(playingId!)} - onStateChange={(s) => { - setIsPlaying(s.playing); - setIsLoadingAudio(s.loading); - }} - onClose={() => setPlayingId(null)} - /> -
- ); + return ; } diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx new file mode 100644 index 0000000..71e2988 --- /dev/null +++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { Clock, Database, Mic, Users } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { StatCard } from "@/components/dashboard/stat-card"; +import { SubNav } from "@/components/layout/sub-nav"; +import { RecordingCard } from "@/components/recordings/recording-card"; +import { RecordingPlayer } from "@/components/recordings/recording-player"; +import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared"; +import { useRecordings, useRecordingsWsSync } from "@/hooks"; +import { formatBytes } from "@/lib/format"; +import type { VoiceRecording } from "@/lib/types"; +import { useWebSocket } from "@/lib/ws/context"; + +type RecordingsTab = "library" | "stats"; + +export default function RecordingsView({ + initialRecordings, +}: { + initialRecordings?: VoiceRecording[]; +}) { + const { + data: recordings, + error, + mutate: refetch, + } = useRecordings(initialRecordings); + const [playingId, setPlayingId] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + const [isLoadingAudio, setIsLoadingAudio] = useState(false); + const [tab, setTab] = useState("library"); + const ws = useWebSocket(); + const audioRef = useRef(null); + + // Live-update the library when the gateway publishes voice_recording_uploaded + useRecordingsWsSync(ws); + + const currentTrack = + playingId && recordings + ? recordings.find((r: VoiceRecording) => r.id === playingId) + : null; + + const togglePlay = (id: string) => { + if (playingId !== id) { + setPlayingId(id); // RecordingPlayer picks up the new url + autoplays + } else { + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) audio.play().catch(() => {}); + else audio.pause(); + } + }; + + const stats = useMemo(() => { + const list = recordings ?? []; + const totalSize = list.reduce((sum, r) => sum + (r.size_bytes ?? 0), 0); + const byUser = new Map< + string, + { name: string; count: number; size: number } + >(); + for (const rec of list) { + const key = rec.user_id ?? rec.username; + const cur = byUser.get(key) ?? { name: rec.username, count: 0, size: 0 }; + cur.count += 1; + cur.size += rec.size_bytes ?? 0; + byUser.set(key, cur); + } + const topUsers = [...byUser.values()] + .sort((a, b) => b.count - a.count) + .slice(0, 8); + return { + total: list.length, + totalSize, + uniqueUsers: byUser.size, + topUsers, + }; + }, [recordings]); + + return ( +
+ setTab(t as RecordingsTab)} + /> + + {tab === "library" && + (error ? ( + + ) : !recordings ? ( + + ) : ( +
+ {(recordings ?? []).map((rec: VoiceRecording) => ( + + ))} + {(recordings ?? []).length === 0 && ( + + )} +
+ ))} + + {tab === "stats" && + (!recordings ? ( + + ) : stats.total === 0 ? ( + + ) : ( +
+
+ + formatBytes(v)} + /> + +
+ + {stats.topUsers.length > 0 && ( +
+

+ Top Speakers +

+
+ {stats.topUsers.map((u) => ( +
+ + {u.count} + + + {u.name} + + + {formatBytes(u.size)} + +
+ ))} +
+
+ )} +
+ ))} + + togglePlay(playingId!)} + onStateChange={(s) => { + setIsPlaying(s.playing); + setIsLoadingAudio(s.loading); + }} + onClose={() => setPlayingId(null)} + /> +
+ ); +} diff --git a/services/frontend/src/app/(dashboard)/voice/page.tsx b/services/frontend/src/app/(dashboard)/voice/page.tsx index f23e16e..f2158a5 100644 --- a/services/frontend/src/app/(dashboard)/voice/page.tsx +++ b/services/frontend/src/app/(dashboard)/voice/page.tsx @@ -1,165 +1,23 @@ -"use client"; +/** + * Voice page — Server Component. + * + * Fetches the authoritative voice connection status + guild list on the server + * so the first paint reflects the shared gateway voice state (which channel is + * joined, across ALL users), independent of any single browser's WS history. + */ +import { getGuilds, getVoiceStatus } from "@/lib/api/server"; +import VoiceView from "./view"; -import { useCallback, useEffect, useState } from "react"; -import { SubNav } from "@/components/layout/sub-nav"; -import { VoiceActivityTimeline } from "@/components/voice/activity-timeline"; -import { VoiceConnectionCard } from "@/components/voice/connection-card"; -import { ListenControl } from "@/components/voice/listen-control"; -import { MicControl } from "@/components/voice/mic-control"; -import { SpeakerWaveform } from "@/components/voice/speaker-waveform"; -import { - useGuilds, - useMicTransmit, - useSpeakers, - useVoiceChannels, - useVoiceConnect, - useVoiceDisconnect, - useVoiceListen, - useVoiceStatus, -} from "@/hooks"; -import { useWebSocket } from "@/lib/ws/context"; -import { toast } from "sonner"; - -type VoiceTab = "connection" | "activity"; - -export default function VoicePage() { - const ws = useWebSocket(); - const { data: voiceStatus } = useVoiceStatus(); - const { data: guilds = [] } = useGuilds(); - const [selectedGuild, setSelectedGuild] = useState(""); - const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild); - const { speakers, subscribe } = useSpeakers(); - const connectMut = useVoiceConnect(); - const disconnectMut = useVoiceDisconnect(); - const micMut = useMicTransmit(ws); - const listen = useVoiceListen(ws); - const [selectedChannel, setSelectedChannel] = useState(""); - const [micActive, setMicActive] = useState(false); - const [volume, setVolume] = useState(75); - const [listenVolume, setListenVolume] = useState(75); - const [tab, setTab] = useState("connection"); - - useEffect(() => { - const unsub = subscribe(ws); - return () => unsub(); - }, [ws, subscribe]); - - const handleMicToggle = useCallback( - async (checked: boolean) => { - if (checked) { - try { - await micMut.mutateAsync(true); - setMicActive(true); - } catch { - setMicActive(false); - } - } else { - setMicActive(false); - try { - await micMut.mutateAsync(false); - } catch { - // Stop already tore down the local transmitter — ignore remote errors - } - } - }, - [micMut], - ); - - const handleVolumeChange = useCallback( - (v: number) => { - setVolume(v); - micMut.setVolume(v); - }, - [micMut], - ); - - const handleGuildChange = useCallback((guildId: string | null) => { - if (!guildId) { - setSelectedGuild(""); - setSelectedChannel(""); - return; - } - setSelectedGuild(guildId); - }, []); - - const activeSpeakers = speakers.filter((s) => s.speaking); - const connected = voiceStatus?.connected ?? false; +export default async function VoicePage() { + const [status, guilds] = await Promise.allSettled([ + getVoiceStatus(), + getGuilds(), + ]); return ( -
- setTab(t as VoiceTab)} - /> - - setSelectedChannel(v ?? "")} - onConnect={() => { - void connectMut - .mutateAsync({ - guildId: selectedGuild, - channelId: selectedChannel, - }) - .catch((err: unknown) => { - const msg = - err instanceof Error - ? err.message - : "Gagal connect ke voice channel"; - toast.error("Voice connect gagal", { - description: msg, - }); - }); - }} - onDisconnect={() => { - if (micActive) { - setMicActive(false); - void micMut.mutateAsync(false).catch(() => {}); - } - if (listen.active) listen.toggle(false); - disconnectMut.mutate(undefined); - }} - connecting={connectMut.isPending} - /> - - {tab === "connection" && ( -
- -
- listen.toggle(on)} - volume={listenVolume} - onVolumeChange={(v) => { - setListenVolume(v); - listen.setVolume(v); - }} - /> - -
-
- )} - - {tab === "activity" && } -
+ ); } diff --git a/services/frontend/src/app/(dashboard)/voice/view.tsx b/services/frontend/src/app/(dashboard)/voice/view.tsx new file mode 100644 index 0000000..50fb7ae --- /dev/null +++ b/services/frontend/src/app/(dashboard)/voice/view.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { SubNav } from "@/components/layout/sub-nav"; +import { VoiceActivityTimeline } from "@/components/voice/activity-timeline"; +import { VoiceConnectionCard } from "@/components/voice/connection-card"; +import { ListenControl } from "@/components/voice/listen-control"; +import { MicControl } from "@/components/voice/mic-control"; +import { SpeakerWaveform } from "@/components/voice/speaker-waveform"; +import { + useGuilds, + useMicTransmit, + useSpeakers, + useVoiceChannels, + useVoiceConnect, + useVoiceDisconnect, + useVoiceListen, + useVoiceStatus, +} from "@/hooks"; +import type { Guild, VoiceStatus } from "@/lib/types"; +import { useWebSocket } from "@/lib/ws/context"; + +type VoiceTab = "connection" | "activity"; + +/** + * Voice view — hydrated on the client. Seeded from server-rendered status + + * guild list so every user's first paint reflects the same shared voice + * connection state; live updates come over WS. + */ +export default function VoiceView({ + initialStatus, + initialGuilds = [], +}: { + initialStatus?: VoiceStatus; + initialGuilds?: Guild[]; +}) { + const ws = useWebSocket(); + const { data: voiceStatus } = useVoiceStatus(initialStatus); + const { data: guilds = [] } = useGuilds(initialGuilds); + const [selectedGuild, setSelectedGuild] = useState(""); + const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild); + const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers); + const connectMut = useVoiceConnect(); + const disconnectMut = useVoiceDisconnect(); + const micMut = useMicTransmit(ws); + const listen = useVoiceListen(ws); + const [selectedChannel, setSelectedChannel] = useState(""); + const [micActive, setMicActive] = useState(false); + const [volume, setVolume] = useState(75); + const [listenVolume, setListenVolume] = useState(75); + const [tab, setTab] = useState("connection"); + + useEffect(() => { + const unsub = subscribe(ws); + return () => unsub(); + }, [ws, subscribe]); + + const handleMicToggle = useCallback( + async (checked: boolean) => { + if (checked) { + try { + await micMut.mutateAsync(true); + setMicActive(true); + } catch { + setMicActive(false); + } + } else { + setMicActive(false); + try { + await micMut.mutateAsync(false); + } catch { + // Stop already tore down the local transmitter — ignore remote errors + } + } + }, + [micMut], + ); + + const handleVolumeChange = useCallback( + (v: number) => { + setVolume(v); + micMut.setVolume(v); + }, + [micMut], + ); + + const handleGuildChange = useCallback((guildId: string | null) => { + if (!guildId) { + setSelectedGuild(""); + setSelectedChannel(""); + return; + } + setSelectedGuild(guildId); + }, []); + + const activeSpeakers = speakers.filter((s) => s.speaking); + const connected = voiceStatus?.connected ?? false; + + return ( +
+ setTab(t as VoiceTab)} + /> + + setSelectedChannel(v ?? "")} + onConnect={() => { + void connectMut + .mutateAsync({ + guildId: selectedGuild, + channelId: selectedChannel, + }) + .catch((err: unknown) => { + const msg = + err instanceof Error + ? err.message + : "Gagal connect ke voice channel"; + toast.error("Voice connect gagal", { + description: msg, + }); + }); + }} + onDisconnect={() => { + if (micActive) { + setMicActive(false); + void micMut.mutateAsync(false).catch(() => {}); + } + if (listen.active) listen.toggle(false); + disconnectMut.mutate(undefined); + }} + connecting={connectMut.isPending} + /> + + {tab === "connection" && ( +
+ +
+ listen.toggle(on)} + volume={listenVolume} + onVolumeChange={(v) => { + setListenVolume(v); + listen.setVolume(v); + }} + /> + +
+
+ )} + + {tab === "activity" && } +
+ ); +} diff --git a/services/frontend/src/components/media/music-player.tsx b/services/frontend/src/components/media/music-player.tsx index 6669f51..64bd83f 100644 --- a/services/frontend/src/components/media/music-player.tsx +++ b/services/frontend/src/components/media/music-player.tsx @@ -18,12 +18,16 @@ import { } from "@/hooks"; import type { WsHook } from "@/lib/ws-hook"; +import type { MediaState } from "@/lib/types"; + interface MusicPlayerProps { ws: WsHook; + /** Server-fetched media snapshot used to seed the first render. */ + initialData?: MediaState; } -export function MusicPlayer({ ws }: MusicPlayerProps) { - const { data: mediaState } = useMediaState(); +export function MusicPlayer({ ws, initialData }: MusicPlayerProps) { + const { data: mediaState } = useMediaState(initialData); const queueMut = useMediaQueue(); const skipMut = useMediaSkip(); const stopMut = useMediaStop(); diff --git a/services/frontend/src/components/moderation/moderation-section.tsx b/services/frontend/src/components/moderation/moderation-section.tsx index f7a4af1..114f5eb 100644 --- a/services/frontend/src/components/moderation/moderation-section.tsx +++ b/services/frontend/src/components/moderation/moderation-section.tsx @@ -17,7 +17,11 @@ import { EmptyState, LoadingSkeleton } from "@/components/shared"; import { Badge } from "@/components/ui/badge"; import { useModerationActions, useModerationStats } from "@/hooks"; import { renderMessageContent } from "@/lib/format"; -import type { ModerationAction, ModerationActionType } from "@/lib/types"; +import type { + ModerationAction, + ModerationActionType, + ModerationStats, +} from "@/lib/types"; import { cn } from "@/lib/utils"; const ACTION_META: Record< @@ -82,13 +86,20 @@ const EMPTY_ACTION_RATE = { failed_rate: 0, }; -export function ModerationSection() { +export function ModerationSection({ + initialStats, + initialActions, +}: { + initialStats?: ModerationStats; + initialActions?: ModerationAction[]; +} = {}) { const [status, setStatus] = useState(""); const [actionType, setActionType] = useState(""); - const { data: stats } = useModerationStats(); + const { data: stats } = useModerationStats(initialStats); const { data: actions, isLoading: actionsLoading } = useModerationActions( status, actionType, + initialActions, ); const s = stats ?? EMPTY_ACTION_RATE; @@ -159,7 +170,7 @@ export function ModerationSection() { {/* Timeline */} - {actionsLoading ? ( + {actionsLoading && !actions ? ( ) : !actions || actions.length === 0 ? ( diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index 56b9960..756d5da 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -4,8 +4,8 @@ export { useChannelDetail, useChannels, useStats, - useTopReactors, useTopReactions, + useTopReactors, useUserDetail, useUsers, } from "./use-dashboard"; diff --git a/services/frontend/src/hooks/use-dashboard.ts b/services/frontend/src/hooks/use-dashboard.ts index 6e6a42f..2a1e090 100644 --- a/services/frontend/src/hooks/use-dashboard.ts +++ b/services/frontend/src/hooks/use-dashboard.ts @@ -10,15 +10,26 @@ import type { TopReactor, } from "@/lib/types"; -export function useStats() { - return useSWR(["dashboard-stats"], () => - dashboardApi.getStats(), +/** + * Server-seeded SWR hooks. + * + * SSR pages fetch the initial payload on the server and hand it here as + * `initialData` — the first render is server data, and SWR takes over for + * revalidation from then on (no blank-spinner-first-load). + */ +export function useStats(initialData?: DashboardStats) { + return useSWR( + ["dashboard-stats"], + () => dashboardApi.getStats(), + { fallbackData: initialData }, ); } -export function useActivity(days = 14) { - return useSWR(["dashboard-activity", days], () => - dashboardApi.getActivity(days), +export function useActivity(days = 14, initialData?: DashboardActivity) { + return useSWR( + ["dashboard-activity", days], + () => dashboardApi.getActivity(days), + { fallbackData: initialData }, ); } diff --git a/services/frontend/src/hooks/use-guilds.ts b/services/frontend/src/hooks/use-guilds.ts index 42de963..a00eb45 100644 --- a/services/frontend/src/hooks/use-guilds.ts +++ b/services/frontend/src/hooks/use-guilds.ts @@ -6,8 +6,9 @@ import type { Guild } from "@/lib/types"; /** * Fetch the list of available Discord guilds. */ -export function useGuilds() { +export function useGuilds(initialData?: Guild[]) { return useSWR(["guilds"], () => voiceApi.getGuilds(), { dedupingInterval: 60_000, + fallbackData: initialData, }); } diff --git a/services/frontend/src/hooks/use-media.ts b/services/frontend/src/hooks/use-media.ts index eb81c75..1034338 100644 --- a/services/frontend/src/hooks/use-media.ts +++ b/services/frontend/src/hooks/use-media.ts @@ -7,10 +7,11 @@ import type { WsHook } from "@/lib/ws-hook"; const MEDIA_KEY = ["media-state"] as const; -export function useMediaState() { +export function useMediaState(initialData?: MediaState) { return useSWR(MEDIA_KEY, () => mediaApi.getStatus(), { refreshInterval: 10_000, shouldRetryOnError: false, + fallbackData: initialData, }); } diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts index 6c6b1a2..f4b9466 100644 --- a/services/frontend/src/hooks/use-messages.ts +++ b/services/frontend/src/hooks/use-messages.ts @@ -24,17 +24,27 @@ type MessagePage = { data: MessageRecord[]; nextCursor: string | null }; * useMessagesHasMore derive from this one SWR key, so the cursor probe no * longer triggers a duplicate API call. */ -function useMessagesPage(guildId: string, channelId?: string) { +function useMessagesPage( + guildId: string, + channelId?: string, + initialPage?: MessagePage, +) { const key = guildId ? msgKeys.list(guildId, channelId) : null; - return useSWR(key, () => - messagesApi.list(guildId, 50, channelId || undefined), + return useSWR( + key, + () => messagesApi.list(guildId, 50, channelId || undefined), + { fallbackData: initialPage }, ); } // ── Messages list (paginated, cursor-based) ────── -export function useMessages(guildId: string, channelId?: string) { - const page = useMessagesPage(guildId, channelId); +export function useMessages( + guildId: string, + channelId?: string, + initialPage?: MessagePage, +) { + const page = useMessagesPage(guildId, channelId, initialPage); return { ...page, data: page.data?.data, diff --git a/services/frontend/src/hooks/use-moderation.ts b/services/frontend/src/hooks/use-moderation.ts index d7ca513..a2b8f99 100644 --- a/services/frontend/src/hooks/use-moderation.ts +++ b/services/frontend/src/hooks/use-moderation.ts @@ -1,20 +1,35 @@ import useSWR from "swr"; import { moderationApi } from "@/lib/api"; -import type { ModerationStats } from "@/lib/types"; +import type { ModerationAction, ModerationStats } from "@/lib/types"; -export function useModerationStats() { - return useSWR(["moderation-stats"], () => - moderationApi.getStats(), +export function useModerationStats(initialData?: ModerationStats) { + return useSWR( + ["moderation-stats"], + () => moderationApi.getStats(), + { fallbackData: initialData }, ); } -export function useModerationActions(status?: string, actionType?: string) { +export function useModerationActions( + status?: string, + actionType?: string, + initialData?: ModerationAction[], +) { + const key = [ + "moderation-actions", + status ?? "__all__", + actionType ?? "__all__", + ]; return useSWR( - ["moderation-actions", status ?? "__all__", actionType ?? "__all__"], + key, async () => { const res = await moderationApi.listActions(100, status, actionType); return res.data; }, - { keepPreviousData: true }, + { + keepPreviousData: true, + fallbackData: + !status && !actionType && initialData ? initialData : undefined, + }, ); } diff --git a/services/frontend/src/hooks/use-recordings.ts b/services/frontend/src/hooks/use-recordings.ts index 3eabe43..7a9f2ee 100644 --- a/services/frontend/src/hooks/use-recordings.ts +++ b/services/frontend/src/hooks/use-recordings.ts @@ -7,11 +7,15 @@ import type { WsHook } from "@/lib/ws-hook"; const RECORDINGS_KEY = ["recordings"] as const; -export function useRecordings() { - return useSWR(RECORDINGS_KEY, async () => { - const res = await recordingsApi.list(50); - return res.items; - }); +export function useRecordings(initialData?: VoiceRecording[]) { + return useSWR( + RECORDINGS_KEY, + async () => { + const res = await recordingsApi.list(50); + return res.items; + }, + { fallbackData: initialData }, + ); } export function useDeleteRecording() { diff --git a/services/frontend/src/hooks/use-voice.ts b/services/frontend/src/hooks/use-voice.ts index ba44d15..d0ebbcb 100644 --- a/services/frontend/src/hooks/use-voice.ts +++ b/services/frontend/src/hooks/use-voice.ts @@ -20,9 +20,10 @@ export function hashUserId(userId: string): number { const STATUS_KEY = ["voice-status"] as const; -export function useVoiceStatus() { +export function useVoiceStatus(initialData?: VoiceStatus) { return useSWR(STATUS_KEY, () => voiceApi.getStatus(), { shouldRetryOnError: false, + fallbackData: initialData, }); } @@ -32,10 +33,31 @@ export function useVoiceChannels(guildId: string) { ); } -export function useSpeakers() { - const [speakers, setSpeakers] = useState([]); +/** + * Live shared speaker state. + * + * Seeded from the server-authored snapshot (`initial` — the voice status the + * server rendered, which includes the authoritative active speakers). From + * there the WS keeps it converged across ALL users: + * - `voice_state` → authoritative FULL replacement (e.g. a late join seeds + * every client with the same list); + * - `voice_active_user` → incremental upsert of a single speaker delta. + * + * This replaces the old per-browser model where each tab accumulated speakers + * only from events it happened to receive while mounted. + */ +export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) { + const [speakers, setSpeakers] = useState( + initialStatusActive ?? [], + ); const subscribe = useCallback((ws: WsHook) => { + const unsubSnapshot = ws.on("voice_state", (data) => { + const state = data as { activeSpeakers?: ActiveSpeaker[] }; + if (Array.isArray(state?.activeSpeakers)) { + setSpeakers(state.activeSpeakers); + } + }); const unsub = ws.on("voice_active_user", (data) => { const speaker = data as ActiveSpeaker; setSpeakers((prev) => { @@ -49,6 +71,7 @@ export function useSpeakers() { }); }); return () => { + unsubSnapshot(); unsub(); setSpeakers([]); }; diff --git a/services/frontend/src/lib/api/server.ts b/services/frontend/src/lib/api/server.ts new file mode 100644 index 0000000..fc3e695 --- /dev/null +++ b/services/frontend/src/lib/api/server.ts @@ -0,0 +1,135 @@ +/** + * Server-only data layer. + * + * These fetchers run exclusively on the Next.js server (React Server + * Components / route handlers). They call the backend over HTTP directly + * (`GMW_BACKEND_URL`), so the browser never needs a client round-trip for the + * initial page data — the first paint is server-rendered. + * + * Never import this module from a client component. Browser code should keep + * using `@/lib/api/client` (same-origin via the reverse proxy) for live ops. + */ + +import type { + AppConfig, + DashboardActivity, + DashboardStats, + Guild, + MediaState, + ModerationAction, + ModerationStats, + PaginatedRecordings, + VoiceStatus, +} from "@/lib/types"; + +const BACKEND_URL = + process.env.GMW_BACKEND_URL?.replace(/\/+$/, "") || "http://127.0.0.1:4001"; + +export class ApiServerError extends Error { + statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.name = "ApiServerError"; + this.statusCode = statusCode; + } +} + +async function serverFetch( + path: string, + init?: { timeoutMs?: number }, +): Promise { + const url = `${BACKEND_URL}${path}`; + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + init?.timeoutMs ?? 8_000, + ); + + let res: Response; + try { + res = await fetch(url, { + headers: { Accept: "application/json" }, + cache: "no-store", + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new ApiServerError(text || `HTTP ${res.status}`, res.status); + } + return res.json() as Promise; +} + +// ---- Dashboard ---- + +export async function getDashboardStats(): Promise { + return serverFetch("/api/dashboard/stats"); +} + +export async function getActivity(days = 14): Promise { + return serverFetch(`/api/dashboard/activity?days=${days}`); +} + +// ---- Media ---- + +export async function getMediaStatus(): Promise { + return serverFetch("/api/media/status"); +} + +// ---- Config ---- + +export async function getConfig(): Promise { + return serverFetch("/api/config"); +} + +// ---- Moderation ---- + +export async function getModerationStats(): Promise { + return serverFetch("/api/moderation/stats"); +} + +export async function getModerationActions( + limit = 100, +): Promise { + const res = await serverFetch<{ data: ModerationAction[] }>( + `/api/moderation/actions?limit=${limit}`, + ); + return res.data; +} + +// ---- Voice ---- + +export async function getGuilds(): Promise { + return serverFetch("/api/guilds"); +} + +export async function getVoiceStatus(): Promise { + return serverFetch("/api/voice/status"); +} + +// ---- Recordings ---- + +export async function getRecordings(limit = 50): Promise { + return serverFetch(`/api/recordings?limit=${limit}`); +} + +// ---- Messages ---- + +export interface MessagePageResult { + data: import("@/lib/types").MessageRecord[]; + nextCursor: string | null; +} + +export async function getMessages( + guildId: string, + channelId?: string, + cursor?: string, +): Promise { + const params = new URLSearchParams({ guildId }); + if (channelId) params.set("channelId", channelId); + if (cursor) params.set("cursor", cursor); + return serverFetch(`/api/messages?${params.toString()}`); +} diff --git a/services/frontend/src/lib/types/voice.ts b/services/frontend/src/lib/types/voice.ts index 44866aa..44dd2d1 100644 --- a/services/frontend/src/lib/types/voice.ts +++ b/services/frontend/src/lib/types/voice.ts @@ -11,6 +11,12 @@ export interface VoiceStatus { activeChannelId?: string | null; activeChannelName?: string | null; connections: GuildVoiceEntry[]; + /** + * Authoritative shared voice snapshot — who is present / speaking right + * now, aggregated server-side from the gateway's `voice_active_user` + * deltas. All browsers converge on this same list. + */ + activeSpeakers?: ActiveSpeaker[]; } export interface ActiveSpeaker { diff --git a/services/frontend/src/lib/ws/types.ts b/services/frontend/src/lib/ws/types.ts index 86be0f6..795f77b 100644 --- a/services/frontend/src/lib/ws/types.ts +++ b/services/frontend/src/lib/ws/types.ts @@ -42,6 +42,12 @@ export interface WsEventMap { voice_recording_stopped: unknown; voice_recording_uploaded: VoiceRecording; voice_active_user: ActiveSpeaker; + /** + * Authoritative shared live-voice snapshot — `{ activeSpeakers: [...] }`. + * The backend sends this on WS connect (initial state) and clients replace + * their local list wholesale so every user converges on the same state. + */ + voice_state: { activeSpeakers: ActiveSpeaker[] }; /** NOT delivered as JSON — arrives only via onPcm() binary handler as PcmChunk */ voice_pcm_data: never; voice_analyzed: unknown;