Browse Source

speed up profile loading

bug-fixes
imwald
Silberengel 1 month ago
parent
commit
bdc98102a9
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 14
      src/components/NoteList/index.tsx
  4. 3
      src/components/PostEditor/PostContent.tsx
  5. 13
      src/components/PostEditor/PostTextarea/Emoji/suggestion.ts
  6. 13
      src/components/PostEditor/PostTextarea/Mention/suggestion.ts
  7. 33
      src/hooks/useFetchProfile.tsx
  8. 5
      src/providers/NostrProvider/index.tsx
  9. 74
      src/services/client.service.ts

4
package-lock.json generated

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

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "jumble-imwald",
"version": "17.0.0",
"version": "17.0.1",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true,
"type": "module",

14
src/components/NoteList/index.tsx

@ -355,6 +355,20 @@ const NoteList = forwardRef( @@ -355,6 +355,20 @@ const NoteList = forwardRef(
}
}, [loading, hasMore, events, showCount, timelineKey])
// Prefetch profiles for visible authors in one batched request (IndexedDB + one relay request)
const visiblePubkeysRef = useRef<Set<string>>(new Set())
useEffect(() => {
const pubkeys = Array.from(
new Set(filteredEvents.slice(0, 80).map((ev) => ev.pubkey).filter((p) => p?.length === 64))
)
if (pubkeys.length === 0) return
const prev = visiblePubkeysRef.current
const same = pubkeys.length === prev.size && pubkeys.every((p) => prev.has(p))
if (same) return
visiblePubkeysRef.current = new Set(pubkeys)
client.fetchProfilesForPubkeys(pubkeys).catch(() => {})
}, [filteredEvents])
const showNewEvents = () => {
setEvents((oldEvents) => [...newEvents, ...oldEvents])
setNewEvents([])

3
src/components/PostEditor/PostContent.tsx

@ -858,12 +858,15 @@ export default function PostContent({ @@ -858,12 +858,15 @@ export default function PostContent({
close()
} catch (error) {
// AggregateError = "Failed to publish to any relay" is already logged in NostrProvider with relayStatuses; avoid duplicate noise
if (!(error instanceof AggregateError && error.message === 'Failed to publish to any relay')) {
logger.error('Publishing error', { error })
logger.error('Publishing error details', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
})
}
// Check if we have relay statuses to display (even if publishing failed)
if (error instanceof AggregateError && (error as any).relayStatuses) {

13
src/components/PostEditor/PostTextarea/Emoji/suggestion.ts

@ -16,6 +16,7 @@ const suggestion = { @@ -16,6 +16,7 @@ const suggestion = {
let popup: Instance[] = []
let touchListener: (e: TouchEvent) => void
let closePopup: () => void
let exited = false
return {
onBeforeStart: () => {
@ -86,9 +87,17 @@ const suggestion = { @@ -86,9 +87,17 @@ const suggestion = {
},
onExit() {
if (exited) return
exited = true
postEditor.isSuggestionPopupOpen = false
popup[0]?.destroy()
component?.destroy()
if (popup[0]) {
popup[0].destroy()
popup = []
}
if (component) {
component.destroy()
component = undefined
}
document.removeEventListener('touchstart', touchListener)
postEditor.removeEventListener('closeSuggestionPopup', closePopup)

13
src/components/PostEditor/PostTextarea/Mention/suggestion.ts

@ -17,6 +17,7 @@ const suggestion = { @@ -17,6 +17,7 @@ const suggestion = {
let popup: Instance[] = []
let touchListener: (e: TouchEvent) => void
let closePopup: () => void
let exited = false
return {
onBeforeStart: () => {
@ -87,9 +88,17 @@ const suggestion = { @@ -87,9 +88,17 @@ const suggestion = {
},
onExit() {
if (exited) return
exited = true
postEditor.isSuggestionPopupOpen = false
popup[0]?.destroy()
component?.destroy()
if (popup[0]) {
popup[0].destroy()
popup = []
}
if (component) {
component.destroy()
component = undefined
}
document.removeEventListener('touchstart', touchListener)
postEditor.removeEventListener('closeSuggestionPopup', closePopup)

33
src/hooks/useFetchProfile.tsx

@ -12,31 +12,40 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -12,31 +12,40 @@ export function useFetchProfile(id?: string, skipCache = false) {
const [pubkey, setPubkey] = useState<string | null>(null)
useEffect(() => {
if (!id) {
setProfile(null)
setPubkey(null)
const fetchProfile = async () => {
setIsFetching(true)
try {
if (!id) {
setIsFetching(false)
setError(new Error('No id provided'))
return
}
let cancelled = false
const pubkey = userIdToPubkey(id)
setPubkey(pubkey)
const profile = await client.fetchProfile(id, skipCache)
if (profile) {
setProfile(profile)
}
} catch (err) {
setError(err as Error)
const run = async () => {
setIsFetching(true)
try {
const [cachedResult, fetchResult] = await Promise.allSettled([
client.getProfileFromIndexedDB(id),
client.fetchProfile(id, skipCache)
])
if (cancelled) return
const cached = cachedResult.status === 'fulfilled' ? cachedResult.value : undefined
const profile = fetchResult.status === 'fulfilled' ? fetchResult.value : undefined
if (cached) setProfile(cached)
if (profile) setProfile(profile)
if (fetchResult.status === 'rejected' && !cancelled) setError(fetchResult.reason as Error)
} finally {
setIsFetching(false)
if (!cancelled) setIsFetching(false)
}
}
fetchProfile()
run()
return () => {
cancelled = true
}
}, [id])
useEffect(() => {

5
src/providers/NostrProvider/index.tsx

@ -922,7 +922,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -922,7 +922,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
// If publishing failed completely, throw an error so the form doesn't close
if (!publishResult.success) {
logger.error('[Publish] Publishing failed to all relays!', {
relayStatuses: publishResult.relayStatuses
eventKind: event.kind,
eventId: event.id?.substring(0, 8),
relayStatuses: publishResult.relayStatuses,
failedUrls: publishResult.relayStatuses.filter((s) => !s.success).map((s) => s.url)
})
const error = new AggregateError(
publishResult.relayStatuses

74
src/services/client.service.ts

@ -6,12 +6,7 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { @@ -6,12 +6,7 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter {
const { search: _search, ...rest } = f
return rest as Filter
}
import {
compareEvents,
getReplaceableCoordinate,
getReplaceableCoordinateFromEvent,
isReplaceableEvent
} from '@/lib/event'
import { getReplaceableCoordinateFromEvent } from '@/lib/event'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
@ -58,7 +53,6 @@ class ClientService extends EventTarget { @@ -58,7 +53,6 @@ class ClientService extends EventTarget {
| string[]
| undefined
> = {}
private replaceableEventCacheMap = new Map<string, NEvent>()
private eventCacheMap = new Map<string, Promise<NEvent | undefined>>()
private relayListRequestCache = new Map<string, Promise<TRelayList>>() // Cache in-flight relay list requests
private eventDataLoader = new DataLoader<string, NEvent | undefined>(
@ -1342,7 +1336,6 @@ class ClientService extends EventTarget { @@ -1342,7 +1336,6 @@ class ClientService extends EventTarget {
async fetchEvent(id: string): Promise<NEvent | undefined> {
if (!/^[0-9a-f]{64}$/.test(id)) {
let eventId: string | undefined
let coordinate: string | undefined
const { type, data } = nip19.decode(id)
switch (type) {
case 'note':
@ -1352,15 +1345,9 @@ class ClientService extends EventTarget { @@ -1352,15 +1345,9 @@ class ClientService extends EventTarget {
eventId = data.id
break
case 'naddr':
coordinate = getReplaceableCoordinate(data.kind, data.pubkey, data.identifier)
break
}
if (coordinate) {
const cache = this.replaceableEventCacheMap.get(coordinate)
if (cache) {
return cache
}
} else if (eventId) {
if (eventId) {
const cache = this.eventCacheMap.get(eventId)
if (cache) {
return cache
@ -1376,13 +1363,7 @@ class ClientService extends EventTarget { @@ -1376,13 +1363,7 @@ class ClientService extends EventTarget {
delete (cleanEvent as any).relayStatuses
this.eventDataLoader.prime(cleanEvent.id, Promise.resolve(cleanEvent))
if (isReplaceableEvent(cleanEvent.kind)) {
const coordinate = getReplaceableCoordinateFromEvent(cleanEvent)
const cachedEvent = this.replaceableEventCacheMap.get(coordinate)
if (!cachedEvent || compareEvents(cleanEvent, cachedEvent) > 0) {
this.replaceableEventCacheMap.set(coordinate, cleanEvent)
}
}
// Replaceable events are not stored in memory; they go to IndexedDB via putReplaceableEvent elsewhere
}
private async fetchEventById(relayUrls: string[], id: string): Promise<NEvent | undefined> {
@ -1764,6 +1745,53 @@ class ClientService extends EventTarget { @@ -1764,6 +1745,53 @@ class ClientService extends EventTarget {
}
}
/**
* Fetch profiles for many pubkeys in one go: one IndexedDB batch read, one relay request for
* any missing. Deduplicates the input. Use when you have a list of visible pubkeys (e.g. from
* a feed) to avoid N separate profile fetches.
*/
async fetchProfilesForPubkeys(pubkeys: string[]): Promise<TProfile[]> {
const deduped = Array.from(new Set(pubkeys.filter((p) => p && p.length === 64)))
if (deduped.length === 0) return []
const events = await this.fetchReplaceableEventsFromBigRelays(deduped, kinds.Metadata)
const profiles: TProfile[] = []
for (let i = 0; i < deduped.length; i++) {
const ev = events[i]
if (ev) {
this.addUsernameToIndex(ev)
profiles.push(getProfileFromEvent(ev))
} else {
const pubkey = deduped[i]!
profiles.push({
pubkey,
npub: pubkeyToNpub(pubkey) ?? '',
username: formatPubkey(pubkey)
})
}
}
return profiles
}
/** Read profile from IndexedDB only (no network). Use for fast avatar/profile display from cache. */
async getProfileFromIndexedDB(id: string): Promise<TProfile | undefined> {
let pubkey: string | undefined
try {
if (/^[0-9a-f]{64}$/.test(id)) {
pubkey = id
} else {
const { data, type } = nip19.decode(id)
if (type === 'npub') pubkey = data
else if (type === 'nprofile') pubkey = data.pubkey
}
} catch {
return undefined
}
if (!pubkey) return undefined
const event = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
if (!event || event === null) return undefined
return getProfileFromEvent(event)
}
async updateProfileEventCache(event: NEvent) {
await this.updateReplaceableEventFromBigRelaysCache(event)
}
@ -1785,7 +1813,6 @@ class ClientService extends EventTarget { @@ -1785,7 +1813,6 @@ class ClientService extends EventTarget {
* Fixes missing profile pics and broken reactions after "Clear cache" on mobile.
*/
clearInMemoryCaches(): void {
this.replaceableEventCacheMap.clear()
this.relayListRequestCache.clear()
this.eventDataLoader.clearAll()
this.replaceableEventFromBigRelaysDataloader.clearAll()
@ -1822,7 +1849,6 @@ class ClientService extends EventTarget { @@ -1822,7 +1849,6 @@ class ClientService extends EventTarget {
})
throw error
} finally {
// Remove from cache after completion (cache result in replaceableEventCacheMap)
this.relayListRequestCache.delete(pubkey)
}
})()

Loading…
Cancel
Save