Browse Source

speed up app

imwald
Silberengel 21 hours ago
parent
commit
5df1d0b346
  1. 32
      package-lock.json
  2. 3
      package.json
  3. 90
      src/PageManager.tsx
  4. 156
      src/components/NoteList/VirtualizedFeedRows.tsx
  5. 46
      src/components/NoteList/index.tsx
  6. 11
      src/hooks/use-event-callback.ts
  7. 11
      src/providers/DeepBrowsingProvider.tsx
  8. 193
      src/providers/NostrProvider/index.tsx
  9. 23
      src/services/client.service.ts
  10. 46
      src/services/indexed-db.service.ts

32
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.3.1", "version": "23.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.3.1", "version": "23.4.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",
@ -47,6 +47,7 @@
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@scure/base": "^2.0.0", "@scure/base": "^2.0.0",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-virtual": "^3.13.24",
"@tiptap/core": "^2.12.0", "@tiptap/core": "^2.12.0",
"@tiptap/extension-document": "^2.12.0", "@tiptap/extension-document": "^2.12.0",
"@tiptap/extension-emoji": "^2.26.1", "@tiptap/extension-emoji": "^2.26.1",
@ -5707,6 +5708,33 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.14.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",

3
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.3.1", "version": "23.4.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",
@ -77,6 +77,7 @@
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@scure/base": "^2.0.0", "@scure/base": "^2.0.0",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tanstack/react-virtual": "^3.13.24",
"@tiptap/core": "^2.12.0", "@tiptap/core": "^2.12.0",
"@tiptap/extension-document": "^2.12.0", "@tiptap/extension-document": "^2.12.0",
"@tiptap/extension-emoji": "^2.26.1", "@tiptap/extension-emoji": "^2.26.1",

90
src/PageManager.tsx

