From 70af3f788da3da8fcac2b72ad909cbf45515b97d Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 13 May 2026 08:51:02 +0200 Subject: [PATCH] make getting profile data more efficient --- package-lock.json | 4 +- package.json | 2 +- src/components/Profile/index.tsx | 14 +- src/constants.ts | 2 +- src/providers/NostrProvider/index.tsx | 162 ++++++++++++++++++ .../client-replaceable-events.service.ts | 113 +++++++++++- src/services/client.service.ts | 22 ++- 7 files changed, 310 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3e3526fc..d6e53e89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.8.1", + "version": "23.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.8.1", + "version": "23.9.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 22a220f2..efe63746 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.8.1", + "version": "23.9.0", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 46913f97..ecf4b5ac 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -201,6 +201,8 @@ export default function Profile({ const publicationsFeedRef = useRef<{ refresh: () => void }>(null) const likedFeedRef = useRef<{ refresh: () => void }>(null) const [profileFeedTab, setProfileFeedTab] = useState<'posts' | 'media' | 'publications' | 'liked'>('posts') + /** Bumped after profile-view relay sync so payment + kind-0 JSON re-query storage and relays. */ + const [authorReplaceablesSyncGen, setAuthorReplaceablesSyncGen] = useState(0) const { profile, isFetching } = useFetchProfile(id) const { pubkey: accountPubkey, publish, checkLogin } = useNostr() @@ -266,11 +268,17 @@ export default function Profile({ } fetchPaymentInfo() - }, [profile?.pubkey]) + }, [profile?.pubkey, authorReplaceablesSyncGen]) useEffect(() => { if (!profile?.pubkey) return - client.prefetchAuthorCoreReplaceables([profile.pubkey], { force: true }) + let cancelled = false + void client.refreshAuthorPublishedReplaceablesOnProfileView(profile.pubkey).finally(() => { + if (!cancelled) setAuthorReplaceablesSyncGen((g) => g + 1) + }) + return () => { + cancelled = true + } }, [profile?.pubkey]) // Fetch profile event (kind 0) for republishing and viewing JSON @@ -297,7 +305,7 @@ export default function Profile({ } fetchProfileEventData() - }, [profile?.pubkey]) + }, [profile?.pubkey, authorReplaceablesSyncGen]) const isFollowingYou = useMemo(() => { // This will be handled by the FollowedBy component diff --git a/src/constants.ts b/src/constants.ts index eb9fe8db..9014620b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -417,7 +417,7 @@ export const FAST_WRITE_RELAY_URLS = [ 'wss://relay.primal.net', 'wss://thecitadel.nostr1.com', 'wss://nos.lol', - 'wss://nostr.einundzwanzig.space' + 'wss://freelay.sovbit.host' ] /** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish. diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 72fd888a..14572769 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -21,6 +21,7 @@ import { createRelayListDraftEvent } from '@/lib/draft-event' import { getLatestEvent, minePow } from '@/lib/event' +import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { LoginRequiredError } from '@/lib/nostr-errors' @@ -28,6 +29,7 @@ import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import client from '@/services/client.service' +import { ReplaceableEventService } from '@/services/client-replaceable-events.service' import { queryService, replaceableEventService } from '@/services/client.service' import customEmojiService from '@/services/custom-emoji.service' import indexedDb from '@/services/indexed-db.service' @@ -141,6 +143,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { */ const [nip07RecoveryBump, setNip07RecoveryBump] = useState(0) + const accountForReplaceablesSyncRef = useRef(null) + useEffect(() => { + accountForReplaceablesSyncRef.current = account + }, [account]) + useEffect(() => { const init = async () => { logger.debug('[NostrProvider] Restoring session (login / first account)…') @@ -640,6 +647,53 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (resolvedMutePut && resolvedMutePut.id === muteListEvent.id) { setMuteListEvent(muteListEvent) } + } else { + const trySetMuteList = (evt: Event) => { + if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) return + indexedDb + .putReplaceableEvent(evt) + .then(() => { + if (hydrationGenForThisRun === accountHydrationGenerationRef.current) { + setMuteListEvent(evt) + logger.info('[NostrProvider] Mute list loaded via fallback fetch') + } + }) + .catch(() => { + if (hydrationGenForThisRun === accountHydrationGenerationRef.current) { + setMuteListEvent(evt) + } + }) + } + const muteListRelays = Array.from( + new Set([ + ...mergedRelayList.write.map((u) => normalizeUrl(u) || u), + ...mergedRelayList.read.map((u) => normalizeUrl(u) || u), + ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), + ...PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u), + ...FAST_WRITE_RELAY_URLS.map((u) => normalizeUrl(u) || u) + ]) + ).filter(Boolean) + queryService + .fetchEvents(muteListRelays, { + authors: [account.pubkey], + kinds: [kinds.Mutelist], + limit: 10 + }) + .then((evts) => { + const evt = getLatestEvent(evts) + if (evt && hydrationGenForThisRun === accountHydrationGenerationRef.current) { + trySetMuteList(evt) + return + } + client.fetchMuteListEvent(account.pubkey).then((m) => { + if (m) trySetMuteList(m) + }) + }) + .catch(() => { + client.fetchMuteListEvent(account.pubkey).then((m) => { + if (m) trySetMuteList(m) + }) + }) } if (bookmarkListEvent) { if (resolvedBookmarkPut && resolvedBookmarkPut.id === bookmarkListEvent.id) { @@ -799,6 +853,114 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } }, [account, followListEvent, isAccountSessionHydrating]) + /** Recovery: if hydrate finished but mute list is still null, query outboxes + search + profile relays (same gap as follow-list recovery). */ + useEffect(() => { + if (!account || muteListEvent !== null || isAccountSessionHydrating) return + let cancelled = false + client + .fetchRelayList(account.pubkey) + .then((rl) => { + const relays = Array.from( + new Set([ + ...rl.write.map((u) => normalizeUrl(u) || u), + ...rl.read.map((u) => normalizeUrl(u) || u), + ...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u), + ...PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u), + ...FAST_WRITE_RELAY_URLS.map((u) => normalizeUrl(u) || u) + ]) + ).filter(Boolean) + return queryService.fetchEvents(relays, { + authors: [account.pubkey], + kinds: [kinds.Mutelist], + limit: 10 + }) + }) + .then((evts) => { + const evt = getLatestEvent(evts) + if (!cancelled && evt) { + void indexedDb.putReplaceableEvent(evt).catch(() => {}) + setMuteListEvent(evt) + return + } + if (!cancelled) { + return client.fetchMuteListEvent(account.pubkey).then((m) => { + if (!cancelled && m) { + void indexedDb.putReplaceableEvent(m).catch(() => {}) + setMuteListEvent(m) + } + }) + } + }) + .catch(() => { + if (!cancelled) { + client.fetchMuteListEvent(account.pubkey).then((m) => { + if (!cancelled && m) { + void indexedDb.putReplaceableEvent(m).catch(() => {}) + setMuteListEvent(m) + } + }) + } + }) + return () => { + cancelled = true + } + }, [account, muteListEvent, isAccountSessionHydrating]) + + useEffect(() => { + const EVENT = ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT + const onRefreshed: EventListener = (domEvt) => { + const ce = domEvt as unknown as CustomEvent<{ pubkey?: string }> + const pk = ce.detail?.pubkey?.toLowerCase() + const acc = accountForReplaceablesSyncRef.current + if (!pk || !acc?.pubkey || pk !== acc.pubkey.toLowerCase()) return + + void (async () => { + const INTEREST_LIST_KIND = 10015 + const loadOk = async (kind: number) => { + const e = await indexedDb.getReplaceableEvent(acc.pubkey, kind).catch(() => null) + return e && !shouldDropEventOnIngest(e) ? e : null + } + try { + const meta = await loadOk(kinds.Metadata) + if (meta) { + setProfileEvent(meta) + setProfile(getProfileFromEvent(meta)) + void replaceableEventService.updateReplaceableEventCache(meta).catch(() => {}) + } + const contacts = await loadOk(kinds.Contacts) + if (contacts) setFollowListEvent(contacts) + const mute = await loadOk(kinds.Mutelist) + if (mute) setMuteListEvent(mute) + const bookmark = await loadOk(kinds.BookmarkList) + if (bookmark) setBookmarkListEvent(bookmark) + const fav = await loadOk(ExtendedKind.FAVORITE_RELAYS) + if (fav) setFavoriteRelaysEvent(fav) + const blocked = await loadOk(ExtendedKind.BLOCKED_RELAYS) + if (blocked) setBlockedRelaysEvent(blocked) + const emoji = await loadOk(kinds.UserEmojiList) + if (emoji) setUserEmojiListEvent(emoji) + const interest = await loadOk(INTEREST_LIST_KIND) + if (interest) setInterestListEvent(interest) + const rss = await loadOk(ExtendedKind.RSS_FEED_LIST) + if (rss) setRssFeedListEvent(rss) + const cacheRel = await loadOk(ExtendedKind.CACHE_RELAYS) + if (cacheRel) setCacheRelayListEvent(cacheRel) + const httpRel = await loadOk(ExtendedKind.HTTP_RELAY_LIST) + if (httpRel) setHttpRelayListEvent(httpRel) + const blossom = await loadOk(ExtendedKind.BLOSSOM_SERVER_LIST) + if (blossom) void client.updateBlossomServerListEventCache(blossom) + + const merged = await client.fetchRelayList(acc.pubkey) + setRelayList(merged) + } catch (e) { + logger.warn('[NostrProvider] Failed to sync account state after replaceables refresh', { error: e }) + } + })() + } + window.addEventListener(EVENT, onRefreshed) + return () => window.removeEventListener(EVENT, onRefreshed) + }, []) + useEffect(() => { if (!account) return diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index f0e6328d..1ac14259 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -640,6 +640,18 @@ export class ReplaceableEventService { ].map((u) => normalizeUrl(u) || u) ) ).filter(Boolean) + } else if (kind === kinds.Mutelist || kind === kinds.BookmarkList) { + // Mute / bookmark lists: same distribution as contacts (writes + mirrors); FAST_READ-only misses many copies. + relayUrls = Array.from( + new Set( + [ + ...FAST_WRITE_RELAY_URLS, + ...READ_ONLY_RELAY_URLS, + ...PROFILE_FETCH_RELAY_URLS, + ...FAST_READ_RELAY_URLS + ].map((u) => normalizeUrl(u) || u) + ) + ).filter(Boolean) } else if (kind === ExtendedKind.PAYMENT_INFO) { // NIP-A3 kind 10133: often published to the user's write relays only; FAST_READ alone misses many copies. // Mirror contacts + pin-list coverage (writes + profile mirrors + aggregators + fast read). @@ -678,7 +690,9 @@ export class ReplaceableEventService { kind === 10001 || kind === ExtendedKind.PAYMENT_INFO || kind === kinds.Contacts || - kind === kinds.RelayList + kind === kinds.RelayList || + kind === kinds.Mutelist || + kind === kinds.BookmarkList const multiAuthorBatch = pubkeys.length > 1 // replaceableRace + default grace closes the REQ shortly after the first EVENT. For batched kind-0 // (many `authors` in one filter) that stops the subscription while most profiles are still in flight. @@ -1419,6 +1433,103 @@ export class ReplaceableEventService { ]) } + /** + * Profile view: query a wide relay set for the author's published replaceables (kind 0, contacts, + * NIP-65, mute list, bookmarks, pay, etc.), persist winners to IndexedDB, refresh in-memory loaders, + * then dispatch `ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT` so the session can re-sync UI. + */ + static readonly AUTHOR_REPLACEABLES_REFRESHED_EVENT = 'jumble:author-replaceables-refreshed' as const + + private static readonly PROFILE_VIEW_AUTHOR_REPLACEABLE_KINDS: readonly number[] = [ + kinds.Metadata, + kinds.Contacts, + kinds.RelayList, + kinds.Mutelist, + kinds.BookmarkList, + 10001, // pins (NIP-51) + 10015, // interests + ExtendedKind.FAVORITE_RELAYS, + ExtendedKind.BLOCKED_RELAYS, + ExtendedKind.BLOSSOM_SERVER_LIST, + ExtendedKind.PAYMENT_INFO, + kinds.UserEmojiList, + ExtendedKind.CACHE_RELAYS, + ExtendedKind.HTTP_RELAY_LIST, + ExtendedKind.RSS_FEED_LIST + ] + + async refreshAuthorPublishedReplaceablesFromRelays(pubkey: string): Promise { + const pk = pubkey.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(pk)) return + + await ReplaceableEventService.acquireProfileFallbackNetworkSlot() + try { + let relayUrls: string[] + try { + relayUrls = await buildComprehensiveRelayList({ + authorPubkey: pk, + userPubkey: client.pubkey || undefined, + includeUserOwnRelays: true, + includeFavoriteRelays: true, + includeProfileFetchRelays: true, + includeFastReadRelays: true, + includeFastWriteRelays: true, + includeSearchableRelays: true, + includeLocalRelays: true + }) + } catch { + relayUrls = [] + } + if (relayUrls.length === 0) return + + const events = await this.queryService.query( + relayUrls, + { authors: [pk], kinds: [...ReplaceableEventService.PROFILE_VIEW_AUTHOR_REPLACEABLE_KINDS] }, + undefined, + { + replaceableRace: false, + eoseTimeout: 2500, + globalTimeout: 14_000 + } + ) + + const bestByKind = new Map() + for (const e of events) { + if (shouldDropEventOnIngest(e)) continue + const prev = bestByKind.get(e.kind) + if (!prev || e.created_at > prev.created_at) { + bestByKind.set(e.kind, e) + } + } + + await Promise.allSettled( + Array.from(bestByKind.values()).map(async (ev) => { + try { + await indexedDb.putReplaceableEvent(ev) + } catch { + /* tombstone / validation */ + } + try { + await this.updateReplaceableEventCache(ev) + } catch { + /* ignore */ + } + if (ev.kind === kinds.Metadata) { + await this.indexProfile(ev) + } + }) + ) + + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent(ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, { detail: { pubkey: pk } }) + ) + } + } finally { + ReplaceableEventService.releaseProfileFallbackNetworkSlot() + } + } + /** * =========== Following Favorite Relays =========== */ diff --git a/src/services/client.service.ts b/src/services/client.service.ts index e1693520..0aea1b85 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3151,7 +3151,9 @@ class ClientService extends EventTarget { try { await Promise.all([ this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(unique, kinds.RelayList), - this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(unique, kinds.Contacts) + this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(unique, kinds.Contacts), + this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(unique, kinds.Mutelist), + this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(unique, ExtendedKind.PAYMENT_INFO) ]) } catch (err) { if (!options?.force) { @@ -3167,6 +3169,24 @@ class ClientService extends EventTarget { })() } + /** + * When opening a user's profile: show cached rows first (hooks), then pull kind 0/3/10002/10000/10133/etc. + * from a comprehensive relay set, persist to IndexedDB, and notify the app (see + * `ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT`). + */ + async refreshAuthorPublishedReplaceablesOnProfileView(pubkey: string): Promise { + const pk = pubkey.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(pk)) return + try { + await this.replaceableEventService.refreshAuthorPublishedReplaceablesFromRelays(pk) + } catch (err) { + logger.debug('[client] refreshAuthorPublishedReplaceablesOnProfileView failed', { + pubkeySlice: pk.slice(0, 12), + error: err instanceof Error ? err.message : String(err) + }) + } + } + /** Part of {@link runSessionPrewarm}; batches followings to limit relay load. */ private async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) { const followings = await this.replaceableEventService.fetchFollowings(pubkey)