- Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS guard EventSponsors already uses. - Official SkateSpots badge only shows when the NIP-05 actually verifies (NIP19Page), not on a self-asserted handle. - Community stats reads are null-guarded on persisted-cache fields (monthly/cumulativeSpots/topMappers) — no more crash on an old cache. - Overpass response no longer assumes .elements (food layer). - user-listings query key is memoized (was Date.now() → refetch storm). - Pro grant requires the l=pro label (was accepted with none). - Verify card RSVP maps accepted/tentative (was going/interested → showed 'No RSVP' on a valid ticket). - Expired listings can't be bought via a stale/shared URL. - Batch spot photo/status hooks include the relay suffix and use querySkateContent (relay-policy parity with useSpotReports). - Offline queue requeues rows stuck in 'publishing' after a crash. - querySkateContent/queryCanonical dedupe replaceable events by author+kind (not id) so a stale profile/relay list can't win. - Route offline cache key includes the origin (was destination+mode only). - Internal links use <Link> (MarketplacePage); EventCountdown reacts to language changes.
281 lines
7.8 KiB
TypeScript
281 lines
7.8 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 { useToast } from './useToast';
|
|
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 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: 'Cache cleaned',
|
|
description: `Removed ${count} old spots from cache`,
|
|
});
|
|
},
|
|
});
|
|
|
|
const clearAll = useMutation({
|
|
mutationFn: clearAllCache,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['cached-spots'] });
|
|
queryClient.invalidateQueries({ queryKey: ['cache-stats'] });
|
|
toast({
|
|
title: 'Cache cleared',
|
|
description: 'All cached spots have been removed',
|
|
});
|
|
},
|
|
});
|
|
|
|
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 isOnline = useOnlineStatus();
|
|
const queryClient = useQueryClient();
|
|
const { nostr } = useNostr();
|
|
const { user } = useCurrentUser();
|
|
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;
|
|
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: 'Back online',
|
|
description: `Sent ${successCount} saved action${successCount > 1 ? 's' : ''}.`,
|
|
});
|
|
}
|
|
};
|
|
|
|
// 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, nostr, queryClient, toast]);
|
|
|
|
return { isSyncing };
|
|
}
|
|
|
|
/**
|
|
* Hook to queue a post for offline publishing
|
|
*/
|
|
export function useQueuePost() {
|
|
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: 'Post queued',
|
|
description: 'Your post will be published when you\'re back online',
|
|
});
|
|
},
|
|
});
|
|
}
|