@@ -241,7 +235,6 @@ export function AdminFeaturesPanel() {
)}
- {/* Boolean TTS toggles */}
{
+ const res = await fetch('/api/admin/providers');
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = (await res.json()) as { providers: AdminProviderMasked[] };
+ return data.providers;
+}
+
+async function upsertAdminProvider(input: {
+ editingId: string | null;
+ form: FormState;
+}): Promise {
+ const isNew = input.editingId === '__new';
+ const body = {
+ slug: input.form.slug.trim(),
+ displayName: input.form.displayName.trim(),
+ providerType: input.form.providerType,
+ baseUrl: input.form.baseUrl.trim() || null,
+ ...(input.form.apiKey.length > 0 ? { apiKey: input.form.apiKey } : {}),
+ defaultModel: input.form.defaultModel.trim() || null,
+ defaultInstructions: input.form.defaultInstructions.trim() || null,
+ enabled: input.form.enabled,
+ };
+ const url = isNew ? '/api/admin/providers' : `/api/admin/providers/${input.editingId}`;
+ const res = await fetch(url, {
+ method: isNew ? 'POST' : 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(data.error || `HTTP ${res.status}`);
+ }
+}
+
+async function deleteAdminProvider(id: string): Promise {
+ const res = await fetch(`/api/admin/providers/${id}`, { method: 'DELETE' });
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(data.error || `HTTP ${res.status}`);
+ }
+}
+
export function AdminProvidersPanel() {
const runtimeConfig = useRuntimeConfig();
- const [providers, setProviders] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [editingId, setEditingId] = useState(null); // null = none, '__new' = create
+ const queryClient = useQueryClient();
+ const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(() => createEmptyForm());
const [customModelInput, setCustomModelInput] = useState('');
- const [saving, setSaving] = useState(false);
- const refresh = useCallback(async () => {
- setIsLoading(true);
- try {
- const res = await fetch('/api/admin/providers');
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = (await res.json()) as { providers: AdminProviderMasked[] };
- setProviders(data.providers);
- } catch (error) {
- console.error('[AdminProvidersPanel] load failed:', error);
- toast.error('Failed to load admin providers');
- } finally {
- setIsLoading(false);
- }
- }, []);
+ const { data: providers = [], isPending: isLoading, error } = useQuery({
+ queryKey: ADMIN_PROVIDERS_QUERY_KEY,
+ queryFn: fetchAdminProviders,
+ });
useEffect(() => {
- refresh();
- }, [refresh]);
+ if (!error) return;
+ console.error('[AdminProvidersPanel] load failed:', error);
+ toast.error('Failed to load admin providers');
+ }, [error]);
+
+ const saveMutation = useMutation({
+ mutationFn: upsertAdminProvider,
+ onSuccess: async (_data, variables) => {
+ toast.success(variables.editingId === '__new' ? 'Provider created' : 'Provider updated');
+ cancelEdit();
+ await queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY });
+ },
+ onError: (mutationError) => {
+ console.error('[AdminProvidersPanel] save failed:', mutationError);
+ toast.error((mutationError as Error).message || 'Save failed');
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: deleteAdminProvider,
+ onSuccess: async () => {
+ toast.success('Provider deleted');
+ await queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY });
+ },
+ onError: (mutationError) => {
+ console.error('[AdminProvidersPanel] delete failed:', mutationError);
+ toast.error((mutationError as Error).message || 'Delete failed');
+ },
+ });
const startCreate = () => {
setForm(createEmptyForm());
@@ -128,62 +167,21 @@ export function AdminProvidersPanel() {
setEditingId(provider.id);
};
- const cancelEdit = () => {
+ function cancelEdit() {
setEditingId(null);
setForm(createEmptyForm());
setCustomModelInput('');
+ }
+
+ const submit = () => {
+ if (saveMutation.isPending) return;
+ saveMutation.mutate({ editingId, form });
};
- const submit = async () => {
- if (saving) return;
- setSaving(true);
- const isNew = editingId === '__new';
- try {
- const body = {
- slug: form.slug.trim(),
- displayName: form.displayName.trim(),
- providerType: form.providerType,
- baseUrl: form.baseUrl.trim() || null,
- ...(form.apiKey.length > 0 ? { apiKey: form.apiKey } : {}),
- defaultModel: form.defaultModel.trim() || null,
- defaultInstructions: form.defaultInstructions.trim() || null,
- enabled: form.enabled,
- };
- const url = isNew ? '/api/admin/providers' : `/api/admin/providers/${editingId}`;
- const res = await fetch(url, {
- method: isNew ? 'POST' : 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- throw new Error(data.error || `HTTP ${res.status}`);
- }
- toast.success(isNew ? 'Provider created' : 'Provider updated');
- cancelEdit();
- await refresh();
- } catch (error) {
- console.error('[AdminProvidersPanel] save failed:', error);
- toast.error((error as Error).message || 'Save failed');
- } finally {
- setSaving(false);
- }
- };
-
- const remove = async (id: string) => {
+ const remove = (id: string) => {
if (!confirm('Delete this shared provider? Users selecting it will lose access until they switch.')) return;
- try {
- const res = await fetch(`/api/admin/providers/${id}`, { method: 'DELETE' });
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- throw new Error(data.error || `HTTP ${res.status}`);
- }
- toast.success('Provider deleted');
- await refresh();
- } catch (error) {
- console.error('[AdminProvidersPanel] delete failed:', error);
- toast.error((error as Error).message || 'Delete failed');
- }
+ if (deleteMutation.isPending) return;
+ deleteMutation.mutate(id);
};
const isEditingExisting = editingId !== null && editingId !== '__new';
@@ -191,7 +189,7 @@ export function AdminProvidersPanel() {
? providers.find((p) => p.id === editingId)
: undefined;
const submitDisabled =
- saving ||
+ saveMutation.isPending ||
!form.slug.trim() ||
!form.displayName.trim();
@@ -213,11 +211,11 @@ export function AdminProvidersPanel() {
? 'custom'
: modelDefinitions[0]?.id ?? '';
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
- const modelSupportsInstructions = (model: string) => resolveTtsProviderModelPolicy({
+ const modelSupportsInstructions = useCallback((model: string) => resolveTtsProviderModelPolicy({
providerRef: form.slug,
providerType: form.providerType,
model,
- }).supportsInstructions;
+ }).supportsInstructions, [form.slug, form.providerType]);
const baseUrlPlaceholder = form.providerType === 'custom-openai'
? 'https://your-tts-host/v1'
: defaultBaseUrlForProviderType(form.providerType);
@@ -241,7 +239,7 @@ export function AdminProvidersPanel() {
if (modelSupportsInstructions(form.defaultModel)) return;
if (!form.defaultInstructions) return;
setForm((prev) => ({ ...prev, defaultInstructions: '' }));
- }, [form.defaultModel, form.defaultInstructions, form.providerType, form.slug]);
+ }, [form.defaultModel, form.defaultInstructions, form.providerType, form.slug, modelSupportsInstructions]);
return (
- {saving ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
+ {saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
@@ -527,14 +525,14 @@ export function AdminProvidersPanel() {
diff --git a/src/contexts/AuthRateLimitContext.tsx b/src/contexts/AuthRateLimitContext.tsx
index 75f8fa8..fb69fb2 100644
--- a/src/contexts/AuthRateLimitContext.tsx
+++ b/src/contexts/AuthRateLimitContext.tsx
@@ -1,6 +1,7 @@
'use client';
-import React, { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react';
+import React, { createContext, useContext, useEffect, useCallback, useRef, ReactNode } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
import { coerceTimestampMs, nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
export interface RateLimitStatus {
@@ -43,7 +44,6 @@ export function useAuthRateLimit(): AuthRateLimitContextType {
return context;
}
-// Re-export specific hooks for backward compatibility or convenience if needed
export function useAuthConfig() {
const { authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthRateLimit();
return { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled };
@@ -94,11 +94,9 @@ function parseRateLimitStatus(raw: unknown): RateLimitStatus | null {
export function formatCharCount(count: number): string {
if (count >= 1_000_000) {
const m = count / 1_000_000;
- // Show up to 1 decimal place, stripping trailing zeros (1.0 -> 1)
return `${parseFloat(m.toFixed(1))}M`;
} else if (count >= 1_000) {
const k = Math.round(count / 1_000);
- // Handle edge case where rounding up reaches 1M (e.g., 999,999 -> 1000K -> 1M)
if (k >= 1_000) return '1M';
return `${k}K`;
}
@@ -113,6 +111,8 @@ interface AuthRateLimitProviderProps {
githubAuthEnabled: boolean;
}
+const RATE_LIMIT_QUERY_KEY = ['rate-limit-status'] as const;
+
export function AuthRateLimitProvider({
children,
authEnabled,
@@ -120,64 +120,63 @@ export function AuthRateLimitProvider({
allowAnonymousAuthSessions,
githubAuthEnabled,
}: AuthRateLimitProviderProps) {
- const [status, setStatus] = useState
(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ const queryClient = useQueryClient();
- // Track pending TTS operations to delay count updates
const pendingTTSRef = useRef(0);
const updateTimeoutRef = useRef(null);
- const fetchStatus = useCallback(async () => {
- // Skip if auth is not enabled
- if (!authEnabled) {
- setStatus({
- allowed: true,
- currentCount: 0,
- // Avoid Infinity to prevent JSON/serialization edge cases elsewhere.
- limit: Number.MAX_SAFE_INTEGER,
- remainingChars: Number.MAX_SAFE_INTEGER,
- resetTimeMs: nextUtcMidnightTimestampMs(),
- userType: 'unauthenticated',
- authEnabled: false
- });
- setLoading(false);
- return;
- }
-
- try {
- setLoading(true);
- setError(null);
-
+ const {
+ data: queryStatus,
+ error: queryError,
+ isPending,
+ isFetching,
+ refetch,
+ } = useQuery({
+ queryKey: RATE_LIMIT_QUERY_KEY,
+ queryFn: async () => {
const response = await fetch('/api/rate-limit/status');
-
if (!response.ok) {
throw new Error(`Failed to fetch rate limit status: ${response.status}`);
}
+ return parseRateLimitStatus(await response.json());
+ },
+ enabled: authEnabled,
+ retry: 0,
+ });
- const data = await response.json();
- setStatus(parseRateLimitStatus(data));
- } catch (err) {
- console.error('Error fetching rate limit status:', err);
- setError(err instanceof Error ? err.message : 'Unknown error');
- } finally {
- setLoading(false);
- }
- }, [authEnabled]);
+ const status = authEnabled
+ ? (queryStatus ?? null)
+ : {
+ allowed: true,
+ currentCount: 0,
+ // Avoid Infinity to prevent JSON/serialization edge cases elsewhere.
+ limit: Number.MAX_SAFE_INTEGER,
+ remainingChars: Number.MAX_SAFE_INTEGER,
+ resetTimeMs: nextUtcMidnightTimestampMs(),
+ userType: 'unauthenticated' as const,
+ authEnabled: false,
+ };
+ const loading = authEnabled ? (isPending || isFetching) : false;
+ const error = authEnabled
+ ? (queryError instanceof Error ? queryError.message : queryError ? 'Unknown error' : null)
+ : null;
useEffect(() => {
- fetchStatus();
- }, [fetchStatus]);
+ if (!queryError) return;
+ console.error('Error fetching rate limit status:', queryError);
+ }, [queryError]);
+
+ const refresh = useCallback(async () => {
+ if (!authEnabled) return;
+ await refetch();
+ }, [authEnabled, refetch]);
- // Calculate time until reset
const timeUntilReset = status ? calculateTimeUntilReset(status.resetTimeMs) : '';
- // Only treat the user as "at limit" when they are truly out of characters.
- // The server allows the final request that may cross the limit, then blocks subsequent ones.
const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false;
- // Increment count locally (for immediate UI feedback)
const incrementCount = useCallback((charCount: number) => {
- setStatus(prevStatus => {
+ if (!authEnabled) return;
+ queryClient.setQueryData(RATE_LIMIT_QUERY_KEY, (prevStatus) => {
if (!prevStatus) return prevStatus;
const newCurrentCount = prevStatus.currentCount + charCount;
@@ -190,39 +189,33 @@ export function AuthRateLimitProvider({
allowed: newRemainingChars > 0
};
});
- }, []);
+ }, [authEnabled, queryClient]);
- // Called when a TTS request starts
const onTTSStart = useCallback(() => {
pendingTTSRef.current += 1;
- // Clear any existing timeout
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
updateTimeoutRef.current = null;
}
}, []);
- // Called when a TTS request completes (success or error)
const onTTSComplete = useCallback(() => {
pendingTTSRef.current = Math.max(0, pendingTTSRef.current - 1);
- // Clear any existing timeout
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
updateTimeoutRef.current = null;
}
- // If no more pending requests, schedule an update
if (pendingTTSRef.current === 0) {
updateTimeoutRef.current = setTimeout(() => {
- fetchStatus();
+ void refresh();
updateTimeoutRef.current = null;
- }, 1000); // Wait 1 second after completion to refresh
+ }, 1000);
}
- }, [fetchStatus]);
+ }, [refresh]);
- // Cleanup timeout on unmount
useEffect(() => {
return () => {
if (updateTimeoutRef.current) {
@@ -239,13 +232,18 @@ export function AuthRateLimitProvider({
status,
loading,
error,
- refresh: fetchStatus,
+ refresh,
isAtLimit,
timeUntilReset,
incrementCount,
onTTSStart,
onTTSComplete,
- triggerRateLimit: () => setStatus(prev => prev ? { ...prev, remainingChars: 0, allowed: false } : null)
+ triggerRateLimit: () => {
+ if (!authEnabled) return;
+ queryClient.setQueryData(RATE_LIMIT_QUERY_KEY, (prev) =>
+ prev ? { ...prev, remainingChars: 0, allowed: false } : null,
+ );
+ },
};
return (
diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx
index 8c1ae50..f45d8b1 100644
--- a/src/contexts/DocumentContext.tsx
+++ b/src/contexts/DocumentContext.tsx
@@ -1,56 +1,65 @@
'use client';
-import { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
+import { createContext, useCallback, useContext, useEffect, useMemo, ReactNode } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { BaseDocument } from '@/types/documents';
import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client/api/documents';
import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/client/cache/documents';
import { useAuthSession } from '@/hooks/useAuthSession';
interface DocumentContextType {
- // PDF Documents
pdfDocs: Array;
addPDFDocument: (file: File) => Promise;
removePDFDocument: (id: string) => Promise;
isPDFLoading: boolean;
- // EPUB Documents
epubDocs: Array;
addEPUBDocument: (file: File) => Promise;
removeEPUBDocument: (id: string) => Promise;
isEPUBLoading: boolean;
- // HTML Documents
htmlDocs: Array;
addHTMLDocument: (file: File) => Promise;
removeHTMLDocument: (id: string) => Promise;
isHTMLLoading: boolean;
refreshDocuments: () => Promise;
-
-
}
const DocumentContext = createContext(undefined);
+const DOCUMENTS_QUERY_KEY = 'documents';
export function DocumentProvider({ children }: { children: ReactNode }) {
- const [docs, setDocs] = useState(null);
- const isLoading = docs === null;
+ const queryClient = useQueryClient();
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
const sessionKey = sessionData?.user?.id ?? 'no-session';
+ const documentsQueryKey = useMemo(() => [DOCUMENTS_QUERY_KEY, sessionKey] as const, [sessionKey]);
- const refreshDocuments = useCallback(async () => {
- const serverDocs = await listDocuments();
- // Keep only viewer-supported types
- setDocs(serverDocs.filter((d) => d.type === 'pdf' || d.type === 'epub' || d.type === 'html'));
+ const loadDocuments = useCallback(async () => {
+ try {
+ const serverDocs = await listDocuments();
+ return serverDocs.filter((d): d is BaseDocument & { type: 'pdf' | 'epub' | 'html' } =>
+ d.type === 'pdf' || d.type === 'epub' || d.type === 'html',
+ );
+ } catch (err) {
+ console.error('Failed to load documents from server:', err);
+ return [];
+ }
}, []);
- useEffect(() => {
+ const { data: docs = [], isPending, refetch } = useQuery({
+ queryKey: documentsQueryKey,
+ queryFn: loadDocuments,
+ enabled: !isSessionPending,
+ });
+
+ const isLoading = isSessionPending || (isPending && docs.length === 0);
+
+ const refreshDocuments = useCallback(async () => {
if (isSessionPending) return;
- refreshDocuments().catch((err) => {
- console.error('Failed to load documents from server:', err);
- setDocs([]);
- });
- }, [refreshDocuments, sessionKey, isSessionPending]);
+ await queryClient.invalidateQueries({ queryKey: documentsQueryKey });
+ await refetch();
+ }, [isSessionPending, queryClient, documentsQueryKey, refetch]);
useEffect(() => {
const handler = () => {
@@ -66,9 +75,9 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
}, [refreshDocuments]);
const docsByType = useMemo(() => {
- const pdfDocs = (docs ?? []).filter((d) => d.type === 'pdf') as Array;
- const epubDocs = (docs ?? []).filter((d) => d.type === 'epub') as Array;
- const htmlDocs = (docs ?? []).filter((d) => d.type === 'html') as Array;
+ const pdfDocs = docs.filter((d) => d.type === 'pdf') as Array;
+ const epubDocs = docs.filter((d) => d.type === 'epub') as Array;
+ const htmlDocs = docs.filter((d) => d.type === 'html') as Array;
return { pdfDocs, epubDocs, htmlDocs };
}, [docs]);
@@ -93,14 +102,15 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
const [stored] = await uploadDocuments([file]);
if (!stored) throw new Error('Upload succeeded but returned no document');
await cacheUploaded(stored, file);
- setDocs((prev) => {
- const current = prev ?? [];
- // Replace if same id exists (e.g. re-upload)
- const next = current.filter((d) => d.id !== stored.id);
- return [stored, ...next];
+ const isSupported = stored.type === 'pdf' || stored.type === 'epub' || stored.type === 'html';
+ if (!isSupported) return stored.id;
+ const supportedStored = stored as BaseDocument & { type: 'pdf' | 'epub' | 'html' };
+ queryClient.setQueryData>(documentsQueryKey, (prev = []) => {
+ const next = prev.filter((d) => d.id !== supportedStored.id);
+ return [supportedStored, ...next];
});
return stored.id;
- }, [cacheUploaded]);
+ }, [cacheUploaded, queryClient, documentsQueryKey]);
const addPDFDocument = useCallback(async (file: File) => addDocument(file), [addDocument]);
const addEPUBDocument = useCallback(async (file: File) => addDocument(file), [addDocument]);
@@ -109,15 +119,15 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
const removeById = useCallback(async (id: string) => {
await deleteDocuments({ ids: [id] });
await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]);
- setDocs((prev) => (prev ?? []).filter((d) => d.id !== id));
- }, []);
+ queryClient.setQueryData>(documentsQueryKey, (prev = []) =>
+ prev.filter((d) => d.id !== id),
+ );
+ }, [queryClient, documentsQueryKey]);
const removePDFDocument = useCallback(async (id: string) => removeById(id), [removeById]);
const removeEPUBDocument = useCallback(async (id: string) => removeById(id), [removeById]);
const removeHTMLDocument = useCallback(async (id: string) => removeById(id), [removeById]);
- // Removed unused clear functions
-
return (
clearTimeout(timeoutId);
}
- }, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]);
+ }, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]);
/**
* Renders the TTS context provider with its children
diff --git a/src/hooks/useSharedProviders.ts b/src/hooks/useSharedProviders.ts
index 03455aa..99d27ab 100644
--- a/src/hooks/useSharedProviders.ts
+++ b/src/hooks/useSharedProviders.ts
@@ -1,6 +1,7 @@
'use client';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { TtsProviderId } from '@/lib/shared/tts-provider-catalog';
export interface SharedProviderEntry {
@@ -11,45 +12,17 @@ export interface SharedProviderEntry {
defaultInstructions: string | null;
}
-interface State {
- data: SharedProviderEntry[];
- loaded: boolean;
-}
+export const SHARED_PROVIDERS_QUERY_KEY = ['tts-shared-providers'] as const;
-const EMPTY: State = { data: [], loaded: false };
-
-/**
- * Fetches the list of admin-configured shared TTS providers visible to the
- * current user. Cached in module-scope state to avoid refetching on every
- * mount. The list rarely changes during a session; admin edits land on the
- * next page load via `__OPENREADER_RUNTIME_CONFIG__`-style behavior.
- */
-let cached: State = EMPTY;
-let inflight: Promise | null = null;
-const listeners = new Set<(s: State) => void>();
-
-function setCached(next: State) {
- cached = next;
- for (const fn of listeners) fn(next);
-}
-
-async function fetchOnce(): Promise {
- if (inflight) return inflight;
- inflight = (async () => {
- try {
- const res = await fetch('/api/tts/shared-providers', { credentials: 'same-origin' });
- if (!res.ok) return [];
- const data = (await res.json()) as { providers?: SharedProviderEntry[] };
- return data.providers ?? [];
- } catch {
- return [];
- } finally {
- inflight = null;
- }
- })();
- const result = await inflight;
- setCached({ data: result, loaded: true });
- return result;
+async function fetchSharedProviders(): Promise {
+ try {
+ const res = await fetch('/api/tts/shared-providers', { credentials: 'same-origin' });
+ if (!res.ok) return [];
+ const data = (await res.json()) as { providers?: SharedProviderEntry[] };
+ return data.providers ?? [];
+ } catch {
+ return [];
+ }
}
export function useSharedProviders(): {
@@ -57,26 +30,17 @@ export function useSharedProviders(): {
isLoading: boolean;
refresh: () => Promise;
} {
- const [state, setState] = useState(cached);
-
- useEffect(() => {
- listeners.add(setState);
- if (!cached.loaded) {
- void fetchOnce();
- }
- return () => {
- listeners.delete(setState);
- };
- }, []);
+ const queryClient = useQueryClient();
+ const { data = [], isPending, refetch } = useQuery({
+ queryKey: SHARED_PROVIDERS_QUERY_KEY,
+ queryFn: fetchSharedProviders,
+ staleTime: 5 * 60 * 1000,
+ });
const refresh = useCallback(async () => {
- setCached({ ...cached, loaded: false });
- await fetchOnce();
- }, []);
+ await queryClient.invalidateQueries({ queryKey: SHARED_PROVIDERS_QUERY_KEY });
+ await refetch();
+ }, [queryClient, refetch]);
- return { providers: state.data, isLoading: !state.loaded, refresh };
-}
-
-export function getCachedSharedProviders(): SharedProviderEntry[] {
- return cached.data;
+ return { providers: data, isLoading: isPending, refresh };
}