1597e7540b
User relays are no longer used until the user explicitly opts in via Settings > Network. Adds a useUserRelays toggle alongside the existing useAppRelays toggle in RelayListManager, defaulting to false. Fresh installs and new accounts will only query app-default relays until the user enables their personal NIP-65 list. The user's relay list (kind 10002) is still synced from Nostr and managed in the UI when logged in — the toggle only controls whether it is included in the effective relay set used by NostrProvider's pool and useNativeNotifications. The setting is persisted to AppConfig and synced cross-device via NIP-78 encrypted settings. getEffectiveRelays now takes both flags and short-circuits accordingly, producing an empty list when both are off (instead of the previous behavior of always returning user relays).
170 lines
6.5 KiB
TypeScript
170 lines
6.5 KiB
TypeScript
import React, { useEffect, useMemo, useRef } from 'react';
|
|
import { NostrEvent, NostrFilter, NPool, NRelay1 } from '@nostrify/nostrify';
|
|
import { NostrContext } from '@nostrify/react';
|
|
import { NUser, useNostrLogin } from '@nostrify/react/login';
|
|
import type { NostrSigner } from '@nostrify/types';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { getEffectiveRelays, DITTO_RELAYS, DIVINE_RELAY, ZAPSTORE_RELAY } from '@/lib/appRelays';
|
|
import { NostrBatcher } from '@/lib/NostrBatcher';
|
|
|
|
interface NostrProviderProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
|
const { children } = props;
|
|
const { config } = useAppContext();
|
|
const { logins } = useNostrLogin();
|
|
|
|
// Create NPool instance only once
|
|
const pool = useRef<NPool | undefined>(undefined);
|
|
|
|
// Use refs so the pool always has the latest data
|
|
const effectiveRelays = useRef(getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays));
|
|
|
|
// Stable ref to the current user's signer for NIP-42 AUTH.
|
|
// The `open()` callback reads from this ref when a relay sends an AUTH
|
|
// challenge, so it always uses the latest signer without recreating the pool.
|
|
const signerRef = useRef<NostrSigner | undefined>(undefined);
|
|
|
|
// Derive the current signer from the active login. This mirrors the
|
|
// logic in useCurrentUser but avoids a circular dependency (useCurrentUser
|
|
// depends on NostrContext which we are providing here).
|
|
const currentLogin = logins[0];
|
|
const currentSigner = useMemo(() => {
|
|
if (!currentLogin) return undefined;
|
|
try {
|
|
switch (currentLogin.type) {
|
|
case 'nsec':
|
|
return NUser.fromNsecLogin(currentLogin).signer;
|
|
case 'bunker':
|
|
// pool.current is guaranteed to exist here: the pool is created
|
|
// synchronously during the first render (below), and useMemo runs
|
|
// after the render body has executed.
|
|
return NUser.fromBunkerLogin(currentLogin, pool.current!).signer;
|
|
case 'extension':
|
|
return NUser.fromExtensionLogin(currentLogin).signer;
|
|
default:
|
|
return undefined;
|
|
}
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}, [currentLogin]);
|
|
|
|
// Keep the ref in sync so the AUTH callback always sees the latest signer.
|
|
signerRef.current = currentSigner;
|
|
|
|
// Update effective relays ref when config changes. The NPool reads from
|
|
// this ref, so new queries automatically use the updated relay set.
|
|
//
|
|
// We intentionally do NOT invalidate existing queries here. When relays
|
|
// are added (e.g. NIP-65 sync merging user relays with app defaults),
|
|
// existing cached data is still valid — we'll just query more relays on
|
|
// the next natural refetch. Blanket invalidation caused a disruptive
|
|
// full-feed rerender ~3s after page load when NostrSync synced relays.
|
|
useEffect(() => {
|
|
effectiveRelays.current = getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays);
|
|
}, [config.relayMetadata, config.useAppRelays, config.useUserRelays]);
|
|
|
|
// Initialize NPool only once
|
|
if (!pool.current) {
|
|
pool.current = new NPool({
|
|
open(url: string) {
|
|
return new NRelay1(url, {
|
|
// NIP-42: Respond to relay AUTH challenges by signing a kind
|
|
// 22242 ephemeral event with the current user's signer.
|
|
auth: async (challenge: string) => {
|
|
const signer = signerRef.current;
|
|
if (!signer) {
|
|
throw new Error('AUTH failed: no signer available (user not logged in)');
|
|
}
|
|
return signer.signEvent({
|
|
kind: 22242,
|
|
content: '',
|
|
tags: [
|
|
['relay', url],
|
|
['challenge', challenge],
|
|
],
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
});
|
|
},
|
|
});
|
|
},
|
|
reqRouter(filters: NostrFilter[]): Map<URL['href'], NostrFilter[]> {
|
|
const routes = new Map<string, NostrFilter[]>();
|
|
|
|
// Search queries must go to search relays
|
|
if (filters.some((f) => "search" in f)) {
|
|
return new Map(DITTO_RELAYS.map(url => [url, filters]));
|
|
}
|
|
|
|
// Include divine relay for kind 34236 queries, which are addressable short videos
|
|
if (filters.every((f) => f?.kinds?.length === 1 && f?.kinds[0] === 34236)) {
|
|
return new Map([...DITTO_RELAYS, DIVINE_RELAY].map(url => [url, filters]));
|
|
}
|
|
|
|
// Route to all read relays
|
|
const readRelays = effectiveRelays.current.relays
|
|
.filter(r => r.read)
|
|
.map(r => r.url);
|
|
|
|
// Include zapstore relay for kind 32267 (apps), 30063 (releases), and 3063 (assets)
|
|
const ZAPSTORE_KINDS = [32267, 30063, 3063];
|
|
if (filters.every((f) => f?.kinds?.every((k) => ZAPSTORE_KINDS.includes(k)))) {
|
|
return new Map([ZAPSTORE_RELAY, ...readRelays].map(url => [url, filters]));
|
|
}
|
|
|
|
for (const url of readRelays) {
|
|
routes.set(url, filters);
|
|
}
|
|
|
|
return routes;
|
|
},
|
|
eventRouter(_event: NostrEvent) {
|
|
// Get write relays from effective relays
|
|
const writeRelays = effectiveRelays.current.relays
|
|
.filter(r => r.write)
|
|
.map(r => r.url);
|
|
|
|
const allRelays = new Set<string>(writeRelays);
|
|
|
|
return [...allRelays];
|
|
},
|
|
// Resolve queries quickly once any relay sends EOSE, instead of
|
|
// waiting for every relay to finish.
|
|
eoseTimeout: 300,
|
|
});
|
|
}
|
|
|
|
// Wrap the pool in a batching proxy. The proxy intercepts `.query()`
|
|
// calls to automatically combine batchable filter patterns (profiles,
|
|
// events by ID, reactions, d-tag lookups) into single REQs.
|
|
// All other methods pass through directly to the underlying pool.
|
|
const batcher = useRef<NostrBatcher | undefined>(undefined);
|
|
if (!batcher.current && pool.current) {
|
|
batcher.current = new NostrBatcher(pool.current);
|
|
}
|
|
|
|
// Cleanup: Close all relay connections when the provider unmounts
|
|
useEffect(() => {
|
|
return () => {
|
|
if (pool.current) {
|
|
pool.current.close();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Provide the batcher as the `nostr` object. It has the same interface
|
|
// as NPool, so hooks using `useNostr()` get transparent batching.
|
|
// The `as unknown as NPool` cast is safe because NostrBatcher exposes
|
|
// all the same methods hooks use: query, event, req, relay, group, close.
|
|
return (
|
|
<NostrContext.Provider value={{ nostr: (batcher.current ?? pool.current) as unknown as NPool }}>
|
|
{children}
|
|
</NostrContext.Provider>
|
|
);
|
|
};
|
|
|
|
export default NostrProvider;
|