+ {showingRelayListFallback && (
+
+ {t('othersRelayListKind10002Fallback', {
+ defaultValue:
+ 'No NIP-65 relay list (kind 10002) was found for this user in local storage yet. The addresses below are default discovery relays, not this user’s published read/write list.'
+ })}
+
+ )}
{relayList.originalRelays.map((relay, index) => (
))}
diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx
index 6f7cd2b7..0ede6287 100644
--- a/src/components/ProfileOptions/index.tsx
+++ b/src/components/ProfileOptions/index.tsx
@@ -8,28 +8,27 @@ import {
} from '@/components/ui/dropdown-menu'
import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
-import { normalizeAnyRelayUrl } from '@/lib/url'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
+import { normalizeAnyRelayUrl } from '@/lib/url'
import { useNostr } from '@/providers/NostrProvider'
-import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
+import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
-import client from '@/services/client.service'
-import { replaceableEventService } from '@/services/client.service'
+import client, { replaceableEventService } from '@/services/client.service'
import { nip66Service } from '@/services/nip66.service'
import RawEventDialog from '@/components/NoteOptions/RawEventDialog'
import {
Bell,
BellOff,
+ Code,
Copy,
Ellipsis,
ThumbsUp,
MessageCircle,
Send,
- Video,
SatelliteDish,
- Code,
+ Video,
LayoutGrid
} from 'lucide-react'
import { useMemo, useState, useEffect } from 'react'
@@ -40,7 +39,7 @@ import { useTranslation } from 'react-i18next'
import { useSmartProfileInteractionsNavigation } from '@/PageManager'
import { toProfileInteractionMap } from '@/lib/link'
import { toast } from 'sonner'
-import { Event } from 'nostr-tools'
+import { Event, kinds } from 'nostr-tools'
export default function ProfileOptions({
pubkey,
@@ -49,7 +48,7 @@ export default function ProfileOptions({
onSendCallInvite
}: {
pubkey: string
- /** Optional profile event (kind 0) for republishing and viewing JSON */
+ /** Optional profile event (kind 0): reply / like, republish to relays, view JSON */
profileEvent?: Event
/** Opens the post editor in public message mode with this profile's pubkey in the mention list. */
onSendPublicMessage?: () => void
@@ -84,7 +83,7 @@ export default function ProfileOptions({
setLocalProfileEvent(event)
}
} catch (error) {
- // Silently fail - menu items just won't show
+ // Silently fail: reply/like stay hidden until the event loads
}
}
@@ -97,28 +96,32 @@ export default function ProfileOptions({
/** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo(() => {
const urls = [
- ...currentBrowsingRelayUrls.map(url => normalizeAnyRelayUrl(url) || url),
- ...favoriteRelays.map(url => normalizeAnyRelayUrl(url) || url),
- ...relaySets.flatMap(set => set.relayUrls.map(url => normalizeAnyRelayUrl(url) || url)),
- ...FAST_READ_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url),
- ...FAST_WRITE_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url)
+ ...currentBrowsingRelayUrls.map((url) => normalizeAnyRelayUrl(url) || url),
+ ...favoriteRelays.map((url) => normalizeAnyRelayUrl(url) || url),
+ ...relaySets.flatMap((set) => set.relayUrls.map((url) => normalizeAnyRelayUrl(url) || url)),
+ ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
+ ...FAST_WRITE_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[]
return Array.from(new Set(urls))
}, [currentBrowsingRelayUrls, favoriteRelays, relaySets])
useEffect(() => {
- nip66Service.getPublicLivelyRelayUrls().then((urls) => {
+ void nip66Service.getPublicLivelyRelayUrls().then((urls) => {
setMonitoringListRelayCount(urls?.length ?? 0)
})
}, [])
+ const eventToUse = localProfileEvent || profileEvent
+ /** Kind 0 only; coerce `kind` in case deserialization yields a string. */
+ const kind0ForRelay =
+ eventToUse != null && Number(eventToUse.kind) === kinds.Metadata ? eventToUse : undefined
+
const handleRepublishToAllAvailable = async () => {
- const eventToPublish = localProfileEvent || profileEvent
- if (!eventToPublish) {
+ if (!kind0ForRelay) {
toast.error(t('Profile event not available'))
return
}
- const promise = client.publishEvent(allAvailableRelayUrls, eventToPublish).then((result) => {
+ const promise = client.publishEvent(allAvailableRelayUrls, kind0ForRelay).then((result) => {
if (result.successCount < 1) {
throw new Error(t('No relay accepted the event'))
}
@@ -132,8 +135,7 @@ export default function ProfileOptions({
}
const handleRepublishToAllActive = async () => {
- const eventToPublish = localProfileEvent || profileEvent
- if (!eventToPublish) {
+ if (!kind0ForRelay) {
toast.error(t('Profile event not available'))
return
}
@@ -146,12 +148,14 @@ export default function ProfileOptions({
if (!relays?.length) {
throw new Error(t('No relays available'))
}
- const result = await client.publishEvent(relays, eventToPublish)
+ const result = await client.publishEvent(relays, kind0ForRelay)
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('Only {{count}} relay(s) accepted the event; at least 5 required for "all active relays".', {
+ count: result.successCount
+ })
: t('No relay accepted the event')
)
}
@@ -164,8 +168,6 @@ export default function ProfileOptions({
})
}
- const eventToUse = localProfileEvent || profileEvent
-
const handleLike = () => {
if (!eventToUse) return
checkLogin(async () => {
@@ -258,7 +260,7 @@ export default function ProfileOptions({
{t('Copy user ID')}
- {(localProfileEvent || profileEvent) && (
+ {kind0ForRelay && (
<>
@@ -268,7 +270,8 @@ export default function ProfileOptions({
{t('Republish to all active relays')}
- {monitoringListRelayCount !== null && ` (${monitoringListRelayCount > 0 ? monitoringListRelayCount : allAvailableRelayUrls.length})`}
+ {monitoringListRelayCount !== null &&
+ ` (${monitoringListRelayCount > 0 ? monitoringListRelayCount : allAvailableRelayUrls.length})`}
setIsRawEventDialogOpen(true)}>
@@ -310,13 +313,13 @@ export default function ProfileOptions({
setOpen={setOpenReply}
/>
)}
- {(localProfileEvent || profileEvent) && (
+ {kind0ForRelay && (
setIsRawEventDialogOpen(false)}
- />
- )}
+ event={kind0ForRelay}
+ isOpen={isRawEventDialogOpen}
+ onClose={() => setIsRawEventDialogOpen(false)}
+ />
+ )}
)
}
diff --git a/src/hooks/useFetchRelayList.tsx b/src/hooks/useFetchRelayList.tsx
index 37f25f43..61e551a5 100644
--- a/src/hooks/useFetchRelayList.tsx
+++ b/src/hooks/useFetchRelayList.tsx
@@ -1,45 +1,87 @@
import logger from '@/lib/logger'
import client from '@/services/client.service'
+import indexedDb from '@/services/indexed-db.service'
import { TRelayList } from '@/types'
+import { kinds } from 'nostr-tools'
import { useEffect, useState } from 'react'
+const emptyRelayList = (): TRelayList => ({
+ write: [],
+ read: [],
+ originalRelays: [],
+ httpRead: [],
+ httpWrite: [],
+ httpOriginalRelays: []
+})
+
export function useFetchRelayList(pubkey?: string | null) {
- const [relayList, setRelayList] = useState({
- write: [],
- read: [],
- originalRelays: [],
- httpRead: [],
- httpWrite: [],
- httpOriginalRelays: []
- })
+ const [relayList, setRelayList] = useState(emptyRelayList)
const [isFetching, setIsFetching] = useState(true)
+ /** True when IndexedDB has this author's kind 10002 (even if `originalRelays` is empty after merge). */
+ const [hasKind10002InStorage, setHasKind10002InStorage] = useState(false)
useEffect(() => {
+ let cancelled = false
+ const targetPk = pubkey?.trim() || null
+
const fetchRelayList = async () => {
setIsFetching(true)
- if (!pubkey) {
+ setHasKind10002InStorage(false)
+ if (!targetPk) {
+ setRelayList(emptyRelayList())
setIsFetching(false)
return
}
+
+ setRelayList(emptyRelayList())
+
try {
- const fromStorage = await client.peekRelayListFromStorage(pubkey)
+ const [fromStorage, k10002] = await Promise.all([
+ client.peekRelayListFromStorage(targetPk),
+ indexedDb.getReplaceableEvent(targetPk, kinds.RelayList).catch(() => null)
+ ])
+ if (cancelled) return
+ setHasKind10002InStorage(!!k10002)
setRelayList(fromStorage)
- const relayList = await client.fetchRelayList(pubkey)
- setRelayList(relayList)
+
+ const merged = await client.fetchRelayList(targetPk)
+ if (cancelled) return
+ setRelayList(merged)
+ const k10002After = await indexedDb.getReplaceableEvent(targetPk, kinds.RelayList).catch(() => null)
+ if (!cancelled) {
+ setHasKind10002InStorage(!!k10002After)
+ }
} catch (err) {
- logger.error('Failed to fetch relay list', { error: err, pubkey })
+ logger.error('Failed to fetch relay list', { error: err, pubkey: targetPk })
try {
- setRelayList(await client.peekRelayListFromStorage(pubkey))
+ const fallback = await client.peekRelayListFromStorage(targetPk)
+ const k10002 = await indexedDb.getReplaceableEvent(targetPk, kinds.RelayList).catch(() => null)
+ if (!cancelled) {
+ setRelayList(fallback)
+ setHasKind10002InStorage(!!k10002)
+ }
} catch {
- /* keep last good state */
+ if (!cancelled) {
+ setRelayList(emptyRelayList())
+ }
}
} finally {
- setIsFetching(false)
+ if (!cancelled) {
+ setIsFetching(false)
+ }
}
}
- fetchRelayList()
+ void fetchRelayList()
+ return () => {
+ cancelled = true
+ }
}, [pubkey])
- return { relayList, isFetching }
+ const showingRelayListFallback =
+ !isFetching &&
+ !hasKind10002InStorage &&
+ relayList.originalRelays.length === 0
+
+ return { relayList, isFetching, hasKind10002InStorage, showingRelayListFallback }
}
diff --git a/src/pages/secondary/OthersRelaySettingsPage/index.tsx b/src/pages/secondary/OthersRelaySettingsPage/index.tsx
index 38fdb700..4a4e95d1 100644
--- a/src/pages/secondary/OthersRelaySettingsPage/index.tsx
+++ b/src/pages/secondary/OthersRelaySettingsPage/index.tsx
@@ -53,6 +53,12 @@ const RelaySettingsPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
setJsonOpen(true)
}, [profile?.pubkey, relayList])
+ useEffect(() => {
+ if (profile?.pubkey) {
+ setListKey((k) => k + 1)
+ }
+ }, [profile?.pubkey])
+
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
diff --git a/src/pages/secondary/RelaySettingsPage/index.tsx b/src/pages/secondary/RelaySettingsPage/index.tsx
index 366e5377..5d91c809 100644
--- a/src/pages/secondary/RelaySettingsPage/index.tsx
+++ b/src/pages/secondary/RelaySettingsPage/index.tsx
@@ -29,6 +29,13 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
const { account, relayList } = useNostr()
const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), [])
+
+ useEffect(() => {
+ if (account?.pubkey) {
+ setContentKey((k) => k + 1)
+ }
+ }, [account?.pubkey])
+
const [tabValue, setTabValue] = useState('favorite-relays')
const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState(null)
@@ -75,7 +82,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
setTabValue('cache-relays')
break
}
- }, [])
+ }, [account?.pubkey])
useEffect(() => {
if (!hideTitlebar) {
diff --git a/src/providers/FavoriteRelaysProvider.tsx b/src/providers/FavoriteRelaysProvider.tsx
index c17ce435..a20050e7 100644
--- a/src/providers/FavoriteRelaysProvider.tsx
+++ b/src/providers/FavoriteRelaysProvider.tsx
@@ -310,6 +310,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const contextValue = useMemo(
() => ({
+ favoriteRelaysFromPublishedList: !!favoriteRelaysEvent,
favoriteRelays,
addFavoriteRelays,
deleteFavoriteRelays,
@@ -325,6 +326,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
reorderRelaySets
}),
[
+ favoriteRelaysEvent,
favoriteRelays,
blockedRelays,
relaySets,
diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx
index 3b7e20c9..72fd888a 100644
--- a/src/providers/NostrProvider/index.tsx
+++ b/src/providers/NostrProvider/index.tsx
@@ -462,6 +462,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
limit: 1
})
])
+ if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
+ return controller
+ }
const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent
const cacheRelayListEvent = getLatestEvent(cacheRelayListEvents) ?? storedCacheRelayListEvent
const httpRelayListEventFetched = getLatestEvent(httpRelayListEvents) ?? storedHttpRelayListEvent ?? null
@@ -487,6 +490,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
// Fetch updated relay list (merges 10002, 10432, 10243)
const mergedRelayList = await client.fetchRelayList(account.pubkey) // Keep using client for relay list merging
+ if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
+ return controller
+ }
setRelayList(mergedRelayList)
const normalizedRelays = [
@@ -512,6 +518,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
authors: [account.pubkey]
}
])
+ if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
+ return controller
+ }
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata)
const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts)
@@ -554,6 +563,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
safePutReplaceable(userEmojiListEvent)
])
+ if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
+ return controller
+ }
+
if (profileEvent) {
const resolvedProfileEvent = resolvedProfilePut ?? profileEvent
try {
@@ -639,7 +652,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
}
if (favoriteRelaysEvent) {
- if (resolvedFavoritePut && resolvedFavoritePut.id === favoriteRelaysEvent.id) {
+ if (
+ hydrationGenForThisRun === accountHydrationGenerationRef.current &&
+ resolvedFavoritePut &&
+ resolvedFavoritePut.id === favoriteRelaysEvent.id
+ ) {
setFavoriteRelaysEvent(favoriteRelaysEvent)
}
}
diff --git a/src/providers/favorite-relays-context.tsx b/src/providers/favorite-relays-context.tsx
index 0bcc63b7..383e5a51 100644
--- a/src/providers/favorite-relays-context.tsx
+++ b/src/providers/favorite-relays-context.tsx
@@ -8,6 +8,8 @@ import { Event } from 'nostr-tools'
import { createContext, useContext } from 'react'
export type TFavoriteRelaysContext = {
+ /** True when rows come from a published kind 10012 (favorite relays) event, not app defaults. */
+ favoriteRelaysFromPublishedList: boolean
favoriteRelays: string[]
addFavoriteRelays: (relayUrls: string[]) => Promise
deleteFavoriteRelays: (relayUrls: string[]) => Promise