diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index d80090e..f29e851 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -4,19 +4,25 @@ import { cn } from '@/lib/utils' import client from '@/services/client.service' import { useTranslation } from 'react-i18next' import { useEffect, useState } from 'react' -import { Event } from 'nostr-tools' +import { Event, nip19 } from 'nostr-tools' import ClientSelect from '../ClientSelect' import MainNoteCard from '../NoteCard/MainNoteCard' +import { Button } from '../ui/button' +import { Search } from 'lucide-react' export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) { const { event, isFetching } = useFetchEvent(noteId) const [retryEvent, setRetryEvent] = useState(undefined) const [isRetrying, setIsRetrying] = useState(false) + const [retryCount, setRetryCount] = useState(0) + const maxRetries = 3 - // If the first fetch fails, try a force retry with the four-tier system + // If the first fetch fails, try a force retry (max 3 attempts) useEffect(() => { - if (!isFetching && !event && !isRetrying) { + if (!isFetching && !event && !isRetrying && retryCount < maxRetries) { setIsRetrying(true) + setRetryCount(prev => prev + 1) + client.fetchEventForceRetry(noteId) .then((retryResult) => { if (retryResult) { @@ -24,23 +30,23 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className? } }) .catch((error) => { - console.warn('Force retry failed for event:', noteId, error) + console.warn(`Retry ${retryCount + 1}/${maxRetries} failed for event:`, noteId, error) }) .finally(() => { setIsRetrying(false) }) } - }, [isFetching, event, noteId, isRetrying]) + }, [isFetching, event, noteId, isRetrying, retryCount]) const finalEvent = event || retryEvent - const finalIsFetching = isFetching || isRetrying + const finalIsFetching = isFetching || (isRetrying && retryCount <= maxRetries) if (finalIsFetching) { return } if (!finalEvent) { - return + return } return ( @@ -72,14 +78,123 @@ function EmbeddedNoteSkeleton({ className }: { className?: string }) { ) } -function EmbeddedNoteNotFound({ noteId, className }: { noteId: string; className?: string }) { +function EmbeddedNoteNotFound({ + noteId, + className, + onEventFound +}: { + noteId: string + className?: string + onEventFound?: (event: Event) => void +}) { const { t } = useTranslation() + const [isSearchingExternal, setIsSearchingExternal] = useState(false) + const [triedExternal, setTriedExternal] = useState(false) + const [externalRelays, setExternalRelays] = useState([]) + + // Calculate which external relays would be tried + useEffect(() => { + const getExternalRelays = async () => { + const relays: string[] = [] + + if (!/^[0-9a-f]{64}$/.test(noteId)) { + try { + const { type, data } = nip19.decode(noteId) + + if (type === 'nevent') { + if (data.relays) relays.push(...data.relays) + if (data.author) { + const authorRelayList = await client.fetchRelayList(data.author) + relays.push(...authorRelayList.write.slice(0, 6)) + } + } else if (type === 'naddr') { + if (data.relays) relays.push(...data.relays) + const authorRelayList = await client.fetchRelayList(data.pubkey) + relays.push(...authorRelayList.write.slice(0, 6)) + } + } catch (err) { + console.error('Failed to parse external relays:', err) + } + } + + const seenOn = client.getSeenEventRelayUrls(noteId) + relays.push(...seenOn) + + setExternalRelays(Array.from(new Set(relays))) + } + + getExternalRelays() + }, [noteId]) + + const handleTryExternalRelays = async () => { + if (isSearchingExternal) return + + setIsSearchingExternal(true) + try { + const event = await client.fetchEventWithExternalRelays(noteId) + if (event && onEventFound) { + onEventFound(event) + } + } catch (error) { + console.error('External relay fetch failed:', error) + } finally { + setIsSearchingExternal(false) + setTriedExternal(true) + } + } + + const hasExternalRelays = externalRelays.length > 0 return ( -
-
-
{t('Sorry! The note cannot be found 😔')}
- +
+
+
{t('Note not found')}
+ + {!triedExternal && hasExternalRelays && ( +
+ +
+ + {t('Show relays')} + +
+ {externalRelays.map((relay, i) => ( +
+ {relay} +
+ ))} +
+
+
+ )} + + {!triedExternal && !hasExternalRelays && ( +

{t('No external relay hints available')}

+ )} + + {triedExternal && ( +

{t('Note could not be found anywhere')}

+ )} + +
) diff --git a/src/components/Note/Poll.tsx b/src/components/Note/Poll.tsx index 26ceb36..b9470c1 100644 --- a/src/components/Note/Poll.tsx +++ b/src/components/Note/Poll.tsx @@ -6,7 +6,6 @@ import { createPollResponseDraftEvent } from '@/lib/draft-event' import { getPollMetadataFromEvent } from '@/lib/event-metadata' import { cn, isPartiallyInViewport } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' -import client from '@/services/client.service' import pollResultsService from '@/services/poll-results.service' import dayjs from 'dayjs' import { CheckCircle2, Loader2 } from 'lucide-react' @@ -251,7 +250,7 @@ export default function Poll({ event, className }: { event: Event; className?: s ) } -async function ensurePollRelays(creator: string, poll: { relayUrls: string[] }) { +async function ensurePollRelays(_creator: string, poll: { relayUrls: string[] }) { const relays = poll.relayUrls.slice(0, 4) // Privacy: Use defaults instead of fetching creator's relays if (!relays.length) { diff --git a/src/components/NoteStats/Likes.tsx b/src/components/NoteStats/Likes.tsx index 0eb1dd7..5f1eda2 100644 --- a/src/components/NoteStats/Likes.tsx +++ b/src/components/NoteStats/Likes.tsx @@ -3,7 +3,6 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { createReactionDraftEvent } from '@/lib/draft-event' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' -import client from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import { TEmoji } from '@/types' import { Loader } from 'lucide-react' diff --git a/src/components/NoteStats/VoteButtons.tsx b/src/components/NoteStats/VoteButtons.tsx index 49799f4..76c3e68 100644 --- a/src/components/NoteStats/VoteButtons.tsx +++ b/src/components/NoteStats/VoteButtons.tsx @@ -1,7 +1,6 @@ import { Button } from '@/components/ui/button' import { createReactionDraftEvent } from '@/lib/draft-event' import { useNostr } from '@/providers/NostrProvider' -import client from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import { Event } from 'nostr-tools' import { ChevronDown, ChevronUp } from 'lucide-react' diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 0dcb326..11027d1 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -8,12 +8,10 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Separator } from '@/components/ui/separator' -import { isProtectedEvent } from '@/lib/event' import { simplifyUrl } from '@/lib/url' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import client from '@/services/client.service' import { Check } from 'lucide-react' import { NostrEvent } from 'nostr-tools' import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react' @@ -35,7 +33,7 @@ type TPostTargetItem = } export default function PostRelaySelector({ - parentEvent, + parentEvent: _parentEvent, openFrom, setIsProtectedEvent, setAdditionalRelayUrls @@ -94,12 +92,9 @@ export default function PostRelaySelector({ setPostTargetItems(Array.from(new Set(openFrom)).map((url) => ({ type: 'relay', url }))) return } - if (parentEventSeenOnRelays && parentEventSeenOnRelays.length) { - setPostTargetItems(parentEventSeenOnRelays.map((url) => ({ type: 'relay', url }))) - return - } + // Privacy: Default to write relays, never parent event's relays setPostTargetItems([{ type: 'writeRelays' }]) - }, [openFrom, parentEventSeenOnRelays]) + }, [openFrom]) useEffect(() => { const isProtectedEvent = postTargetItems.every((item) => item.type !== 'writeRelays') diff --git a/src/components/Profile/ProfileFeed.tsx b/src/components/Profile/ProfileFeed.tsx index 13c9c7e..ca00be5 100644 --- a/src/components/Profile/ProfileFeed.tsx +++ b/src/components/Profile/ProfileFeed.tsx @@ -42,7 +42,7 @@ export default function ProfileFeed({ const init = async () => { // Privacy: Only use user's own relays + defaults, never connect to other users' relays const myRelayList = myPubkey ? await client.fetchRelayList(myPubkey) : { write: [], read: [] } - const userRelays = myRelayList.read.concat(BIG_RELAY_URLS) + const userRelays = [...myRelayList.read, ...BIG_RELAY_URLS] if (listMode === 'you') { if (!myPubkey) { diff --git a/src/constants.ts b/src/constants.ts index 1cce013..f330361 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -91,7 +91,14 @@ export const SEARCHABLE_RELAY_URLS = [ 'wss://search.nos.today/', 'wss://nostr.wine', 'wss://orly-relay.imwald.eu', - 'wss://aggr.nostr.land' + 'wss://aggr.nostr.land', + 'wss://nos.lol', + 'wss://thecitadel.nostr1.com', + 'wss://relay.primal.net', + 'wss://relay.damus.io', + 'wss://relay.lumina.rocks', + 'wss://relay.snort.social', + 'wss://freelay.sovbit.host' ] export const PROFILE_RELAY_URLS = [ diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7ddef6c..89d28f4 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -204,6 +204,15 @@ export default { 'Seen on': 'Seen on', 'Temporarily display this reply': 'Temporarily display this reply', 'Note not found': 'Note not found', + 'The note was not found on your relays or default relays.': 'The note was not found on your relays or default relays.', + 'Try searching author\'s relays': 'Try searching author\'s relays', + 'Searching external relays...': 'Searching external relays...', + 'This will connect to the author\'s relays and relay hints': 'This will connect to the author\'s relays and relay hints', + 'Note could not be found anywhere': 'Note could not be found anywhere', + 'Try external relays': 'Try external relays', + 'Searching...': 'Searching...', + 'Show relays': 'Show relays', + 'No external relay hints available': 'No external relay hints available', 'no more replies': 'no more replies', 'Relay sets': 'Relay sets', 'Favorite Relays': 'Favorite Relays', diff --git a/src/pages/secondary/NotePage/NotFound.tsx b/src/pages/secondary/NotePage/NotFound.tsx index 99c3acc..afbd516 100644 --- a/src/pages/secondary/NotePage/NotFound.tsx +++ b/src/pages/secondary/NotePage/NotFound.tsx @@ -1,12 +1,134 @@ import ClientSelect from '@/components/ClientSelect' +import { Button } from '@/components/ui/button' +import client from '@/services/client.service' +import { AlertCircle, Search } from 'lucide-react' +import { nip19 } from 'nostr-tools' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -export default function NotFound({ bech32Id }: { bech32Id?: string }) { +export default function NotFound({ + bech32Id, + onEventFound +}: { + bech32Id?: string + onEventFound?: (event: any) => void +}) { const { t } = useTranslation() + const [isSearchingExternal, setIsSearchingExternal] = useState(false) + const [triedExternal, setTriedExternal] = useState(false) + const [externalRelays, setExternalRelays] = useState([]) + + // Calculate which external relays would be tried + useEffect(() => { + if (!bech32Id) return + + const getExternalRelays = async () => { + const relays: string[] = [] + + // Parse relay hints and author from bech32 ID + if (!/^[0-9a-f]{64}$/.test(bech32Id)) { + try { + const { type, data } = nip19.decode(bech32Id) + + if (type === 'nevent') { + if (data.relays) relays.push(...data.relays) + if (data.author) { + const authorRelayList = await client.fetchRelayList(data.author) + relays.push(...authorRelayList.write.slice(0, 6)) + } + } else if (type === 'naddr') { + if (data.relays) relays.push(...data.relays) + const authorRelayList = await client.fetchRelayList(data.pubkey) + relays.push(...authorRelayList.write.slice(0, 6)) + } + } catch (err) { + console.error('Failed to parse external relays:', err) + } + } + + const seenOn = client.getSeenEventRelayUrls(bech32Id) + relays.push(...seenOn) + + setExternalRelays(Array.from(new Set(relays))) + } + + getExternalRelays() + }, [bech32Id]) + + const handleTryExternalRelays = async () => { + if (!bech32Id || isSearchingExternal) return + + setIsSearchingExternal(true) + try { + const event = await client.fetchEventWithExternalRelays(bech32Id) + if (event && onEventFound) { + onEventFound(event) + } + } catch (error) { + console.error('External relay fetch failed:', error) + } finally { + setIsSearchingExternal(false) + setTriedExternal(true) + } + } + + const hasExternalRelays = externalRelays.length > 0 return ( -
-
{t('Note not found')}
+
+ +
{t('Note not found')}
+ + {bech32Id && !triedExternal && hasExternalRelays && ( +
+

+ {t('The note was not found on your relays or default relays.')} +

+ + + +
+ + {t('Show relays')} ({externalRelays.length}) + +
+ {externalRelays.map((relay, i) => ( +
+ {relay} +
+ ))} +
+
+
+ )} + + {bech32Id && !triedExternal && !hasExternalRelays && ( +

+ {t('No external relay hints available')} +

+ )} + + {triedExternal && ( +

{t('Note could not be found anywhere')}

+ )} +
) diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 0515861..3279209 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -16,18 +16,21 @@ import { tagNameEquals } from '@/lib/tag' import { cn } from '@/lib/utils' import { Ellipsis } from 'lucide-react' import { Event } from 'nostr-tools' -import { forwardRef, useMemo } from 'react' +import { forwardRef, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import NotFound from './NotFound' const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => { const { t } = useTranslation() const { event, isFetching } = useFetchEvent(id) - const parentEventId = useMemo(() => getParentBech32Id(event), [event]) - const rootEventId = useMemo(() => getRootBech32Id(event), [event]) + const [externalEvent, setExternalEvent] = useState(undefined) + const finalEvent = event || externalEvent + + const parentEventId = useMemo(() => getParentBech32Id(finalEvent), [finalEvent]) + const rootEventId = useMemo(() => getRootBech32Id(finalEvent), [finalEvent]) const rootITag = useMemo( - () => (event?.kind === ExtendedKind.COMMENT ? event.tags.find(tagNameEquals('I')) : undefined), - [event] + () => (finalEvent?.kind === ExtendedKind.COMMENT ? finalEvent.tags.find(tagNameEquals('I')) : undefined), + [finalEvent] ) const { isFetching: isFetchingRootEvent, event: rootEvent } = useFetchEvent(rootEventId) const { isFetching: isFetchingParentEvent, event: parentEvent } = useFetchEvent(parentEventId) @@ -59,10 +62,10 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref ) } - if (!event) { + if (!finalEvent) { return ( - + ) } @@ -73,7 +76,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref {rootITag && } {rootEventId && rootEventId !== parentEventId && ( )} - +
- + ) }) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 854b5ea..2cbee74 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -10,7 +10,6 @@ import { import { getLatestEvent, getReplaceableEventIdentifier, - isProtectedEvent, minePow } from '@/lib/event' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 77b7623..ae3402d 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1,4 +1,4 @@ -import { BIG_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS } from '@/constants' +import { BIG_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { compareEvents, getReplaceableCoordinate, @@ -55,10 +55,6 @@ class ClientService extends EventTarget { (ids) => Promise.all(ids.map((id) => this._fetchEvent(id))), { cacheMap: this.eventCacheMap } ) - private fetchEventFromBigRelaysDataloader = new DataLoader( - this.fetchEventsFromBigRelays.bind(this), - { cache: false, batchScheduleFn: (callback) => setTimeout(callback, 50) } - ) private trendingNotesCache: NEvent[] | null = null private requestThrottle = new Map() // Track request timestamps per relay private readonly REQUEST_COOLDOWN = 2000 // 2 second cooldown between requests to prevent "too many REQs" @@ -99,8 +95,12 @@ class ClientService extends EventTarget { } else { const _additionalRelayUrls: string[] = additionalRelayUrls ?? [] - // For kind 1 (notes) and kind 24 (public messages), publish to mentioned users' inboxes - if (event.kind === kinds.ShortTextNote || event.kind === ExtendedKind.PUBLIC_MESSAGE) { + // Check if this is a discussion thread or reply to a discussion + const isDiscussionRelated = event.kind === ExtendedKind.DISCUSSION || + event.tags.some(tag => tag[0] === 'k' && tag[1] === '11') + + // Publish to mentioned users' inboxes for all events EXCEPT discussions + if (!isDiscussionRelated) { const mentions: string[] = [] event.tags.forEach(([tagName, tagValue]) => { if ( @@ -137,16 +137,6 @@ class ClientService extends EventTarget { const senderWriteRelays = relayList?.write.slice(0, 6) ?? [] const recipientReadRelays = Array.from(new Set(_additionalRelayUrls)) relays = senderWriteRelays.concat(recipientReadRelays) - - // Special logging for public messages - if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { - // console.log('🎯 Final relay selection for public message:', { - // eventId: event.id.substring(0, 8) + '...', - // senderWriteRelays: senderWriteRelays.length, - // recipientReadRelays: recipientReadRelays.length, - // finalRelays: relays.length - // }) - } } if (!relays.length) { @@ -168,16 +158,6 @@ class ClientService extends EventTarget { totalCount: number }> { const uniqueRelayUrls = this.optimizeRelaySelection(Array.from(new Set(relayUrls))) - console.log(`Publishing kind ${event.kind} event to ${uniqueRelayUrls.length} relays:`, uniqueRelayUrls) - // if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { - // console.log('Public message event details:', { - // id: event.id, - // pubkey: event.pubkey, - // content: event.content.substring(0, 50), - // tags: event.tags, - // targetRelays: uniqueRelayUrls - // }) - // } const relayStatuses: Array<{ url: string @@ -203,7 +183,6 @@ class ClientService extends EventTarget { // If one third of the relays have accepted the event, consider it a success const isSuccess = successCount >= Math.max(1, Math.ceil(uniqueRelayUrls.length / 3)) if (isSuccess && !resolved) { - console.log(`✓ Publishing successful (${successCount}/${uniqueRelayUrls.length} relays)`) this.emitNewEvent(event) resolved = true resolve({ @@ -217,7 +196,6 @@ class ClientService extends EventTarget { if (finishedCount >= uniqueRelayUrls.length && !resolved) { if (successCount > 0) { - console.log(`✓ Publishing successful (${successCount}/${uniqueRelayUrls.length} relays)`) this.emitNewEvent(event) resolved = true resolve({ @@ -227,7 +205,6 @@ class ClientService extends EventTarget { totalCount: uniqueRelayUrls.length }) } else { - console.log(`✗ Publishing failed (0/${uniqueRelayUrls.length} relays)`) resolved = true reject( new AggregateError( @@ -251,7 +228,6 @@ class ClientService extends EventTarget { // Add overall timeout to prevent hanging const overallTimeout = setTimeout(() => { if (!resolved) { - console.log(`⚠ Publishing timeout after 15s (${successCount}/${uniqueRelayUrls.length} relays succeeded)`) resolved = true if (successCount > 0) { this.emitNewEvent(event) @@ -280,7 +256,6 @@ class ClientService extends EventTarget { relay.publishTimeout = 8_000 // 8s await relay.publish(event) - console.log(`✓ Published to ${url}`) this.trackEventSeenOn(event.id, relay) this.recordSuccess(url) successCount++ @@ -299,7 +274,6 @@ class ClientService extends EventTarget { } else if (error !== null && error !== undefined) { errorMessage = String(error) } - console.log(`✗ Failed to publish to ${url}:`, errorMessage) // Record failure for exponential backoff this.recordFailure(url) @@ -330,14 +304,12 @@ class ClientService extends EventTarget { !!that.signer ) { try { - console.log(`Attempting auth for ${url}`) // Throttle auth requests too await this.throttleRequest(url) const relay = await this.pool.ensureRelay(url) await relay.auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt)) await relay.publish(event) - console.log(`✓ Published to ${url} after auth`) this.trackEventSeenOn(event.id, relay) this.recordSuccess(url) successCount++ @@ -357,7 +329,6 @@ class ClientService extends EventTarget { } else if (authError !== null && authError !== undefined) { authErrorMessage = String(authError) } - console.log(`✗ Auth failed for ${url}:`, authErrorMessage) this.recordFailure(url) errors.push({ url, error: authError }) finishedCount++ @@ -1020,32 +991,89 @@ class ClientService extends EventTarget { } } - private async fetchEventById(relayUrls: string[], id: string): Promise { - // First try the big relays - const event = await this.fetchEventFromBigRelaysDataloader.load(id) - if (event) { - return event + private async fetchEventById(_relayUrls: string[], id: string): Promise { + // Get user's relay list if available + const userRelayList = this.pubkey ? await this.fetchRelayList(this.pubkey) : { read: [], write: [] } + + // Tier 1: User's read relays + fast read relays (deduplicated) + const tier1Relays = Array.from(new Set([ + ...userRelayList.read, + ...FAST_READ_RELAY_URLS + ])) + + const tier1Event = await this.tryHarderToFetchEvent(tier1Relays, { ids: [id], limit: 1 }) + if (tier1Event) { + return tier1Event + } + + // Tier 2: User's write relays + fast write relays (deduplicated) + const tier2Relays = Array.from(new Set([ + ...userRelayList.write, + ...FAST_WRITE_RELAY_URLS + ])) + + const tier2Event = await this.tryHarderToFetchEvent(tier2Relays, { ids: [id], limit: 1 }) + if (tier2Event) { + return tier2Event } - // Privacy: Don't try "seen on" relays - only use defaults - // Fallback to BIG_RELAY_URLS if not found + // Tier 3: Search relays + big relays (deduplicated) + const tier3Relays = Array.from(new Set([ + ...SEARCHABLE_RELAY_URLS, + ...BIG_RELAY_URLS + ])) + + const tier3Event = await this.tryHarderToFetchEvent(tier3Relays, { ids: [id], limit: 1 }) + if (tier3Event) { + return tier3Event + } - // Third, try the provided relay URLs - if (relayUrls.length > 0) { - const providedEvent = await this.tryHarderToFetchEvent(relayUrls, { ids: [id], limit: 1 }, true) - if (providedEvent) { - return providedEvent + // Tier 4: Not found - external relays require opt-in (see fetchEventWithExternalRelays) + return undefined + } + + // Opt-in method to fetch from author's relays, relay hints, and "seen on" relays + async fetchEventWithExternalRelays(id: string): Promise { + // Clear cache to force new fetch + this.eventCacheMap.delete(id) + + // Parse the ID to extract relay hints and author + let relayHints: string[] = [] + let author: string | undefined + + if (!/^[0-9a-f]{64}$/.test(id)) { + const { type, data } = nip19.decode(id) + if (type === 'nevent') { + if (data.relays) relayHints = data.relays + if (data.author) author = data.author + } else if (type === 'naddr') { + if (data.relays) relayHints = data.relays + author = data.pubkey } } - // Privacy: Use defaults and provided relays only - const allAvailableRelays = Array.from(new Set([ - ...FAST_READ_RELAY_URLS, - ...FAST_WRITE_RELAY_URLS, - ...relayUrls - ])) + // Collect external relays: author's outbox + relay hints + seen on + const externalRelays: string[] = [] + + if (author) { + const authorRelayList = await this.fetchRelayList(author) + externalRelays.push(...authorRelayList.write.slice(0, 6)) + } + + if (relayHints.length > 0) { + externalRelays.push(...relayHints) + } + + const seenOn = this.getSeenEventRelayUrls(id) + externalRelays.push(...seenOn) + + const uniqueExternalRelays = Array.from(new Set(externalRelays)) - return this.tryHarderToFetchEvent(allAvailableRelays, { ids: [id], limit: 1 }, true) + if (uniqueExternalRelays.length === 0) { + return undefined + } + + return this.tryHarderToFetchEvent(uniqueExternalRelays, { ids: [id], limit: 1 }) } private async _fetchEvent(id: string): Promise { @@ -1112,18 +1140,6 @@ class ClientService extends EventTarget { return events.sort((a, b) => b.created_at - a.created_at)[0] } - private async fetchEventsFromBigRelays(ids: readonly string[]) { - const events = await this.query(FAST_READ_RELAY_URLS, { - ids: Array.from(new Set(ids)), - limit: ids.length - }) - const eventsMap = new Map() - for (const event of events) { - eventsMap.set(event.id, event) - } - - return ids.map((id) => eventsMap.get(id)) - } /** =========== Following favorite relays =========== */ @@ -1627,10 +1643,8 @@ class ClientService extends EventTarget { let delay = this.REQUEST_COOLDOWN if (failures >= this.MAX_FAILURES) { delay = Math.min(this.REQUEST_COOLDOWN * Math.pow(2, failures - this.MAX_FAILURES), 30000) // Max 30 seconds - console.log(`⏳ Exponential backoff for ${relayUrl}: ${delay}ms (${failures} failures)`) } else if (now - lastRequest < this.REQUEST_COOLDOWN) { delay = this.REQUEST_COOLDOWN - (now - lastRequest) - console.log(`⏳ Throttling request to ${relayUrl} for ${delay}ms`) } if (delay > 0) { diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 1d74803..6bd2e7f 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -127,7 +127,7 @@ class NoteStatsService { }) } const events: Event[] = [] - await client.fetchEvents(relayList.read.concat(BIG_RELAY_URLS).slice(0, 5), filters, { + await client.fetchEvents([...relayList.read, ...BIG_RELAY_URLS].slice(0, 5), filters, { onevent: (evt) => { this.updateNoteStatsByEvents([evt]) events.push(evt)