Browse Source

speed up entire app

imwald
Silberengel 3 weeks ago
parent
commit
a24f12554f
  1. 2
      src/components/EventPowLabel/index.tsx
  2. 15
      src/components/Note/Superchat.tsx
  3. 2
      src/components/Note/SuperchatCommentMarkdown.tsx
  4. 6
      src/components/Note/SuperchatPaymentMethodLabel.tsx
  5. 16
      src/components/Note/Zap.tsx
  6. 22
      src/components/Note/index.tsx
  7. 29
      src/components/NoteCard/MainNoteCard.tsx
  8. 8
      src/components/NoteCard/index.tsx
  9. 241
      src/components/NoteList/index.tsx
  10. 4
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  11. 2
      src/constants.ts
  12. 71
      src/hooks/usePaymentAttestationStatus.tsx
  13. 2
      src/i18n/locales/en.ts
  14. 95
      src/lib/payment-attestation-cache.ts
  15. 1
      src/pages/primary/SpellsPage/index.tsx
  16. 4
      src/providers/LiveActivitiesProvider.tsx
  17. 46
      src/services/client-query.service.ts

2
src/components/EventPowLabel/index.tsx

@ -30,7 +30,7 @@ export default function EventPowLabel({
title={t('Proof of Work')} title={t('Proof of Work')}
> >
<Pickaxe className="size-3.5 shrink-0" strokeWidth={2.5} aria-hidden /> <Pickaxe className="size-3.5 shrink-0" strokeWidth={2.5} aria-hidden />
{t('POW: difficulty {{difficulty}}', { difficulty })} {t('POW {{difficulty}}', { difficulty })}
</span> </span>
) )
} }

15
src/components/Note/Superchat.tsx

@ -16,10 +16,13 @@ import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
export default function Superchat({ export default function Superchat({
event, event,
className className,
showAttestationAction = false
}: { }: {
event: Event event: Event
className?: string className?: string
/** Notifications feed only — attest incoming payments. */
showAttestationAction?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const info = useMemo(() => getPaymentNotificationInfo(event), [event]) const info = useMemo(() => getPaymentNotificationInfo(event), [event])
@ -96,13 +99,19 @@ export default function Superchat({
hasMetaLine && 'mt-1' hasMetaLine && 'mt-1'
)} )}
> >
<SuperchatPaymentMethodLabel paytoType={paytoType} /> <SuperchatPaymentMethodLabel
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span> paytoType={paytoType}
className="px-2.5 py-1.5 text-lg"
imgClassName="size-5"
/>
<span className="text-xl font-semibold text-yellow-400/90">{t('Superchat')}</span>
</div> </div>
{comment ? ( {comment ? (
<SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" /> <SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" />
) : null} ) : null}
{showAttestationAction ? (
<TurnIntoSuperchatButton event={event} prominent className="mt-3" /> <TurnIntoSuperchatButton event={event} prominent className="mt-3" />
) : null}
</div> </div>
) )
} }

2
src/components/Note/SuperchatCommentMarkdown.tsx

@ -23,7 +23,7 @@ export default function SuperchatCommentMarkdown({
hideMetadata hideMetadata
lazyMedia={false} lazyMedia={false}
className={cn( className={cn(
'prose-lg max-w-none text-foreground [&_p]:text-xl [&_p]:font-semibold [&_p]:leading-snug', 'prose-xl max-w-none text-foreground [&_p]:text-[1.6875rem] [&_p]:font-semibold [&_p]:leading-snug',
className className
)} )}
/> />

6
src/components/Note/SuperchatPaymentMethodLabel.tsx

@ -4,11 +4,13 @@ import { cn } from '@/lib/utils'
export default function SuperchatPaymentMethodLabel({ export default function SuperchatPaymentMethodLabel({
paytoType, paytoType,
className className,
imgClassName
}: { }: {
/** Canonical or alias payto type (`lightning`, `monero`, `geyser`, …). */ /** Canonical or alias payto type (`lightning`, `monero`, `geyser`, …). */
paytoType: string paytoType: string
className?: string className?: string
imgClassName?: string
}) { }) {
const canonical = getCanonicalPaytoType(paytoType) const canonical = getCanonicalPaytoType(paytoType)
const label = getPaytoEditorTypeLabel(canonical) const label = getPaytoEditorTypeLabel(canonical)
@ -21,7 +23,7 @@ export default function SuperchatPaymentMethodLabel({
className className
)} )}
> >
<PaytoTypeIcon type={paytoType} /> <PaytoTypeIcon type={paytoType} imgClassName={imgClassName} />
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
</span> </span>
) )

16
src/components/Note/Zap.tsx