@ -37,6 +37,7 @@ import {
useRef, useRef,
useState useState
} from 'react' } from 'react'
import { useEventCallback } from '@/hooks/use-event-callback'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp' import { KeyboardShortcutsHelpProvider } from '@/components/KeyboardShortcutsHelp'
@ -233,6 +234,27 @@ function mergePrimaryPageEntry(
return [...prev, { name: entry.name, element, props: entry.props }] return [...prev, { name: entry.name, element, props: entry.props }]
} }
function renderActivePrimaryPageContent(
primaryPages: TPrimaryPageStateEntry[],
currentPrimaryPage: TPrimaryPageName
): ReactNode {
const entry =
primaryPages.find((p) => p.name === currentPrimaryPage) ??
(primaryPages.length > 0 ? primaryPages[0] : undefined)
if (!entry) return null
try {
logger.debug(`Rendering active primary page: ${entry.name}`)
return entry.props ? applyPrimaryPageProps(entry.element, entry.props) : entry.element
} catch (error) {
logger.error(`Error rendering ${entry.name} component:`, error)
return (
<div>
Error rendering {entry.name}: {error instanceof Error ? error.message : String(error)}
</div>
)
}
}
export { PrimaryPageContext, usePrimaryPage } export { PrimaryPageContext, usePrimaryPage }
export { useSecondaryPage, useSecondaryPageOptional } export { useSecondaryPage, useSecondaryPageOptional }
@ -987,30 +1009,9 @@ function MainContentArea({
</div> </div>
</div> </div>
) : ( ) : (
// Show normal primary pages <div className="flex h-full min-h-0 w-full min-w-0 flex-col">
primaryPages.map(({ name, element, props }) => { {renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)}
const isCurrentPage = currentPrimaryPage === name </div>
logger.debug(`Primary page ${name}:`, { isCurrentPage, currentPrimaryPage })
return (
<div
key={name}
className={cn(
'flex h-full min-h-0 w-full min-w-0 flex-col',
isCurrentPage ? 'flex' : 'hidden'
)}
>
{(() => {
try {
logger.debug(`Rendering ${name} component`)
return props ? applyPrimaryPageProps(element, props) : element
} catch (error) {
logger.error(`Error rendering ${name} component:`, error)
return <div>Error rendering {name}: {error instanceof Error ? error.message : String(error)}</div>
}
})()}
</div>
)
})
)} )}
</div> </div>
{/* DEPRECATED: Secondary panel removed - double-panel functionality disabled */} {/* DEPRECATED: Secondary panel removed - double-panel functionality disabled */}
@ -1820,6 +1821,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// NEVER scroll to top - feed should maintain scroll position at all times // NEVER scroll to top - feed should maintain scroll position at all times
} }
const navigatePrimaryPageStable = useEventCallback(navigatePrimaryPage)
const goBack = () => { const goBack = () => {
if (primaryViewType === 'settings-sub') { if (primaryViewType === 'settings-sub') {
navigatePrimaryPage('settings') navigatePrimaryPage('settings')
@ -2031,12 +2034,21 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
setSinglePaneSheetOpen(shouldBeOpen) setSinglePaneSheetOpen(shouldBeOpen)
}, [panelMode, isSmallScreen, secondaryStack.length, drawerOpen]) }, [panelMode, isSmallScreen, secondaryStack.length, drawerOpen])
const primaryPageContextValue: PrimaryPageContextValue = { const primaryPageContextValue = useMemo(
navigate: navigatePrimaryPage, (): PrimaryPageContextValue => ({
current: currentPrimaryPage, navigate: navigatePrimaryPageStable,
currentPageProps, current: currentPrimaryPage,
display: isSmallScreen ? secondaryStack.length === 0 : true currentPageProps,
} display: isSmallScreen ? secondaryStack.length === 0 : true
}),
[
navigatePrimaryPageStable,
currentPrimaryPage,
currentPageProps,
isSmallScreen,
secondaryStack.length
]
)
return ( return (
<PrimaryPageContext.Provider value={primaryPageContextValue}> <PrimaryPageContext.Provider value={primaryPageContextValue}>
@ -2049,7 +2061,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
currentIndex: secondaryStack.length currentIndex: secondaryStack.length
? secondaryStack[secondaryStack.length - 1].index ? secondaryStack[secondaryStack.length - 1].index
: 0, : 0,
navigateToPrimaryPage: navigatePrimaryPage navigateToPrimaryPage: navigatePrimaryPageStable
}} }}
> >
<CurrentRelaysProvider> <CurrentRelaysProvider>
@ -2119,17 +2131,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
</div> </div>
) )
})} })}
{primaryPages.map(({ name, element, props }) => ( {secondaryStack.length === 0 ? (
<div <div className="block h-full min-h-0 min-w-0">
key={name} {renderActivePrimaryPageContent(primaryPages, currentPrimaryPage)}
style={{
display:
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
}}
>
{props ? applyPrimaryPageProps(element, props) : element}
</div> </div>
))} ) : null}
</> </>
)} )}
</div> </div>
@ -2165,7 +2171,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
push: pushSecondaryPage, push: pushSecondaryPage,
pop: popSecondaryPage, pop: popSecondaryPage,
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0, currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0,
navigateToPrimaryPage: navigatePrimaryPage navigateToPrimaryPage: navigatePrimaryPageStable
}} }}
> >
<CurrentRelaysProvider> <CurrentRelaysProvider>

156
src/components/NoteList/VirtualizedFeedRows.tsx

