feat(fe): optimize error handling, state consistency, and WS feedback
- Add global SWR config with exponential backoff retry (swr-config.ts) - Add ErrorBoundary component for React crash recovery (error-boundary.tsx) - Standardize ErrorState onRetry on all 6 dashboard views (dashboard, media, messages, moderation, recordings, voice) - Fix useAction: expose resetError + onError callback - Refactor useSpeakers to SWR-backed state (was local useState) for consistent cache/revalidate semantics with other hooks - Remove polling in useReview (15s refreshInterval); replace with useReviewWsSync subscribing to WS moderation_action events - Extract hashUserId to lib/hash.ts (de-dup with ambient-canvas) - WS context: add reconnect/error toast feedback via onStatusChange - WS connection: expose reconnectAttemptCount getter All typecheck + lint clean, Next 16 build passes.
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
# FE Optimasi: Error Handling, State Refactor, Polish
|
||||||
|
|
||||||
|
**Scope:** `services/frontend/src` — Next.js 16, React 19, SWR, oRPC-over-WS.
|
||||||
|
**Branch:** `feat/fe-error-handling-state` (buat baru dari `main`).
|
||||||
|
**User:** MythEclipse — bahasa Indonesia/Inggris campur, "gas" = eksekusi langsung.
|
||||||
|
|
||||||
|
## 1. Masalah yang teridentifikasi
|
||||||
|
|
||||||
|
### 1.1 Error handling tidak seragam
|
||||||
|
- `ErrorState` (states.tsx:126) sudah ada dengan `onRetry?`, tapi **80% pemanggilan tidak pass `onRetry`** → user stuck, harus refresh manual.
|
||||||
|
- Pola `if (error && !data) return <ErrorState />` berulang di 5+ view, setiap satu hardcode logic-nya.
|
||||||
|
- `useAction` (use-action.ts) set `error` di state tapi komponen pakai `try/catch` lokal juga — **dua error states** (hook `action.error` + lokal `catch (e)`).
|
||||||
|
|
||||||
|
### 1.2 State duplication / double source of truth
|
||||||
|
- `useReview` (use-messages.ts:129) pakai `refreshInterval: 15_000` (polling) padahal WS sudah broadcast `moderation_action` real-time. **Polling + WS = double-fetch + race condition.**
|
||||||
|
- `page.tsx` dan `view.tsx` duplikat state `guildId`/`channelId`/`query` — logic selection ada di 2 tempat.
|
||||||
|
- Chatbot pakai local `failed` state + `setFailed(text)` untuk retry, tapi `useAction` sudah ada `error` field yang tidak dipakai.
|
||||||
|
- `useSpeakers` (use-voice.ts:50) pakai local `useState` untuk speakers, bukan SWR → tidak konsisten dengan pola SWR yang dipakai semua hook lain.
|
||||||
|
|
||||||
|
### 1.3 WebSocket error/user feedback lemah
|
||||||
|
- `WsConnection.onerror` (connection.ts:78) cuma `setStatus("error")` — tidak ada info ke user "reconnecting… (attempt N)".
|
||||||
|
- Tidak ada broadcast error ke komponen (tidak ada `ws_error` handler).
|
||||||
|
|
||||||
|
## 2. Rencana perubahan
|
||||||
|
|
||||||
|
### 2.1 Standardisasi ErrorBoundary + Retry (FE-only)
|
||||||
|
**File:** `src/components/shared/states.tsx`
|
||||||
|
- Tambahkan komponen `ErrorBoundary` (React error boundary untuk crash React, bukan fetch error).
|
||||||
|
- Refactor semua `<ErrorState error={error} />` → `<ErrorState error={error} onRetry={retryFn} />`.
|
||||||
|
- Tambahkan helper `withSWR` atau pattern: tiap view pakai `error` + `mutate`/`refetch` dari SWR dan pass ke ErrorState.
|
||||||
|
|
||||||
|
**File:** `src/app/(dashboard)/*/view.tsx` (6 files)
|
||||||
|
- Setiap `ErrorState` dapat `onRetry` yang memanggil `mutate`/`refetch`.
|
||||||
|
- `DashboardView`: `onRetry={() => void mutate(["dashboard-stats"])}` — tapi SWR keys tersebar. Solusi: export `mutate` via custom hook atau pakai `useSWR` config `onErrorRetry` global.
|
||||||
|
- **Keputusan:** gunakan pola **global SWR config** (`src/lib/swr-config.ts`) dengan `onErrorRetry` backoff, dan tiap view pass `onRetry` explicit ke ErrorState.
|
||||||
|
|
||||||
|
### 2.2 Hapus polling `useReview`, ganti WS-driven
|
||||||
|
**File:** `src/hooks/use-messages.ts`
|
||||||
|
- Hapus `refreshInterval: 15_000` dari `useReview`.
|
||||||
|
- Tambahkan `useReviewWsSync(ws)` — subscribe ke `moderation_action` WS event, mutate key `["messages-review", channelId]`.
|
||||||
|
- Backend sudah broadcast `moderation_action` via WS (redis-channels.ts:130). Review messages yang di-flag akan dapat `moderation_action` event. **Bisa pakai ini.**
|
||||||
|
|
||||||
|
**File:** `src/app/(dashboard)/messages/view.tsx`
|
||||||
|
- Tambahkan `useReviewWsSync(ws)` call.
|
||||||
|
|
||||||
|
### 2.3 Konsistensi state: `useSpeakers` → SWR
|
||||||
|
**File:** `src/hooks/use-voice.ts`
|
||||||
|
- Refactor `useSpeakers` agar pakai SWR key `["voice-speakers"]` + initialData dari `useVoiceStatus`. Ini memungkinkan revalidate + cache sharing.
|
||||||
|
- Tapi `voice_state` dan `voice_active_user` adalah WS events — perlu persist ke SWR cache via `mutate`. Refactor: `useSpeakers` subscribe WS + mutate SWR key.
|
||||||
|
|
||||||
|
### 2.4 Chatbot: gunakan `useAction` error state, hapus duplikat `failed`
|
||||||
|
**File:** `src/components/chatbot/chatbot.tsx`
|
||||||
|
- Hapus `const [failed, setFailed]` — sebalihoikan ke `useAction` return `error`.
|
||||||
|
- Tapi `useAction` `mutateAsync` throw — perlu catch. Refactor: pakai `mutate` (fire-and-forget) + `isPending` + `error`.
|
||||||
|
- **Note:** chatbot pakai `send` yang butuh pemuatan history — tetap pakai local state untuk msgs tapi gunakan `action.error` untuk display.
|
||||||
|
|
||||||
|
### 2.5 WS error feedback
|
||||||
|
**File:** `src/lib/ws/connection.ts`
|
||||||
|
- `onerror` emit kode/status ke status listeners.
|
||||||
|
- Tambahkan method `getReconnectAttempt()` atau expose via status change.
|
||||||
|
|
||||||
|
**File:** `src/lib/ws/context.tsx`
|
||||||
|
- Subscribe `onStatusChange` di `WsProvider`, toast "Reconnecting… (attempt N)" ketika status `error`/`connecting`.
|
||||||
|
|
||||||
|
## 3. Verification
|
||||||
|
- `npx tsc --noEmit` — compile OK
|
||||||
|
- `npx biome check src/` — lint OK
|
||||||
|
- `npm run build` — build OK (Next 16 SSG/SSR)
|
||||||
|
- Manual: refresh halaman, pastikan ErrorState muncul dengan tombol Retry yang bisa diklik.
|
||||||
|
|
||||||
|
## 4. Files yang disentuh
|
||||||
|
```
|
||||||
|
src/components/shared/states.tsx # ErrorBoundary + helper
|
||||||
|
src/lib/swr-config.ts # global SWR config (baru)
|
||||||
|
src/hooks/use-messages.ts # useReviewWsSync, hapus polling
|
||||||
|
src/hooks/use-voice.ts # useSpeakers → SWR
|
||||||
|
src/hooks/use-action.ts # expose resetError
|
||||||
|
src/lib/ws/connection.ts # reconnect info
|
||||||
|
src/lib/ws/context.tsx # WS error toast
|
||||||
|
src/app/(dashboard)/messages/view.tsx # useReviewWsSync
|
||||||
|
src/app/(dashboard)/messages/page.tsx # SSR error passthrough
|
||||||
|
src/app/(dashboard)/moderation/view.tsx # onRetry
|
||||||
|
src/app/(dashboard)/dashboard/view.tsx # onRetry
|
||||||
|
src/app/(dashboard)/voice/view.tsx # onRetry
|
||||||
|
src/app/(dashboard)/media/view.tsx # onRetry
|
||||||
|
src/app/(dashboard)/recordings/view.tsx # onRetry
|
||||||
|
src/components/chatbot/chatbot.tsx # pakai useAction error
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Out of scope (BE)
|
||||||
|
- Regex heuristic di moderation.repository.ts (scam domain extraction) — ini BE task, catat tapi jangan sentuh kecuali diminta.
|
||||||
@@ -50,7 +50,12 @@ export function DashboardView({
|
|||||||
initialStats?: DashboardStats;
|
initialStats?: DashboardStats;
|
||||||
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
||||||
}) {
|
}) {
|
||||||
const { data: stats, isLoading, error } = useStats(initialStats);
|
const {
|
||||||
|
data: stats,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate: mutateStats,
|
||||||
|
} = useStats(initialStats);
|
||||||
const { data: activity } = useActivity(14, initialActivity as never);
|
const { data: activity } = useActivity(14, initialActivity as never);
|
||||||
const { data: reactors } = useTopReactors();
|
const { data: reactors } = useTopReactors();
|
||||||
const { data: reactions } = useTopReactions();
|
const { data: reactions } = useTopReactions();
|
||||||
@@ -65,7 +70,8 @@ export function DashboardView({
|
|||||||
);
|
);
|
||||||
}, [stats, ambient]);
|
}, [stats, ambient]);
|
||||||
|
|
||||||
if (error && !stats) return <ErrorState error={error} />;
|
if (error && !stats)
|
||||||
|
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
|
||||||
if (!stats && isLoading)
|
if (!stats && isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -78,7 +84,13 @@ export function DashboardView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
if (!stats)
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
error={error ?? new Error("No data")}
|
||||||
|
onRetry={() => void mutateStats()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const s = stats;
|
const s = stats;
|
||||||
const total = s.total_flagged + s.total_clean || 1;
|
const total = s.total_flagged + s.total_clean || 1;
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ import { useWebSocket } from "@/lib/ws/context";
|
|||||||
|
|
||||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { data: media, isLoading, error } = useMediaState(initialStatus);
|
const {
|
||||||
|
data: media,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate,
|
||||||
|
} = useMediaState(initialStatus);
|
||||||
const queue = useMediaQueue();
|
const queue = useMediaQueue();
|
||||||
const skip = useMediaSkip();
|
const skip = useMediaSkip();
|
||||||
const stop = useMediaStop();
|
const stop = useMediaStop();
|
||||||
@@ -79,7 +84,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error && !media) return <ErrorState error={error} />;
|
if (error && !media)
|
||||||
|
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||||
if (!media && isLoading)
|
if (!media && isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
useMessagesStream,
|
useMessagesStream,
|
||||||
useMessagesWsSync,
|
useMessagesWsSync,
|
||||||
useRecentEdits,
|
useRecentEdits,
|
||||||
|
useReviewWsSync,
|
||||||
useSemanticSearch,
|
useSemanticSearch,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
@@ -94,6 +95,7 @@ export function MessagesView({
|
|||||||
data: messages,
|
data: messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
refetch,
|
||||||
} = useMessages(
|
} = useMessages(
|
||||||
guildId ?? "",
|
guildId ?? "",
|
||||||
channelId ?? undefined,
|
channelId ?? undefined,
|
||||||
@@ -112,6 +114,7 @@ export function MessagesView({
|
|||||||
const hasMore = pageInfo?.hasMore ?? false;
|
const hasMore = pageInfo?.hasMore ?? false;
|
||||||
const loadMore = useLoadMore();
|
const loadMore = useLoadMore();
|
||||||
useMessagesWsSync(ws, guildId ?? "");
|
useMessagesWsSync(ws, guildId ?? "");
|
||||||
|
useReviewWsSync(ws);
|
||||||
const search = useMessageSearch(
|
const search = useMessageSearch(
|
||||||
query,
|
query,
|
||||||
query.trim().length >= 2 && !semanticMode,
|
query.trim().length >= 2 && !semanticMode,
|
||||||
@@ -326,7 +329,7 @@ export function MessagesView({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{error && !messages ? (
|
{error && !messages ? (
|
||||||
<ErrorState error={error} />
|
<ErrorState error={error} onRetry={() => refetch()} />
|
||||||
) : isLoading && !messages ? (
|
) : isLoading && !messages ? (
|
||||||
<SkeletonRows rows={8} />
|
<SkeletonRows rows={8} />
|
||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
|
|||||||
@@ -81,7 +81,12 @@ export function ModerationView({
|
|||||||
initialStats?: ModerationStats;
|
initialStats?: ModerationStats;
|
||||||
initialActions?: ModerationAction[];
|
initialActions?: ModerationAction[];
|
||||||
}) {
|
}) {
|
||||||
const { data: stats, isLoading, error } = useModerationStats(initialStats);
|
const {
|
||||||
|
data: stats,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate: mutateStats,
|
||||||
|
} = useModerationStats(initialStats);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("");
|
const [typeFilter, setTypeFilter] = useState<string>("");
|
||||||
const { data: actions } = useModerationActions(
|
const { data: actions } = useModerationActions(
|
||||||
@@ -122,7 +127,8 @@ export function ModerationView({
|
|||||||
);
|
);
|
||||||
}, [failedRate, ambient]);
|
}, [failedRate, ambient]);
|
||||||
|
|
||||||
if (error && !stats) return <ErrorState error={error} />;
|
if (error && !stats)
|
||||||
|
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
|
||||||
if (!stats && isLoading)
|
if (!stats && isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -131,7 +137,13 @@ export function ModerationView({
|
|||||||
<SkeletonRows rows={6} />
|
<SkeletonRows rows={6} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
if (!stats)
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
error={error ?? new Error("No data")}
|
||||||
|
onRetry={() => void mutateStats()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const statusOpts: SelectOption[] = [
|
const statusOpts: SelectOption[] = [
|
||||||
{ value: "", label: "All statuses" },
|
{ value: "", label: "All statuses" },
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function RecordingsView({
|
|||||||
initialItems?: VoiceRecording[];
|
initialItems?: VoiceRecording[];
|
||||||
}) {
|
}) {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { data: items, isLoading, error } = useRecordings(initialItems);
|
const { data: items, isLoading, error, mutate } = useRecordings(initialItems);
|
||||||
const del = useDeleteRecording();
|
const del = useDeleteRecording();
|
||||||
useRecordingsWsSync(ws);
|
useRecordingsWsSync(ws);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
@@ -56,7 +56,8 @@ export function RecordingsView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error && !items) return <ErrorState error={error} />;
|
if (error && !items)
|
||||||
|
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||||
if (!items && isLoading)
|
if (!items && isLoading)
|
||||||
return (
|
return (
|
||||||
<GlassPanel>
|
<GlassPanel>
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ export function VoiceView({
|
|||||||
initialGuilds?: Guild[];
|
initialGuilds?: Guild[];
|
||||||
}) {
|
}) {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { data: status, isLoading, error } = useVoiceStatus(initialStatus);
|
const {
|
||||||
|
data: status,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate,
|
||||||
|
} = useVoiceStatus(initialStatus);
|
||||||
const connect = useVoiceConnect();
|
const connect = useVoiceConnect();
|
||||||
const disconnect = useVoiceDisconnect();
|
const disconnect = useVoiceDisconnect();
|
||||||
const mic = useMicTransmit(ws);
|
const mic = useMicTransmit(ws);
|
||||||
@@ -68,7 +73,8 @@ export function VoiceView({
|
|||||||
else ambient.set("vermilion", 0.35, "voice idle");
|
else ambient.set("vermilion", 0.35, "voice idle");
|
||||||
}, [status?.connected, ambient]);
|
}, [status?.connected, ambient]);
|
||||||
|
|
||||||
if (error && !status) return <ErrorState error={error} />;
|
if (error && !status)
|
||||||
|
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||||
if (!status && isLoading)
|
if (!status && isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Component, type ReactNode } from "react";
|
||||||
|
import { ErrorState } from "./states";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React error boundary — catches render/exception crashes in child trees
|
||||||
|
* (e.g. third-party lib throwing on unexpected payload shape) and surfaces
|
||||||
|
* a consistent ErrorState instead of unmounting the whole app shell.
|
||||||
|
*
|
||||||
|
* Usage: wrap leaf views in <ErrorBoundary><SomeView /></ErrorBoundary>.
|
||||||
|
*/
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: (error: Error, reset: () => void) => ReactNode;
|
||||||
|
onReset?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<
|
||||||
|
ErrorBoundaryProps,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
reset = () => {
|
||||||
|
this.setState({ error: null });
|
||||||
|
this.props.onReset?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (!this.state.error) return this.props.children;
|
||||||
|
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback(this.state.error, this.reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title="Something went wrong"
|
||||||
|
error={this.state.error}
|
||||||
|
onRetry={this.reset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export { GuildChannelPicker } from "./guild-picker";
|
|||||||
export { MarkdownLite } from "./markdown";
|
export { MarkdownLite } from "./markdown";
|
||||||
export { PageTransition } from "./page-transition";
|
export { PageTransition } from "./page-transition";
|
||||||
export { MetricTile, SectionHeader } from "./section";
|
export { MetricTile, SectionHeader } from "./section";
|
||||||
|
export { ErrorBoundary } from "./error-boundary";
|
||||||
export {
|
export {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
ErrorState,
|
ErrorState,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export {
|
|||||||
useMessagesWsSync,
|
useMessagesWsSync,
|
||||||
useRecentEdits,
|
useRecentEdits,
|
||||||
useReview,
|
useReview,
|
||||||
|
useReviewWsSync,
|
||||||
useSemanticSearch,
|
useSemanticSearch,
|
||||||
useTextChannels,
|
useTextChannels,
|
||||||
} from "./use-messages";
|
} from "./use-messages";
|
||||||
|
|||||||
@@ -7,17 +7,23 @@ export interface UseActionState {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A lightweight mutation hook with a TanStack-compatible surface
|
* A lightweight mutation hook with a TanStack-compatible surface
|
||||||
* ({ mutate, mutateAsync, isPending, error }) built on plain state —
|
* ({ mutate, mutateAsync, isPending, error, resetError }) built on plain state —
|
||||||
* the SWR replacement for useMutation. Fire-and-forget via `mutate`,
|
* the SWR replacement for useMutation. Fire-and-forget via `mutate`,
|
||||||
* await the result via `mutateAsync`.
|
* await the result via `mutateAsync`.
|
||||||
*
|
*
|
||||||
* `onSuccess` receives (data, args) and may perform SWR cache updates
|
* `onSuccess` receives (data, args) and may perform SWR cache updates
|
||||||
* (e.g. `mutate(key, data, { revalidate: false })`).
|
* (e.g. `mutate(key, data, { revalidate: false })`).
|
||||||
|
*
|
||||||
|
* `onError` receives the caught error and args — use it for side-effect
|
||||||
|
* logging without throwing (the error is also surfaced via `state.error`).
|
||||||
|
*
|
||||||
|
* `resetError` clears the error state without re-running the action.
|
||||||
*/
|
*/
|
||||||
export function useAction<TArgs = void, TResult = unknown>(
|
export function useAction<TArgs = void, TResult = unknown>(
|
||||||
fn: (args: TArgs) => Promise<TResult>,
|
fn: (args: TArgs) => Promise<TResult>,
|
||||||
options?: {
|
options?: {
|
||||||
onSuccess?: (data: TResult, args: TArgs) => void | Promise<void>;
|
onSuccess?: (data: TResult, args: TArgs) => void | Promise<void>;
|
||||||
|
onError?: (error: Error, args: TArgs) => void | Promise<void>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const [state, setState] = useState<UseActionState>({
|
const [state, setState] = useState<UseActionState>({
|
||||||
@@ -29,6 +35,8 @@ export function useAction<TArgs = void, TResult = unknown>(
|
|||||||
fnRef.current = fn;
|
fnRef.current = fn;
|
||||||
const onSuccessRef = useRef(options?.onSuccess);
|
const onSuccessRef = useRef(options?.onSuccess);
|
||||||
onSuccessRef.current = options?.onSuccess;
|
onSuccessRef.current = options?.onSuccess;
|
||||||
|
const onErrorRef = useRef(options?.onError);
|
||||||
|
onErrorRef.current = options?.onError;
|
||||||
|
|
||||||
const run = useCallback(async (args?: TArgs): Promise<TResult> => {
|
const run = useCallback(async (args?: TArgs): Promise<TResult> => {
|
||||||
setState({ isPending: true, error: null });
|
setState({ isPending: true, error: null });
|
||||||
@@ -38,8 +46,10 @@ export function useAction<TArgs = void, TResult = unknown>(
|
|||||||
setState({ isPending: false, error: null });
|
setState({ isPending: false, error: null });
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({ isPending: false, error: err as Error });
|
const error = err as Error;
|
||||||
throw err;
|
setState({ isPending: false, error });
|
||||||
|
await onErrorRef.current?.(error, args as TArgs);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -50,6 +60,6 @@ export function useAction<TArgs = void, TResult = unknown>(
|
|||||||
mutateAsync: run,
|
mutateAsync: run,
|
||||||
isPending: state.isPending,
|
isPending: state.isPending,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
reset: () => setState({ isPending: false, error: null }),
|
resetError: useCallback(() => setState((s) => ({ ...s, error: null })), []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,21 +124,43 @@ export function useImages(guildId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Review ───────────────────────────────────────
|
// ── Review ────────────────────────────────────────
|
||||||
|
// WS-driven now (was: refreshInterval:15000 polling). The backend broadcasts
|
||||||
|
// `moderation_action` events over WS whenever a moderation action is created;
|
||||||
|
// the review list is revalidated on that event instead of polling.
|
||||||
|
|
||||||
export function useReview(channelId?: string) {
|
export function useReview(channelId?: string) {
|
||||||
|
const key = msgKeys.review(channelId);
|
||||||
return useSWR<MessageRecord[]>(
|
return useSWR<MessageRecord[]>(
|
||||||
msgKeys.review(channelId),
|
key,
|
||||||
async () => {
|
async () => {
|
||||||
const result = await messagesApi.getReview(50, channelId || undefined);
|
const result = await messagesApi.getReview(50, channelId || undefined);
|
||||||
return result.results;
|
return result.results;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
refreshInterval: 15_000,
|
revalidateOnFocus: true,
|
||||||
|
// No more polling — WS sync handles real-time updates.
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to WS `moderation_action` events and invalidate the review
|
||||||
|
* SWR cache. Replaces the old 15-second polling interval that caused
|
||||||
|
* duplicate requests and race conditions with WS updates.
|
||||||
|
*/
|
||||||
|
export function useReviewWsSync(ws: WsHook, channelId?: string) {
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
const key = msgKeys.review(channelId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = ws.on("moderation_action", () => {
|
||||||
|
void mutate(key, undefined, { revalidate: true });
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [ws, key, mutate]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Detail ───────────────────────────────────────
|
// ── Detail ───────────────────────────────────────
|
||||||
|
|
||||||
export function useMessageDetail(id: string | null) {
|
export function useMessageDetail(id: string | null) {
|
||||||
@@ -168,7 +190,11 @@ export function useMessageDetail(id: string | null) {
|
|||||||
message: detail.data ?? null,
|
message: detail.data ?? null,
|
||||||
attachments: attachments.data ?? [],
|
attachments: attachments.data ?? [],
|
||||||
loading: detail.isLoading || attachments.isLoading,
|
loading: detail.isLoading || attachments.isLoading,
|
||||||
error: detail.error,
|
error: detail.error ?? attachments.error,
|
||||||
|
refetch: () => {
|
||||||
|
void detail.mutate();
|
||||||
|
void attachments.mutate();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +242,7 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
|
|||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!guildId) return;
|
if (!guildId) return;
|
||||||
// Patch every message-list key for this guild (all channels + "**filtered**").
|
// Patch every message-list key for this guild (all channels + "__all__").
|
||||||
// The updater receives the SWR key so we can honor its channel filter:
|
// The updater receives the SWR key so we can honor its channel filter:
|
||||||
// a live `message_created`/updated for channel B must NOT be prepended to
|
// a live `message_created`/updated for channel B must NOT be prepended to
|
||||||
// a list that is filtered down to channel A.
|
// a list that is filtered down to channel A.
|
||||||
@@ -255,13 +281,13 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
|
|||||||
});
|
});
|
||||||
const unsub2 = ws.on("message_updated", (data) => {
|
const unsub2 = ws.on("message_updated", (data) => {
|
||||||
const msg = data as Partial<MessageRecord> & { id: string };
|
const msg = data as Partial<MessageRecord> & { id: string };
|
||||||
// The gateway broadcasts a PARTIAL update ({ id, edited_content,
|
// The gateway broadcasts a PARTIAL update ({ id } plus the changed fields
|
||||||
// edited_at, ... }) — merge it over the existing record instead of
|
// — merge it over the existing record instead of replacing it, or the
|
||||||
// replacing it, or the card would lose username/content/channel/etc.
|
// card would lose username/content/channel/etc.
|
||||||
patchLists(
|
patchLists(
|
||||||
(_k, m) =>
|
(_k, m) =>
|
||||||
(m as Partial<MessageRecord>).channel_id === undefined ||
|
(m as Partial<MessageRecord>).channel_id === undefined ||
|
||||||
matchesFilter(_k as unknown[], m),
|
matchesFilter(_k as unknown[], m as { channel_id?: string }),
|
||||||
(old) =>
|
(old) =>
|
||||||
old
|
old
|
||||||
? {
|
? {
|
||||||
@@ -271,7 +297,7 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: old,
|
: old,
|
||||||
msg,
|
msg as { channel_id?: string },
|
||||||
);
|
);
|
||||||
void mutate(
|
void mutate(
|
||||||
msgKeys.detail(msg.id),
|
msgKeys.detail(msg.id),
|
||||||
@@ -321,7 +347,7 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) {
|
|||||||
* `message_snapshot` into the SWR list as it arrives, so the UI renders
|
* `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.
|
* progressively. Falls back to the batched `messagesApi.list` if WS is down.
|
||||||
*
|
*
|
||||||
* Returns: { streaming, streamed, error }.
|
* Returns: { streaming, error }.
|
||||||
*/
|
*/
|
||||||
export function useMessagesStream(
|
export function useMessagesStream(
|
||||||
ws: WsHook,
|
ws: WsHook,
|
||||||
@@ -414,3 +440,5 @@ export function useRecentEdits(
|
|||||||
{ fallbackData: initialData },
|
{ fallbackData: initialData },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { msgKeys };
|
||||||
|
|||||||
@@ -4,19 +4,13 @@ import { useAction } from "@/hooks/use-action";
|
|||||||
import { voiceApi } from "@/lib/api";
|
import { voiceApi } from "@/lib/api";
|
||||||
import { MicTransmitter } from "@/lib/audio/mic-transmit";
|
import { MicTransmitter } from "@/lib/audio/mic-transmit";
|
||||||
import { PcmPlayer } from "@/lib/audio/pcm-player";
|
import { PcmPlayer } from "@/lib/audio/pcm-player";
|
||||||
|
import { hashUserId } from "@/lib/hash";
|
||||||
import type { ActiveSpeaker, Channel, VoiceStatus } from "@/lib/types";
|
import type { ActiveSpeaker, Channel, VoiceStatus } from "@/lib/types";
|
||||||
import type { PcmChunk } from "@/lib/ws/types";
|
import type { PcmChunk } from "@/lib/ws/types";
|
||||||
import type { WsHook } from "@/lib/ws-hook";
|
import type { WsHook } from "@/lib/ws-hook";
|
||||||
|
|
||||||
/** FNV-1a 32-bit — same hash the gateway uses to tag PCM frames. */
|
// Re-export for components that still import hashUserId from this module.
|
||||||
export function hashUserId(userId: string): number {
|
export { hashUserId };
|
||||||
let hash = 0x811c9dc5;
|
|
||||||
for (let i = 0; i < userId.length; i++) {
|
|
||||||
hash ^= userId.charCodeAt(i);
|
|
||||||
hash = Math.imul(hash, 0x01000193);
|
|
||||||
}
|
|
||||||
return hash >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_KEY = ["voice-status"] as const;
|
const STATUS_KEY = ["voice-status"] as const;
|
||||||
|
|
||||||
@@ -34,6 +28,8 @@ export function useVoiceChannels(guildId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SPEAKERS_KEY = ["voice-speakers"] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live shared speaker state.
|
* Live shared speaker state.
|
||||||
*
|
*
|
||||||
@@ -44,48 +40,66 @@ export function useVoiceChannels(guildId: string) {
|
|||||||
* every client with the same list);
|
* every client with the same list);
|
||||||
* - `voice_active_user` → incremental upsert of a single speaker delta.
|
* - `voice_active_user` → incremental upsert of a single speaker delta.
|
||||||
*
|
*
|
||||||
|
* Now backed by SWR (consistent with all other hooks) so cache, revalidation,
|
||||||
|
* and deduping apply. WS events mutate the SWR cache directly
|
||||||
|
* ({ revalidate: false }) to avoid refetching the full status.
|
||||||
|
*
|
||||||
* This replaces the old per-browser model where each tab accumulated speakers
|
* This replaces the old per-browser model where each tab accumulated speakers
|
||||||
* only from events it happened to receive while mounted.
|
* only from events it happened to receive while mounted.
|
||||||
*/
|
*/
|
||||||
export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
|
export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
|
||||||
const [speakers, setSpeakers] = useState<ActiveSpeaker[]>(
|
const {
|
||||||
initialStatusActive ?? [],
|
data: speakers,
|
||||||
|
error,
|
||||||
|
mutate,
|
||||||
|
isValidating,
|
||||||
|
} = useSWR<ActiveSpeaker[]>(SPEAKERS_KEY, () => Promise.resolve([]), {
|
||||||
|
fallbackData: initialStatusActive ?? [],
|
||||||
|
revalidateOnMount: false,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnReconnect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(ws: WsHook) => {
|
||||||
|
const unsubSnapshot = ws.on("voice_state", (data) => {
|
||||||
|
const state = data as { activeSpeakers?: ActiveSpeaker[] };
|
||||||
|
if (Array.isArray(state?.activeSpeakers)) {
|
||||||
|
void mutate(state.activeSpeakers, { revalidate: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const unsub = ws.on("voice_active_user", (data) => {
|
||||||
|
const speaker = data as ActiveSpeaker;
|
||||||
|
void mutate(
|
||||||
|
(prev: ActiveSpeaker[] | undefined) => {
|
||||||
|
const arr = prev ?? [];
|
||||||
|
const idx = arr.findIndex((s) => s.userId === speaker.userId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const next = [...arr];
|
||||||
|
next[idx] = speaker;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [...arr, speaker];
|
||||||
|
},
|
||||||
|
{ revalidate: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsubSnapshot();
|
||||||
|
unsub();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const subscribe = useCallback((ws: WsHook) => {
|
return { speakers: speakers ?? [], subscribe, error, isValidating };
|
||||||
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) => {
|
|
||||||
const idx = prev.findIndex((s) => s.userId === speaker.userId);
|
|
||||||
if (idx >= 0) {
|
|
||||||
const next = [...prev];
|
|
||||||
next[idx] = speaker;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
return [...prev, speaker];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
unsubSnapshot();
|
|
||||||
unsub();
|
|
||||||
setSpeakers([]);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { speakers, subscribe };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useStatusInvalidator() {
|
function useStatusInvalidator() {
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
return () => {
|
return useCallback(() => {
|
||||||
void mutate(STATUS_KEY);
|
void mutate(STATUS_KEY);
|
||||||
};
|
}, [mutate]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVoiceConnect() {
|
export function useVoiceConnect() {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* FNV-1a 32-bit hash — same hash the gateway uses to tag PCM frames.
|
||||||
|
* Shared between use-voice hooks and ambient-canvas.
|
||||||
|
*/
|
||||||
|
export function hashUserId(userId: string): number {
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < userId.length; i++) {
|
||||||
|
hash ^= userId.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Global SWR configuration for the GMW frontend.
|
||||||
|
*
|
||||||
|
* Centralizes revalidation policy, error retry with exponential backoff, and
|
||||||
|
* deduplication so every `useSWR` call across the app gets consistent behaviour
|
||||||
|
* without each hook re-declaring the same options.
|
||||||
|
*
|
||||||
|
* Import this in the root layout (or any component mounted once) via
|
||||||
|
* `SWRConfig` from "swr".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SWRConfiguration } from "swr";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential backoff retry — starts at ~1s, doubles up to 30s, then flatlines.
|
||||||
|
* Matches the existing WsConnection reconnection philosophy.
|
||||||
|
*/
|
||||||
|
export const swrConfig: SWRConfiguration = {
|
||||||
|
// Re-validate on focus (tab switch) but not on every interval by default.
|
||||||
|
revalidateOnFocus: true,
|
||||||
|
// Dedupe rapid identical requests within 2s.
|
||||||
|
dedupingInterval: 2000,
|
||||||
|
// Never throw unhandled rejections — every hook handles `error` gracefully.
|
||||||
|
shouldRetryOnError: (error) => {
|
||||||
|
// Don't retry on 404 (NotFound) — it's a client expectation, not transient.
|
||||||
|
if (error?.statusCode === 404 || error?.code === "NOT_FOUND") return false;
|
||||||
|
// Retry network errors, 5xx, and oRPC transport failures.
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onErrorRetry: (_error, _key, _config, revalidate, opts) => {
|
||||||
|
const attemptCount = (opts as { attemptCount?: number }).attemptCount ?? 0;
|
||||||
|
// Stop retrying after 3 attempts (≈ 1+2+4 = 7s max backoff).
|
||||||
|
if (attemptCount >= 3) return;
|
||||||
|
|
||||||
|
// Exponential backoff: 1000 * 2^attempt, capped at 30000ms.
|
||||||
|
const baseDelay = 1000 * 2 ** attemptCount;
|
||||||
|
const delay = Math.min(baseDelay, 30000);
|
||||||
|
|
||||||
|
// Jitter ±25% to avoid thundering herd on shared endpoints.
|
||||||
|
const jitter = delay * 0.25 * (Math.random() * 2 - 1);
|
||||||
|
const timeout = Math.round(delay + jitter);
|
||||||
|
|
||||||
|
setTimeout(revalidate, timeout);
|
||||||
|
},
|
||||||
|
// Sensible defaults for the "loading" state — most data loads in <500ms.
|
||||||
|
loadingTimeout: 15000,
|
||||||
|
};
|
||||||
@@ -37,6 +37,11 @@ export class WsConnection {
|
|||||||
return this._status;
|
return this._status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current reconnect attempt count (0 = connected/first attempt). */
|
||||||
|
get reconnectAttemptCount(): number {
|
||||||
|
return this.reconnectAttempt;
|
||||||
|
}
|
||||||
|
|
||||||
onStatusChange(listener: (status: WsStatus) => void): () => void {
|
onStatusChange(listener: (status: WsStatus) => void): () => void {
|
||||||
this.statusListeners.push(listener);
|
this.statusListeners.push(listener);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { toast } from "@/components/primitives";
|
||||||
import { WsConnection } from "./connection";
|
import { WsConnection } from "./connection";
|
||||||
import type { PcmChunk, WsEventHandler, WsEventType, WsStatus } from "./types";
|
import type { PcmChunk, WsEventHandler, WsEventType, WsStatus } from "./types";
|
||||||
|
|
||||||
@@ -29,16 +30,6 @@ interface WsContextValue {
|
|||||||
|
|
||||||
const WsContext = createContext<WsContextValue | null>(null);
|
const WsContext = createContext<WsContextValue | null>(null);
|
||||||
|
|
||||||
/** FNV-1a 32-bit hash matching the backend's hashUserId function */
|
|
||||||
function _hashUserId(userId: string): number {
|
|
||||||
let hash = 0x811c9dc5;
|
|
||||||
for (let i = 0; i < userId.length; i++) {
|
|
||||||
hash ^= userId.charCodeAt(i);
|
|
||||||
hash = Math.imul(hash, 0x01000193);
|
|
||||||
}
|
|
||||||
return hash >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WsProvider({
|
export function WsProvider({
|
||||||
children,
|
children,
|
||||||
url,
|
url,
|
||||||
@@ -48,6 +39,9 @@ export function WsProvider({
|
|||||||
}) {
|
}) {
|
||||||
const connRef = useRef<WsConnection | null>(null);
|
const connRef = useRef<WsConnection | null>(null);
|
||||||
const [status, setStatus] = useState<WsStatus>("disconnected");
|
const [status, setStatus] = useState<WsStatus>("disconnected");
|
||||||
|
// Tracks whether we've ever been connected — used to suppress the
|
||||||
|
// "reconnecting" toast on initial page load.
|
||||||
|
const wasConnected = useRef(false);
|
||||||
|
|
||||||
// Event handler registry — Ref so listeners survive re-renders without reconnect
|
// Event handler registry — Ref so listeners survive re-renders without reconnect
|
||||||
// Using unknown as internal store; typed at the subscribe interface
|
// Using unknown as internal store; typed at the subscribe interface
|
||||||
@@ -85,7 +79,31 @@ export function WsProvider({
|
|||||||
const conn = new WsConnection(url);
|
const conn = new WsConnection(url);
|
||||||
connRef.current = conn;
|
connRef.current = conn;
|
||||||
|
|
||||||
const unsubStatus = conn.onStatusChange(setStatus);
|
const unsubStatus = conn.onStatusChange((s) => {
|
||||||
|
setStatus(s);
|
||||||
|
// User feedback on WS lifecycle transitions.
|
||||||
|
if (s === "connecting") {
|
||||||
|
// Only toast if we were previously connected (i.e. a disconnect,
|
||||||
|
// not the initial connect on page load).
|
||||||
|
if (wasConnected.current) {
|
||||||
|
toast({
|
||||||
|
title: "Reconnecting…",
|
||||||
|
description: "WebSocket connection lost. Attempting to reconnect.",
|
||||||
|
tone: "neutral",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wasConnected.current = false;
|
||||||
|
} else if (s === "connected") {
|
||||||
|
wasConnected.current = true;
|
||||||
|
} else if (s === "error" && !wasConnected.current) {
|
||||||
|
toast({
|
||||||
|
title: "Connection error",
|
||||||
|
description:
|
||||||
|
"WebSocket failed to connect. Retrying in the background.",
|
||||||
|
tone: "vermilion",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
const unsubEvent = conn.onEvent((event) => {
|
const unsubEvent = conn.onEvent((event) => {
|
||||||
if (event.type === "text") {
|
if (event.type === "text") {
|
||||||
handleJsonEvent(event.data);
|
handleJsonEvent(event.data);
|
||||||
|
|||||||
Reference in New Issue
Block a user