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.
295 lines
8.6 KiB
TypeScript
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'),
|
|
});
|
|
},
|
|
});
|
|
}
|