diff --git a/nip66-cron/index.mjs b/nip66-cron/index.mjs index 8aafb3d5..01e0e951 100644 --- a/nip66-cron/index.mjs +++ b/nip66-cron/index.mjs @@ -63,7 +63,14 @@ const DEFAULT_RELAYS_TO_MONITOR = [ 'wss://relay.lumina.rocks', 'wss://nostrelites.org', 'wss://relay.nsec.app', - 'wss://bucket.coracle.social' + 'wss://bucket.coracle.social', + 'wss://relay.nostr.bg', + 'wss://spatia-arcana.com', + 'wss://sendit.nosflare.com', + 'wss://nostr-pub.wellorder.net', + 'wss://pyramid.fiatjaf.com/', + 'wss://nostr.lopp.social/', + 'wss://relay.dergigi.com/' ] /** Relays to publish 30166/10166 and to REQ kind 10002 from; broad enough for Imwald + NIP-66 discovery. */ diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index c95ff1d8..b7789f31 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -37,9 +37,10 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { FileText, Link, Film, Copy, Ellipsis, Calendar, MapPin, Pencil } from 'lucide-react' +import { FileText, Link, Film, Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code } from 'lucide-react' import { useEffect, useMemo, useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -63,6 +64,12 @@ import { ScheduleVideoCallDialog, ScheduleInPersonMeetingDialog } from '@/components/ScheduleVideoCallDialog' +import RawEventDialog from '@/components/NoteOptions/RawEventDialog' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' +import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' +import { nip66Service } from '@/services/nip66.service' +import { normalizeUrl } from '@/lib/url' import type { TProfile } from '@/types' type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you' | 'notes' @@ -173,11 +180,15 @@ export default function Profile({ id }: { id?: string }) { const { profile, isFetching } = useFetchProfile(id) const { pubkey: accountPubkey } = useNostr() const [paymentInfo, setPaymentInfo] = useState | null>(null) + const [profileEvent, setProfileEvent] = useState(undefined) const [openZapDialog, setOpenZapDialog] = useState(false) const [openPublicMessageTo, setOpenPublicMessageTo] = useState(null) const [openCallInviteTo, setOpenCallInviteTo] = useState<{ pubkey: string; url: string } | null>(null) const [openScheduleOwnCall, setOpenScheduleOwnCall] = useState(false) const [openScheduleInPersonMeeting, setOpenScheduleInPersonMeeting] = useState(false) + const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false) + const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() + const { relaySets, favoriteRelays } = useFavoriteRelays() const mergedPaymentMethods = useMemo(() => { const list = mergePaymentMethods(paymentInfo, profile ?? null) @@ -229,6 +240,32 @@ export default function Profile({ id }: { id?: string }) { fetchPaymentInfo() }, [profile?.pubkey]) + + // Fetch profile event (kind 0) for republishing and viewing JSON + // Use fetchProfileEvent which does comprehensive search, not fetchReplaceableEvent + useEffect(() => { + if (!profile?.pubkey) { + setProfileEvent(undefined) + return + } + + const fetchProfileEventData = async () => { + try { + // Use fetchProfileEvent which includes comprehensive relay search + const event = await replaceableEventService.fetchProfileEvent(profile.pubkey, false) + if (event) { + setProfileEvent(event) + } else { + setProfileEvent(undefined) + } + } catch (error) { + logger.error('Failed to fetch profile event', { error, pubkey: profile.pubkey }) + setProfileEvent(undefined) + } + } + + fetchProfileEventData() + }, [profile?.pubkey]) const [activeTab, setActiveTab] = useState('posts') const [searchQuery, setSearchQuery] = useState('') const [articleKindFilter, setArticleKindFilter] = useState('all') @@ -331,6 +368,62 @@ export default function Profile({ id }: { id?: string }) { ) const isSelf = accountPubkey === profile?.pubkey + /** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */ + const allAvailableRelayUrls = useMemo(() => { + const urls = [ + ...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url), + ...favoriteRelays.map(url => normalizeUrl(url) || url), + ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeUrl(url) || url)), + ...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url), + ...FAST_WRITE_RELAY_URLS.map(url => normalizeUrl(url) || url) + ].filter(Boolean) as string[] + return Array.from(new Set(urls)) + }, [currentBrowsingRelayUrls, favoriteRelays, relaySets]) + + const handleRepublishToAllAvailable = async () => { + if (!profileEvent) return + const promise = client.publishEvent(allAvailableRelayUrls, profileEvent).then((result) => { + if (result.successCount < 1) { + throw new Error(t('No relay accepted the event')) + } + return result + }) + toast.promise(promise, { + loading: t('Republishing...'), + success: () => t('Successfully republish to all available relays'), + error: (err) => t('Failed to republish to all available relays: {{error}}', { error: err.message }) + }) + } + + const handleRepublishToAllActive = async () => { + if (!profileEvent) return + const promise = (async () => { + let relays = await nip66Service.getPublicLivelyRelayUrls() + const usedMonitoringList = !!relays?.length + if (!relays?.length) { + relays = allAvailableRelayUrls + } + if (!relays?.length) { + throw new Error(t('No relays available')) + } + const result = await client.publishEvent(relays, profileEvent) + const minRequired = usedMonitoringList ? 5 : 1 + if (result.successCount < minRequired) { + throw new Error( + usedMonitoringList + ? t('Only {{count}} relay(s) accepted the event; at least 5 required for "all active relays".', { count: result.successCount }) + : t('No relay accepted the event') + ) + } + return result + })() + toast.promise(promise, { + loading: t('Republishing...'), + success: () => t('Successfully republish to all active relays'), + error: (err) => t('Failed to republish to all active relays: {{error}}', { error: err.message }) + }) + } + // Refresh functions for each tab const handleRefresh = () => { if (activeTab === 'posts') { @@ -433,10 +526,17 @@ export default function Profile({ id }: { id?: string }) { +
+
+ {t('Searching all available relays...')} +
+
) } - if (!profile) return + if (!profile && !isFetching) return + + if (!profile) return null // TypeScript guard - should never reach here but satisfies type checker const { banner, username, about, avatar, pubkey, website, websiteList, nip05List } = profile @@ -463,6 +563,7 @@ export default function Profile({ id }: { id?: string }) {
setOpenPublicMessageTo(pubkey) : undefined} onSendCallInvite={ !isSelf @@ -494,6 +595,23 @@ export default function Profile({ id }: { id?: string }) { {t('Edit')} + {profileEvent && ( + <> + + + + {t('Republish to all available relays')} ({allAvailableRelayUrls.length}) + + + + {t('Republish to all active relays')} + + setIsRawEventDialogOpen(true)}> + + {t('View JSON')} + + + )} ) : ( @@ -545,7 +663,7 @@ export default function Profile({ id }: { id?: string }) { )} {websiteList && websiteList.length > 1 && (
- {websiteList.slice(1).map((url, idx) => ( + {websiteList.slice(1).map((url: string, idx: number) => (
+ {profileEvent && ( + setIsRawEventDialogOpen(false)} + /> + )} ) } diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx index 8ffeabd3..8b9281e8 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -8,19 +8,31 @@ import { } from '@/components/ui/dropdown-menu' import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' +import { normalizeUrl } from '@/lib/url' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' -import { Bell, BellOff, Copy, Ellipsis, MessageCircle, Send, Video } from 'lucide-react' -import { useMemo } from 'react' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' +import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' +import client from '@/services/client.service' +import { replaceableEventService } from '@/services/client.service' +import { nip66Service } from '@/services/nip66.service' +import RawEventDialog from '@/components/NoteOptions/RawEventDialog' +import { Bell, BellOff, Copy, Ellipsis, MessageCircle, Send, Video, SatelliteDish, Code } from 'lucide-react' +import { useMemo, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { Event, kinds } from 'nostr-tools' export default function ProfileOptions({ pubkey, + profileEvent, onSendPublicMessage, onSendCallInvite }: { pubkey: string + /** Optional profile event (kind 0) for republishing and viewing JSON */ + profileEvent?: Event /** Opens the post editor in public message mode with this profile's pubkey in the mention list. */ onSendPublicMessage?: () => void /** Opens the post editor to send the call invite URL as a public message to this profile. */ @@ -29,9 +41,108 @@ export default function ProfileOptions({ const { t } = useTranslation() const { pubkey: accountPubkey, profile } = useNostr() const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList() + const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() + const { relaySets, favoriteRelays } = useFavoriteRelays() + const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false) + const [monitoringListRelayCount, setMonitoringListRelayCount] = useState(null) + const [localProfileEvent, setLocalProfileEvent] = useState(profileEvent) + + // Fetch profile event if not provided + useEffect(() => { + if (profileEvent) { + setLocalProfileEvent(profileEvent) + return + } + + // If profileEvent is not provided, try to fetch it using comprehensive search + const fetchEvent = async () => { + try { + // Use fetchProfileEvent which includes comprehensive relay search + const event = await replaceableEventService.fetchProfileEvent(pubkey, false) + if (event) { + setLocalProfileEvent(event) + } + } catch (error) { + // Silently fail - menu items just won't show + } + } + + fetchEvent() + }, [pubkey, profileEvent]) + const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey]) const displayName = profile?.username ?? (accountPubkey ? formatPubkey(accountPubkey) : 'jumble') + /** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */ + const allAvailableRelayUrls = useMemo(() => { + const urls = [ + ...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url), + ...favoriteRelays.map(url => normalizeUrl(url) || url), + ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeUrl(url) || url)), + ...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url), + ...FAST_WRITE_RELAY_URLS.map(url => normalizeUrl(url) || url) + ].filter(Boolean) as string[] + return Array.from(new Set(urls)) + }, [currentBrowsingRelayUrls, favoriteRelays, relaySets]) + + useEffect(() => { + nip66Service.getPublicLivelyRelayUrls().then((urls) => { + setMonitoringListRelayCount(urls?.length ?? 0) + }) + }, []) + + const handleRepublishToAllAvailable = async () => { + const eventToPublish = localProfileEvent || profileEvent + if (!eventToPublish) { + toast.error(t('Profile event not available')) + return + } + const promise = client.publishEvent(allAvailableRelayUrls, eventToPublish).then((result) => { + if (result.successCount < 1) { + throw new Error(t('No relay accepted the event')) + } + return result + }) + toast.promise(promise, { + loading: t('Republishing...'), + success: () => t('Successfully republish to all available relays'), + error: (err) => t('Failed to republish to all available relays: {{error}}', { error: err.message }) + }) + } + + const handleRepublishToAllActive = async () => { + const eventToPublish = localProfileEvent || profileEvent + if (!eventToPublish) { + toast.error(t('Profile event not available')) + return + } + const promise = (async () => { + let relays = await nip66Service.getPublicLivelyRelayUrls() + const usedMonitoringList = !!relays?.length + if (!relays?.length) { + relays = allAvailableRelayUrls + } + if (!relays?.length) { + throw new Error(t('No relays available')) + } + const result = await client.publishEvent(relays, eventToPublish) + const minRequired = usedMonitoringList ? 5 : 1 + if (result.successCount < minRequired) { + throw new Error( + usedMonitoringList + ? t('Only {{count}} relay(s) accepted the event; at least 5 required for "all active relays".', { count: result.successCount }) + : t('No relay accepted the event') + ) + } + return result + })() + toast.promise(promise, { + loading: t('Republishing...'), + success: () => t('Successfully republish to all active relays'), + error: (err) => t('Failed to republish to all active relays: {{error}}', { error: err.message }) + }) + } + if (pubkey === accountPubkey) return null const callInviteUrl = @@ -88,6 +199,24 @@ export default function ProfileOptions({ {t('Copy user ID')} + {(localProfileEvent || profileEvent) && ( + <> + + + + {t('Republish to all available relays')} ({allAvailableRelayUrls.length}) + + + + {t('Republish to all active relays')} + {monitoringListRelayCount !== null && ` (${monitoringListRelayCount > 0 ? monitoringListRelayCount : allAvailableRelayUrls.length})`} + + setIsRawEventDialogOpen(true)}> + + {t('View JSON')} + + + )} {isMuted ? ( unmutePubkey(pubkey)} @@ -115,6 +244,13 @@ export default function ProfileOptions({ )} + {(localProfileEvent || profileEvent) && ( + setIsRawEventDialogOpen(false)} + /> + )} ) } diff --git a/src/constants.ts b/src/constants.ts index c29f45e0..53753b4d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -158,7 +158,16 @@ export const SEARCHABLE_RELAY_URLS = [ 'wss://relay.wikifreedia.xyz', 'wss://nostr.einundzwanzig.space', 'wss://relay.lumina.rocks', - 'wss://nostrelites.org' + 'wss://nostrelites.org', + 'wss://relay.nsec.app', + 'wss://bucket.coracle.social', + 'wss://relay.nostr.bg', + 'wss://spatia-arcana.com', + 'wss://sendit.nosflare.com', + 'wss://nostr-pub.wellorder.net', + 'wss://pyramid.fiatjaf.com/', + 'wss://nostr.lopp.social/', + 'wss://relay.dergigi.com/' ] export const PROFILE_RELAY_URLS = [ diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 874e396b..b5c17f30 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -7,12 +7,13 @@ import { useEffect, useState, useRef, useCallback } from 'react' import logger from '@/lib/logger' export function useFetchProfile(id?: string, skipCache = false) { - // Log hook invocation immediately - this will show if the hook is even being called - logger.info('[useFetchProfile] Hook called', { - id: id || 'undefined', - skipCache, - stack: new Error().stack?.split('\n').slice(1, 4).join('\n') - }) + // CRITICAL: Reduce logging to prevent performance issues during infinite loops + // Only log if we're actually going to process (not just checking) + // logger.info('[useFetchProfile] Hook called', { + // id: id || 'undefined', + // skipCache, + // stack: new Error().stack?.split('\n').slice(1, 4).join('\n') + // }) const { profile: currentAccountProfile } = useNostr() const [isFetching, setIsFetching] = useState(true) @@ -22,6 +23,7 @@ export function useFetchProfile(id?: string, skipCache = false) { const checkIntervalRef = useRef(null) const processingPubkeyRef = useRef(null) // Track which pubkey we're currently processing (prevents duplicate fetches) const effectRunCountRef = useRef>(new Map()) // Track how many times effect has run for each pubkey (safety guard against infinite loops) + const initializedPubkeysRef = useRef>(new Set()) // Track pubkeys we've successfully initialized (have profile or failed) // Function to check for profile updates // fetchProfileEvent already checks: 1) IndexedDB, 2) network (with author's relays) @@ -77,6 +79,8 @@ export function useFetchProfile(id?: string, skipCache = false) { }) setProfile(newProfile) setIsFetching(false) + // Mark as initialized + initializedPubkeysRef.current.add(pubkey) // Keep processingPubkeyRef set so we don't re-fetch // Clear interval once we have a profile if (checkIntervalRef.current) { @@ -108,25 +112,57 @@ export function useFetchProfile(id?: string, skipCache = false) { }, [skipCache]) useEffect(() => { - logger.info('[useFetchProfile] useEffect triggered', { - id: id || 'undefined', - skipCache, - processingPubkey: processingPubkeyRef.current - }) + // CRITICAL: Reduce logging - only log when actually processing, not on every render + // logger.info('[useFetchProfile] useEffect triggered', { + // id: id || 'undefined', + // skipCache, + // processingPubkey: processingPubkeyRef.current, + // hasProfile: !!profile, + // profilePubkey: profile?.pubkey + // }) // Extract pubkey early to check if id has changed const extractedPubkey = id ? userIdToPubkey(id) : null // CRITICAL: Early exit if already processing this exact pubkey - prevents infinite loops + // This check must happen FIRST, before any other logic if (extractedPubkey && processingPubkeyRef.current === extractedPubkey) { - logger.info('[useFetchProfile] EARLY EXIT: Already processing this pubkey', { - extractedPubkey, - processingPubkey: processingPubkeyRef.current - }) + // Silently exit - no logging to reduce noise + return + } + + // CRITICAL: Early exit if we already have a profile for this pubkey + // This prevents re-fetching when we already have the profile + if (extractedPubkey && profile && profile.pubkey === extractedPubkey) { + // Ensure processingPubkeyRef is set to prevent re-fetch + if (processingPubkeyRef.current !== extractedPubkey) { + processingPubkeyRef.current = extractedPubkey + } + // Mark as initialized + initializedPubkeysRef.current.add(extractedPubkey) + // Ensure fetching is false (but don't call setState if already false to avoid re-renders) + if (isFetching) { + setIsFetching(false) + } + // Clear run count since we have the profile + effectRunCountRef.current.delete(extractedPubkey) + return + } + + // CRITICAL: Early exit if we've already initialized this pubkey (even if profile is null) + // This prevents re-fetching when we've already tried and failed + // BUT: Allow retry if skipCache is true (user explicitly wants to refresh) + if (extractedPubkey && initializedPubkeysRef.current.has(extractedPubkey) && !profile && !skipCache) { + // Already tried and failed - don't retry unless explicitly requested + // Ensure fetching is false + if (isFetching) { + setIsFetching(false) + } return } // CRITICAL: Guard against infinite loops - limit effect runs per pubkey (reduced from 10 to 3) + // Only increment if we're actually going to process (not early exiting) if (extractedPubkey) { const runCount = effectRunCountRef.current.get(extractedPubkey) || 0 if (runCount >= 3) { @@ -140,19 +176,17 @@ export function useFetchProfile(id?: string, skipCache = false) { }, 30000) // Clear after 30 seconds return } + // Only increment if we're actually going to process effectRunCountRef.current.set(extractedPubkey, runCount + 1) } - // If id has changed (extractedPubkey is different from processingPubkeyRef), clear the ref + // If id has changed (extractedPubkey is different from processingPubkeyRef), clear the refs // This allows a new fetch to start for a different pubkey if (extractedPubkey && processingPubkeyRef.current && processingPubkeyRef.current !== extractedPubkey) { const oldPubkey = processingPubkeyRef.current - logger.info('[useFetchProfile] ID changed, clearing refs', { - oldPubkey, - newPubkey: extractedPubkey - }) - // Clear run count for old pubkey before clearing ref + // Clear run count and initialized status for old pubkey before clearing ref effectRunCountRef.current.delete(oldPubkey) + initializedPubkeysRef.current.delete(oldPubkey) processingPubkeyRef.current = null } @@ -212,27 +246,22 @@ export function useFetchProfile(id?: string, skipCache = false) { return } - // CRITICAL: Check if we're already processing this pubkey IMMEDIATELY after validation - // This must happen before any other logic to prevent infinite loops + // These checks are now done earlier in the effect (before incrementing run count) + // Keeping this as a safety check, but it should rarely be hit if (processingPubkeyRef.current === extractedPubkey) { - logger.info('[useFetchProfile] Already processing this pubkey, skipping duplicate fetch', { + logger.info('[useFetchProfile] Already processing this pubkey (safety check)', { extractedPubkey, - processingPubkey: processingPubkeyRef.current, - hasProfile: !!profile + processingPubkey: processingPubkeyRef.current }) return } - // CRITICAL: Check if we already have a profile for this pubkey before starting a new fetch - // This prevents re-fetching when profile state already exists if (profile && profile.pubkey === extractedPubkey) { - logger.info('[useFetchProfile] Already have profile for this pubkey, skipping fetch', { + logger.info('[useFetchProfile] Already have profile for this pubkey (safety check)', { extractedPubkey }) - // Mark as processing to prevent re-fetch, but don't update state unnecessarily processingPubkeyRef.current = extractedPubkey setIsFetching(false) - // Clear run count since we have the profile effectRunCountRef.current.delete(extractedPubkey) return } @@ -360,6 +389,8 @@ export function useFetchProfile(id?: string, skipCache = false) { // CRITICAL: Only use currentAccountProfile if it matches the pubkey we're looking for // Use pubkey from the profile object to avoid reference equality issues // Only update if we don't have a profile yet AND we're not currently processing + // CRITICAL FIX: Don't include profile in dependencies to prevent infinite loops + // We only read profile to check if it exists, we don't need to re-run when it changes if (currentAccountProfile?.pubkey && pubkey && pubkey === currentAccountProfile.pubkey) { // Only update if we don't have a profile yet (avoid unnecessary updates) // Also check that we're processing this pubkey to prevent race conditions @@ -375,7 +406,8 @@ export function useFetchProfile(id?: string, skipCache = false) { effectRunCountRef.current.delete(pubkey) } } - }, [currentAccountProfile?.pubkey, pubkey, profile]) // Include profile to prevent unnecessary updates + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentAccountProfile?.pubkey, pubkey]) // Removed profile from dependencies to prevent infinite loops return { isFetching, error, profile } } diff --git a/src/pages/secondary/NotePage/NotFound.tsx b/src/pages/secondary/NotePage/NotFound.tsx index 25831840..1869b07a 100644 --- a/src/pages/secondary/NotePage/NotFound.tsx +++ b/src/pages/secondary/NotePage/NotFound.tsx @@ -27,79 +27,108 @@ export default function NotFound({ if (!bech32Id) return const getExternalRelays = async () => { - // Get all relays that have already been tried (FAST_READ_RELAY_URLS) - // These are the relays used in the initial fetch - const alreadyTriedRelaysSet = new Set() - ;[...FAST_READ_RELAY_URLS].forEach(url => { - const normalized = normalizeUrl(url) - if (normalized) alreadyTriedRelaysSet.add(normalized) - }) - - let hintRelays: string[] = [] - let extractedHexEventId: string | null = null - - // 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') { - extractedHexEventId = data.id - if (data.relays) hintRelays.push(...data.relays) - if (data.author) { - const authorRelayList = await client.fetchRelayList(data.author).catch(() => ({ read: [] as string[], write: [] as string[] })) - hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4)) + try { + // Get all relays that have already been tried (FAST_READ_RELAY_URLS) + // These are the relays used in the initial fetch + const alreadyTriedRelaysSet = new Set() + ;[...FAST_READ_RELAY_URLS].forEach(url => { + const normalized = normalizeUrl(url) + if (normalized) alreadyTriedRelaysSet.add(normalized) + }) + + let bech32HintRelays: string[] = [] // Relay hints from bech32 (highest priority) + let extractedHexEventId: string | null = null + + // CRITICAL: Parse relay hints from bech32 ID FIRST (highest priority) + // These are explicit hints from the bech32 address and should always be used + if (!/^[0-9a-f]{64}$/.test(bech32Id)) { + try { + const { type, data } = nip19.decode(bech32Id) + + if (type === 'nevent') { + extractedHexEventId = data.id + // CRITICAL: Always extract relay hints from nevent bech32 + if (data.relays && Array.isArray(data.relays)) { + bech32HintRelays.push(...data.relays) + logger.debug('Extracted relay hints from nevent', { + bech32Id, + hintCount: data.relays.length, + hints: data.relays + }) + } + // Note: We skip fetching author relay list here to avoid infinite loops + // The relay hints from bech32 are the most reliable source + } else if (type === 'naddr') { + // CRITICAL: Always extract relay hints from naddr bech32 + if (data.relays && Array.isArray(data.relays)) { + bech32HintRelays.push(...data.relays) + logger.debug('Extracted relay hints from naddr', { + bech32Id, + hintCount: data.relays.length, + hints: data.relays + }) + } + // Note: We skip fetching author relay list here to avoid infinite loops + } else if (type === 'note') { + extractedHexEventId = data } - } else if (type === 'naddr') { - if (data.relays) hintRelays.push(...data.relays) - const authorRelayList = await client.fetchRelayList(data.pubkey).catch(() => ({ read: [] as string[], write: [] as string[] })) - hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4)) - } else if (type === 'note') { - extractedHexEventId = data + } catch (err) { + logger.error('Failed to parse bech32 ID for relay hints', { error: err, bech32Id }) } - } catch (err) { - logger.error('Failed to parse external relays', { error: err, bech32Id }) + } else { + extractedHexEventId = bech32Id } - } else { - extractedHexEventId = bech32Id + + setHexEventId(extractedHexEventId) + + // Get relays where this event was seen (if we have the hex ID) + const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : [] + + // Normalize bech32 hint relays (highest priority - these come from the bech32 address itself) + const normalizedBech32Hints = bech32HintRelays + .map(url => normalizeUrl(url)) + .filter((url): url is string => Boolean(url)) + + // Normalize seen relays + const normalizedSeenRelays = seenOn + .map(url => normalizeUrl(url)) + .filter((url): url is string => Boolean(url)) + + // Normalize SEARCHABLE_RELAY_URLS (fallback) + const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS + .map(url => normalizeUrl(url)) + .filter((url): url is string => Boolean(url)) + + // CRITICAL: Preserve order - bech32 hints first, then seen, then searchable + // This ensures relay hints from bech32 are shown first in the UI + // Order matters: bech32 hints (explicit) > seen relays > searchable (fallback) + const orderedExternalRelays = [ + ...normalizedBech32Hints.filter(r => !alreadyTriedRelaysSet.has(r)), + ...normalizedSeenRelays.filter(r => !alreadyTriedRelaysSet.has(r) && !normalizedBech32Hints.includes(r)), + ...normalizedSearchableRelays.filter(r => !alreadyTriedRelaysSet.has(r) && !normalizedBech32Hints.includes(r) && !normalizedSeenRelays.includes(r)) + ] + + setExternalRelays(orderedExternalRelays) + + logger.debug('External relays calculated (NotFound)', { + bech32Id, + bech32HintCount: normalizedBech32Hints.length, + seenRelayCount: normalizedSeenRelays.length, + searchableRelaysCount: normalizedSearchableRelays.length, + alreadyTriedCount: alreadyTriedRelaysSet.size, + externalRelaysCount: orderedExternalRelays.length, + bech32Hints: normalizedBech32Hints, + externalRelays: orderedExternalRelays.slice(0, 10) // Log first 10 + }) + } catch (error) { + logger.error('Error calculating external relays (NotFound)', { + error, + bech32Id, + errorMessage: error instanceof Error ? error.message : String(error) + }) + // Set empty array on error to prevent UI issues + setExternalRelays([]) } - - setHexEventId(extractedHexEventId) - - // Get relays where this event was seen - const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : [] - hintRelays.push(...seenOn) - - // Normalize all hint relays - const normalizedHints = hintRelays - .map(url => normalizeUrl(url)) - .filter((url): url is string => Boolean(url)) - - // Combine hints with SEARCHABLE_RELAY_URLS (always include as fallback) - // Normalize SEARCHABLE_RELAY_URLS for comparison - const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS - .map(url => normalizeUrl(url)) - .filter((url): url is string => Boolean(url)) - - // Combine all potential relays (hints + searchable) - const allPotentialRelays = new Set([...normalizedHints, ...normalizedSearchableRelays]) - - // Filter out relays that were already tried - const externalRelays = Array.from(allPotentialRelays).filter( - relay => !alreadyTriedRelaysSet.has(relay) - ) - - // Deduplicate final relay list - setExternalRelays(externalRelays) - - logger.debug('External relays calculated (NotFound)', { - bech32Id, - hintRelaysCount: normalizedHints.length, - searchableRelaysCount: normalizedSearchableRelays.length, - alreadyTriedCount: alreadyTriedRelaysSet.size, - externalRelaysCount: externalRelays.length, - externalRelays: externalRelays.slice(0, 10) // Log first 10 - }) } getExternalRelays() @@ -172,7 +201,7 @@ export default function NotFound({ ) : ( <> - {t('Try external relays')} + {t('Try external relays')} ({externalRelays.length}) )} diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 7c921a07..c1198249 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -612,21 +612,19 @@ export class ReplaceableEventService { // Relay hints should have highest priority and always be included const relayHints = relays.length > 0 ? [...relays] : [] - // Step 1: Try with relay hints + default relays first (checks IndexedDB via DataLoader, then network) - // Always include relay hints if provided, then add default profile fetch relays - const defaultRelays = relayHints.length > 0 - ? [...new Set([...relayHints, ...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS])] - : [...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS] - - logger.info('[ReplaceableEventService] Step 1: Trying with relay hints + default relays (checks cache first)', { + // Step 1: ALWAYS use DataLoader first (checks IndexedDB, then uses default relays) + // CRITICAL: Do NOT pass relay hints here - passing any relays bypasses DataLoader and creates individual subscriptions + // DataLoader already uses default relays internally and batches all profile fetches + // We'll use relay hints in Step 2/3 only if Step 1 fails + logger.info('[ReplaceableEventService] Step 1: Trying with DataLoader (checks cache first, uses default relays, batched)', { pubkey, relayHintCount: relayHints.length, - totalRelayCount: defaultRelays.length, hasRelayHints: relayHints.length > 0 }) - // fetchReplaceableEvent uses DataLoader which checks IndexedDB first, then queries relays - const profileEvent = await this.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, defaultRelays) + // fetchReplaceableEvent uses DataLoader which checks IndexedDB first, then queries default relays + // Passing empty array ensures DataLoader is used (batched) - this prevents individual subscriptions + const profileEvent = await this.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, []) if (profileEvent) { logger.info('[ReplaceableEventService] Profile found with relay hints + default relays', { @@ -637,79 +635,155 @@ export class ReplaceableEventService { return profileEvent } - // Step 2: Not found in cache or default relays - fetch author's relay list as fallback - logger.info('[ReplaceableEventService] Step 2: Profile not found, fetching author relay list as fallback', { - pubkey - }) - - let authorRelayList: { read?: string[]; write?: string[] } | null = null - try { - const relayListStartTime = Date.now() - // Add timeout to prevent hanging - 2 seconds max - const relayListPromise = client.fetchRelayList(pubkey) - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - logger.warn('[ReplaceableEventService] fetchRelayList timeout, giving up', { - pubkey - }) - resolve(null) - }, 2000) - }) - authorRelayList = await Promise.race([relayListPromise, timeoutPromise]) - const relayListTime = Date.now() - relayListStartTime - logger.info('[ReplaceableEventService] Author relay list fetched', { + // Step 2: Only fetch author's relay list as fallback if we have relay hints from bech32 + // This prevents creating many individual subscriptions when profiles aren't found + // If we have relay hints, it's worth trying author relays. Otherwise, Step 1 should be sufficient. + if (relayHints.length > 0) { + logger.info('[ReplaceableEventService] Step 2: Profile not found, but we have relay hints - fetching author relay list as fallback', { pubkey, - hasRelayList: !!authorRelayList, - fetchTime: `${relayListTime}ms` + relayHintCount: relayHints.length }) - } catch (error) { - logger.error('[ReplaceableEventService] Failed to fetch author relay list', { - pubkey, - error: error instanceof Error ? error.message : String(error) + + let authorRelayList: { read?: string[]; write?: string[] } | null = null + try { + const relayListStartTime = Date.now() + // Add timeout to prevent hanging - 2 seconds max + const relayListPromise = client.fetchRelayList(pubkey) + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + logger.warn('[ReplaceableEventService] fetchRelayList timeout, giving up', { + pubkey + }) + resolve(null) + }, 2000) + }) + authorRelayList = await Promise.race([relayListPromise, timeoutPromise]) + const relayListTime = Date.now() - relayListStartTime + logger.info('[ReplaceableEventService] Author relay list fetched', { + pubkey, + hasRelayList: !!authorRelayList, + fetchTime: `${relayListTime}ms` + }) + } catch (error) { + logger.error('[ReplaceableEventService] Failed to fetch author relay list', { + pubkey, + error: error instanceof Error ? error.message : String(error) + }) + } + + // Step 3: Try with relay hints + author's relays if we got them + // CRITICAL: Always include relay hints first (highest priority), then author relays, then defaults + if (authorRelayList) { + const authorRelays = [ + ...(authorRelayList.write || []).slice(0, 10), + ...(authorRelayList.read || []).slice(0, 10) + ] + // Relay hints first (highest priority), then author relays, then defaults + const allRelays = [...new Set([ + ...relayHints, // Relay hints from bech32 (highest priority) + ...authorRelays, // Author's relays + ...PROFILE_FETCH_RELAY_URLS, // Default profile relays + ...FAST_READ_RELAY_URLS // Fast read relays + ])] + + logger.info('[ReplaceableEventService] Step 3: Trying with relay hints + author relays', { + pubkey, + relayHintCount: relayHints.length, + authorRelayCount: authorRelays.length, + totalRelayCount: allRelays.length + }) + + // Use fetchReplaceableEvent with relay hints + author's relays + const profileEventFromAuthorRelays = await this.fetchReplaceableEvent( + pubkey, + kinds.Metadata, + undefined, + allRelays + ) + + if (profileEventFromAuthorRelays) { + logger.info('[ReplaceableEventService] Profile found with relay hints + author relays', { + pubkey, + eventId: profileEventFromAuthorRelays.id + }) + await this.indexProfile(profileEventFromAuthorRelays) + return profileEventFromAuthorRelays + } + } + } else { + // No relay hints - Step 1 with default relays should be sufficient + // Skip Step 2/3 to avoid creating individual subscriptions + logger.debug('[ReplaceableEventService] Profile not found, but no relay hints - skipping author relay fallback to avoid individual subscriptions', { + pubkey }) } - // Step 3: Try with relay hints + author's relays if we got them - // CRITICAL: Always include relay hints first (highest priority), then author relays, then defaults - if (authorRelayList) { - const authorRelays = [ - ...(authorRelayList.write || []).slice(0, 10), - ...(authorRelayList.read || []).slice(0, 10) - ] - // Relay hints first (highest priority), then author relays, then defaults - const allRelays = [...new Set([ - ...relayHints, // Relay hints from bech32 (highest priority) - ...authorRelays, // Author's relays - ...PROFILE_FETCH_RELAY_URLS, // Default profile relays - ...FAST_READ_RELAY_URLS // Fast read relays - ])] + // Step 3: Comprehensive search across ALL available relays before giving up + // This includes: local relays, user inboxes/outboxes, fast read/write, searchable relays + logger.info('[ReplaceableEventService] Step 3: Profile not found, trying comprehensive relay list (all available relays)', { + pubkey + }) + + try { + const userPubkey = client.pubkey + const comprehensiveRelays = await buildComprehensiveRelayList({ + authorPubkey: pubkey, + userPubkey: userPubkey || undefined, + relayHints: relayHints.length > 0 ? relayHints : undefined, + includeUserOwnRelays: true, // Include user's read/write relays + includeProfileFetchRelays: true, // Include PROFILE_FETCH_RELAY_URLS + includeFastReadRelays: true, // Include FAST_READ_RELAY_URLS + includeFastWriteRelays: true, // Include FAST_WRITE_RELAY_URLS + includeSearchableRelays: true, // Include SEARCHABLE_RELAY_URLS + includeLocalRelays: true // Include local/cache relays + }) - logger.info('[ReplaceableEventService] Step 3: Trying with relay hints + author relays', { + logger.info('[ReplaceableEventService] Comprehensive relay list built', { pubkey, - relayHintCount: relayHints.length, - authorRelayCount: authorRelays.length, - totalRelayCount: allRelays.length + relayCount: comprehensiveRelays.length, + relays: comprehensiveRelays.slice(0, 10) // Log first 10 for debugging }) - // Use fetchReplaceableEvent with relay hints + author's relays - const profileEventFromAuthorRelays = await this.fetchReplaceableEvent( - pubkey, - kinds.Metadata, - undefined, - allRelays - ) - - if (profileEventFromAuthorRelays) { - logger.info('[ReplaceableEventService] Profile found with relay hints + author relays', { + if (comprehensiveRelays.length > 0) { + // Query the comprehensive relay list + const startTime = Date.now() + const events = await this.queryService.query(comprehensiveRelays, { + authors: [pubkey], + kinds: [kinds.Metadata] + }, undefined, { + replaceableRace: true, + eoseTimeout: 500, + globalTimeout: 10000 // 10 second timeout for comprehensive search + }) + const queryTime = Date.now() - startTime + + logger.info('[ReplaceableEventService] Comprehensive search completed', { pubkey, - eventId: profileEventFromAuthorRelays.id + eventCount: events.length, + queryTime: `${queryTime}ms`, + relayCount: comprehensiveRelays.length }) - await this.indexProfile(profileEventFromAuthorRelays) - return profileEventFromAuthorRelays + + if (events.length > 0) { + const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) + const profileEvent = sortedEvents[0] + logger.info('[ReplaceableEventService] Profile found via comprehensive search', { + pubkey, + eventId: profileEvent.id + }) + await this.indexProfile(profileEvent) + return profileEvent + } } + } catch (error) { + logger.error('[ReplaceableEventService] Comprehensive search failed', { + pubkey, + error: error instanceof Error ? error.message : String(error) + }) + // Continue to return undefined below } - logger.warn('[ReplaceableEventService] Profile not found after trying all relays', { + logger.warn('[ReplaceableEventService] Profile not found after trying all relays (including comprehensive search)', { pubkey, triedRelayHints: relayHints.length > 0 })