From a58224c55aa33e0e7327ca98a9c922189537466e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 11 May 2026 10:06:22 +0200 Subject: [PATCH] bug-fixes --- .../FavoriteRelayList.tsx | 16 +++- src/components/OthersRelayList/index.tsx | 13 +++- src/components/ProfileOptions/index.tsx | 67 ++++++++-------- src/hooks/useFetchRelayList.tsx | 78 ++++++++++++++----- .../OthersRelaySettingsPage/index.tsx | 6 ++ .../secondary/RelaySettingsPage/index.tsx | 9 ++- src/providers/FavoriteRelaysProvider.tsx | 2 + src/providers/NostrProvider/index.tsx | 19 ++++- src/providers/favorite-relays-context.tsx | 2 + 9 files changed, 158 insertions(+), 54 deletions(-) diff --git a/src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx b/src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx index edbd25cd..6906e32f 100644 --- a/src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx +++ b/src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx @@ -1,4 +1,5 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' import { closestCenter, DndContext, @@ -20,7 +21,9 @@ import RelayItem from './RelayItem' export default function FavoriteRelayList() { const { t } = useTranslation() - const { favoriteRelays, blockedRelays, reorderFavoriteRelays } = useFavoriteRelays() + const { pubkey } = useNostr() + const { favoriteRelays, blockedRelays, reorderFavoriteRelays, favoriteRelaysFromPublishedList } = + useFavoriteRelays() // Show all relays including blocked ones (they'll be marked visually) @@ -46,6 +49,17 @@ export default function FavoriteRelayList() { return (
{t('Relays')}
+ {!!pubkey && !favoriteRelaysFromPublishedList && ( +

+ {t('favoriteRelaysDefaultsBanner', { + defaultValue: + 'No favorite-relays list (kind 10012) is loaded for this account yet. The relays below are app defaults and local relay sets, not a published list from your relays.' + })} +

+ )} userIdToPubkey(userId), [userId]) - const { relayList, isFetching } = useFetchRelayList(pubkey) + const { relayList, isFetching, showingRelayListFallback } = useFetchRelayList(pubkey) if (isFetching) { return
{t('loading...')}
@@ -19,6 +19,17 @@ export default function OthersRelayList({ userId }: { userId: string }) { return (
+ {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