Browse Source

make getting profile data more efficient

imwald
Silberengel 1 month ago
parent
commit
70af3f788d
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 14
      src/components/Profile/index.tsx
  4. 2
      src/constants.ts
  5. 162
      src/providers/NostrProvider/index.tsx
  6. 113
      src/services/client-replaceable-events.service.ts
  7. 22
      src/services/client.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.8.1", "version": "23.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.8.1", "version": "23.9.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "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", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

14
src/components/Profile/index.tsx

@ -201,6 +201,8 @@ export default function Profile({
const publicationsFeedRef = useRef<{ refresh: () => void }>(null) const publicationsFeedRef = useRef<{ refresh: () => void }>(null)
const likedFeedRef = useRef<{ refresh: () => void }>(null) const likedFeedRef = useRef<{ refresh: () => void }>(null)
const [profileFeedTab, setProfileFeedTab] = useState<'posts' | 'media' | 'publications' | 'liked'>('posts') 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 { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey, publish, checkLogin } = useNostr() const { pubkey: accountPubkey, publish, checkLogin } = useNostr()
@ -266,11 +268,17 @@ export default function Profile({
} }
fetchPaymentInfo() fetchPaymentInfo()
}, [profile?.pubkey]) }, [profile?.pubkey, authorReplaceablesSyncGen])
useEffect(() => { useEffect(() => {
if (!profile?.pubkey) return 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]) }, [profile?.pubkey])
// Fetch profile event (kind 0) for republishing and viewing JSON // Fetch profile event (kind 0) for republishing and viewing JSON
@ -297,7 +305,7 @@ export default function Profile({
} }
fetchProfileEventData() fetchProfileEventData()
}, [profile?.pubkey]) }, [profile?.pubkey, authorReplaceablesSyncGen])
const isFollowingYou = useMemo(() => { const isFollowingYou = useMemo(() => {
// This will be handled by the FollowedBy component // This will be handled by the FollowedBy component

2
src/constants.ts

@ -417,7 +417,7 @@ export const FAST_WRITE_RELAY_URLS = [
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://nos.lol', 'wss://nos.lol',
'wss://nostr.einundzwanzig.space' 'wss://freelay.sovbit.host'
] ]
/** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish. /** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish.

162
src/providers/NostrProvider/index.tsx

@ -21,6 +21,7 @@ import {
createRelayListDraftEvent createRelayListDraftEvent
} from '@/lib/draft-event' } from '@/lib/draft-event'
import { getLatestEvent, minePow } from '@/lib/event' import { getLatestEvent, minePow } from '@/lib/event'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getHttpRelayListFromEvent, getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { LoginRequiredError } from '@/lib/nostr-errors' import { LoginRequiredError } from '@/lib/nostr-errors'
@ -28,6 +29,7 @@ import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import client from '@/services/client.service' import client from '@/services/client.service'
import { ReplaceableEventService } from '@/services/client-replaceable-events.service'
import { queryService, replaceableEventService } from '@/services/client.service' import { queryService, replaceableEventService } from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import indexedDb from '@/services/indexed-db.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 [nip07RecoveryBump, setNip07RecoveryBump] = useState(0)
const accountForReplaceablesSyncRef = useRef<TAccountPointer | null>(null)
useEffect(() => {
accountForReplaceablesSyncRef.current = account
}, [account])
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
logger.debug('[NostrProvider] Restoring session (login / first account)…') 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) { if (resolvedMutePut && resolvedMutePut.id === muteListEvent.id) {
setMuteListEvent(muteListEvent) 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 (bookmarkListEvent) {
if (resolvedBookmarkPut && resolvedBookmarkPut.id === bookmarkListEvent.id) { if (resolvedBookmarkPut && resolvedBookmarkPut.id === bookmarkListEvent.id) {
@ -799,6 +853,114 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
}, [account, followListEvent, isAccountSessionHydrating]) }, [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(() => { useEffect(() => {
if (!account) return if (!account) return

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

@ -640,6 +640,18 @@ export class ReplaceableEventService {
].map((u) => normalizeUrl(u) || u) ].map((u) => normalizeUrl(u) || u)
) )
).filter(Boolean) ).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) { } 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. // 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). // Mirror contacts + pin-list coverage (writes + profile mirrors + aggregators + fast read).
@ -678,7 +690,9 @@ export class ReplaceableEventService {
kind === 10001 || kind === 10001 ||
kind === ExtendedKind.PAYMENT_INFO || kind === ExtendedKind.PAYMENT_INFO ||
kind === kinds.Contacts || kind === kinds.Contacts ||
kind === kinds.RelayList kind === kinds.RelayList ||
kind === kinds.Mutelist ||
kind === kinds.BookmarkList
const multiAuthorBatch = pubkeys.length > 1 const multiAuthorBatch = pubkeys.length > 1
// replaceableRace + default grace closes the REQ shortly after the first EVENT. For batched kind-0 // 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. // (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<void> {
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<number, NEvent>()
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 =========== * =========== Following Favorite Relays ===========
*/ */

22
src/services/client.service.ts

@ -3151,7 +3151,9 @@ class ClientService extends EventTarget {
try { try {
await Promise.all([ await Promise.all([
this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(unique, kinds.RelayList), 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) { } catch (err) {
if (!options?.force) { 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<void> {
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. */ /** Part of {@link runSessionPrewarm}; batches followings to limit relay load. */
private async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) { private async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) {
const followings = await this.replaceableEventService.fetchFollowings(pubkey) const followings = await this.replaceableEventService.fetchFollowings(pubkey)

Loading…
Cancel
Save