@ -18,10 +18,12 @@ import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
export default function Zap({ export default function Zap({
event, event,
className className,
showAttestationAction = false
}: { }: {
event: Event event: Event
className?: string className?: string
showAttestationAction?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const zapInfo = useMemo(() => getZapInfoFromEvent(event), [event]) const zapInfo = useMemo(() => getZapInfoFromEvent(event), [event])
@ -112,10 +114,14 @@ export default function Zap({
hasMetaLine && 'mt-1' hasMetaLine && 'mt-1'
)} )}
> >
<SuperchatPaymentMethodLabel paytoType={paytoType} /> <SuperchatPaymentMethodLabel
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span> paytoType={paytoType}
className="px-2.5 py-1.5 text-lg"
imgClassName="size-5"
/>
<span className="text-xl font-semibold text-yellow-400/90">{t('Superchat')}</span>
{amount != null ? ( {amount != null ? (
<span className="text-lg font-bold tabular-nums tracking-tight text-foreground"> <span className="text-xl font-bold tabular-nums tracking-tight text-foreground">
{formatAmount(amount)} {t('sats')} {formatAmount(amount)} {t('sats')}
</span> </span>
) : null} ) : null}
@ -123,7 +129,9 @@ export default function Zap({
{comment ? ( {comment ? (
<SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" /> <SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" />
) : null} ) : null}
{showAttestationAction ? (
<TurnIntoSuperchatButton event={event} prominent className="mt-3" /> <TurnIntoSuperchatButton event={event} prominent className="mt-3" />
) : null}
</div> </div>
) )
} }

22
src/components/Note/index.tsx

