Browse Source

made relay selection privacy-protecting, with opt-in to unkown relays and mention-outboxes being pulled

cleaned up console
imwald
Silberengel 5 months ago
parent
commit
ce631f1b09
  1. 139
      src/components/Embedded/EmbeddedNote.tsx
  2. 3
      src/components/Note/Poll.tsx
  3. 1
      src/components/NoteStats/Likes.tsx
  4. 1
      src/components/NoteStats/VoteButtons.tsx
  5. 11
      src/components/PostEditor/PostRelaySelector.tsx
  6. 2
      src/components/Profile/ProfileFeed.tsx
  7. 9
      src/constants.ts
  8. 9
      src/i18n/locales/en.ts
  9. 128
      src/pages/secondary/NotePage/NotFound.tsx
  10. 29
      src/pages/secondary/NotePage/index.tsx
  11. 1
      src/providers/NostrProvider/index.tsx
  12. 152
      src/services/client.service.ts
  13. 2
      src/services/note-stats.service.ts

139
src/components/Embedded/EmbeddedNote.tsx

@ -4,19 +4,25 @@ import { cn } from '@/lib/utils' @@ -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<Event | undefined>(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? @@ -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 <EmbeddedNoteSkeleton className={className} />
}
if (!finalEvent) {
return <EmbeddedNoteNotFound className={className} noteId={noteId} />
return <EmbeddedNoteNotFound className={className} noteId={noteId} onEventFound={setRetryEvent} />
}
return (
@ -72,14 +78,123 @@ function EmbeddedNoteSkeleton({ className }: { className?: string }) { @@ -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<string[]>([])
// 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 (
<div className={cn('text-left p-2 sm:p-3 border rounded-lg', className)}>
<div className="flex flex-col items-center text-muted-foreground font-medium gap-2">
<div>{t('Sorry! The note cannot be found 😔')}</div>
<ClientSelect className="w-full mt-2" originalNoteId={noteId} />
<div className={cn('text-left p-3 border rounded-lg', className)}>
<div className="flex flex-col items-center text-muted-foreground gap-3">
<div className="text-sm font-medium">{t('Note not found')}</div>
{!triedExternal && hasExternalRelays && (
<div className="flex flex-col items-center gap-2 w-full">
<Button
variant="outline"
size="sm"
onClick={handleTryExternalRelays}
disabled={isSearchingExternal}
className="gap-2 w-full"
>
{isSearchingExternal ? (
<>
<Search className="w-4 h-4 animate-spin" />
{t('Searching...')}
</>
) : (
<>
<Search className="w-4 h-4" />
{t('Try external relays')} ({externalRelays.length})
</>
)}
</Button>
<details className="text-xs text-muted-foreground w-full">
<summary className="cursor-pointer hover:text-foreground text-center list-none">
{t('Show relays')}
</summary>
<div className="mt-2 space-y-1 max-h-24 overflow-y-auto">
{externalRelays.map((relay, i) => (
<div key={i} className="font-mono text-[10px] truncate px-2 py-0.5 bg-muted/50 rounded">
{relay}
</div>
))}
</div>
</details>
</div>
)}
{!triedExternal && !hasExternalRelays && (
<p className="text-xs text-center">{t('No external relay hints available')}</p>
)}
{triedExternal && (
<p className="text-xs text-center">{t('Note could not be found anywhere')}</p>
)}
<ClientSelect className="w-full" originalNoteId={noteId} />
</div>
</div>
)

3
src/components/Note/Poll.tsx

@ -6,7 +6,6 @@ import { createPollResponseDraftEvent } from '@/lib/draft-event' @@ -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 @@ -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) {

1
src/components/NoteStats/Likes.tsx

@ -3,7 +3,6 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById' @@ -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'

1
src/components/NoteStats/VoteButtons.tsx

@ -1,7 +1,6 @@ @@ -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'

11
src/components/PostEditor/PostRelaySelector.tsx

@ -8,12 +8,10 @@ import { @@ -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 = @@ -35,7 +33,7 @@ type TPostTargetItem =
}
export default function PostRelaySelector({
parentEvent,
parentEvent: _parentEvent,
openFrom,
setIsProtectedEvent,
setAdditionalRelayUrls
@ -94,12 +92,9 @@ export default function PostRelaySelector({ @@ -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')

2
src/components/Profile/ProfileFeed.tsx

@ -42,7 +42,7 @@ export default function ProfileFeed({ @@ -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) {

9
src/constants.ts

@ -91,7 +91,14 @@ export const SEARCHABLE_RELAY_URLS = [ @@ -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 = [

9
src/i18n/locales/en.ts

@ -204,6 +204,15 @@ export default { @@ -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',

128
src/pages/secondary/NotePage/NotFound.tsx

@ -1,12 +1,134 @@ @@ -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<string[]>([])
// 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 (
<div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2">
<div>{t('Note not found')}</div>
<div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-4 p-4">
<AlertCircle className="w-12 h-12 text-muted-foreground/50" />
<div className="text-lg font-medium">{t('Note not found')}</div>
{bech32Id && !triedExternal && hasExternalRelays && (
<div className="flex flex-col items-center gap-3 max-w-md">
<p className="text-sm text-center text-muted-foreground">
{t('The note was not found on your relays or default relays.')}
</p>
<Button
variant="default"
onClick={handleTryExternalRelays}
disabled={isSearchingExternal}
className="gap-2"
>
{isSearchingExternal ? (
<>
<Search className="w-4 h-4 animate-spin" />
{t('Searching external relays...')}
</>
) : (
<>
<Search className="w-4 h-4" />
{t('Try searching author\'s relays')}
</>
)}
</Button>
<details className="text-xs text-muted-foreground w-full">
<summary className="cursor-pointer hover:text-foreground text-center list-none">
{t('Show relays')} ({externalRelays.length})
</summary>
<div className="mt-2 space-y-1 max-h-32 overflow-y-auto">
{externalRelays.map((relay, i) => (
<div key={i} className="font-mono text-[10px] truncate px-2 py-1 bg-muted/50 rounded">
{relay}
</div>
))}
</div>
</details>
</div>
)}
{bech32Id && !triedExternal && !hasExternalRelays && (
<p className="text-sm text-muted-foreground">
{t('No external relay hints available')}
</p>
)}
{triedExternal && (
<p className="text-sm">{t('Note could not be found anywhere')}</p>
)}
<ClientSelect originalNoteId={bech32Id} />
</div>
)

29
src/pages/secondary/NotePage/index.tsx

@ -16,18 +16,21 @@ import { tagNameEquals } from '@/lib/tag' @@ -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<Event | undefined>(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 @@ -59,10 +62,10 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
</SecondaryPageLayout>
)
}
if (!event) {
if (!finalEvent) {
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
<NotFound bech32Id={id} />
<NotFound bech32Id={id} onEventFound={setExternalEvent} />
</SecondaryPageLayout>
)
}
@ -73,7 +76,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref @@ -73,7 +76,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
{rootITag && <ExternalRoot value={rootITag[1]} />}
{rootEventId && rootEventId !== parentEventId && (
<ParentNote
key={`root-note-${event.id}`}
key={`root-note-${finalEvent.id}`}
isFetching={isFetchingRootEvent}
event={rootEvent}
eventBech32Id={rootEventId}
@ -82,24 +85,24 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref @@ -82,24 +85,24 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
)}
{parentEventId && (
<ParentNote
key={`parent-note-${event.id}`}
key={`parent-note-${finalEvent.id}`}
isFetching={isFetchingParentEvent}
event={parentEvent}
eventBech32Id={parentEventId}
/>
)}
<Note
key={`note-${event.id}`}
event={event}
key={`note-${finalEvent.id}`}
event={finalEvent}
className="select-text"
hideParentNotePreview
originalNoteId={id}
showFull
/>
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
<NoteStats className="mt-3" event={finalEvent} fetchIfNotExisting displayTopZapsAndLikes />
</div>
<Separator className="mt-4" />
<NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} />
<NoteInteractions key={`note-interactions-${finalEvent.id}`} pageIndex={index} event={finalEvent} />
</SecondaryPageLayout>
)
})

1
src/providers/NostrProvider/index.tsx

@ -10,7 +10,6 @@ import { @@ -10,7 +10,6 @@ import {
import {
getLatestEvent,
getReplaceableEventIdentifier,
isProtectedEvent,
minePow
} from '@/lib/event'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'

152
src/services/client.service.ts

@ -1,4 +1,4 @@ @@ -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 { @@ -55,10 +55,6 @@ class ClientService extends EventTarget {
(ids) => Promise.all(ids.map((id) => this._fetchEvent(id))),
{ cacheMap: this.eventCacheMap }
)
private fetchEventFromBigRelaysDataloader = new DataLoader<string, NEvent | undefined>(
this.fetchEventsFromBigRelays.bind(this),
{ cache: false, batchScheduleFn: (callback) => setTimeout(callback, 50) }
)
private trendingNotesCache: NEvent[] | null = null
private requestThrottle = new Map<string, number>() // 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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -1020,32 +991,89 @@ class ClientService extends EventTarget {
}
}
private async fetchEventById(relayUrls: string[], id: string): Promise<NEvent | undefined> {
// First try the big relays
const event = await this.fetchEventFromBigRelaysDataloader.load(id)
if (event) {
return event
}
private async fetchEventById(_relayUrls: string[], id: string): Promise<NEvent | undefined> {
// Get user's relay list if available
const userRelayList = this.pubkey ? await this.fetchRelayList(this.pubkey) : { read: [], write: [] }
// Privacy: Don't try "seen on" relays - only use defaults
// Fallback to BIG_RELAY_URLS if not found
// Tier 1: User's read relays + fast read relays (deduplicated)
const tier1Relays = Array.from(new Set([
...userRelayList.read,
...FAST_READ_RELAY_URLS
]))
// 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
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: Use defaults and provided relays only
const allAvailableRelays = Array.from(new Set([
...FAST_READ_RELAY_URLS,
...FAST_WRITE_RELAY_URLS,
...relayUrls
// Tier 3: Search relays + big relays (deduplicated)
const tier3Relays = Array.from(new Set([
...SEARCHABLE_RELAY_URLS,
...BIG_RELAY_URLS
]))
return this.tryHarderToFetchEvent(allAvailableRelays, { ids: [id], limit: 1 }, true)
const tier3Event = await this.tryHarderToFetchEvent(tier3Relays, { ids: [id], limit: 1 })
if (tier3Event) {
return tier3Event
}
// 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<NEvent | undefined> {
// 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
}
}
// 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))
if (uniqueExternalRelays.length === 0) {
return undefined
}
return this.tryHarderToFetchEvent(uniqueExternalRelays, { ids: [id], limit: 1 })
}
private async _fetchEvent(id: string): Promise<NEvent | undefined> {
@ -1112,18 +1140,6 @@ class ClientService extends EventTarget { @@ -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<string, NEvent>()
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 { @@ -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) {

2
src/services/note-stats.service.ts

@ -127,7 +127,7 @@ class NoteStatsService { @@ -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)

Loading…
Cancel
Save