import { useState, useEffect, useCallback, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { cacheSpot, cacheSpots, getCachedSpot, getAllCachedSpots, getRecentlyViewedSpots, getCacheStats, clearOldCache, clearAllCache, isOffline, onConnectionChange, queuePost, getPendingPosts, getPendingPostsCount, prunePendingPosts, reconcileStalePendingPosts, removePendingPost, updatePendingPostStatus, } from '@/lib/offlineCache'; import { useCurrentUser } from './useCurrentUser'; import { useNostrLogin } from '@nostrify/react/login'; import { isInteractiveSigner } from '@/lib/signerCapabilities'; import { useToast } from './useToast'; import { useTranslation } from '@/contexts/language'; import type { NostrEvent } from '@nostrify/nostrify'; import type { SkateSpot } from '@/lib/types'; // ============ Connection Status Hook ============ /** * Hook to track online/offline status */ export function useOnlineStatus() { const [online, setOnline] = useState(!isOffline()); useEffect(() => { const unsubscribe = onConnectionChange(setOnline); return unsubscribe; }, []); return online; } // ============ Spot Caching Hooks ============ /** * Hook to cache a spot when viewing */ export function useCacheSpotOnView() { return useCallback(async (event: NostrEvent, spot: SkateSpot) => { try { await cacheSpot(event, spot); } catch (error) { console.warn('[OfflineCache] Failed to cache spot:', error); } }, []); } /** * Hook to cache multiple spots (e.g., when loading map) */ export function useCacheSpotsOnLoad() { return useCallback(async (spotsWithEvents: Array<{ event: NostrEvent; spot: SkateSpot }>) => { try { await cacheSpots(spotsWithEvents); } catch (error) { console.warn('[OfflineCache] Failed to cache spots:', error); } }, []); } /** * Hook to get a cached spot (for offline fallback) */ export function useCachedSpot(pubkey: string | undefined, identifier: string | undefined) { return useQuery({ queryKey: ['cached-spot', pubkey, identifier], queryFn: async () => { if (!pubkey || !identifier) return null; return getCachedSpot(pubkey, identifier); }, enabled: !!pubkey && !!identifier, staleTime: Infinity, // Cache never goes stale }); } /** * Hook to get all cached spots (for offline browsing) */ export function useCachedSpots() { return useQuery({ queryKey: ['cached-spots'], queryFn: getAllCachedSpots, staleTime: 1000 * 60, // 1 minute }); } /** * Hook to get recently viewed spots */ export function useRecentlyViewedSpots(limit: number = 50) { return useQuery({ queryKey: ['recently-viewed-spots', limit], queryFn: () => getRecentlyViewedSpots(limit), staleTime: 1000 * 60, }); } /** * Hook to get cache statistics */ export function useCacheStats() { return useQuery({ queryKey: ['cache-stats'], queryFn: getCacheStats, staleTime: 1000 * 60 * 5, // 5 minutes }); } /** * Hook to manage cache (clear old/all) */ export function useCacheManagement() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { toast } = useToast(); const clearOld = useMutation({ mutationFn: async (maxAgeDays: number = 7) => { const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000; return clearOldCache(maxAgeMs); }, onSuccess: (count) => { queryClient.invalidateQueries({ queryKey: ['cached-spots'] }); queryClient.invalidateQueries({ queryKey: ['cache-stats'] }); toast({ title: t('toastsUi.cacheCleaned'), description: t('toastsUi.cacheCleanedDesc', { count }), }); }, }); const clearAll = useMutation({ mutationFn: clearAllCache, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cached-spots'] }); queryClient.invalidateQueries({ queryKey: ['cache-stats'] }); toast({ title: t('toastsUi.cacheCleared'), description: t('toastsUi.cacheClearedDesc'), }); }, }); return { clearOld, clearAll }; } // ============ Pending Posts Hooks ============ /** * Module-level lock: the queue must have exactly ONE publisher even though the * `OfflineIndicator` (which owns the sync) is mounted twice (mobile + desktop). */ let globalSyncing = false; /** * Hook to get pending posts count */ export function usePendingPostsCount() { const isOnline = useOnlineStatus(); return useQuery({ queryKey: ['pending-posts-count'], queryFn: getPendingPostsCount, refetchInterval: isOnline ? 5000 : false, // Only refresh when online }); } /** * Deliver queued actions when we're back online. * * Publishes the SIGNED event we stored (so an interactive signer is never * re-prompted), falls back to signing legacy/unsigned entries, retries failures * on an interval, and prunes stale ephemeral actions. Deliberately does NOT go * through `useNostrPublish` — that would re-queue on failure and duplicate. */ export function useSyncPendingPosts() { const { t } = useTranslation(); const isOnline = useOnlineStatus(); const queryClient = useQueryClient(); const { nostr } = useNostr(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); // Amber must not be popped from a background interval. New queued posts carry // an already-signed event; only legacy/unsigned entries need a `signEvent`, and // for an interactive signer we leave those queued rather than prompt silently. const interactive = isInteractiveSigner(user, logins); const { toast } = useToast(); const [isSyncing, setIsSyncing] = useState(false); const syncingRef = useRef(false); useEffect(() => { if (!isOnline || !user) return; let cancelled = false; const syncPosts = async () => { // The `OfflineIndicator` is mounted twice (mobile + desktop headers), so a // module-level lock keeps exactly one publisher at a time. if (cancelled || syncingRef.current || globalSyncing) return; await prunePendingPosts().catch(() => {}); await reconcileStalePendingPosts().catch(() => {}); const pending = await getPendingPosts(); if (pending.length === 0) return; if (cancelled) return; syncingRef.current = true; globalSyncing = true; setIsSyncing(true); let successCount = 0; for (const post of pending) { if (cancelled) break; // Interactive signer (Amber): never sign from a background loop. // Pre-signed posts still publish; legacy unsigned ones stay queued. if (!post.signedEvent && interactive) continue; try { await updatePendingPostStatus(post.id, 'publishing'); const event: NostrEvent = post.signedEvent ?? (await user.signer.signEvent({ kind: post.kind, content: post.content, tags: post.tags, created_at: Math.floor(Date.now() / 1000), })); await nostr.event(event, { signal: AbortSignal.timeout(15000) }); await removePendingPost(post.id); successCount++; } catch { // Keep it queued; the interval below will try again. await updatePendingPostStatus(post.id, 'failed', post.retries + 1); } } syncingRef.current = false; globalSyncing = false; setIsSyncing(false); queryClient.invalidateQueries({ queryKey: ['pending-posts-count'] }); if (successCount > 0) { toast({ title: t('toastsUi.backOnline'), description: t('toastsUi.backOnlineDesc', { count: successCount }), }); } }; // Small delay so the network settles, then keep retrying while items remain. const timeout = setTimeout(syncPosts, 2000); const interval = setInterval(syncPosts, 30000); return () => { cancelled = true; clearTimeout(timeout); clearInterval(interval); }; }, [isOnline, user, interactive, nostr, queryClient, toast, t]); return { isSyncing }; } /** * Hook to queue a post for offline publishing */ export function useQueuePost() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { toast } = useToast(); return useMutation({ mutationFn: async ({ kind, content, tags }: { kind: number; content: string; tags: string[][] }) => { return queuePost(kind, content, tags); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['pending-posts-count'] }); toast({ title: t('toastsUi.postQueued'), description: t('toastsUi.postQueuedDesc'), }); }, }); }