@ -227,6 +227,9 @@ export default function Note({
fullCalendarInvite, fullCalendarInvite,
nip84HighlightEvents, nip84HighlightEvents,
deferAuthorAvatar = false, deferAuthorAvatar = false,
/** When true, parent list already prefetches embeds — skip per-row duplicate fetches. */
skipEmbedPrefetch = false,
showPaymentAttestationAction = false,
pinned = false pinned = false
}: { }: {
event: Event event: Event
@ -245,6 +248,10 @@ export default function Note({
nip84HighlightEvents?: Event[] nip84HighlightEvents?: Event[]
/** When true, defer remote profile avatars until near-viewport (dense lists e.g. merged NIP-50 search). */ /** When true, defer remote profile avatars until near-viewport (dense lists e.g. merged NIP-50 search). */
deferAuthorAvatar?: boolean deferAuthorAvatar?: boolean
/** Skip embedded-note prefetch when the feed list handles it in batch. */
skipEmbedPrefetch?: boolean
/** Notifications feed: show attest-superchat action on incoming payments. */
showPaymentAttestationAction?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
@ -278,8 +285,9 @@ export default function Note({
const displayEvent = useMemo(() => mergeTranslatedNote(event, noteTranslation), [event, noteTranslation]) const displayEvent = useMemo(() => mergeTranslatedNote(event, noteTranslation), [event, noteTranslation])
useLayoutEffect(() => { useLayoutEffect(() => {
if (skipEmbedPrefetch) return
client.prefetchEmbeddedEventsForParents([event]) client.prefetchEmbeddedEventsForParents([event])
}, [event.id]) }, [event.id, skipEmbedPrefetch])
const reactionDisplay = useNotificationReactionDisplay(event) const reactionDisplay = useNotificationReactionDisplay(event)
const webReactionParentUrl = useMemo( const webReactionParentUrl = useMemo(
@ -565,9 +573,17 @@ export default function Note({
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { } else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
content = renderEventContent({ hideMetadata: true }) content = renderEventContent({ hideMetadata: true })
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content = <Zap className="mt-2" event={displayEvent} /> content = (
<Zap className="mt-2" event={displayEvent} showAttestationAction={showPaymentAttestationAction} />
)
} else if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { } else if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
content = <Superchat className="mt-2" event={displayEvent} /> content = (
<Superchat
className="mt-2"
event={displayEvent}
showAttestationAction={showPaymentAttestationAction}
/>
)
} else if (event.kind === ExtendedKind.FOLLOW_PACK) { } else if (event.kind === ExtendedKind.FOLLOW_PACK) {
content = <FollowPackPreview className="mt-2" event={displayEvent} /> content = <FollowPackPreview className="mt-2" event={displayEvent} />
} else if ( } else if (

29
src/components/NoteCard/MainNoteCard.tsx

@ -1,3 +1,4 @@
import { memo } from 'react'
import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' import { ExtendedKind, isNip52CalendarCardKind } from '@/constants'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
@ -13,7 +14,27 @@ import Note from '../Note'
import NoteStats from '../NoteStats' import NoteStats from '../NoteStats'
import RepostDescription from './RepostDescription' import RepostDescription from './RepostDescription'
export default function MainNoteCard({ export default memo(MainNoteCard, (prev, next) => {
return (
prev.event.id === next.event.id &&
prev.event.created_at === next.event.created_at &&
prev.className === next.className &&
prev.reposter === next.reposter &&
prev.embedded === next.embedded &&
prev.originalNoteId === next.originalNoteId &&
prev.pinned === next.pinned &&
prev.hideParentNotePreview === next.hideParentNotePreview &&
prev.bottomNoteLabel === next.bottomNoteLabel &&
prev.showFull === next.showFull &&
prev.fetchNoteStatsIfMissing === next.fetchNoteStatsIfMissing &&
prev.deferAuthorAvatar === next.deferAuthorAvatar &&
prev.searchListPreview === next.searchListPreview &&
prev.seenOnAllowlist === next.seenOnAllowlist &&
prev.showPaymentAttestationAction === next.showPaymentAttestationAction
)
})
function MainNoteCard({
event, event,
className, className,
reposter, reposter,
@ -26,7 +47,8 @@ export default function MainNoteCard({
fetchNoteStatsIfMissing = true, fetchNoteStatsIfMissing = true,
deferAuthorAvatar = false, deferAuthorAvatar = false,
searchListPreview = false, searchListPreview = false,
seenOnAllowlist seenOnAllowlist,
showPaymentAttestationAction = false
}: { }: {
event: Event event: Event
className?: string className?: string
@ -46,6 +68,7 @@ export default function MainNoteCard({
/** Compact row: no stats bar, no separator, no boost badges (e.g. merged NIP-50 search). */ /** Compact row: no stats bar, no separator, no boost badges (e.g. merged NIP-50 search). */
searchListPreview?: boolean searchListPreview?: boolean
seenOnAllowlist?: readonly string[] seenOnAllowlist?: readonly string[]
showPaymentAttestationAction?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
@ -123,6 +146,8 @@ export default function MainNoteCard({
hideParentNotePreview={hideParentNotePreview} hideParentNotePreview={hideParentNotePreview}
showFull={showFull} showFull={showFull}
deferAuthorAvatar={deferAuthorAvatar} deferAuthorAvatar={deferAuthorAvatar}
skipEmbedPrefetch={deferAuthorAvatar}
showPaymentAttestationAction={showPaymentAttestationAction}
pinned={pinned} pinned={pinned}
/> />
</Collapsible> </Collapsible>

8
src/components/NoteCard/index.tsx

@ -19,7 +19,8 @@ const NoteCard = memo(function NoteCard({
fetchNoteStatsIfMissing = true, fetchNoteStatsIfMissing = true,
deferAuthorAvatar = true, deferAuthorAvatar = true,
searchListPreview = false, searchListPreview = false,
seenOnAllowlist seenOnAllowlist,
showPaymentAttestationAction = false
}: { }: {
event: Event event: Event
className?: string className?: string
@ -33,6 +34,7 @@ const NoteCard = memo(function NoteCard({
deferAuthorAvatar?: boolean deferAuthorAvatar?: boolean
searchListPreview?: boolean searchListPreview?: boolean
seenOnAllowlist?: readonly string[] seenOnAllowlist?: readonly string[]
showPaymentAttestationAction?: boolean
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -74,6 +76,7 @@ const NoteCard = memo(function NoteCard({
deferAuthorAvatar={deferAuthorAvatar} deferAuthorAvatar={deferAuthorAvatar}
searchListPreview={searchListPreview} searchListPreview={searchListPreview}
seenOnAllowlist={seenOnAllowlist} seenOnAllowlist={seenOnAllowlist}
showPaymentAttestationAction={showPaymentAttestationAction}
/> />
) )
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
@ -89,7 +92,8 @@ const NoteCard = memo(function NoteCard({
prevProps.fetchNoteStatsIfMissing === nextProps.fetchNoteStatsIfMissing && prevProps.fetchNoteStatsIfMissing === nextProps.fetchNoteStatsIfMissing &&
prevProps.seenOnAllowlist === nextProps.seenOnAllowlist && prevProps.seenOnAllowlist === nextProps.seenOnAllowlist &&
prevProps.deferAuthorAvatar === nextProps.deferAuthorAvatar && prevProps.deferAuthorAvatar === nextProps.deferAuthorAvatar &&
prevProps.searchListPreview === nextProps.searchListPreview prevProps.searchListPreview === nextProps.searchListPreview &&
prevProps.showPaymentAttestationAction === nextProps.showPaymentAttestationAction
) )
}) })

241
src/components/NoteList/index.tsx

@ -129,7 +129,7 @@ if (import.meta.env.DEV && import.meta.hot) {
} }
const SHOW_COUNT = 36 // Initial visible-row quota (filtered); higher = more rows on first paint const SHOW_COUNT = 36 // Initial visible-row quota (filtered); higher = more rows on first paint
/** Extra visible-row quota each time the user reaches the bottom while draining an already-loaded timeline. */ /** Extra visible-row quota each time the user reaches the bottom while draining an already-loaded timeline. */
const REVEAL_BATCH_STEP = 96 const REVEAL_BATCH_STEP = 64
/** /**
* One load more chains relay pages until at least this many **new** events (after kind filter + id de-dupe) are * One load more chains relay pages until at least this many **new** events (after kind filter + id de-dupe) are
* collected, so sparse kind filters do not feel stuck at ~10 rows per scroll. * collected, so sparse kind filters do not feel stuck at ~10 rows per scroll.
@ -154,6 +154,8 @@ const LOAD_MORE_SCROLL_PREFETCH_MIN_PX = 960
const LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS = 180 const LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS = 180
/** When the scroll container is within this many px of the top, auto-merge pending live notes (see {@link NewNotesButton}). */ /** When the scroll container is within this many px of the top, auto-merge pending live notes (see {@link NewNotesButton}). */
const AUTO_MERGE_NEW_EVENTS_TOP_PX = 280 const AUTO_MERGE_NEW_EVENTS_TOP_PX = 280
/** Coalesce live `onNew` timeline updates to one React commit per frame burst. */
const LIVE_ON_NEW_FLUSH_MS = 72
function getNearestScrollableAncestor(node: HTMLElement | null): HTMLElement | null { function getNearestScrollableAncestor(node: HTMLElement | null): HTMLElement | null {
if (!node) return null if (!node) return null
@ -761,7 +763,9 @@ const NoteList = forwardRef(
* When set and the timeline is empty (after relays finish), show a link to Alexandria with a matching query * When set and the timeline is empty (after relays finish), show a link to Alexandria with a matching query
* (hashtag / d-tag browse from {@link NormalFeed}). * (hashtag / d-tag browse from {@link NormalFeed}).
*/ */
alexandriaEmptyUrl = null alexandriaEmptyUrl = null,
/** Notifications feed: show attest-superchat bar on incoming payment cards. */
showPaymentAttestationAction = false
}: { }: {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
showKinds: number[] showKinds: number[]
@ -824,6 +828,7 @@ const NoteList = forwardRef(
relayAuthoritativeFeedOnly?: boolean relayAuthoritativeFeedOnly?: boolean
/** Optional Alexandria `/events` URL when this feed’s timeline is empty (search / tag browse). */ /** Optional Alexandria `/events` URL when this feed’s timeline is empty (search / tag browse). */
alexandriaEmptyUrl?: string | null alexandriaEmptyUrl?: string | null
showPaymentAttestationAction?: boolean
}, },
ref ref
) => { ) => {
@ -949,6 +954,18 @@ const NoteList = forwardRef(
/** Dedupes layout-time pending sync so a new `events` array reference alone cannot loop setState. */ /** Dedupes layout-time pending sync so a new `events` array reference alone cannot loop setState. */
const lastProfilePrefetchPubkeysKeyRef = useRef('') const lastProfilePrefetchPubkeysKeyRef = useRef('')
const clientFilteredVisibleCountRef = useRef(0) const clientFilteredVisibleCountRef = useRef(0)
const liveOnNewPendingRef = useRef<
Array<{ event: Event; route: 'profile' | 'home' | 'pending' }>
>([])
const liveOnNewFlushTimerRef = useRef<number | null>(null)
const liveOnNewFlushRef = useRef<() => void>(() => {})
const scheduleLiveOnNewFlush = useCallback(() => {
if (liveOnNewFlushTimerRef.current != null) return
liveOnNewFlushTimerRef.current = window.setTimeout(() => {
liveOnNewFlushTimerRef.current = null
liveOnNewFlushRef.current()
}, LIVE_ON_NEW_FLUSH_MS)
}, [])
const noteFeedProfileContextValue = useMemo<NoteFeedProfileContextValue>( const noteFeedProfileContextValue = useMemo<NoteFeedProfileContextValue>(
() => ({ () => ({
@ -2187,6 +2204,96 @@ const NoteList = forwardRef(
eventMatchesSubRequestFilterWithWindow(event, filter as Filter) eventMatchesSubRequestFilterWithWindow(event, filter as Filter)
) )
liveOnNewFlushRef.current = () => {
if (!effectActive) return
const batch = liveOnNewPendingRef.current.splice(0)
if (batch.length === 0) return
const profileBatch = batch.filter((row) => row.route === 'profile').map((row) => row.event)
const homeBatch = batch.filter((row) => row.route === 'home').map((row) => row.event)
const pendingBatch = batch.filter((row) => row.route === 'pending').map((row) => row.event)
if (profileBatch.length > 0 || homeBatch.length > 0) {
setEvents((oldEvents) => {
let base = timelineMergeBootstrapRef.current ?? oldEvents
let changed = false
const statsOnly: Event[] = []
for (const event of profileBatch) {
if (base.some((e) => e.id === event.id)) continue
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base)
) {
statsOnly.push(event)
continue
}
if (timelineMergeBootstrapRef.current !== null) {
timelineMergeBootstrapRef.current = null
}
base = [event, ...base]
changed = true
}
for (const event of homeBatch) {
if (base.some((e) => e.id === event.id)) continue
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base)
) {
statsOnly.push(event)
continue
}
if (timelineMergeBootstrapRef.current !== null) {
timelineMergeBootstrapRef.current = null
}
const cap = allowKindlessRelayExploreRef.current
? RELAY_EXPLORE_LIMIT
: areAlgoRelays
? ALGO_LIMIT
: LIMIT
base = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(base, [event], cap, areAlgoRelays)
)
changed = true
}
if (statsOnly.length > 0) {
noteStatsService.updateNoteStatsByEvents(statsOnly, undefined)
}
if (!changed) {
return timelineMergeBootstrapRef.current !== null ? base : oldEvents
}
lastEventsForTimelinePrefetchRef.current = base
return base
})
}
if (pendingBatch.length > 0) {
setNewEvents((oldEvents) => {
const pool: Event[] = [...eventsRef.current, ...oldEvents]
const statsOnly: Event[] = []
const kept: Event[] = []
for (const ev of pendingBatch) {
if (
isNip18RepostKind(ev.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(ev), pool)
) {
statsOnly.push(ev)
continue
}
kept.push(ev)
pool.push(ev)
}
if (statsOnly.length > 0) {
noteStatsService.updateNoteStatsByEvents(statsOnly, undefined)
}
if (kept.length === 0) return oldEvents
return [...kept, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
})
}
}
const eventCapEarly = allowKindlessRelayExplore const eventCapEarly = allowKindlessRelayExplore
? RELAY_EXPLORE_LIMIT ? RELAY_EXPLORE_LIMIT
: areAlgoRelays : areAlgoRelays
@ -3157,68 +3264,14 @@ const NoteList = forwardRef(
} }
} }
if (shouldHideEventRef.current(event)) return if (shouldHideEventRef.current(event)) return
if ((pubkey && event.pubkey === pubkey) || eventMatchesProfileTimelineRequest(event)) { const route: 'profile' | 'home' | 'pending' =
setEvents((oldEvents) => { (pubkey && event.pubkey === pubkey) || eventMatchesProfileTimelineRequest(event)
const boot = timelineMergeBootstrapRef.current ? 'profile'
const base = boot !== null ? boot : oldEvents : hostPrimaryPageNameRef.current === 'feed'
if (base.some((e) => e.id === event.id)) { ? 'home'
return boot !== null ? base : oldEvents : 'pending'
} liveOnNewPendingRef.current.push({ event, route })
if ( scheduleLiveOnNewFlush()
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return boot !== null ? base : oldEvents
}
if (boot !== null) {
timelineMergeBootstrapRef.current = null
}
return [event, ...base]
})
} else if (hostPrimaryPageNameRef.current === 'feed') {
// Primary home relay feeds: merge live EVENTs into the timeline immediately. The generic path
// buffered everyone else's notes in `newEvents` until scroll-to-top — that felt like no streaming.
setEvents((oldEvents) => {
const boot = timelineMergeBootstrapRef.current
const base = boot !== null ? boot : oldEvents
if (base.some((e) => e.id === event.id)) {
return boot !== null ? base : oldEvents
}
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), base)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return boot !== null ? base : oldEvents
}
if (boot !== null) {
timelineMergeBootstrapRef.current = null
}
const cap = allowKindlessRelayExploreRef.current
? RELAY_EXPLORE_LIMIT
: areAlgoRelays
? ALGO_LIMIT
: LIMIT
const next = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(base, [event], cap, areAlgoRelays)
)
lastEventsForTimelinePrefetchRef.current = next
return next
})
} else {
setNewEvents((oldEvents) => {
const pool = [...eventsRef.current, ...oldEvents]
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), pool)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return oldEvents
}
return [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
})
}
}, },
}, },
{ {
@ -3277,6 +3330,11 @@ const NoteList = forwardRef(
const snapshotKeyForCleanup = sessionSnapshotIdentityKey const snapshotKeyForCleanup = sessionSnapshotIdentityKey
return () => { return () => {
effectActive = false effectActive = false
if (liveOnNewFlushTimerRef.current != null) {
clearTimeout(liveOnNewFlushTimerRef.current)
liveOnNewFlushTimerRef.current = null
}
liveOnNewPendingRef.current = []
profileLocalPrimingPendingRef.current = false profileLocalPrimingPendingRef.current = false
timelineMergeBootstrapRef.current = null timelineMergeBootstrapRef.current = null
setProgressiveLayersSearching(false) setProgressiveLayersSearching(false)
@ -3519,47 +3577,14 @@ const NoteList = forwardRef(
} }
} }
if (shouldHideEventRef.current(event)) return if (shouldHideEventRef.current(event)) return
if ((pubkey && event.pubkey === pubkey) || eventMatchesProfileDeltaRequest(event)) { const route: 'profile' | 'home' | 'pending' =
setEvents((oldEvents) => { (pubkey && event.pubkey === pubkey) || eventMatchesProfileDeltaRequest(event)
if (oldEvents.some((e) => e.id === event.id)) return oldEvents ? 'profile'
if ( : hostPrimaryPageNameRef.current === 'feed'
isNip18RepostKind(event.kind) && ? 'home'
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), oldEvents) : 'pending'
) { liveOnNewPendingRef.current.push({ event, route })
noteStatsService.updateNoteStatsByEvents([event], undefined) scheduleLiveOnNewFlush()
return oldEvents
}
return [event, ...oldEvents]
})
} else if (hostPrimaryPageNameRef.current === 'feed') {
setEvents((oldEvents) => {
if (oldEvents.some((e) => e.id === event.id)) return oldEvents
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), oldEvents)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return oldEvents
}
const next = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(oldEvents, [event], eventCapDelta, areAlgoRelays)
)
lastEventsForTimelinePrefetchRef.current = next
return next
})
} else {
setNewEvents((oldEvents) => {
const pool = [...eventsRef.current, ...oldEvents]
if (
isNip18RepostKind(event.kind) &&
feedTimelineAlreadyRepresentsNip18Target(getNip18RepostTargetId(event), pool)
) {
noteStatsService.updateNoteStatsByEvents([event], undefined)
return oldEvents
}
return [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
})
}
} }
}, },
{ {
@ -4536,7 +4561,8 @@ const NoteList = forwardRef(
const reqs = subRequestsRef.current.filter((req) => req.reasonLabel && req.reasonLabel.trim().length > 0) const reqs = subRequestsRef.current.filter((req) => req.reasonLabel && req.reasonLabel.trim().length > 0)
if (!reqs.length || !clientFilteredEvents.length) return new Map<string, string>() if (!reqs.length || !clientFilteredEvents.length) return new Map<string, string>()
const map = new Map<string, string>() const map = new Map<string, string>()
for (const event of clientFilteredEvents) { const labelEvents = clientFilteredEvents.slice(0, Math.min(showCount + 24, clientFilteredEvents.length))
for (const event of labelEvents) {
const labels: string[] = [] const labels: string[] = []
for (const req of reqs) { for (const req of reqs) {
if (!eventMatchesSubRequestFilter(event, req.filter as Filter)) continue if (!eventMatchesSubRequestFilter(event, req.filter as Filter)) continue
@ -4554,7 +4580,7 @@ const NoteList = forwardRef(
} }
} }
return map return map
}, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick]) }, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick, showCount])
const list = ( const list = (
<div className="min-h-0 w-full"> <div className="min-h-0 w-full">
@ -4585,6 +4611,7 @@ const NoteList = forwardRef(
bottomNoteLabel={eventReasonLabelMap.get(event.id)} bottomNoteLabel={eventReasonLabelMap.get(event.id)}
deferAuthorAvatar deferAuthorAvatar
seenOnAllowlist={homeFeedActiveSeenOnAllowlist} seenOnAllowlist={homeFeedActiveSeenOnAllowlist}
showPaymentAttestationAction={showPaymentAttestationAction}
/> />
)) ))
)} )}

