Browse Source

speed up app

imwald
Silberengel 22 hours ago
parent
commit
5df1d0b346
  1. 32
      package-lock.json
  2. 3
      package.json
  3. 82
      src/PageManager.tsx
  4. 156
      src/components/NoteList/VirtualizedFeedRows.tsx
  5. 42
      src/components/NoteList/index.tsx
  6. 11
      src/hooks/use-event-callback.ts
  7. 11
      src/providers/DeepBrowsingProvider.tsx
  8. 153
      src/providers/NostrProvider/index.tsx
  9. 23
      src/services/client.service.ts
  10. 24
      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",

82
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
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> </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 => ({
navigate: navigatePrimaryPageStable,
current: currentPrimaryPage, current: currentPrimaryPage,
currentPageProps, currentPageProps,
display: isSmallScreen ? secondaryStack.length === 0 : true 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}
/>
)
})

42
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}
))}
</div>
) : (
clientFilteredEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={filterMutedNotes} filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(event.id)} eventReasonLabelMap={eventReasonLabelMap}
useWindowScroll={feedVirtualScrollParent === null}
scrollElement={feedVirtualScrollParent}
scrollMarginTop={feedVirtualScrollMarginTop}
/> />
)) ) : null}
)}
{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>
} }

153
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,9 +1574,38 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}) })
}, [account]) }, [account])
return ( const startLogin = useCallback(() => setOpenLoginDialog(true), [])
<NostrContext.Provider
value={{ 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, isInitialized,
isAccountSessionHydrating, isAccountSessionHydrating,
pubkey: account?.pubkey ?? null, pubkey: account?.pubkey ?? null,
@ -1596,37 +1626,90 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
accounts, accounts,
nsec, nsec,
ncryptsec, ncryptsec,
switchAccount, switchAccount: switchAccountStable,
nsecLogin, nsecLogin: nsecLoginStable,
ncryptsecLogin, ncryptsecLogin: ncryptsecLoginStable,
nip07Login, nip07Login: nip07LoginStable,
bunkerLogin, bunkerLogin: bunkerLoginStable,
nostrConnectionLogin, nostrConnectionLogin: nostrConnectionLoginStable,
npubLogin, npubLogin: npubLoginStable,
removeAccount, removeAccount: removeAccountStable,
publish, publish: publishStable,
attemptDelete, attemptDelete: attemptDeleteStable,
signHttpAuth, signHttpAuth: signHttpAuthStable,
nip04Encrypt, nip04Encrypt: nip04EncryptStable,
nip04Decrypt, nip04Decrypt: nip04DecryptStable,
startLogin: () => setOpenLoginDialog(true), startLogin,
checkLogin, checkLogin: checkLoginStable,
signEvent, signEvent: signEventStable,
updateRelayListEvent, updateRelayListEvent: updateRelayListEventStable,
updateCacheRelayListEvent, updateCacheRelayListEvent: updateCacheRelayListEventStable,
updateHttpRelayListEvent, updateHttpRelayListEvent: updateHttpRelayListEventStable,
updateProfileEvent, updateProfileEvent: updateProfileEventStable,
updateFollowListEvent, updateFollowListEvent: updateFollowListEventStable,
updateMuteListEvent, updateMuteListEvent: updateMuteListEventStable,
updateBookmarkListEvent, updateBookmarkListEvent: updateBookmarkListEventStable,
updateInterestListEvent, updateInterestListEvent: updateInterestListEventStable,
updateUserEmojiListEvent, updateUserEmojiListEvent: updateUserEmojiListEventStable,
updateFavoriteRelaysEvent, updateFavoriteRelaysEvent: updateFavoriteRelaysEventStable,
updateBlockedRelaysEvent, updateBlockedRelaysEvent: updateBlockedRelaysEventStable,
updateRssFeedListEvent, updateRssFeedListEvent: updateRssFeedListEventStable,
requestAccountNetworkHydrate 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 (
<NostrContext.Provider value={nostrContextValue}>
{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 = () => {}

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

Loading…
Cancel
Save