Stop opening duplicate WebSocket connections

The real problem: nostr.relay() and nostr.group() each open brand new
WebSocket connections outside the pool. Every hook that called these
was creating duplicate connections to relays the pool already had open.

Fixed by routing all queries and subscriptions through the pool (nostr)
directly, which reuses its internal connections:

- useStreamPosts: nostr.relay('wss://relay.ditto.pub') → nostr
- useStreamKind: nostr.relay('wss://relay.ditto.pub') → nostr
- useSearchProfiles: nostr.relay('wss://relay.ditto.pub') → nostr
- useFollowActions: nostr.group([...]) → nostr
- FollowPackDetailContent: nostr.group([...]) → nostr

Also reverted the hacky relay cleanup code in NostrProvider since
it was trying to solve the wrong problem.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-18 00:11:28 -06:00
parent a85d50dc28
commit 4fdd596cbc
6 changed files with 18 additions and 87 deletions
+2 -16
View File
@@ -15,7 +15,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useNostr } from '@nostrify/react';
import { useAppContext } from '@/hooks/useAppContext';
import { genUserName } from '@/lib/genUserName';
/** Parse a follow pack / starter pack event into structured data. */
@@ -37,7 +36,6 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { data: followList } = useFollowList();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -71,21 +69,9 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
setIsFollowingAll(true);
try {
const discoveryRelays = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://purplepag.es',
];
const userRelays = config.relayMetadata.relays
.filter((r) => r.read)
.map((r) => r.url);
const allRelayUrls = [...new Set([...discoveryRelays, ...userRelays])];
const signal = AbortSignal.timeout(10_000);
const relayGroup = nostr.group(allRelayUrls);
const followEvents = await relayGroup.query(
const followEvents = await nostr.query(
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
{ signal },
);
@@ -123,7 +109,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
} finally {
setIsFollowingAll(false);
}
}, [user, pubkeys, nostr, config, publishEvent, toast]);
}, [user, pubkeys, nostr, publishEvent, toast]);
const handleCopyLink = useCallback(() => {
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
-27
View File
@@ -21,9 +21,6 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
// Use refs so the pool always has the latest data
const effectiveRelays = useRef(getEffectiveRelays(config.relayMetadata, config.useAppRelays));
// Track previous relay URLs to detect changes
const prevRelayUrlsRef = useRef<string>('');
// Update effective relays ref and invalidate all queries when relays change,
// since any cached query may have been fetched from a different set of relays.
useEffect(() => {
@@ -33,31 +30,7 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
// Only invalidate if the relay URLs actually changed
const prevUrls = prev.relays.map(r => r.url).sort().join(',');
const nextUrls = effectiveRelays.current.relays.map(r => r.url).sort().join(',');
if (prevUrls !== nextUrls) {
// Close connections to relays that are no longer in the list
if (pool.current && prevRelayUrlsRef.current) {
const prevSet = new Set(prevUrls.split(',').filter(Boolean));
const nextSet = new Set(nextUrls.split(',').filter(Boolean));
// Find relays that were removed
const removedRelays = [...prevSet].filter(url => !nextSet.has(url));
// Close removed relay connections
for (const url of removedRelays) {
try {
const relay = (pool.current as any).relays?.get?.(url);
if (relay) {
relay.close();
(pool.current as any).relays?.delete?.(url);
}
} catch (e) {
// Ignore errors
}
}
}
prevRelayUrlsRef.current = nextUrls;
queryClient.invalidateQueries();
}
}, [config.relayMetadata, config.useAppRelays, queryClient]);
+6 -29
View File
@@ -2,37 +2,20 @@ import { useCallback, useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useAppContext } from './useAppContext';
import { useNostrPublish } from './useNostrPublish';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Discovery relays used to find the user's latest kind 3 event.
* We cast a wide net to avoid acting on stale data.
*/
const DISCOVERY_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://purplepag.es',
'wss://relay.snort.social',
];
/**
* Fetches the absolute freshest kind 3 follow list from multiple relays.
* This prevents stale-data mutations that could accidentally wipe follows.
* Fetches the absolute freshest kind 3 follow list via the pool.
* The pool already routes to all configured read relays.
*/
async function fetchFreshFollowEvent(
nostr: ReturnType<typeof useNostr>['nostr'],
pubkey: string,
userRelayUrls: string[],
): Promise<NostrEvent | null> {
const allRelayUrls = [...new Set([...DISCOVERY_RELAYS, ...userRelayUrls])];
const signal = AbortSignal.timeout(10_000);
const relayGroup = nostr.group(allRelayUrls);
const followEvents = await relayGroup.query(
const followEvents = await nostr.query(
[{ kinds: [3], authors: [pubkey], limit: 1 }],
{ signal },
);
@@ -109,25 +92,19 @@ export interface UseFollowActionsReturn {
export function useFollowActions(): UseFollowActionsReturn {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const [isPending, setIsPending] = useState(false);
/** User's read-enabled relay URLs (from NIP-65 config). */
const userRelayUrls = config.relayMetadata.relays
.filter((r) => r.read)
.map((r) => r.url);
const mutateFollowList = useCallback(
async (targetPubkey: string, action: 'follow' | 'unfollow') => {
if (!user) throw new Error('Not logged in');
setIsPending(true);
try {
// ① Fetch the freshest kind 3 event from a wide relay set
const latestEvent = await fetchFreshFollowEvent(nostr, user.pubkey, userRelayUrls);
// ① Fetch the freshest kind 3 event via pool
const latestEvent = await fetchFreshFollowEvent(nostr, user.pubkey);
// ② Separate tags into `p` tags (follow entries) and everything else
const existingTags = latestEvent?.tags ?? [];
@@ -163,7 +140,7 @@ export function useFollowActions(): UseFollowActionsReturn {
setIsPending(false);
}
},
[nostr, user, userRelayUrls, publishEvent, queryClient],
[nostr, user, publishEvent, queryClient],
);
const follow = useCallback(
+2 -4
View File
@@ -18,10 +18,8 @@ export function useSearchProfiles(query: string) {
queryFn: async ({ signal }) => {
if (!query.trim()) return [];
// Use relay.ditto.pub specifically for NIP-50 profile search
const relay = nostr.relay('wss://relay.ditto.pub');
const events = await relay.query(
// NIP-50 profile search (uses pool, reuses existing connections)
const events = await nostr.query(
[{ kinds: [0], search: query.trim(), limit: 10 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
+4 -5
View File
@@ -67,13 +67,12 @@ export function useStreamKind(kind: number | number[]) {
setEvents(Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at));
}
const relay = nostr.relay('wss://relay.ditto.pub');
const filter = { kinds };
// 1. Fetch initial batch
// 1. Fetch initial batch (uses pool, reuses existing connections)
(async () => {
try {
const results = await relay.query(
const results = await nostr.query(
[{ ...filter, limit: 40 }],
{ signal: ac.signal },
);
@@ -86,11 +85,11 @@ export function useStreamKind(kind: number | number[]) {
if (alive) setIsLoading(false);
})();
// 2. Stream new events
// 2. Stream new events (uses pool, reuses existing connections)
(async () => {
try {
const now = Math.floor(Date.now() / 1000);
for await (const msg of relay.req(
for await (const msg of nostr.req(
[{ ...filter, since: now, limit: 100 }],
{ signal: ac.signal },
)) {
+4 -6
View File
@@ -95,8 +95,6 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
setAllEvents(Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at));
}
const relay = nostr.relay('wss://relay.ditto.pub');
// Build the kinds list: either vines-only or kind 1 + enabled extras
const kinds: number[] = isVines
? [34236]
@@ -108,10 +106,10 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
baseFilter.search = query.trim();
}
// 1. Fetch initial batch
// 1. Fetch initial batch (uses pool, reuses existing connections)
(async () => {
try {
const events = await relay.query(
const events = await nostr.query(
[{ ...baseFilter, limit: 40 }],
{ signal: ac.signal },
);
@@ -124,11 +122,11 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
if (alive) setIsLoading(false);
})();
// 2. Stream new events
// 2. Stream new events (uses pool, reuses existing connections)
(async () => {
try {
const now = Math.floor(Date.now() / 1000);
for await (const msg of relay.req(
for await (const msg of nostr.req(
[{ ...baseFilter, since: now, limit: 100 }],
{ signal: ac.signal },
)) {