4
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -28,7 +28,7 @@ import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
/** Global calendar REQ: relays often cap; larger limit reduces “missing” older-published rows for this week. */ /** Global calendar REQ: relays often cap; larger limit reduces “missing” older-published rows for this week. */
const FETCH_LIMIT = 1200 const FETCH_LIMIT = 400
/** Supplementary `authors` REQ: community calls (e.g. Edufeed) may not appear in the global slice. */ /** Supplementary `authors` REQ: community calls (e.g. Edufeed) may not appear in the global slice. */
const FOLLOWING_CALENDAR_AUTHORS_CAP = 200 const FOLLOWING_CALENDAR_AUTHORS_CAP = 200
const FOLLOWING_CALENDAR_AUTHORS_CHUNK = 80 const FOLLOWING_CALENDAR_AUTHORS_CHUNK = 80
@ -37,7 +37,7 @@ const FOLLOWING_CALENDAR_CHUNK_LIMIT = 350
const LIST_MAX_HEIGHT_PX = 240 const LIST_MAX_HEIGHT_PX = 240
const SIDEBAR_CALENDAR_MAX_RELAYS = 24 const SIDEBAR_CALENDAR_MAX_RELAYS = 24
/** Merge session cache so events already loaded in feeds (but missed by this REQ) still appear. */ /** Merge session cache so events already loaded in feeds (but missed by this REQ) still appear. */
const SESSION_CALENDAR_MERGE_CAP = 5000 const SESSION_CALENDAR_MERGE_CAP = 1200
export default function SidebarCalendarWeekWidget() { export default function SidebarCalendarWeekWidget() {
const { t } = useTranslation() const { t } = useTranslation()

2
src/constants.ts

@ -945,7 +945,7 @@ export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter(
) )
/** REQ `limit` for profile page timelines (single feed; narrow with kind filter or 🔍 search). */ /** REQ `limit` for profile page timelines (single feed; narrow with kind filter or 🔍 search). */
export const PROFILE_TIMELINE_REQ_LIMIT = 500 export const PROFILE_TIMELINE_REQ_LIMIT = 200
/** Long-form, wiki, and publication index events for the profile "Articles and Publications" tab. */ /** Long-form, wiki, and publication index events for the profile "Articles and Publications" tab. */
export const PROFILE_PUBLICATIONS_TAB_KINDS: readonly number[] = [ export const PROFILE_PUBLICATIONS_TAB_KINDS: readonly number[] = [

71
src/hooks/usePaymentAttestationStatus.tsx

@ -1,11 +1,15 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import {
findPaymentAttestationForTarget,
getPaymentAttestationTargetId, getPaymentAttestationTargetId,
getSuperchatPaymentRecipientPubkey getSuperchatPaymentRecipientPubkey
} from '@/lib/superchat' } from '@/lib/superchat'
import {
loadPaymentAttestationLocal,
peekCachedPaymentAttestation,
refreshPaymentAttestationFromRelays,
rememberPaymentAttestationFromPublish
} from '@/lib/payment-attestation-cache'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { Event as NostrEvent } from 'nostr-tools' import { Event as NostrEvent } from 'nostr-tools'
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
@ -18,19 +22,7 @@ function attestationFilter(recipientPubkey: string, targetEventId: string) {
} }
} }
function resolveAttestationMatch(
attestations: NostrEvent[],
targetEventId: string,
recipientPubkey: string
): NostrEvent | undefined {
return findPaymentAttestationForTarget(attestations, targetEventId, recipientPubkey)
}
export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) { export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) {
const [attested, setAttested] = useState(false)
const [attestationEvent, setAttestationEvent] = useState<NostrEvent | null>(null)
const [checking, setChecking] = useState(false)
const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null
const targetId = targetEvent?.id?.toLowerCase() const targetId = targetEvent?.id?.toLowerCase()
@ -42,6 +34,18 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
[targetEvent?.id, recipientPubkey] [targetEvent?.id, recipientPubkey]
) )
const cached = useMemo(
() =>
targetEvent?.id && recipientPubkey
? peekCachedPaymentAttestation(targetEvent.id, recipientPubkey)
: undefined,
[targetEvent?.id, recipientPubkey, targetId]
)
const [attested, setAttested] = useState(Boolean(cached))
const [attestationEvent, setAttestationEvent] = useState<NostrEvent | null>(cached ?? null)
const [checking, setChecking] = useState(false)
const applyMatch = useCallback((match: NostrEvent | undefined) => { const applyMatch = useCallback((match: NostrEvent | undefined) => {
if (!match) return if (!match) return
setAttestationEvent(match) setAttestationEvent(match)
@ -55,19 +59,24 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
if (attestation.pubkey.toLowerCase() !== recipientPubkey.toLowerCase()) return if (attestation.pubkey.toLowerCase() !== recipientPubkey.toLowerCase()) return
const attestedId = getPaymentAttestationTargetId(attestation) const attestedId = getPaymentAttestationTargetId(attestation)
if (attestedId?.toLowerCase() !== targetEvent.id.toLowerCase()) return if (attestedId?.toLowerCase() !== targetEvent.id.toLowerCase()) return
rememberPaymentAttestationFromPublish(attestation)
applyMatch(attestation) applyMatch(attestation)
}, },
[applyMatch, recipientPubkey, targetEvent?.id] [applyMatch, recipientPubkey, targetEvent?.id]
) )
useLayoutEffect(() => { useLayoutEffect(() => {
if (!targetEvent?.id || !recipientPubkey) {
setAttested(false) setAttested(false)
setAttestationEvent(null) setAttestationEvent(null)
if (!targetEvent?.id || !recipientPubkey || !filter) return return
}
const sessionHits = client.eventService.getSessionEventsMatchingFilters([filter], 5) const hit = peekCachedPaymentAttestation(targetEvent.id, recipientPubkey)
applyMatch(resolveAttestationMatch(sessionHits, targetEvent.id, recipientPubkey)) if (hit) {
}, [applyMatch, filter, recipientPubkey, targetEvent?.id]) setAttestationEvent(hit)
setAttested(true)
}
}, [recipientPubkey, targetEvent?.id])
useEffect(() => { useEffect(() => {
if (!targetEvent?.id || !recipientPubkey || !filter) return if (!targetEvent?.id || !recipientPubkey || !filter) return
@ -77,20 +86,18 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
void (async () => { void (async () => {
try { try {
const [idbAttestations, localFeedAttestations, relayAttestations] = await Promise.all([ const local = await loadPaymentAttestationLocal(targetEvent.id, recipientPubkey, filter)
indexedDb.getPaymentAttestationsForTargetEvent(targetEvent.id, 20),
client.getLocalFeedEvents([{ urls: [], filter }], { maxMatches: 5 }),
client.fetchEvents([], filter, {
cache: true,
eoseTimeout: 4000,
globalTimeout: 10_000
})
])
if (cancelled) return if (cancelled) return
if (local) {
const merged = [...idbAttestations, ...localFeedAttestations, ...relayAttestations] applyMatch(local)
applyMatch(resolveAttestationMatch(merged, targetEvent.id, recipientPubkey)) return
}
const relay = await refreshPaymentAttestationFromRelays(
targetEvent.id,
recipientPubkey,
filter
)
if (!cancelled) applyMatch(relay)
} catch { } catch {
/* optional */ /* optional */
} finally { } finally {

2
src/i18n/locales/en.ts

@ -1368,7 +1368,7 @@ export default {
"Submit Relay": "Submit Relay", "Submit Relay": "Submit Relay",
Homepage: "Homepage", Homepage: "Homepage",
"Proof of Work (difficulty {{minPow}})": "Proof of Work (difficulty {{minPow}})", "Proof of Work (difficulty {{minPow}})": "Proof of Work (difficulty {{minPow}})",
"POW: difficulty {{difficulty}}": "POW: difficulty {{difficulty}}", "POW {{difficulty}}": "POW {{difficulty}}",
"via {{client}}": "via {{client}}", "via {{client}}": "via {{client}}",
"Auto-load media": "Auto-load media", "Auto-load media": "Auto-load media",
Always: "Always", Always: "Always",

95
src/lib/payment-attestation-cache.ts

@ -0,0 +1,95 @@
import { ExtendedKind } from '@/constants'
import { findPaymentAttestationForTarget } from '@/lib/superchat'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import type { Event as NostrEvent, Filter } from 'nostr-tools'
const attestationByTargetKey = new Map<string, NostrEvent>()
const relayFetchByTargetKey = new Map<string, Promise<NostrEvent[]>>()
export function paymentAttestationCacheKey(targetEventId: string, recipientPubkey: string): string {
return `${targetEventId.trim().toLowerCase()}:${recipientPubkey.trim().toLowerCase()}`
}
export function peekCachedPaymentAttestation(
targetEventId: string,
recipientPubkey: string
): NostrEvent | undefined {
return attestationByTargetKey.get(paymentAttestationCacheKey(targetEventId, recipientPubkey))
}
export function rememberPaymentAttestation(
targetEventId: string,
recipientPubkey: string,
attestation: NostrEvent
): void {
attestationByTargetKey.set(
paymentAttestationCacheKey(targetEventId, recipientPubkey),
attestation
)
}
export function resolvePaymentAttestationFromEvents(
events: NostrEvent[],
targetEventId: string,
recipientPubkey: string
): NostrEvent | undefined {
const match = findPaymentAttestationForTarget(events, targetEventId, recipientPubkey)
if (match) {
rememberPaymentAttestation(targetEventId, recipientPubkey, match)
}
return match
}
export async function loadPaymentAttestationLocal(
targetEventId: string,
recipientPubkey: string,
filter: Filter
): Promise<NostrEvent | undefined> {
const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey)
if (cached) return cached
const sessionHits = client.eventService.getSessionEventsMatchingFilters([filter], 5)
const fromSession = resolvePaymentAttestationFromEvents(sessionHits, targetEventId, recipientPubkey)
if (fromSession) return fromSession
const idbAttestations = await indexedDb.getPaymentAttestationsForTargetEvent(targetEventId, 20)
return resolvePaymentAttestationFromEvents(idbAttestations, targetEventId, recipientPubkey)
}
/** One coalesced relay refresh per payment target (shared by all visible superchat rows). */
export async function refreshPaymentAttestationFromRelays(
targetEventId: string,
recipientPubkey: string,
filter: Filter
): Promise<NostrEvent | undefined> {
const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey)
if (cached) return cached
const key = paymentAttestationCacheKey(targetEventId, recipientPubkey)
let inflight = relayFetchByTargetKey.get(key)
if (!inflight) {
inflight = client
.fetchEvents([], filter, {
cache: true,
eoseTimeout: 2500,
globalTimeout: 6000
})
.finally(() => {
if (relayFetchByTargetKey.get(key) === inflight) {
relayFetchByTargetKey.delete(key)
}
})
relayFetchByTargetKey.set(key, inflight)
}
const relayAttestations = await inflight
return resolvePaymentAttestationFromEvents(relayAttestations, targetEventId, recipientPubkey)
}
export function rememberPaymentAttestationFromPublish(attestation: NostrEvent): void {
if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return
const targetId = attestation.tags.find(([name]) => name === 'e' || name === 'E')?.[1]?.trim().toLowerCase()
if (!targetId || !/^[0-9a-f]{64}$/.test(targetId)) return
rememberPaymentAttestation(targetId, attestation.pubkey, attestation)
}

1
src/pages/primary/SpellsPage/index.tsx

@ -1108,6 +1108,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
hideUntrustedNotes={ hideUntrustedNotes={
selectedFauxSpell === 'notifications' ? hideUntrustedNotifications : false selectedFauxSpell === 'notifications' ? hideUntrustedNotifications : false
} }
showPaymentAttestationAction={selectedFauxSpell === 'notifications'}
/> />
</div> </div>
</> </>

4
src/providers/LiveActivitiesProvider.tsx

@ -86,8 +86,8 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
try { try {
const events = await client.fetchEvents( const events = await client.fetchEvents(
urls, urls,
{ kinds: [...LIVE_ACTIVITY_KINDS], limit: 500 }, { kinds: [...LIVE_ACTIVITY_KINDS], limit: 120 },
{ eoseTimeout: 6000, globalTimeout: 14_000 } { eoseTimeout: 5000, globalTimeout: 10_000 }
) )
const parentByAddress = await resolveParentSpacesForLiveActivities(events, urls, (u, f, o) => const parentByAddress = await resolveParentSpacesForLiveActivities(events, urls, (u, f, o) =>
client.fetchEvents(u, f, o) client.fetchEvents(u, f, o)

46
src/services/client-query.service.ts

@ -281,6 +281,27 @@ export class QueryService {
* feed / prefetch / replaceable fetches yield to search and publish. * feed / prefetch / replaceable fetches yield to search and publish.
*/ */
private backgroundInterruptController = new AbortController() private backgroundInterruptController = new AbortController()
/** Coalesce identical read-only REQs (no per-event callback) for a few seconds. */
private queryInFlightByKey = new Map<string, Promise<NEvent[]>>()
private buildReadQueryDedupKey(
relayUrls: readonly string[],
filters: readonly Filter[],
opts?: { globalTimeout?: number; eoseTimeout?: number }
): string {
const relays = relayUrls
.map((u) => normalizeUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('|')
const filterKey = JSON.stringify(
filters.map((filter) => {
const entries = Object.entries(filter).sort(([a], [b]) => a.localeCompare(b))
return Object.fromEntries(entries)
})
)
return `${relays}::${filterKey}::${opts?.globalTimeout ?? 0}::${opts?.eoseTimeout ?? 0}`
}
/** /**
* Best-effort: abort in-flight {@link query} calls that did not pass `foreground: true`, then reset the token so * Best-effort: abort in-flight {@link query} calls that did not pass `foreground: true`, then reset the token so
@ -493,7 +514,19 @@ export class QueryService {
const foreground = options?.foreground === true const foreground = options?.foreground === true
return await new Promise<NEvent[]>((resolve) => { const dedupKey =
!onevent && !foreground && !immediateReturn && !options?.signal?.aborted
? this.buildReadQueryDedupKey([...wsQueryUrls, ...httpRelayBases], sanitizedFilters, {
globalTimeout,
eoseTimeout
})
: null
if (dedupKey) {
const inflight = this.queryInFlightByKey.get(dedupKey)
if (inflight) return inflight
}
const resultPromise = new Promise<NEvent[]>((resolve) => {
const events: NEvent[] = [] const events: NEvent[] = []
const cancelAbortRegistrations: Array<() => void> = [] const cancelAbortRegistrations: Array<() => void> = []
const abortHttp = new AbortController() const abortHttp = new AbortController()
@ -767,6 +800,17 @@ export class QueryService {
globalTimeoutId = setTimeout(() => resolveWithEvents(), globalTimeout) globalTimeoutId = setTimeout(() => resolveWithEvents(), globalTimeout)
}) })
if (dedupKey) {
this.queryInFlightByKey.set(dedupKey, resultPromise)
void resultPromise.finally(() => {
if (this.queryInFlightByKey.get(dedupKey) === resultPromise) {
this.queryInFlightByKey.delete(dedupKey)
}
})
}
return resultPromise
} }
/** /**

Loading…
Cancel
Save