Browse Source

bug-fix

imwald
Silberengel 1 month ago
parent
commit
b51c86bfa4
  1. 9
      nip66-cron/index.mjs
  2. 131
      src/components/Profile/index.tsx
  3. 140
      src/components/ProfileOptions/index.tsx
  4. 11
      src/constants.ts
  5. 96
      src/hooks/useFetchProfile.tsx
  6. 87
      src/pages/secondary/NotePage/NotFound.tsx
  7. 102
      src/services/client-replaceable-events.service.ts

9
nip66-cron/index.mjs

@ -63,7 +63,14 @@ const DEFAULT_RELAYS_TO_MONITOR = [
'wss://relay.lumina.rocks', 'wss://relay.lumina.rocks',
'wss://nostrelites.org', 'wss://nostrelites.org',
'wss://relay.nsec.app', '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. */ /** Relays to publish 30166/10166 and to REQ kind 10002 from; broad enough for Imwald + NIP-66 discovery. */

131
src/components/Profile/index.tsx

@ -37,9 +37,10 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } 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 { useEffect, useMemo, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -63,6 +64,12 @@ import {
ScheduleVideoCallDialog, ScheduleVideoCallDialog,
ScheduleInPersonMeetingDialog ScheduleInPersonMeetingDialog
} from '@/components/ScheduleVideoCallDialog' } 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' import type { TProfile } from '@/types'
type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you' | 'notes' 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 { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey } = useNostr() const { pubkey: accountPubkey } = useNostr()
const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null) const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null)
const [profileEvent, setProfileEvent] = useState<Event | undefined>(undefined)
const [openZapDialog, setOpenZapDialog] = useState(false) const [openZapDialog, setOpenZapDialog] = useState(false)
const [openPublicMessageTo, setOpenPublicMessageTo] = useState<string | null>(null) const [openPublicMessageTo, setOpenPublicMessageTo] = useState<string | null>(null)
const [openCallInviteTo, setOpenCallInviteTo] = useState<{ pubkey: string; url: string } | null>(null) const [openCallInviteTo, setOpenCallInviteTo] = useState<{ pubkey: string; url: string } | null>(null)
const [openScheduleOwnCall, setOpenScheduleOwnCall] = useState(false) const [openScheduleOwnCall, setOpenScheduleOwnCall] = useState(false)
const [openScheduleInPersonMeeting, setOpenScheduleInPersonMeeting] = useState(false) const [openScheduleInPersonMeeting, setOpenScheduleInPersonMeeting] = useState(false)
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const mergedPaymentMethods = useMemo(() => { const mergedPaymentMethods = useMemo(() => {
const list = mergePaymentMethods(paymentInfo, profile ?? null) const list = mergePaymentMethods(paymentInfo, profile ?? null)
@ -229,6 +240,32 @@ export default function Profile({ id }: { id?: string }) {
fetchPaymentInfo() fetchPaymentInfo()
}, [profile?.pubkey]) }, [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<ProfileTabValue>('posts') const [activeTab, setActiveTab] = useState<ProfileTabValue>('posts')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [articleKindFilter, setArticleKindFilter] = useState<string>('all') const [articleKindFilter, setArticleKindFilter] = useState<string>('all')
@ -331,6 +368,62 @@ export default function Profile({ id }: { id?: string }) {
) )
const isSelf = accountPubkey === profile?.pubkey 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 // Refresh functions for each tab
const handleRefresh = () => { const handleRefresh = () => {
if (activeTab === 'posts') { if (activeTab === 'posts') {
@ -433,10 +526,17 @@ export default function Profile({ id }: { id?: string }) {
<Skeleton className="h-5 w-28 mt-14 mb-1" /> <Skeleton className="h-5 w-28 mt-14 mb-1" />
<Skeleton className="h-5 w-56 mt-2 my-1 rounded-full" /> <Skeleton className="h-5 w-56 mt-2 my-1 rounded-full" />
</div> </div>
<div className="px-4 pt-4 flex items-center justify-center">
<div className="text-sm text-muted-foreground">
{t('Searching all available relays...')}
</div>
</div>
</> </>
) )
} }
if (!profile) return <NotFound /> if (!profile && !isFetching) return <NotFound />
if (!profile) return null // TypeScript guard - should never reach here but satisfies type checker
const { banner, username, about, avatar, pubkey, website, websiteList, nip05List } = profile const { banner, username, about, avatar, pubkey, website, websiteList, nip05List } = profile
@ -463,6 +563,7 @@ export default function Profile({ id }: { id?: string }) {
<div className="flex justify-end h-8 gap-2 items-center"> <div className="flex justify-end h-8 gap-2 items-center">
<ProfileOptions <ProfileOptions
pubkey={pubkey} pubkey={pubkey}
profileEvent={profileEvent}
onSendPublicMessage={!isSelf ? () => setOpenPublicMessageTo(pubkey) : undefined} onSendPublicMessage={!isSelf ? () => setOpenPublicMessageTo(pubkey) : undefined}
onSendCallInvite={ onSendCallInvite={
!isSelf !isSelf
@ -494,6 +595,23 @@ export default function Profile({ id }: { id?: string }) {
<Pencil /> <Pencil />
{t('Edit')} {t('Edit')}
</DropdownMenuItem> </DropdownMenuItem>
{profileEvent && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleRepublishToAllAvailable}>
<SatelliteDish />
{t('Republish to all available relays')} ({allAvailableRelayUrls.length})
</DropdownMenuItem>
<DropdownMenuItem onClick={handleRepublishToAllActive}>
<SatelliteDish />
{t('Republish to all active relays')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}>
<Code />
{t('View JSON')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (
@ -545,7 +663,7 @@ export default function Profile({ id }: { id?: string }) {
)} )}
{websiteList && websiteList.length > 1 && ( {websiteList && websiteList.length > 1 && (
<div className="flex flex-col gap-1 mt-1"> <div className="flex flex-col gap-1 mt-1">
{websiteList.slice(1).map((url, idx) => ( {websiteList.slice(1).map((url: string, idx: number) => (
<div key={idx} className="flex gap-1 items-center text-primary truncate select-text"> <div key={idx} className="flex gap-1 items-center text-primary truncate select-text">
<Link size={12} className="shrink-0" /> <Link size={12} className="shrink-0" />
<a <a
@ -847,6 +965,13 @@ export default function Profile({ id }: { id?: string }) {
open={openScheduleInPersonMeeting} open={openScheduleInPersonMeeting}
onOpenChange={setOpenScheduleInPersonMeeting} onOpenChange={setOpenScheduleInPersonMeeting}
/> />
{profileEvent && (
<RawEventDialog
event={profileEvent}
isOpen={isRawEventDialogOpen}
onClose={() => setIsRawEventDialogOpen(false)}
/>
)}
</> </>
) )
} }

140
src/components/ProfileOptions/index.tsx

@ -8,19 +8,31 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk' import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Bell, BellOff, Copy, Ellipsis, MessageCircle, Send, Video } from 'lucide-react' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMemo } from 'react' 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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Event, kinds } from 'nostr-tools'
export default function ProfileOptions({ export default function ProfileOptions({
pubkey, pubkey,
profileEvent,
onSendPublicMessage, onSendPublicMessage,
onSendCallInvite onSendCallInvite
}: { }: {
pubkey: string 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. */ /** Opens the post editor in public message mode with this profile's pubkey in the mention list. */
onSendPublicMessage?: () => void onSendPublicMessage?: () => void
/** Opens the post editor to send the call invite URL as a public message to this profile. */ /** 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 { t } = useTranslation()
const { pubkey: accountPubkey, profile } = useNostr() const { pubkey: accountPubkey, profile } = useNostr()
const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList() const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
const [monitoringListRelayCount, setMonitoringListRelayCount] = useState<number | null>(null)
const [localProfileEvent, setLocalProfileEvent] = useState<Event | undefined>(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 isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey])
const displayName = profile?.username ?? (accountPubkey ? formatPubkey(accountPubkey) : 'jumble') 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 if (pubkey === accountPubkey) return null
const callInviteUrl = const callInviteUrl =
@ -88,6 +199,24 @@ export default function ProfileOptions({
<Copy /> <Copy />
{t('Copy user ID')} {t('Copy user ID')}
</DropdownMenuItem> </DropdownMenuItem>
{(localProfileEvent || profileEvent) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleRepublishToAllAvailable}>
<SatelliteDish />
{t('Republish to all available relays')} ({allAvailableRelayUrls.length})
</DropdownMenuItem>
<DropdownMenuItem onClick={handleRepublishToAllActive}>
<SatelliteDish />
{t('Republish to all active relays')}
{monitoringListRelayCount !== null && ` (${monitoringListRelayCount > 0 ? monitoringListRelayCount : allAvailableRelayUrls.length})`}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}>
<Code />
{t('View JSON')}
</DropdownMenuItem>
</>
)}
{isMuted ? ( {isMuted ? (
<DropdownMenuItem <DropdownMenuItem
onClick={() => unmutePubkey(pubkey)} onClick={() => unmutePubkey(pubkey)}
@ -115,6 +244,13 @@ export default function ProfileOptions({
</> </>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
{(localProfileEvent || profileEvent) && (
<RawEventDialog
event={(localProfileEvent || profileEvent)!}
isOpen={isRawEventDialogOpen}
onClose={() => setIsRawEventDialogOpen(false)}
/>
)}
</DropdownMenu> </DropdownMenu>
) )
} }

11
src/constants.ts

@ -158,7 +158,16 @@ export const SEARCHABLE_RELAY_URLS = [
'wss://relay.wikifreedia.xyz', 'wss://relay.wikifreedia.xyz',
'wss://nostr.einundzwanzig.space', 'wss://nostr.einundzwanzig.space',
'wss://relay.lumina.rocks', '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 = [ export const PROFILE_RELAY_URLS = [

96
src/hooks/useFetchProfile.tsx

@ -7,12 +7,13 @@ import { useEffect, useState, useRef, useCallback } from 'react'
import logger from '@/lib/logger' import logger from '@/lib/logger'
export function useFetchProfile(id?: string, skipCache = false) { export function useFetchProfile(id?: string, skipCache = false) {
// Log hook invocation immediately - this will show if the hook is even being called // CRITICAL: Reduce logging to prevent performance issues during infinite loops
logger.info('[useFetchProfile] Hook called', { // Only log if we're actually going to process (not just checking)
id: id || 'undefined', // logger.info('[useFetchProfile] Hook called', {
skipCache, // id: id || 'undefined',
stack: new Error().stack?.split('\n').slice(1, 4).join('\n') // skipCache,
}) // stack: new Error().stack?.split('\n').slice(1, 4).join('\n')
// })
const { profile: currentAccountProfile } = useNostr() const { profile: currentAccountProfile } = useNostr()
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
@ -22,6 +23,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
const checkIntervalRef = useRef<NodeJS.Timeout | null>(null) const checkIntervalRef = useRef<NodeJS.Timeout | null>(null)
const processingPubkeyRef = useRef<string | null>(null) // Track which pubkey we're currently processing (prevents duplicate fetches) const processingPubkeyRef = useRef<string | null>(null) // Track which pubkey we're currently processing (prevents duplicate fetches)
const effectRunCountRef = useRef<Map<string, number>>(new Map()) // Track how many times effect has run for each pubkey (safety guard against infinite loops) const effectRunCountRef = useRef<Map<string, number>>(new Map()) // Track how many times effect has run for each pubkey (safety guard against infinite loops)
const initializedPubkeysRef = useRef<Set<string>>(new Set()) // Track pubkeys we've successfully initialized (have profile or failed)
// Function to check for profile updates // Function to check for profile updates
// fetchProfileEvent already checks: 1) IndexedDB, 2) network (with author's relays) // fetchProfileEvent already checks: 1) IndexedDB, 2) network (with author's relays)
@ -77,6 +79,8 @@ export function useFetchProfile(id?: string, skipCache = false) {
}) })
setProfile(newProfile) setProfile(newProfile)
setIsFetching(false) setIsFetching(false)
// Mark as initialized
initializedPubkeysRef.current.add(pubkey)
// Keep processingPubkeyRef set so we don't re-fetch // Keep processingPubkeyRef set so we don't re-fetch
// Clear interval once we have a profile // Clear interval once we have a profile
if (checkIntervalRef.current) { if (checkIntervalRef.current) {
@ -108,25 +112,57 @@ export function useFetchProfile(id?: string, skipCache = false) {
}, [skipCache]) }, [skipCache])
useEffect(() => { useEffect(() => {
logger.info('[useFetchProfile] useEffect triggered', { // CRITICAL: Reduce logging - only log when actually processing, not on every render
id: id || 'undefined', // logger.info('[useFetchProfile] useEffect triggered', {
skipCache, // id: id || 'undefined',
processingPubkey: processingPubkeyRef.current // skipCache,
}) // processingPubkey: processingPubkeyRef.current,
// hasProfile: !!profile,
// profilePubkey: profile?.pubkey
// })
// Extract pubkey early to check if id has changed // Extract pubkey early to check if id has changed
const extractedPubkey = id ? userIdToPubkey(id) : null const extractedPubkey = id ? userIdToPubkey(id) : null
// CRITICAL: Early exit if already processing this exact pubkey - prevents infinite loops // 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) { if (extractedPubkey && processingPubkeyRef.current === extractedPubkey) {
logger.info('[useFetchProfile] EARLY EXIT: Already processing this pubkey', { // Silently exit - no logging to reduce noise
extractedPubkey, return
processingPubkey: processingPubkeyRef.current }
})
// 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 return
} }
// CRITICAL: Guard against infinite loops - limit effect runs per pubkey (reduced from 10 to 3) // 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) { if (extractedPubkey) {
const runCount = effectRunCountRef.current.get(extractedPubkey) || 0 const runCount = effectRunCountRef.current.get(extractedPubkey) || 0
if (runCount >= 3) { if (runCount >= 3) {
@ -140,19 +176,17 @@ export function useFetchProfile(id?: string, skipCache = false) {
}, 30000) // Clear after 30 seconds }, 30000) // Clear after 30 seconds
return return
} }
// Only increment if we're actually going to process
effectRunCountRef.current.set(extractedPubkey, runCount + 1) 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 // This allows a new fetch to start for a different pubkey
if (extractedPubkey && processingPubkeyRef.current && processingPubkeyRef.current !== extractedPubkey) { if (extractedPubkey && processingPubkeyRef.current && processingPubkeyRef.current !== extractedPubkey) {
const oldPubkey = processingPubkeyRef.current const oldPubkey = processingPubkeyRef.current
logger.info('[useFetchProfile] ID changed, clearing refs', { // Clear run count and initialized status for old pubkey before clearing ref
oldPubkey,
newPubkey: extractedPubkey
})
// Clear run count for old pubkey before clearing ref
effectRunCountRef.current.delete(oldPubkey) effectRunCountRef.current.delete(oldPubkey)
initializedPubkeysRef.current.delete(oldPubkey)
processingPubkeyRef.current = null processingPubkeyRef.current = null
} }
@ -212,27 +246,22 @@ export function useFetchProfile(id?: string, skipCache = false) {
return return
} }
// CRITICAL: Check if we're already processing this pubkey IMMEDIATELY after validation // These checks are now done earlier in the effect (before incrementing run count)
// This must happen before any other logic to prevent infinite loops // Keeping this as a safety check, but it should rarely be hit
if (processingPubkeyRef.current === extractedPubkey) { if (processingPubkeyRef.current === extractedPubkey) {
logger.info('[useFetchProfile] Already processing this pubkey, skipping duplicate fetch', { logger.info('[useFetchProfile] Already processing this pubkey (safety check)', {
extractedPubkey, extractedPubkey,
processingPubkey: processingPubkeyRef.current, processingPubkey: processingPubkeyRef.current
hasProfile: !!profile
}) })
return 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) { 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 extractedPubkey
}) })
// Mark as processing to prevent re-fetch, but don't update state unnecessarily
processingPubkeyRef.current = extractedPubkey processingPubkeyRef.current = extractedPubkey
setIsFetching(false) setIsFetching(false)
// Clear run count since we have the profile
effectRunCountRef.current.delete(extractedPubkey) effectRunCountRef.current.delete(extractedPubkey)
return 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 // CRITICAL: Only use currentAccountProfile if it matches the pubkey we're looking for
// Use pubkey from the profile object to avoid reference equality issues // 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 // 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) { if (currentAccountProfile?.pubkey && pubkey && pubkey === currentAccountProfile.pubkey) {
// Only update if we don't have a profile yet (avoid unnecessary updates) // 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 // 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) 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 } return { isFetching, error, profile }
} }

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

@ -27,6 +27,7 @@ export default function NotFound({
if (!bech32Id) return if (!bech32Id) return
const getExternalRelays = async () => { const getExternalRelays = async () => {
try {
// Get all relays that have already been tried (FAST_READ_RELAY_URLS) // Get all relays that have already been tried (FAST_READ_RELAY_URLS)
// These are the relays used in the initial fetch // These are the relays used in the initial fetch
const alreadyTriedRelaysSet = new Set<string>() const alreadyTriedRelaysSet = new Set<string>()
@ -35,30 +36,44 @@ export default function NotFound({
if (normalized) alreadyTriedRelaysSet.add(normalized) if (normalized) alreadyTriedRelaysSet.add(normalized)
}) })
let hintRelays: string[] = [] let bech32HintRelays: string[] = [] // Relay hints from bech32 (highest priority)
let extractedHexEventId: string | null = null let extractedHexEventId: string | null = null
// Parse relay hints and author from bech32 ID // 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)) { if (!/^[0-9a-f]{64}$/.test(bech32Id)) {
try { try {
const { type, data } = nip19.decode(bech32Id) const { type, data } = nip19.decode(bech32Id)
if (type === 'nevent') { if (type === 'nevent') {
extractedHexEventId = data.id extractedHexEventId = data.id
if (data.relays) hintRelays.push(...data.relays) // CRITICAL: Always extract relay hints from nevent bech32
if (data.author) { if (data.relays && Array.isArray(data.relays)) {
const authorRelayList = await client.fetchRelayList(data.author).catch(() => ({ read: [] as string[], write: [] as string[] })) bech32HintRelays.push(...data.relays)
hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4)) 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') { } else if (type === 'naddr') {
if (data.relays) hintRelays.push(...data.relays) // CRITICAL: Always extract relay hints from naddr bech32
const authorRelayList = await client.fetchRelayList(data.pubkey).catch(() => ({ read: [] as string[], write: [] as string[] })) if (data.relays && Array.isArray(data.relays)) {
hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4)) 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') { } else if (type === 'note') {
extractedHexEventId = data extractedHexEventId = data
} }
} catch (err) { } catch (err) {
logger.error('Failed to parse external relays', { error: err, bech32Id }) logger.error('Failed to parse bech32 ID for relay hints', { error: err, bech32Id })
} }
} else { } else {
extractedHexEventId = bech32Id extractedHexEventId = bech32Id
@ -66,40 +81,54 @@ export default function NotFound({
setHexEventId(extractedHexEventId) setHexEventId(extractedHexEventId)
// Get relays where this event was seen // Get relays where this event was seen (if we have the hex ID)
const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : [] const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : []
hintRelays.push(...seenOn)
// Normalize all hint relays // Normalize bech32 hint relays (highest priority - these come from the bech32 address itself)
const normalizedHints = hintRelays const normalizedBech32Hints = bech32HintRelays
.map(url => normalizeUrl(url)) .map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url)) .filter((url): url is string => Boolean(url))
// Combine hints with SEARCHABLE_RELAY_URLS (always include as fallback) // Normalize seen relays
// Normalize SEARCHABLE_RELAY_URLS for comparison const normalizedSeenRelays = seenOn
const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS
.map(url => normalizeUrl(url)) .map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url)) .filter((url): url is string => Boolean(url))
// Combine all potential relays (hints + searchable) // Normalize SEARCHABLE_RELAY_URLS (fallback)
const allPotentialRelays = new Set([...normalizedHints, ...normalizedSearchableRelays]) const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS
.map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url))
// Filter out relays that were already tried // CRITICAL: Preserve order - bech32 hints first, then seen, then searchable
const externalRelays = Array.from(allPotentialRelays).filter( // This ensures relay hints from bech32 are shown first in the UI
relay => !alreadyTriedRelaysSet.has(relay) // 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))
]
// Deduplicate final relay list setExternalRelays(orderedExternalRelays)
setExternalRelays(externalRelays)
logger.debug('External relays calculated (NotFound)', { logger.debug('External relays calculated (NotFound)', {
bech32Id, bech32Id,
hintRelaysCount: normalizedHints.length, bech32HintCount: normalizedBech32Hints.length,
seenRelayCount: normalizedSeenRelays.length,
searchableRelaysCount: normalizedSearchableRelays.length, searchableRelaysCount: normalizedSearchableRelays.length,
alreadyTriedCount: alreadyTriedRelaysSet.size, alreadyTriedCount: alreadyTriedRelaysSet.size,
externalRelaysCount: externalRelays.length, externalRelaysCount: orderedExternalRelays.length,
externalRelays: externalRelays.slice(0, 10) // Log first 10 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([])
}
} }
getExternalRelays() getExternalRelays()
@ -172,7 +201,7 @@ export default function NotFound({
) : ( ) : (
<> <>
<Search className="w-4 h-4" /> <Search className="w-4 h-4" />
{t('Try external relays')} {t('Try external relays')} ({externalRelays.length})
</> </>
)} )}
</Button> </Button>

102
src/services/client-replaceable-events.service.ts

@ -612,21 +612,19 @@ export class ReplaceableEventService {
// Relay hints should have highest priority and always be included // Relay hints should have highest priority and always be included
const relayHints = relays.length > 0 ? [...relays] : [] const relayHints = relays.length > 0 ? [...relays] : []
// Step 1: Try with relay hints + default relays first (checks IndexedDB via DataLoader, then network) // Step 1: ALWAYS use DataLoader first (checks IndexedDB, then uses default relays)
// Always include relay hints if provided, then add default profile fetch relays // CRITICAL: Do NOT pass relay hints here - passing any relays bypasses DataLoader and creates individual subscriptions
const defaultRelays = relayHints.length > 0 // DataLoader already uses default relays internally and batches all profile fetches
? [...new Set([...relayHints, ...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS])] // We'll use relay hints in Step 2/3 only if Step 1 fails
: [...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS] logger.info('[ReplaceableEventService] Step 1: Trying with DataLoader (checks cache first, uses default relays, batched)', {
logger.info('[ReplaceableEventService] Step 1: Trying with relay hints + default relays (checks cache first)', {
pubkey, pubkey,
relayHintCount: relayHints.length, relayHintCount: relayHints.length,
totalRelayCount: defaultRelays.length,
hasRelayHints: relayHints.length > 0 hasRelayHints: relayHints.length > 0
}) })
// fetchReplaceableEvent uses DataLoader which checks IndexedDB first, then queries relays // fetchReplaceableEvent uses DataLoader which checks IndexedDB first, then queries default relays
const profileEvent = await this.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, defaultRelays) // Passing empty array ensures DataLoader is used (batched) - this prevents individual subscriptions
const profileEvent = await this.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, [])
if (profileEvent) { if (profileEvent) {
logger.info('[ReplaceableEventService] Profile found with relay hints + default relays', { logger.info('[ReplaceableEventService] Profile found with relay hints + default relays', {
@ -637,9 +635,13 @@ export class ReplaceableEventService {
return profileEvent return profileEvent
} }
// Step 2: Not found in cache or default relays - fetch author's relay list as fallback // Step 2: Only fetch author's relay list as fallback if we have relay hints from bech32
logger.info('[ReplaceableEventService] Step 2: Profile not found, fetching author relay list as fallback', { // This prevents creating many individual subscriptions when profiles aren't found
pubkey // 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,
relayHintCount: relayHints.length
}) })
let authorRelayList: { read?: string[]; write?: string[] } | null = null let authorRelayList: { read?: string[]; write?: string[] } | null = null
@ -708,8 +710,80 @@ export class ReplaceableEventService {
return 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: 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] Comprehensive relay list built', {
pubkey,
relayCount: comprehensiveRelays.length,
relays: comprehensiveRelays.slice(0, 10) // Log first 10 for debugging
})
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,
eventCount: events.length,
queryTime: `${queryTime}ms`,
relayCount: comprehensiveRelays.length
})
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, pubkey,
triedRelayHints: relayHints.length > 0 triedRelayHints: relayHints.length > 0
}) })

Loading…
Cancel
Save