@ -0,0 +1,156 @@
import NoteCard from '@/components/NoteCard'
import MediaGridItem from '@/components/MediaGridItem'
import { useVirtualizer, useWindowVirtualizer } from '@tanstack/react-virtual'
import type { Event } from 'nostr-tools'
import { memo } from 'react'
const ESTIMATE_NOTE_ROW_PX = 280
const ESTIMATE_GRID_ROW_PX = 120
const VIRTUAL_OVERSCAN = 10
export type VirtualizedFeedRowsProps = {
events: Event[]
gridLayout: boolean
filterMutedNotes: boolean
eventReasonLabelMap: Map<string, string>
/** When true, list scrolls with `window`; otherwise `scrollElement` must be set. */
useWindowScroll: boolean
scrollElement: HTMLElement | null
/** Document offset of the list root (window virtualizer scroll margin). */
scrollMarginTop: number
}
const WindowRows = memo(function WindowRows({
events,
gridLayout,
filterMutedNotes,
eventReasonLabelMap,
scrollMarginTop
}: Omit<VirtualizedFeedRowsProps, 'useWindowScroll' | 'scrollElement'>) {
const rowCount = gridLayout ? Math.ceil(events.length / 3) : events.length
const virtualizer = useWindowVirtualizer({
count: rowCount,
estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX),
overscan: VIRTUAL_OVERSCAN,
scrollMargin: scrollMarginTop,
getItemKey: (index) => (gridLayout ? `grid-${index}` : (events[index]?.id ?? `i-${index}`))
})
return (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${vi.start}px)` }}
>
{gridLayout ? (
<div className="grid grid-cols-3 gap-0.5 pr-4">
{events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => (
<MediaGridItem key={event.id} event={event} />
))}
</div>
) : (
<NoteCard
className="w-full"
event={events[vi.index]!}
filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(events[vi.index]!.id)}
/>
)}
</div>
))}
</div>
)
})
const ElementRows = memo(function ElementRows({
events,
gridLayout,
filterMutedNotes,
eventReasonLabelMap,
scrollElement
}: Omit<VirtualizedFeedRowsProps, 'useWindowScroll' | 'scrollMarginTop'> & {
scrollElement: HTMLElement
}) {
const rowCount = gridLayout ? Math.ceil(events.length / 3) : events.length
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollElement,
estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX),
overscan: VIRTUAL_OVERSCAN,
getItemKey: (index) => (gridLayout ? `grid-${index}` : (events[index]?.id ?? `i-${index}`))
})
return (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((vi) => (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${vi.start}px)` }}
>
{gridLayout ? (
<div className="grid grid-cols-3 gap-0.5 pr-4">
{events.slice(vi.index * 3, vi.index * 3 + 3).map((event) => (
<MediaGridItem key={event.id} event={event} />
))}
</div>
) : (
<NoteCard
className="w-full"
event={events[vi.index]!}
filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(events[vi.index]!.id)}
/>
)}
</div>
))}
</div>
)
})
/** Window- or element-scrolling virtual list for feed rows (and 3-column media grid by row). */
export default memo(function VirtualizedFeedRows({
events,
gridLayout,
filterMutedNotes,
eventReasonLabelMap,
useWindowScroll,
scrollElement,
scrollMarginTop
}: VirtualizedFeedRowsProps) {
if (events.length === 0) {
return null
}
if (useWindowScroll) {
return (
<WindowRows
events={events}
gridLayout={gridLayout}
filterMutedNotes={filterMutedNotes}
eventReasonLabelMap={eventReasonLabelMap}
scrollMarginTop={scrollMarginTop}
/>
)
}
if (!scrollElement) {
return null
}
return (
<ElementRows
events={events}
gridLayout={gridLayout}
filterMutedNotes={filterMutedNotes}
eventReasonLabelMap={eventReasonLabelMap}
scrollElement={scrollElement}
/>
)
})

46
src/components/NoteList/index.tsx

