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:
asepharyana
2026-08-25 13:46:15 +07:00
parent 796c6390ac
commit 31e303c187
17 changed files with 402 additions and 79 deletions
@@ -50,7 +50,12 @@ export function DashboardView({
initialStats?: DashboardStats;
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: reactors } = useTopReactors();
const { data: reactions } = useTopReactions();
@@ -65,7 +70,8 @@ export function DashboardView({
);
}, [stats, ambient]);
if (error && !stats) return <ErrorState error={error} />;
if (error && !stats)
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
if (!stats && isLoading)
return (
<div className="space-y-5">
@@ -78,7 +84,13 @@ export function DashboardView({
</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 total = s.total_flagged + s.total_clean || 1;
@@ -33,7 +33,12 @@ import { useWebSocket } from "@/lib/ws/context";
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const ws = useWebSocket();
const { data: media, isLoading, error } = useMediaState(initialStatus);
const {
data: media,
isLoading,
error,
mutate,
} = useMediaState(initialStatus);
const queue = useMediaQueue();
const skip = useMediaSkip();
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)
return (
<div className="space-y-5">
@@ -39,6 +39,7 @@ import {
useMessagesStream,
useMessagesWsSync,
useRecentEdits,
useReviewWsSync,
useSemanticSearch,
} from "@/hooks";
import { aiTone } from "@/lib/ai-status";
@@ -94,6 +95,7 @@ export function MessagesView({
data: messages,
isLoading,
error,
refetch,
} = useMessages(
guildId ?? "",
channelId ?? undefined,
@@ -112,6 +114,7 @@ export function MessagesView({
const hasMore = pageInfo?.hasMore ?? false;
const loadMore = useLoadMore();
useMessagesWsSync(ws, guildId ?? "");
useReviewWsSync(ws);
const search = useMessageSearch(
query,
query.trim().length >= 2 && !semanticMode,
@@ -326,7 +329,7 @@ export function MessagesView({
}
/>
{error && !messages ? (
<ErrorState error={error} />
<ErrorState error={error} onRetry={() => refetch()} />
) : isLoading && !messages ? (
<SkeletonRows rows={8} />
) : list.length === 0 ? (
@@ -81,7 +81,12 @@ export function ModerationView({
initialStats?: ModerationStats;
initialActions?: ModerationAction[];
}) {
const { data: stats, isLoading, error } = useModerationStats(initialStats);
const {
data: stats,
isLoading,
error,
mutate: mutateStats,
} = useModerationStats(initialStats);
const [statusFilter, setStatusFilter] = useState<string>("");
const [typeFilter, setTypeFilter] = useState<string>("");
const { data: actions } = useModerationActions(
@@ -122,7 +127,8 @@ export function ModerationView({
);
}, [failedRate, ambient]);
if (error && !stats) return <ErrorState error={error} />;
if (error && !stats)
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
if (!stats && isLoading)
return (
<div className="space-y-5">
@@ -131,7 +137,13 @@ export function ModerationView({
<SkeletonRows rows={6} />
</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[] = [
{ value: "", label: "All statuses" },
@@ -33,7 +33,7 @@ export function RecordingsView({
initialItems?: VoiceRecording[];
}) {
const ws = useWebSocket();
const { data: items, isLoading, error } = useRecordings(initialItems);
const { data: items, isLoading, error, mutate } = useRecordings(initialItems);
const del = useDeleteRecording();
useRecordingsWsSync(ws);
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)
return (
<GlassPanel>
@@ -40,7 +40,12 @@ export function VoiceView({
initialGuilds?: Guild[];
}) {
const ws = useWebSocket();
const { data: status, isLoading, error } = useVoiceStatus(initialStatus);
const {
data: status,
isLoading,
error,
mutate,
} = useVoiceStatus(initialStatus);
const connect = useVoiceConnect();
const disconnect = useVoiceDisconnect();
const mic = useMicTransmit(ws);
@@ -68,7 +73,8 @@ export function VoiceView({
else ambient.set("vermilion", 0.35, "voice idle");
}, [status?.connected, ambient]);
if (error && !status) return <ErrorState error={error} />;
if (error && !status)
return <ErrorState error={error} onRetry={() => void mutate()} />;
if (!status && isLoading)
return (
<div className="space-y-5">