194 lines
6.8 KiB
TypeScript
194 lines
6.8 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 { 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<NostrEvent[]> => {
|
|
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<NostrEvent[]> => {
|
|
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,
|
|
};
|
|
}
|