@ -77,8 +77,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import { NoteCardLoadingSkeleton } from '../NoteCard'
import MediaGridItem from '../MediaGridItem' import VirtualizedFeedRows from './VirtualizedFeedRows'
const LIMIT = 150 // Per-shard REQ limit for timeline + loadMore (larger batches = fewer round-trips) const LIMIT = 150 // Per-shard REQ limit for timeline + loadMore (larger batches = fewer round-trips)
const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds
@ -846,6 +846,7 @@ const NoteList = forwardRef(
}, [hostPrimaryPageName, primaryPageCurrent, resetFeedClientFilterState]) }, [hostPrimaryPageName, primaryPageCurrent, resetFeedClientFilterState])
const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey
const prevSubRequestsKeyForTimelineRef = useRef<string | null>(null) const prevSubRequestsKeyForTimelineRef = useRef<string | null>(null)
const feedTimelineScopePrevRef = useRef<string | undefined>(undefined) const feedTimelineScopePrevRef = useRef<string | undefined>(undefined)
/** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */ /** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */
@ -1258,6 +1259,19 @@ const NoteList = forwardRef(
[showFeedClientFilter, applyClientFeedFilter, filteredEvents] [showFeedClientFilter, applyClientFeedFilter, filteredEvents]
) )
const [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState<HTMLElement | null>(null)
const [feedVirtualScrollMarginTop, setFeedVirtualScrollMarginTop] = useState(0)
useLayoutEffect(() => {
const root = feedRootRef.current
if (!root) {
setFeedVirtualScrollParent(null)
setFeedVirtualScrollMarginTop(0)
return
}
setFeedVirtualScrollParent(getNearestScrollableAncestor(root))
setFeedVirtualScrollMarginTop(root.offsetTop)
}, [timelineSubscriptionKey, refreshCount, clientFilteredEvents.length])
const clientFilteredNewEvents = useMemo( const clientFilteredNewEvents = useMemo(
() => () =>
showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents, showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents,
@ -3371,23 +3385,17 @@ const NoteList = forwardRef(
{t('Feed full search empty')} {t('Feed full search empty')}
</div> </div>
) : null} ) : null}
{gridLayout ? ( {clientFilteredEvents.length > 0 ? (
<div className="grid grid-cols-3 gap-0.5 pr-4"> <VirtualizedFeedRows
{clientFilteredEvents.map((event) => ( events={clientFilteredEvents}
<MediaGridItem key={event.id} event={event} /> gridLayout={gridLayout}
))} filterMutedNotes={filterMutedNotes}
</div> eventReasonLabelMap={eventReasonLabelMap}
) : ( useWindowScroll={feedVirtualScrollParent === null}
clientFilteredEvents.map((event) => ( scrollElement={feedVirtualScrollParent}
<NoteCard scrollMarginTop={feedVirtualScrollMarginTop}
key={event.id} />
className="w-full" ) : null}
event={event}
filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(event.id)}
/>
))
)}
{listSourceEvents.length === 0 && {listSourceEvents.length === 0 &&
!feedFullSearchActive && !feedFullSearchActive &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? ( (loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (

11
src/hooks/use-event-callback.ts

@ -0,0 +1,11 @@
import { useCallback, useRef } from 'react'
/**
* Stable callback identity with always-fresh implementation (React `useEvent` pattern).
* Avoids stale closures without forcing unrelated consumers to re-render.
*/
export function useEventCallback<A extends readonly unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
const ref = useRef(fn)
ref.current = fn
return useCallback((...args: A) => ref.current(...args), [])
}

11
src/providers/DeepBrowsingProvider.tsx

@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useRef, useState } from 'react' import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
type TDeepBrowsingContext = { type TDeepBrowsingContext = {
deepBrowsing: boolean deepBrowsing: boolean
@ -69,9 +69,10 @@ export function DeepBrowsingProvider({
} }
}, [active, scrollAreaRef]) }, [active, scrollAreaRef])
return ( const value = useMemo(
<DeepBrowsingContext.Provider value={{ deepBrowsing, lastScrollTop }}> () => ({ deepBrowsing, lastScrollTop }),
{children} [deepBrowsing, lastScrollTop]
</DeepBrowsingContext.Provider>
) )
return <DeepBrowsingContext.Provider value={value}>{children}</DeepBrowsingContext.Provider>
} }

193
src/providers/NostrProvider/index.tsx

