import { useQuery } from '@tanstack/react-query'; import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; import { hasLocalDeletion } from '@/lib/localDeletions'; import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache'; // Spots change infrequently — cache for 5 minutes, keep for 30 minutes const SPOTS_STALE_TIME = 5 * 60 * 1000; // 5 min const SPOTS_GC_TIME = 30 * 60 * 1000; // 30 min const QUERY_TIMEOUT = 15000; // 15s — mobile cold-start needs more time /** * Spots, newest first. * * `includeHidden` is for ADMIN TOOLING ONLY (the review queue): without it a spot * you just hid vanishes from the list, so there is no way to undo the verdict. * Normal callers must leave it off so hidden spots stay hidden. */ export function useSkateSpots(limit = 100, options: { includeHidden?: boolean } = {}) { const { includeHidden = false } = options; const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-spots', limit, includeHidden, getQueryKeySuffix()], select: (spots: SkateSpot[]) => includeHidden ? spots : filterAdminHidden(spots, SKATE_SPOT_KIND, hidden), queryFn: async () => { const fromCache = async () => { const cachedSpots = await getAllCachedSpots(); return cachedSpots.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); }; // If offline, return cached spots if (isOffline()) { return fromCache(); } const signal = AbortSignal.timeout(QUERY_TIMEOUT); try { const events = await querySkateContent( [ { kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit, }, ], { signal, requireAny: true } ); const spots: SkateSpot[] = []; const spotsWithEvents: Array<{ event: typeof events[0]; spot: SkateSpot }> = []; for (const event of events) { const spot = parseSkateSpot(event); if (spot) { spots.push(spot); spotsWithEvents.push({ event, spot }); } } // Cache spots in the background for offline use if (spotsWithEvents.length > 0) { cacheSpots(spotsWithEvents).catch(() => {}); } return spots.sort((a, b) => b.createdAt - a.createdAt); } catch (err) { // Relays unreachable — NOT "no spots". Fall back to the last-known cache // so the map still works at the skatepark; only surface the error when we // have nothing saved at all (then the UI shows an honest offline card). const cached = await fromCache(); if (cached.length > 0) return cached; throw err; } }, staleTime: SPOTS_STALE_TIME, gcTime: SPOTS_GC_TIME, // Don't force a relay round-trip on every navigation — use cache if fresh refetchOnMount: true, }); } export function useSkateSpot(identifier: string, pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); const { user } = useCurrentUser(); return useQuery({ queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()], select: (spot: SkateSpot | null) => { if (!spot) return null; const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`; // Locally-deleted (NIP-09 pending): treat as gone immediately so a // stale cached copy can't resurface on a direct visit either. if (hasLocalDeletion({ coord, id: spot.id })) return null; if (!hidden.has({ coord, id: spot.id })) { return spot; } // A hidden spot stays reachable for its OWN AUTHOR — otherwise their spot // silently 404s and they never learn why. Everyone else gets null (the map // and lists filter it too). The detail page shows them what happened. return user?.pubkey === spot.pubkey ? spot : null; }, queryFn: async () => { const { getCachedSpot, cacheSpot } = await import('@/lib/offlineCache'); // If offline, try to get from cache first if (isOffline() && pubkey) { const cached = await getCachedSpot(pubkey, identifier); if (cached) return cached; } const signal = AbortSignal.timeout(QUERY_TIMEOUT); const filter: { kinds: number[]; '#d': string[]; authors?: string[]; limit: number; } = { kinds: [SKATE_SPOT_KIND], '#d': [identifier], limit: 1, }; if (pubkey) { filter.authors = [pubkey]; } try { const events = await querySkateContent([filter], { signal, requireAny: true }); if (events.length === 0) return null; const spot = parseSkateSpot(events[0]); // Cache the spot for offline use if (spot) { cacheSpot(events[0], spot).catch(() => {}); } return spot; } catch (err) { // Relays unreachable: serve the cached copy if we have one, else error. if (pubkey) { const cached = await getCachedSpot(pubkey, identifier); if (cached) return cached; } throw err; } }, enabled: !!identifier, staleTime: SPOTS_STALE_TIME, gcTime: SPOTS_GC_TIME, refetchOnMount: true, }); } export function useUserSkateSpots(pubkey: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); return useQuery({ queryKey: ['user-skate-spots', pubkey, getQueryKeySuffix()], queryFn: async () => { const signal = AbortSignal.timeout(QUERY_TIMEOUT); const events = await querySkateContent( [ { kinds: [SKATE_SPOT_KIND], authors: [pubkey], '#t': ['skatespot'], limit: 50, }, ], { signal } ); const spots: SkateSpot[] = []; for (const event of events) { const spot = parseSkateSpot(event); if (spot) spots.push(spot); } return spots.sort((a, b) => b.createdAt - a.createdAt); }, enabled: !!pubkey, staleTime: SPOTS_STALE_TIME, gcTime: SPOTS_GC_TIME, refetchOnMount: true, }); }