Files
eranos/src/components/NostrProvider.tsx
T
Alex Gleason 1597e7540b Disable user relays by default
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).
2026-05-03 09:00:43 -05:00

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;