Browse Source

fix random relay selection

imwald
Silberengel 2 weeks ago
parent
commit
82b5354ed7
  1. 20
      src/components/PostEditor/PostRelaySelector.tsx
  2. 27
      src/lib/random-publish-relay-pool.test.ts
  3. 39
      src/lib/random-publish-relay-pool.ts
  4. 20
      src/services/nip66.service.ts
  5. 64
      src/services/relay-selection.service.ts

20
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 { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon' 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 relaySelectionService, { type RelaySourceType } from '@/services/relay-selection.service'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' 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. */ /** Stable default when `mentions` is omitted — inline `= []` is a new array every render and retriggers effects. */
const NO_MENTIONS: string[] = [] 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[] { function capAutoSelectedRelays(selectableRelaysOrder: string[], selectedWithCache: string[]): string[] {
const norm = (u: string) => normalizeRelayUrlByScheme(u) || u const norm = (u: string) => normalizeRelayUrlByScheme(u) || u
const selectedNormSet = new Set(selectedWithCache.map(norm)) const selectedNormSet = new Set(selectedWithCache.map(norm))
@ -68,7 +70,9 @@ export default function PostRelaySelector({
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
useCurrentRelays() // Keep this hook call for any side effects useCurrentRelays() // Keep this hook call for any side effects
const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays() const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
const { addRandomRelaysToPublish } = useUserPreferences()
const { pubkey, relayList, cacheRelayListEvent } = useNostr() const { pubkey, relayList, cacheRelayListEvent } = useNostr()
const [publicLivelyRevision, setPublicLivelyRevision] = useState(0)
const userReadRelaysForSelection = useMemo( const userReadRelaysForSelection = useMemo(
() => userReadInboxUrls(relayList, cacheRelayListEvent), () => userReadInboxUrls(relayList, cacheRelayListEvent),
[relayList, cacheRelayListEvent] [relayList, cacheRelayListEvent]
@ -86,6 +90,18 @@ export default function PostRelaySelector({
// it's still the latest invocation before committing state, preventing stale races. // it's still the latest invocation before committing state, preventing stale races.
const selectionGenRef = useRef(0) 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 // 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" // Check if this is a reply to a discussion by looking for "K" tag with "11"
const isDiscussionReply = useMemo(() => { const isDiscussionReply = useMemo(() => {
@ -235,6 +251,8 @@ export default function PostRelaySelector({
contentRelaySignature, contentRelaySignature,
mentions, mentions,
describeRelaySelection, describeRelaySelection,
addRandomRelaysToPublish,
publicLivelyRevision,
t t
]) ])

27
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/)
})
})

39
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<string>
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<string>()
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
}

20
src/services/nip66.service.ts

@ -66,6 +66,7 @@ class Nip66Service {
private static instance: Nip66Service private static instance: Nip66Service
/** Normalized relay URL -> latest discovery (we keep the most recent 30166 per relay). */ /** Normalized relay URL -> latest discovery (we keep the most recent 30166 per relay). */
private discoveryByUrl = new Map<string, TNip66RelayDiscovery>() private discoveryByUrl = new Map<string, TNip66RelayDiscovery>()
private publicLivelyListeners = new Set<() => void>()
static getInstance(): Nip66Service { static getInstance(): Nip66Service {
if (!Nip66Service.instance) { if (!Nip66Service.instance) {
@ -74,6 +75,24 @@ class Nip66Service {
return Nip66Service.instance 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 { private isDiscoveryStale(cachedAt: number): boolean {
return Date.now() - cachedAt > DISCOVERY_CACHE_TTL_MS return Date.now() - cachedAt > DISCOVERY_CACHE_TTL_MS
} }
@ -106,6 +125,7 @@ class Nip66Service {
const publicLively = this.buildPublicLivelyFromDiscovery() const publicLively = this.buildPublicLivelyFromDiscovery()
if (publicLively.length > 0 && typeof window !== 'undefined') { if (publicLively.length > 0 && typeof window !== 'undefined') {
indexDb.setPublicLivelyRelayUrlsCache(publicLively).catch(() => {}) indexDb.setPublicLivelyRelayUrlsCache(publicLively).catch(() => {})
this.notifyPublicLivelyUpdated()
} }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
for (const key of updatedKeys) { for (const key of updatedKeys) {

64
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 { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns'
import client from '@/services/client.service' import client from '@/services/client.service'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import { buildRandomPublishRelayCandidateList, normalizePublishRelayCandidate } from '@/lib/random-publish-relay-pool'
import { import {
canonicalRelaySessionKey, canonicalRelaySessionKey,
isLocalNetworkUrl, isLocalNetworkUrl,
@ -61,6 +62,8 @@ export interface RelaySelectionResult {
description: string description: string
/** Source type per relay URL (for UI labels). */ /** Source type per relay URL (for UI labels). */
relayTypes: Record<string, RelaySourceType> relayTypes: Record<string, RelaySourceType>
/** Optional random publish relays (NIP-66 / session / write fallbacks), independent of metadata-only read policy. */
randomRelayUrls: string[]
} }
class RelaySelectionService { class RelaySelectionService {
@ -110,7 +113,33 @@ class RelaySelectionService {
selectableRelays, selectableRelays,
selectedRelays, selectedRelays,
description, 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<string>): Promise<string[]> {
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')) 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 existingSessionKeys = new Set(
const randomRelayUrls: string[] = [] order.map((o) => canonicalRelaySessionKey(o.url)).filter(Boolean)
if (typeof window !== 'undefined') { )
try { const randomRelayUrls = await this.pickRandomPublishRelayUrls(existingSessionKeys)
const publicLively = await nip66Service.getPublicLivelyRelayUrls() randomRelayUrls.forEach((url) => {
/** Session OK relays first so they stay candidates even if absent from NIP-66 lively list */ addRelay(url, 'randomly_selected')
const sessionBoost = client.getSessionSuccessfulPublishRelayUrlsForRandomPool() })
const existing = new Set(order.map((o) => o.url))
const seenCand = new Set<string>()
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 deduplicatedRelays = order.map((o) => o.url) const deduplicatedRelays = order.map((o) => o.url)
const filtered = this.filterPublishPickerRelays( const filtered = this.filterPublishPickerRelays(

Loading…
Cancel
Save