From 1a2adb87f88e41e89f5a994ea237504415234ddf Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 24 Mar 2026 09:57:43 +0100 Subject: [PATCH] bug-fixes --- src/PageManager.tsx | 2 +- .../LatestFromFollowsSection/index.tsx | 16 ++- src/components/NoteBoostBadges/index.tsx | 16 ++- src/components/ReplyNote/index.tsx | 1 - src/i18n/locales/de.ts | 1 + src/i18n/locales/en.ts | 1 + .../FavoriteRelaysActivityProvider.tsx | 13 ++- src/providers/NostrProvider/index.tsx | 103 +++++++++++++++++- .../client-replaceable-events.service.ts | 29 ++++- src/services/client.service.ts | 6 +- 10 files changed, 165 insertions(+), 23 deletions(-) diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 411b8d14..aede1dab 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -203,7 +203,7 @@ function mergePrimaryPageEntry( export { PrimaryPageContext, usePrimaryPage } -export { useSecondaryPage } +export { useSecondaryPage, useSecondaryPageOptional } // Helper function to build contextual note URL function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): string { diff --git a/src/components/LatestFromFollowsSection/index.tsx b/src/components/LatestFromFollowsSection/index.tsx index ab720045..63c8bc67 100644 --- a/src/components/LatestFromFollowsSection/index.tsx +++ b/src/components/LatestFromFollowsSection/index.tsx @@ -88,7 +88,7 @@ function recommendedCuratorHexPubkey(): string | null { export default function LatestFromFollowsSection({ defaultOpen = false }: { defaultOpen?: boolean } = {}) { const { t } = useTranslation() const { push } = useSecondaryPage() - const { pubkey, followListEvent, isInitialized } = useNostr() + const { pubkey, followListEvent, isInitialized, isAccountSessionHydrating } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { mutePubkeySet } = useMuteList() const { isEventDeleted } = useDeletedEvent() @@ -110,7 +110,19 @@ export default function LatestFromFollowsSection({ defaultOpen = false }: { defa const followPubkeys = pubkey ? (loggedInFollowPubkeys ?? []) : guestFollowPubkeys const followsLabel: 'self' | 'recommended' = pubkey ? 'self' : 'recommended' - const loadingFollowList = !pubkey && isInitialized && !guestListReady + const [followListGraceExpired, setFollowListGraceExpired] = useState(false) + useEffect(() => { + if (!pubkey || followListEvent) { + setFollowListGraceExpired(false) + return + } + const t = setTimeout(() => setFollowListGraceExpired(true), 4000) + return () => clearTimeout(t) + }, [pubkey, followListEvent]) + + const loadingFollowList = + (!pubkey && isInitialized && !guestListReady) || + (!!pubkey && !followListEvent && (isAccountSessionHydrating || !followListGraceExpired)) const [aggregateRelayUrls, setAggregateRelayUrls] = useState([]) const [aggregateRelaysReady, setAggregateRelaysReady] = useState(false) diff --git a/src/components/NoteBoostBadges/index.tsx b/src/components/NoteBoostBadges/index.tsx index 92e7e391..c99e4f3c 100644 --- a/src/components/NoteBoostBadges/index.tsx +++ b/src/components/NoteBoostBadges/index.tsx @@ -34,23 +34,21 @@ export default function NoteBoostBadges({ event, className }: { event: Event; cl return (
- {visible.map((r, i) => ( -
0 && '-ml-2')} - style={{ zIndex: visible.length - i }} - > + + {t('Boosted by:')} + + {visible.map((r) => ( +
))} {overflow > 0 ? ( +{overflow} diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index eaabee50..3a58e62d 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -3,7 +3,6 @@ import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { isMentioningMutedUsers } from '@/lib/event' import { toNote } from '@/lib/link' -import client from '@/services/client.service' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/contexts/mute-list-context' import { useScreenSize } from '@/providers/ScreenSizeProvider' diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 4cf1e1fc..514bc4d2 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -33,6 +33,7 @@ export default { Following: 'Folgende', followings: 'Folgekonten', boosted: 'geboostet', + 'Boosted by:': 'Geboostet von:', 'just now': 'gerade eben', 'n minutes ago': 'vor {{n}} Minuten', 'n m': 'vor {{n}}m', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c90a1ddb..24278b8b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -31,6 +31,7 @@ export default { Following: 'Following', followings: 'followings', boosted: 'boosted', + 'Boosted by:': 'Boosted by:', 'just now': 'just now', 'n minutes ago': '{{n}} minutes ago', 'n m': '{{n}}m', diff --git a/src/providers/FavoriteRelaysActivityProvider.tsx b/src/providers/FavoriteRelaysActivityProvider.tsx index d2662b5d..43954eb2 100644 --- a/src/providers/FavoriteRelaysActivityProvider.tsx +++ b/src/providers/FavoriteRelaysActivityProvider.tsx @@ -116,6 +116,13 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R const fetchRef = useRef(fetchActive) fetchRef.current = fetchActive + /** Reset pulse state when account or relay set changes so we show loading until fresh data. */ + const resetForRefetch = useCallback(() => { + setRelayActivityReady(false) + setOrderedPubkeys([]) + setProfileKind0ByPubkey({}) + }, []) + /** Initial fetch on mount and when relay set changes (refresh snapshot, not hourly cadence). */ const prevRelayKeyRef = useRef(undefined) useEffect(() => { @@ -126,17 +133,19 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R } if (prevRelayKeyRef.current === relayKey) return prevRelayKeyRef.current = relayKey + resetForRefetch() void fetchRef.current() - }, [relayKey]) + }, [relayKey, resetForRefetch]) /** Logged-in user changed — refetch for the new account. Follow list changes update partition via useMemo. */ const prevViewerRef = useRef(undefined) useEffect(() => { if (prevViewerRef.current !== undefined && prevViewerRef.current !== viewerPubkey) { + resetForRefetch() void fetchRef.current() } prevViewerRef.current = viewerPubkey ?? undefined - }, [viewerPubkey]) + }, [viewerPubkey, resetForRefetch]) /** While the document is visible: poll once per hour; when returning after a long background, catch up if due. */ useEffect(() => { diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 0ba883d2..2e06a0bb 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -3,10 +3,11 @@ import { ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS, DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS, - ExtendedKind, FAST_WRITE_RELAY_URLS, + ExtendedKind, PROFILE_FETCH_RELAY_URLS, - PROFILE_RELAY_URLS + PROFILE_RELAY_URLS, + SEARCHABLE_RELAY_URLS } from '@/constants' import { buildAltTag, @@ -458,9 +459,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const normalizedRelays = [ ...relayList.write.map((url: string) => normalizeUrl(url) || url), + ...FAST_WRITE_RELAY_URLS.map((url: string) => normalizeUrl(url) || url), ...PROFILE_FETCH_RELAY_URLS.map((url: string) => normalizeUrl(url) || url) ] - const fetchRelays = Array.from(new Set(normalizedRelays)).slice(0, 8) + const fetchRelays = Array.from(new Set(normalizedRelays)).slice(0, 16) const events = await queryService.fetchEvents(fetchRelays, [ { kinds: [ @@ -509,6 +511,51 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (updatedFollowListEvent.id === followListEvent.id) { setFollowListEvent(followListEvent) } + } else { + // Hydrate batch uses limited relays; fallback fetches from broader set (author relays, etc.) + const trySetFollowList = (evt: Event) => { + if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) return + indexedDb + .putReplaceableEvent(evt) + .then(() => { + if (hydrationGenForThisRun === accountHydrationGenerationRef.current) { + setFollowListEvent(evt) + logger.info('[NostrProvider] Follow list loaded via fallback fetch') + } + }) + .catch(() => { + if (hydrationGenForThisRun === accountHydrationGenerationRef.current) { + setFollowListEvent(evt) + } + }) + } + const followListRelays = Array.from( + new Set([ + ...mergedRelayList.write.map((u) => normalizeUrl(u) || u), + ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u) + ]) + ).filter(Boolean) + queryService + .fetchEvents(followListRelays, { + authors: [account.pubkey], + kinds: [kinds.Contacts], + limit: 1 + }) + .then((evts) => { + const evt = evts.sort((a, b) => b.created_at - a.created_at)[0] + if (evt && hydrationGenForThisRun === accountHydrationGenerationRef.current) { + trySetFollowList(evt) + return + } + client.fetchFollowListEvent(account.pubkey, followListRelays).then((f) => { + if (f) trySetFollowList(f) + }) + }) + .catch(() => { + client.fetchFollowListEvent(account.pubkey, followListRelays).then((f) => { + if (f) trySetFollowList(f) + }) + }) } if (muteListEvent) { const updatedMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent) @@ -581,6 +628,36 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (storedRelayListEvent) { client.updateRelayListCache(storedRelayListEvent) } + if (!storedFollowListEvent) { + const trySetFollowListSkip = (evt: Event) => { + if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) return + indexedDb + .putReplaceableEvent(evt) + .then(() => { + if (hydrationGenForThisRun === accountHydrationGenerationRef.current) { + setFollowListEvent(evt) + logger.info('[NostrProvider] Follow list loaded via fallback (skip-network path)') + } + }) + .catch(() => { + if (hydrationGenForThisRun === accountHydrationGenerationRef.current) { + setFollowListEvent(evt) + } + }) + } + const getFollowListRelays = async () => { + const rl = storedRelayListEvent + ? getRelayListFromEvent(storedRelayListEvent, blockedRelays) + : { write: [] as string[], read: [] as string[] } + const writes = rl.write.map((u) => normalizeUrl(u) || u).filter(Boolean) + return Array.from(new Set([...writes, ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u)])).filter(Boolean) + } + getFollowListRelays().then((relays) => + client.fetchFollowListEvent(account.pubkey, relays.length > 0 ? relays : undefined).then((fallback) => { + if (fallback) trySetFollowListSkip(fallback) + }) + ) + } } return controller } @@ -611,6 +688,26 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } }, [account, accountNetworkHydrateBump]) + /** Recovery: if hydrate finished but follow list is still null, fetch using user write + search relays. */ + useEffect(() => { + if (!account || followListEvent !== null || isAccountSessionHydrating) return + let cancelled = false + client + .fetchRelayList(account.pubkey) + .then((rl) => { + const writes = rl.write.map((u) => normalizeUrl(u) || u).filter(Boolean) + const relays = Array.from(new Set([...writes, ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u)])).filter(Boolean) + return client.fetchFollowListEvent(account.pubkey, relays.length > 0 ? relays : undefined) + }) + .then((evt) => { + if (!cancelled && evt) setFollowListEvent(evt) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [account, followListEvent, isAccountSessionHydrating]) + useEffect(() => { if (!account) return diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 81dbbd7e..8f78c1b5 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -1,6 +1,7 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, + FAST_WRITE_RELAY_URLS, MAX_CONCURRENT_RELAY_CONNECTIONS, METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, @@ -499,6 +500,15 @@ export class ReplaceableEventService { ) ) ).filter(Boolean) + } else if (kind === kinds.Contacts) { + // Contacts (follow list) are published to user's write relays; use write + read + profile relays + relayUrls = Array.from( + new Set( + [...FAST_WRITE_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS].map( + (u) => normalizeUrl(u) || u + ) + ) + ).filter(Boolean) } else { relayUrls = [...FAST_READ_RELAY_URLS] } @@ -1001,9 +1011,24 @@ export class ReplaceableEventService { */ /** - * Fetch follow list event + * Fetch follow list event. + * When relayUrls are provided (e.g. user write + search relays), queries those directly. + * Otherwise uses the default relay set (FAST_WRITE + PROFILE_FETCH + FAST_READ). */ - async fetchFollowListEvent(pubkey: string): Promise { + async fetchFollowListEvent(pubkey: string, relayUrls?: string[]): Promise { + if (relayUrls && relayUrls.length > 0) { + const normalized = Array.from( + new Set(relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean)) + ) + const events = await this.queryService.query( + normalized, + { authors: [pubkey], kinds: [kinds.Contacts], limit: 1 }, + undefined, + { replaceableRace: true, eoseTimeout: 1500, globalTimeout: 8000 } + ) + const latest = events.sort((a, b) => b.created_at - a.created_at)[0] + return latest + } return await this.fetchReplaceableEvent(pubkey, kinds.Contacts) } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 0caf7854..2f1c835f 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -2579,9 +2579,9 @@ class ClientService extends EventTarget { /** =========== Replaceable event =========== */ - // Delegate to ReplaceableEventService - async fetchFollowListEvent(pubkey: string) { - return this.replaceableEventService.fetchFollowListEvent(pubkey) + // Delegate to ReplaceableEventService. Pass relayUrls for fallback (user write + search relays). + async fetchFollowListEvent(pubkey: string, relayUrls?: string[]) { + return this.replaceableEventService.fetchFollowListEvent(pubkey, relayUrls) } async fetchFollowings(pubkey: string): Promise {