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 { mergeEvents } from '@/lib/eventMerge'; import { getLocalDeletions } from '@/lib/localDeletions'; import { onlyVerified } from '@/lib/verifyEvents'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; /** * Merge relay results deterministically and drop this device's local deletion * tombstones. Consensus is deliberately absent: relays are not voters, and this * client must not let relay timing decide which version of an event is shown. */ function dedupeEvents(events: NostrEvent[]): NostrEvent[] { const deleted = getLocalDeletions(); return mergeEvents(events, { isDeleted: (event) => { const dTag = event.tags.find(([name]) => name === 'd')?.[1]; const coord = dTag !== undefined ? `${event.kind}:${event.pubkey}:${dTag}` : undefined; return !!( (coord && deleted.has(coord)) || deleted.has(`e:${event.id}`) ); }, }); } /** * 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; requireAny?: boolean } ): Promise => { const relays = getReadRelays(); const { requireAny = false } = options; // 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. let succeeded = 0; const queryPromises = relays.map(async (relayUrl) => { try { const relay = nostr.relay(relayUrl); const events = await relay.query(filters, { signal: options.signal }); succeeded += 1; return events; } catch { return []; } }); const results = await Promise.all(queryPromises); // If NO relay answered, that is an OUTAGE, not an empty result. Returning [] // here would masquerade as "no spots" and overwrite good cached data with an // empty list. Callers that can show cached content pass `requireAny` so they // get a real error and fall back instead of blanking the screen. if (requireAny && relays.length > 0 && succeeded === 0) { throw new Error('No relay could be reached'); } const allEvents = onlyVerified(results.flat()); return dedupeEvents(allEvents); }; /** * 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; requireAny?: boolean; onError?: () => void } ): Promise => { try { const relay = nostr.relay(SKATE_RELAY); const events = await relay.query(filters, { signal: options.signal }); return dedupeEvents(onlyVerified(events)); } catch (err) { // See querySkateContent: an outage must not look like "empty". options.onError?.(); if (options.requireAny) throw err; 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, }; }