@ -49,8 +49,9 @@ import dayjs from 'dayjs'
import { Event, kinds, VerifiedEvent, getEventHash, validateEvent } from 'nostr-tools' import { Event, kinds, VerifiedEvent, getEventHash, validateEvent } from 'nostr-tools'
import * as nip19 from 'nostr-tools/nip19' import * as nip19 from 'nostr-tools/nip19'
import * as nip49 from 'nostr-tools/nip49' import * as nip49 from 'nostr-tools/nip49'
import { NostrContext } from '@/providers/nostr-context' import { NostrContext, type TNostrContext } from '@/providers/nostr-context'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEventCallback } from '@/hooks/use-event-callback'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { BunkerSigner } from './bunker.signer' import { BunkerSigner } from './bunker.signer'
@ -1573,60 +1574,142 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}) })
}, [account]) }, [account])
const startLogin = useCallback(() => setOpenLoginDialog(true), [])
const removeAccountStable = useEventCallback(removeAccount)
const switchAccountStable = useEventCallback(switchAccount)
const nsecLoginStable = useEventCallback(nsecLogin)
const ncryptsecLoginStable = useEventCallback(ncryptsecLogin)
const npubLoginStable = useEventCallback(npubLogin)
const nip07LoginStable = useEventCallback(nip07Login)
const bunkerLoginStable = useEventCallback(bunkerLogin)
const nostrConnectionLoginStable = useEventCallback(nostrConnectionLogin)
const publishStable = useEventCallback(publish)
const attemptDeleteStable = useEventCallback(attemptDelete)
const signHttpAuthStable = useEventCallback(signHttpAuth)
const nip04EncryptStable = useEventCallback(nip04Encrypt)
const nip04DecryptStable = useEventCallback(nip04Decrypt)
const checkLoginStable = useEventCallback(checkLogin)
const signEventStable = useEventCallback(signEvent)
const updateRelayListEventStable = useEventCallback(updateRelayListEvent)
const updateCacheRelayListEventStable = useEventCallback(updateCacheRelayListEvent)
const updateHttpRelayListEventStable = useEventCallback(updateHttpRelayListEvent)
const updateProfileEventStable = useEventCallback(updateProfileEvent)
const updateFollowListEventStable = useEventCallback(updateFollowListEvent)
const updateMuteListEventStable = useEventCallback(updateMuteListEvent)
const updateBookmarkListEventStable = useEventCallback(updateBookmarkListEvent)
const updateInterestListEventStable = useEventCallback(updateInterestListEvent)
const updateUserEmojiListEventStable = useEventCallback(updateUserEmojiListEvent)
const updateFavoriteRelaysEventStable = useEventCallback(updateFavoriteRelaysEvent)
const updateBlockedRelaysEventStable = useEventCallback(updateBlockedRelaysEvent)
const updateRssFeedListEventStable = useEventCallback(updateRssFeedListEvent)
const nostrContextValue = useMemo(
(): TNostrContext => ({
isInitialized,
isAccountSessionHydrating,
pubkey: account?.pubkey ?? null,
profile,
profileEvent,
relayList,
cacheRelayListEvent,
httpRelayListEvent,
followListEvent,
muteListEvent,
bookmarkListEvent,
interestListEvent,
favoriteRelaysEvent,
blockedRelaysEvent,
userEmojiListEvent,
rssFeedListEvent,
account,
accounts,
nsec,
ncryptsec,
switchAccount: switchAccountStable,
nsecLogin: nsecLoginStable,
ncryptsecLogin: ncryptsecLoginStable,
nip07Login: nip07LoginStable,
bunkerLogin: bunkerLoginStable,
nostrConnectionLogin: nostrConnectionLoginStable,
npubLogin: npubLoginStable,
removeAccount: removeAccountStable,
publish: publishStable,
attemptDelete: attemptDeleteStable,
signHttpAuth: signHttpAuthStable,
nip04Encrypt: nip04EncryptStable,
nip04Decrypt: nip04DecryptStable,
startLogin,
checkLogin: checkLoginStable,
signEvent: signEventStable,
updateRelayListEvent: updateRelayListEventStable,
updateCacheRelayListEvent: updateCacheRelayListEventStable,
updateHttpRelayListEvent: updateHttpRelayListEventStable,
updateProfileEvent: updateProfileEventStable,
updateFollowListEvent: updateFollowListEventStable,
updateMuteListEvent: updateMuteListEventStable,
updateBookmarkListEvent: updateBookmarkListEventStable,
updateInterestListEvent: updateInterestListEventStable,
updateUserEmojiListEvent: updateUserEmojiListEventStable,
updateFavoriteRelaysEvent: updateFavoriteRelaysEventStable,
updateBlockedRelaysEvent: updateBlockedRelaysEventStable,
updateRssFeedListEvent: updateRssFeedListEventStable,
requestAccountNetworkHydrate
}),
[
isInitialized,
isAccountSessionHydrating,
account,
accounts,
attemptDeleteStable,
blockedRelaysEvent,
bookmarkListEvent,
bunkerLoginStable,
cacheRelayListEvent,
checkLoginStable,
favoriteRelaysEvent,
followListEvent,
httpRelayListEvent,
interestListEvent,
muteListEvent,
ncryptsec,
ncryptsecLoginStable,
nip04DecryptStable,
nip04EncryptStable,
nip07LoginStable,
nostrConnectionLoginStable,
npubLoginStable,
nsec,
nsecLoginStable,
profile,
profileEvent,
publishStable,
relayList,
removeAccountStable,
requestAccountNetworkHydrate,
rssFeedListEvent,
signEventStable,
signHttpAuthStable,
startLogin,
switchAccountStable,
updateBlockedRelaysEventStable,
updateBookmarkListEventStable,
updateCacheRelayListEventStable,
updateFavoriteRelaysEventStable,
updateFollowListEventStable,
updateHttpRelayListEventStable,
updateInterestListEventStable,
updateMuteListEventStable,
updateProfileEventStable,
updateRelayListEventStable,
updateRssFeedListEventStable,
updateUserEmojiListEventStable,
userEmojiListEvent
]
)
return ( return (
<NostrContext.Provider <NostrContext.Provider value={nostrContextValue}>
value={{
isInitialized,
isAccountSessionHydrating,
pubkey: account?.pubkey ?? null,
profile,
profileEvent,
relayList,
cacheRelayListEvent,
httpRelayListEvent,
followListEvent,
muteListEvent,
bookmarkListEvent,
interestListEvent,
favoriteRelaysEvent,
blockedRelaysEvent,
userEmojiListEvent,
rssFeedListEvent,
account,
accounts,
nsec,
ncryptsec,
switchAccount,
nsecLogin,
ncryptsecLogin,
nip07Login,
bunkerLogin,
nostrConnectionLogin,
npubLogin,
removeAccount,
publish,
attemptDelete,
signHttpAuth,
nip04Encrypt,
nip04Decrypt,
startLogin: () => setOpenLoginDialog(true),
checkLogin,
signEvent,
updateRelayListEvent,
updateCacheRelayListEvent,
updateHttpRelayListEvent,
updateProfileEvent,
updateFollowListEvent,
updateMuteListEvent,
updateBookmarkListEvent,
updateInterestListEvent,
updateUserEmojiListEvent,
updateFavoriteRelaysEvent,
updateBlockedRelaysEvent,
updateRssFeedListEvent,
requestAccountNetworkHydrate
}}
>
{children} {children}
<LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} /> <LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} />
<NcryptsecPasswordPrompt open={ncryptsecPasswordOpen} onResult={finishNcryptsecPasswordPrompt} /> <NcryptsecPasswordPrompt open={ncryptsecPasswordOpen} onResult={finishNcryptsecPasswordPrompt} />

