Browse Source

speed up app

imwald
Silberengel 20 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 @@ @@ -1,12 +1,12 @@
{
"name": "imwald",
"version": "23.3.1",
"version": "23.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
"version": "23.3.1",
"version": "23.4.0",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",
@ -47,6 +47,7 @@ @@ -47,6 +47,7 @@
"@radix-ui/react-tabs": "^1.1.2",
"@scure/base": "^2.0.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-virtual": "^3.13.24",
"@tiptap/core": "^2.12.0",
"@tiptap/extension-document": "^2.12.0",
"@tiptap/extension-emoji": "^2.26.1",
@ -5707,6 +5708,33 @@ @@ -5707,6 +5708,33 @@
"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": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",

3
package.json

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

90
src/PageManager.tsx

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

156
src/components/NoteList/VirtualizedFeedRows.tsx

@ -0,0 +1,156 @@ @@ -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 { @@ -77,8 +77,8 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import MediaGridItem from '../MediaGridItem'
import { NoteCardLoadingSkeleton } from '../NoteCard'
import VirtualizedFeedRows from './VirtualizedFeedRows'
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
@ -846,6 +846,7 @@ const NoteList = forwardRef( @@ -846,6 +846,7 @@ const NoteList = forwardRef(
}, [hostPrimaryPageName, primaryPageCurrent, resetFeedClientFilterState])
const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey
const prevSubRequestsKeyForTimelineRef = useRef<string | null>(null)
const feedTimelineScopePrevRef = useRef<string | undefined>(undefined)
/** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */
@ -1258,6 +1259,19 @@ const NoteList = forwardRef( @@ -1258,6 +1259,19 @@ const NoteList = forwardRef(
[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(
() =>
showFeedClientFilter ? applyClientFeedFilter(filteredNewEvents) : filteredNewEvents,
@ -3371,23 +3385,17 @@ const NoteList = forwardRef( @@ -3371,23 +3385,17 @@ const NoteList = forwardRef(
{t('Feed full search empty')}
</div>
) : null}
{gridLayout ? (
<div className="grid grid-cols-3 gap-0.5 pr-4">
{clientFilteredEvents.map((event) => (
<MediaGridItem key={event.id} event={event} />
))}
</div>
) : (
clientFilteredEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(event.id)}
/>
))
)}
{clientFilteredEvents.length > 0 ? (
<VirtualizedFeedRows
events={clientFilteredEvents}
gridLayout={gridLayout}
filterMutedNotes={filterMutedNotes}
eventReasonLabelMap={eventReasonLabelMap}
useWindowScroll={feedVirtualScrollParent === null}
scrollElement={feedVirtualScrollParent}
scrollMarginTop={feedVirtualScrollMarginTop}
/>
) : null}
{listSourceEvents.length === 0 &&
!feedFullSearchActive &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (

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

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

193
src/providers/NostrProvider/index.tsx

@ -49,8 +49,9 @@ import dayjs from 'dayjs' @@ -49,8 +49,9 @@ import dayjs from 'dayjs'
import { Event, kinds, VerifiedEvent, getEventHash, validateEvent } from 'nostr-tools'
import * as nip19 from 'nostr-tools/nip19'
import * as nip49 from 'nostr-tools/nip49'
import { NostrContext } from '@/providers/nostr-context'
import { useCallback, useEffect, useRef, useState } from 'react'
import { NostrContext, type TNostrContext } from '@/providers/nostr-context'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEventCallback } from '@/hooks/use-event-callback'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { BunkerSigner } from './bunker.signer'
@ -1573,60 +1574,142 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1573,60 +1574,142 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
})
}, [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 (
<NostrContext.Provider
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
}}
>
<NostrContext.Provider value={nostrContextValue}>
{children}
<LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} />
<NcryptsecPasswordPrompt open={ncryptsecPasswordOpen} onResult={finishNcryptsecPasswordPrompt} />

23
src/services/client.service.ts

@ -2470,12 +2470,13 @@ class ClientService extends EventTarget { @@ -2470,12 +2470,13 @@ class ClientService extends EventTarget {
/**
* 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).
* 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 = () => {
if (eosedAt) return
const emit = () => {
streamFlushMicrotask = false
streamFlushRafId = null
if (eosedAt) return
if (needSort) {
const sorted = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
@ -2485,13 +2486,15 @@ class ClientService extends EventTarget { @@ -2485,13 +2486,15 @@ class ClientService extends EventTarget {
}
}
if (events.length <= 1) {
streamFlushMicrotask = false
if (streamFlushRafId != null) {
cancelAnimationFrame(streamFlushRafId)
streamFlushRafId = null
}
emit()
return
}
if (!streamFlushMicrotask) {
streamFlushMicrotask = true
queueMicrotask(emit)
if (streamFlushRafId == null) {
streamFlushRafId = requestAnimationFrame(emit)
}
}
@ -2627,6 +2630,10 @@ class ClientService extends EventTarget { @@ -2627,6 +2630,10 @@ class ClientService extends EventTarget {
if (eosedAt != null) return
clearFirstResultGraceTimer()
if (streamFlushRafId != null) {
cancelAnimationFrame(streamFlushRafId)
streamFlushRafId = null
}
eosedAt = dayjs().unix()
@ -2713,6 +2720,10 @@ class ClientService extends EventTarget { @@ -2713,6 +2720,10 @@ class ClientService extends EventTarget {
timelineKey: key,
closer: () => {
clearFirstResultGraceTimer()
if (streamFlushRafId != null) {
cancelAnimationFrame(streamFlushRafId)
streamFlushRafId = null
}
clearHttpTimelinePoll()
onEvents = () => {}
onNew = () => {}

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

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

Loading…
Cancel
Save