From 82b5354ed792e1b706ef0c571dca02b1c4284d27 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 31 May 2026 10:58:37 +0200 Subject: [PATCH] fix random relay selection --- .../PostEditor/PostRelaySelector.tsx | 20 +++++- src/lib/random-publish-relay-pool.test.ts | 27 ++++++++ src/lib/random-publish-relay-pool.ts | 39 +++++++++++ src/services/nip66.service.ts | 20 ++++++ src/services/relay-selection.service.ts | 64 +++++++++++-------- 5 files changed, 142 insertions(+), 28 deletions(-) create mode 100644 src/lib/random-publish-relay-pool.test.ts create mode 100644 src/lib/random-publish-relay-pool.ts diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 4a4ee352..c8060635 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -12,6 +12,8 @@ import { NostrEvent } from 'nostr-tools' import { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import RelayIcon from '../RelayIcon' +import { useUserPreferences } from '@/providers/UserPreferencesProvider' +import nip66Service from '@/services/nip66.service' import relaySelectionService, { type RelaySourceType } from '@/services/relay-selection.service' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -22,7 +24,7 @@ import { computePrePublishRelayCapPreview, type TPrePublishRelayCapPreview } fro /** Stable default when `mentions` is omitted — inline `= []` is a new array every render and retriggers effects. */ const NO_MENTIONS: string[] = [] -/** Keep auto-selection within {@link MAX_PUBLISH_RELAYS}, preserving {@link selectableRelaysOrder} (top of list first). */ +/** Keep auto-selection within {@link MAX_PUBLISH_RELAYS}, preserving picker order (outboxes before randoms). */ function capAutoSelectedRelays(selectableRelaysOrder: string[], selectedWithCache: string[]): string[] { const norm = (u: string) => normalizeRelayUrlByScheme(u) || u const selectedNormSet = new Set(selectedWithCache.map(norm)) @@ -68,7 +70,9 @@ export default function PostRelaySelector({ const { isSmallScreen } = useScreenSize() useCurrentRelays() // Keep this hook call for any side effects const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays() + const { addRandomRelaysToPublish } = useUserPreferences() const { pubkey, relayList, cacheRelayListEvent } = useNostr() + const [publicLivelyRevision, setPublicLivelyRevision] = useState(0) const userReadRelaysForSelection = useMemo( () => userReadInboxUrls(relayList, cacheRelayListEvent), [relayList, cacheRelayListEvent] @@ -86,6 +90,18 @@ export default function PostRelaySelector({ // it's still the latest invocation before committing state, preventing stale races. const selectionGenRef = useRef(0) + useEffect(() => { + return nip66Service.subscribePublicLivelyUpdated(() => { + setPublicLivelyRevision((v) => v + 1) + }) + }, []) + + useEffect(() => { + void nip66Service.getPublicLivelyRelayUrls().then(() => { + setPublicLivelyRevision((v) => v + 1) + }) + }, []) + // For discussion replies, content doesn't affect relay selection // Check if this is a reply to a discussion by looking for "K" tag with "11" const isDiscussionReply = useMemo(() => { @@ -235,6 +251,8 @@ export default function PostRelaySelector({ contentRelaySignature, mentions, describeRelaySelection, + addRandomRelaysToPublish, + publicLivelyRevision, t ]) diff --git a/src/lib/random-publish-relay-pool.test.ts b/src/lib/random-publish-relay-pool.test.ts new file mode 100644 index 00000000..3a9246fe --- /dev/null +++ b/src/lib/random-publish-relay-pool.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { buildRandomPublishRelayCandidateList } from './random-publish-relay-pool' + +describe('buildRandomPublishRelayCandidateList', () => { + it('fills from fallback write relays when NIP-66 list is empty', () => { + const candidates = buildRandomPublishRelayCandidateList({ + excludeSessionKeys: new Set(['wss://relay.user.example']), + sessionBoost: [], + nip66Lively: [], + fallbackWriteRelays: ['wss://alpha.example', 'wss://beta.example', 'wss://gamma.example'] + }) + expect(candidates.length).toBeGreaterThanOrEqual(3) + expect(candidates.some((u) => u.includes('alpha.example'))).toBe(true) + expect(candidates.some((u) => u.includes('relay.user.example'))).toBe(false) + }) + + it('dedupes session boost and NIP-66 entries', () => { + const candidates = buildRandomPublishRelayCandidateList({ + excludeSessionKeys: new Set(), + sessionBoost: ['wss://same.example/'], + nip66Lively: ['wss://same.example'], + fallbackWriteRelays: [] + }) + expect(candidates).toHaveLength(1) + expect(candidates[0]).toMatch(/same\.example/) + }) +}) diff --git a/src/lib/random-publish-relay-pool.ts b/src/lib/random-publish-relay-pool.ts new file mode 100644 index 00000000..481dd21b --- /dev/null +++ b/src/lib/random-publish-relay-pool.ts @@ -0,0 +1,39 @@ +import { RANDOM_PUBLISH_RELAY_COUNT } from '@/constants' +import { canonicalRelaySessionKey, normalizeAnyRelayUrl, normalizeRelayUrlByScheme } from '@/lib/url' + +export function normalizePublishRelayCandidate(url: string): string { + return normalizeRelayUrlByScheme(normalizeAnyRelayUrl(url) || url) || url.trim() +} + +/** + * Ordered candidate pool for optional random publish relays (NIP-66 lively, then write fallbacks). + * Excludes relays already in the user's publish picker list ({@link excludeSessionKeys}). + */ +export function buildRandomPublishRelayCandidateList(args: { + excludeSessionKeys: ReadonlySet + sessionBoost: readonly string[] + nip66Lively: readonly string[] + fallbackWriteRelays: readonly string[] + /** Upper bound on candidates before {@link pickRandomPublishRelays} narrows to {@link RANDOM_PUBLISH_RELAY_COUNT}. */ + maxCandidates?: number +}): string[] { + const seen = new Set() + const out: string[] = [] + const max = args.maxCandidates ?? RANDOM_PUBLISH_RELAY_COUNT * 8 + + const push = (raw: string) => { + if (out.length >= max) return + const normalized = normalizePublishRelayCandidate(raw) + const key = canonicalRelaySessionKey(normalized) + if (!key || args.excludeSessionKeys.has(key) || seen.has(key)) return + seen.add(key) + out.push(normalized) + } + + for (const u of args.sessionBoost) push(u) + for (const u of args.nip66Lively) push(u) + if (out.length < RANDOM_PUBLISH_RELAY_COUNT) { + for (const u of args.fallbackWriteRelays) push(u) + } + return out +} diff --git a/src/services/nip66.service.ts b/src/services/nip66.service.ts index c49659dd..49a07ddf 100644 --- a/src/services/nip66.service.ts +++ b/src/services/nip66.service.ts @@ -66,6 +66,7 @@ class Nip66Service { private static instance: Nip66Service /** Normalized relay URL -> latest discovery (we keep the most recent 30166 per relay). */ private discoveryByUrl = new Map() + private publicLivelyListeners = new Set<() => void>() static getInstance(): Nip66Service { if (!Nip66Service.instance) { @@ -74,6 +75,24 @@ class Nip66Service { return Nip66Service.instance } + /** Fired when in-memory public lively list changes (e.g. after 30166 ingest). */ + subscribePublicLivelyUpdated(listener: () => void): () => void { + this.publicLivelyListeners.add(listener) + return () => { + this.publicLivelyListeners.delete(listener) + } + } + + private notifyPublicLivelyUpdated(): void { + for (const listener of this.publicLivelyListeners) { + try { + listener() + } catch { + // ignore subscriber errors + } + } + } + private isDiscoveryStale(cachedAt: number): boolean { return Date.now() - cachedAt > DISCOVERY_CACHE_TTL_MS } @@ -106,6 +125,7 @@ class Nip66Service { const publicLively = this.buildPublicLivelyFromDiscovery() if (publicLively.length > 0 && typeof window !== 'undefined') { indexDb.setPublicLivelyRelayUrlsCache(publicLively).catch(() => {}) + this.notifyPublicLivelyUpdated() } if (typeof window !== 'undefined') { for (const key of updatedKeys) { diff --git a/src/services/relay-selection.service.ts b/src/services/relay-selection.service.ts index db509d35..320b4e7d 100644 --- a/src/services/relay-selection.service.ts +++ b/src/services/relay-selection.service.ts @@ -7,6 +7,7 @@ import storage from '@/services/local-storage.service' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' import client from '@/services/client.service' import { eventService } from '@/services/client.service' +import { buildRandomPublishRelayCandidateList, normalizePublishRelayCandidate } from '@/lib/random-publish-relay-pool' import { canonicalRelaySessionKey, isLocalNetworkUrl, @@ -61,6 +62,8 @@ export interface RelaySelectionResult { description: string /** Source type per relay URL (for UI labels). */ relayTypes: Record + /** Optional random publish relays (NIP-66 / session / write fallbacks), independent of metadata-only read policy. */ + randomRelayUrls: string[] } class RelaySelectionService { @@ -110,7 +113,33 @@ class RelaySelectionService { selectableRelays, selectedRelays, description, - relayTypes + relayTypes, + randomRelayUrls: contextWithRandom.randomRelayUrls ?? [] + } + } + + /** + * Pick random publish relays for the post picker. Uses NIP-66 lively list and session stats when + * available; falls back to {@link FAST_WRITE_RELAY_URLS} so random relays still appear when the + * viewer restricts reads to their own relay lists (NIP-66 discovery fetch is skipped in that mode). + */ + private async pickRandomPublishRelayUrls(existingSessionKeys: Set): Promise { + if (typeof window === 'undefined') return [] + try { + const sessionBoost = client.getSessionSuccessfulPublishRelayUrlsForRandomPool() + const publicLively = await nip66Service.getPublicLivelyRelayUrls() + const candidates = buildRandomPublishRelayCandidateList({ + excludeSessionKeys: existingSessionKeys, + sessionBoost, + nip66Lively: publicLively, + fallbackWriteRelays: FAST_WRITE_RELAY_URLS + }) + const preferred = client.getPreferredRelaysForRandom(candidates, RANDOM_PUBLISH_RELAY_COUNT) + return preferred + .map((url) => normalizePublishRelayCandidate(url)) + .filter((url) => url.length > 0) + } catch { + return [] } } @@ -194,32 +223,13 @@ class RelaySelectionService { openFrom.forEach((url) => addRelay(url, 'open_from')) } - // Random relays: prefer session-proven fast relays, then fill with random from rest (selection only random between sessions) - const randomRelayUrls: string[] = [] - if (typeof window !== 'undefined') { - try { - const publicLively = await nip66Service.getPublicLivelyRelayUrls() - /** Session OK relays first so they stay candidates even if absent from NIP-66 lively list */ - const sessionBoost = client.getSessionSuccessfulPublishRelayUrlsForRandomPool() - const existing = new Set(order.map((o) => o.url)) - const seenCand = new Set() - const candidates: string[] = [] - for (const u of [...sessionBoost, ...publicLively]) { - const n = normalizeAnyRelayUrl(u) || u - if (!n || existing.has(n) || seenCand.has(n)) continue - seenCand.add(n) - candidates.push(n) - } - const preferred = client.getPreferredRelaysForRandom(candidates, RANDOM_PUBLISH_RELAY_COUNT) - preferred.forEach((url) => { - const normalized = normalizeAnyRelayUrl(url) || url - addRelay(normalized, 'randomly_selected') - randomRelayUrls.push(normalized) - }) - } catch { - // ignore - } - } + const existingSessionKeys = new Set( + order.map((o) => canonicalRelaySessionKey(o.url)).filter(Boolean) + ) + const randomRelayUrls = await this.pickRandomPublishRelayUrls(existingSessionKeys) + randomRelayUrls.forEach((url) => { + addRelay(url, 'randomly_selected') + }) const deduplicatedRelays = order.map((o) => o.url) const filtered = this.filterPublishPickerRelays(