23
src/services/client.service.ts

@ -2470,12 +2470,13 @@ class ClientService extends EventTarget {
/** /**
* Stream matching events to the UI immediately. Initial completion is either aggregate `oneose` from all * Stream matching events to the UI immediately. Initial completion is either aggregate `oneose` from all
* relays, or {@link firstRelayResultGraceMs} after the first event (whichever comes first). * relays, or {@link firstRelayResultGraceMs} after the first event (whichever comes first).
* While still before EOSE, coalesce bursts onto one rAF so we do not sort the full buffer on every microtask.
*/ */
let streamFlushMicrotask = false let streamFlushRafId: number | null = null
const flushStreamingSnapshot = () => { const flushStreamingSnapshot = () => {
if (eosedAt) return if (eosedAt) return
const emit = () => { const emit = () => {
streamFlushMicrotask = false streamFlushRafId = null
if (eosedAt) return if (eosedAt) return
if (needSort) { if (needSort) {
const sorted = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) const sorted = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
@ -2485,13 +2486,15 @@ class ClientService extends EventTarget {
} }
} }
if (events.length <= 1) { if (events.length <= 1) {
streamFlushMicrotask = false if (streamFlushRafId != null) {
cancelAnimationFrame(streamFlushRafId)
streamFlushRafId = null
}
emit() emit()
return return
} }
if (!streamFlushMicrotask) { if (streamFlushRafId == null) {
streamFlushMicrotask = true streamFlushRafId = requestAnimationFrame(emit)
queueMicrotask(emit)
} }
} }
@ -2627,6 +2630,10 @@ class ClientService extends EventTarget {
if (eosedAt != null) return if (eosedAt != null) return
clearFirstResultGraceTimer() clearFirstResultGraceTimer()
if (streamFlushRafId != null) {
cancelAnimationFrame(streamFlushRafId)
streamFlushRafId = null
}
eosedAt = dayjs().unix() eosedAt = dayjs().unix()
@ -2713,6 +2720,10 @@ class ClientService extends EventTarget {
timelineKey: key, timelineKey: key,
closer: () => { closer: () => {
clearFirstResultGraceTimer() clearFirstResultGraceTimer()
if (streamFlushRafId != null) {
cancelAnimationFrame(streamFlushRafId)
streamFlushRafId = null
}
clearHttpTimelinePoll() clearHttpTimelinePoll()
onEvents = () => {} onEvents = () => {}
onNew = () => {} onNew = () => {}

46
src/services/indexed-db.service.ts

@ -2798,27 +2798,31 @@ class IndexedDbService {
if (uniq.length === 0) return [] if (uniq.length === 0) return []
await this.initPromise await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return [] if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return []
const out: Event[] = [] return new Promise((resolve, reject) => {
await Promise.all( const out: Event[] = []
uniq.map( const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly')
(id) => const store = tx.objectStore(StoreNames.EVENT_ARCHIVE)
new Promise<void>((resolve, reject) => { let pending = uniq.length
const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') const doneOne = () => {
const get = tx.objectStore(StoreNames.EVENT_ARCHIVE).get(id) pending -= 1
get.onsuccess = () => { if (pending === 0) {
const row = get.result as TArchivedEventRow | undefined tx.commit()
if (row?.value) out.push(row.value) resolve(out)
tx.commit() }
resolve() }
} for (const id of uniq) {
get.onerror = (e) => { const get = store.get(id)
tx.commit() get.onsuccess = () => {
reject(idbEventToError(e)) const row = get.result as TArchivedEventRow | undefined
} if (row?.value) out.push(row.value)
}) doneOne()
) }
) get.onerror = (e) => {
return out tx.commit()
reject(idbEventToError(e))
}
}
})
} }
async deleteArchivedEvent(eventId: string): Promise<void> { async deleteArchivedEvent(eventId: string): Promise<void> {

Loading…
Cancel
Save