import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; import { useRelays } from '@/lib/relays'; import { useCitrine } from '@/hooks/useCitrine'; import { SKATE_RELAY } from '@/lib/types'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; /** * Hook that provides relay-aware query functions for skate-specific content. * * Logic: * - Logged out: Always use community relay only (bootstrap) * - Logged in + useSkateRelay=true: Community relay + user relays (default) * - Logged in + useSkateRelay=false: User relays only (opted out) */ export function useSkateRelays() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { config } = useAppContext(); const { read, write } = useRelays(); const citrine = useCitrine(); const isLoggedIn = !!user; const useSkateRelay = config.useSkateRelay ?? true; /** * Prepend the local Citrine relay (when active) to a relay list, deduped. * Local-first: a localhost hit returns in ~1ms so it should be queried first; * remote relays still run in parallel and reconcile. No-op unless the user * opted in AND a Citrine relay is actually reachable (Android only). */ const withCitrine = (relays: string[]): string[] => { if (!citrine.url) return relays; return Array.from(new Set([citrine.url, ...relays])); }; /** * Get the list of relay URLs to query for skate content */ const getReadRelays = (): string[] => { // Logged out: always use community relay only (+ local Citrine if active) if (!isLoggedIn) { return withCitrine([SKATE_RELAY]); } // Logged in: check setting const userRelays = read; if (useSkateRelay) { // Include community relay + user relays (deduplicated) const relaySet = new Set([SKATE_RELAY, ...userRelays]); return withCitrine(Array.from(relaySet)); } else { // User opted out - only their relays // If they have no relays configured, fall back to community relay return withCitrine(userRelays.length > 0 ? userRelays : [SKATE_RELAY]); } }; /** * Get the list of relay URLs to publish skate content to */ const getWriteRelays = (): string[] => { // Logged out shouldn't be publishing, but just in case if (!isLoggedIn) { return [SKATE_RELAY]; } const userRelays = write; if (useSkateRelay) { // Include community relay + user relays (deduplicated) const relaySet = new Set([SKATE_RELAY, ...userRelays]); // Also write to the local Citrine relay when active — a durable on-device // copy (backup + offline capture) alongside the remote publish. return withCitrine(Array.from(relaySet)); } else { // User opted out - only their relays // If they have no relays configured, fall back to community relay return withCitrine(userRelays.length > 0 ? userRelays : [SKATE_RELAY]); } }; /** * Query skate content from appropriate relays based on user state/settings. * Automatically handles parallel queries and deduplication for addressable events. */ const querySkateContent = async ( filters: NostrFilter[], options: { signal: AbortSignal } ): Promise => { const relays = getReadRelays(); // Query all relays in parallel. Individual relay failures/timeouts are // expected (some relays are slow or offline) and handled silently — the // other relays still return results. const queryPromises = relays.map(async (relayUrl) => { try { const relay = nostr.relay(relayUrl); const events = await relay.query(filters, options); return events; } catch { return []; } }); const results = await Promise.all(queryPromises); const allEvents = results.flat(); // Deduplicate addressable events by pubkey+kind+d-tag const eventMap = new Map(); for (const event of allEvents) { const dTag = event.tags.find(([name]) => name === 'd')?.[1]; if (dTag) { // Addressable event - dedupe by coordinate const key = `${event.pubkey}:${event.kind}:${dTag}`; const existing = eventMap.get(key); if (!existing || event.created_at > existing.created_at) { eventMap.set(key, event); } } else { // Regular event - dedupe by id eventMap.set(event.id, event); } } const uniqueEvents = Array.from(eventMap.values()); return uniqueEvents; }; /** * Query the CANONICAL community relay (goskatespots) ONLY. * * Use this for anything that must reflect the single, authoritative community * dataset — the map and the community stats — so what users see always matches * the goskatespots relay exactly, regardless of which personal relays they have * configured. Content-discovery features (feeds, individual profile lookups) * can still use querySkateContent to blend in the user's own relays. */ const queryCanonical = async ( filters: NostrFilter[], options: { signal: AbortSignal } ): Promise => { try { const relay = nostr.relay(SKATE_RELAY); const events = await relay.query(filters, options); // Dedupe addressable events by pubkey+kind+d-tag (keep newest). const eventMap = new Map(); for (const event of events) { const dTag = event.tags.find(([name]) => name === 'd')?.[1]; if (dTag) { const key = `${event.pubkey}:${event.kind}:${dTag}`; const existing = eventMap.get(key); if (!existing || event.created_at > existing.created_at) eventMap.set(key, event); } else { eventMap.set(event.id, event); } } return Array.from(eventMap.values()); } catch { return []; } }; /** * Generate a stable query key suffix based on relay configuration. * Used to invalidate cache when relay settings change. */ const getQueryKeySuffix = (): string => { const relays = getReadRelays(); return relays.sort().join(','); }; return { getReadRelays, getWriteRelays, querySkateContent, queryCanonical, getQueryKeySuffix, isLoggedIn, useSkateRelay, /** Whether the local Citrine relay is currently folded into reads/writes. */ citrineActive: citrine.active, }; }