SkateSpots/src/hooks/useSkateRelays.ts
SkateSpots 21a0390ed5 refactor(relays): route every relay consumer through useRelays()
So Local relays are used and Blocked relays are excluded EVERYWHERE, not just in
the NPool routers:
- useListingReports, useSellerInfo, useSkateRelays (read+write), useZaps
  (NIP-57 relays tag), usePurchasePro, CollectionDetailPage, CrewForm,
  useDirectMessages write fallback
- AppProvider's empty-relays defaults check is the only intentional direct reader
- AGENTS.md updated

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 22:36:14 +07:00

188 lines
6.3 KiB
TypeScript

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<NostrEvent[]> => {
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<string, NostrEvent>();
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<NostrEvent[]> => {
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<string, NostrEvent>();
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,
};
}