SkateSpots/src/hooks/useOfflineCache.ts
SkateSpots 3ae05a0c38 fix(amber): stop DM/read hooks re-decrypting on every navigation
For an interactive signer (Amber) each nip44 decrypt opens the app. The DM/NIP-17
read hooks decrypt 2x per gift wrap and DirectMessagesPage mounts two of them, so
navigating to Messages stormed Amber. Strengthened amberSafeQueryOptions for
interactive signers (staleTime/gcTime Infinity, refetchOnMount/refetchOnReconnect
false) so decryption happens once per session, mirroring useStash; same guard on
usePrivateContacts + useCordnChat. Added a manual Refresh to the Messages header
(Amber gets no polling). Gated useSyncPendingPosts' legacy re-sign loop for
interactive signers.
2026-10-12 23:28:03 +07:00

295 lines
8.6 KiB
TypeScript

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'),
});
},
});
}