Browse Source

speed up entire app

imwald
Silberengel 3 weeks ago
parent
commit
a24f12554f
  1. 2
      src/components/EventPowLabel/index.tsx
  2. 17
      src/components/Note/Superchat.tsx
  3. 2
      src/components/Note/SuperchatCommentMarkdown.tsx
  4. 6
      src/components/Note/SuperchatPaymentMethodLabel.tsx
  5. 18
      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. 75
      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({ @@ -30,7 +30,7 @@ export default function EventPowLabel({
title={t('Proof of Work')}
>
<Pickaxe className="size-3.5 shrink-0" strokeWidth={2.5} aria-hidden />
{t('POW: difficulty {{difficulty}}', { difficulty })}
{t('POW {{difficulty}}', { difficulty })}
</span>
)
}

17
src/components/Note/Superchat.tsx

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

2
src/components/Note/SuperchatCommentMarkdown.tsx

@ -23,7 +23,7 @@ export default function SuperchatCommentMarkdown({ @@ -23,7 +23,7 @@ export default function SuperchatCommentMarkdown({
hideMetadata
lazyMedia={false}
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
)}
/>

6
src/components/Note/SuperchatPaymentMethodLabel.tsx

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

18
src/components/Note/Zap.tsx

@ -18,10 +18,12 @@ import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton' @@ -18,10 +18,12 @@ import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
export default function Zap({
event,
className
className,
showAttestationAction = false
}: {
event: Event
className?: string
showAttestationAction?: boolean
}) {
const { t } = useTranslation()
const zapInfo = useMemo(() => getZapInfoFromEvent(event), [event])
@ -112,10 +114,14 @@ export default function Zap({ @@ -112,10 +114,14 @@ export default function Zap({
hasMetaLine && 'mt-1'
)}
>
<SuperchatPaymentMethodLabel paytoType={paytoType} />
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span>
<SuperchatPaymentMethodLabel
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 ? (
<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')}
</span>
) : null}
@ -123,7 +129,9 @@ export default function Zap({ @@ -123,7 +129,9 @@ export default function Zap({
{comment ? (
<SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" />
) : null}
<TurnIntoSuperchatButton event={event} prominent className="mt-3" />
{showAttestationAction ? (
<TurnIntoSuperchatButton event={event} prominent className="mt-3" />
) : null}
</div>
)
}

22
src/components/Note/index.tsx

@ -227,6 +227,9 @@ export default function Note({ @@ -227,6 +227,9 @@ export default function Note({
fullCalendarInvite,
nip84HighlightEvents,
deferAuthorAvatar = false,
/** When true, parent list already prefetches embeds — skip per-row duplicate fetches. */
skipEmbedPrefetch = false,
showPaymentAttestationAction = false,
pinned = false
}: {
event: Event
@ -245,6 +248,10 @@ export default function Note({ @@ -245,6 +248,10 @@ export default function Note({
nip84HighlightEvents?: Event[]
/** When true, defer remote profile avatars until near-viewport (dense lists e.g. merged NIP-50 search). */
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 { navigateToNote } = useSmartNoteNavigationOptional()
@ -278,8 +285,9 @@ export default function Note({ @@ -278,8 +285,9 @@ export default function Note({
const displayEvent = useMemo(() => mergeTranslatedNote(event, noteTranslation), [event, noteTranslation])
useLayoutEffect(() => {
if (skipEmbedPrefetch) return
client.prefetchEmbeddedEventsForParents([event])
}, [event.id])
}, [event.id, skipEmbedPrefetch])
const reactionDisplay = useNotificationReactionDisplay(event)
const webReactionParentUrl = useMemo(
@ -565,9 +573,17 @@ export default function Note({ @@ -565,9 +573,17 @@ export default function Note({
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
content = renderEventContent({ hideMetadata: true })
} 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) {
content = <Superchat className="mt-2" event={displayEvent} />
content = (
<Superchat
className="mt-2"
event={displayEvent}
showAttestationAction={showPaymentAttestationAction}
/>
)
} else if (event.kind === ExtendedKind.FOLLOW_PACK) {
content = <FollowPackPreview className="mt-2" event={displayEvent} />
} else if (

29
src/components/NoteCard/MainNoteCard.tsx

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

8
src/components/NoteCard/index.tsx

@ -19,7 +19,8 @@ const NoteCard = memo(function NoteCard({ @@ -19,7 +19,8 @@ const NoteCard = memo(function NoteCard({
fetchNoteStatsIfMissing = true,
deferAuthorAvatar = true,
searchListPreview = false,
seenOnAllowlist
seenOnAllowlist,
showPaymentAttestationAction = false
}: {
event: Event
className?: string
@ -33,6 +34,7 @@ const NoteCard = memo(function NoteCard({ @@ -33,6 +34,7 @@ const NoteCard = memo(function NoteCard({
deferAuthorAvatar?: boolean
searchListPreview?: boolean
seenOnAllowlist?: readonly string[]
showPaymentAttestationAction?: boolean
}) {
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -74,6 +76,7 @@ const NoteCard = memo(function NoteCard({ @@ -74,6 +76,7 @@ const NoteCard = memo(function NoteCard({
deferAuthorAvatar={deferAuthorAvatar}
searchListPreview={searchListPreview}
seenOnAllowlist={seenOnAllowlist}
showPaymentAttestationAction={showPaymentAttestationAction}
/>
)
}, (prevProps, nextProps) => {
@ -89,7 +92,8 @@ const NoteCard = memo(function NoteCard({ @@ -89,7 +92,8 @@ const NoteCard = memo(function NoteCard({
prevProps.fetchNoteStatsIfMissing === nextProps.fetchNoteStatsIfMissing &&
prevProps.seenOnAllowlist === nextProps.seenOnAllowlist &&
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) { @@ -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
/** 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
* 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 @@ -154,6 +154,8 @@ const LOAD_MORE_SCROLL_PREFETCH_MIN_PX = 960
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}). */
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 {
if (!node) return null
@ -761,7 +763,9 @@ const NoteList = forwardRef( @@ -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
* (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[]
showKinds: number[]
@ -824,6 +828,7 @@ const NoteList = forwardRef( @@ -824,6 +828,7 @@ const NoteList = forwardRef(
relayAuthoritativeFeedOnly?: boolean
/** Optional Alexandria `/events` URL when this feed’s timeline is empty (search / tag browse). */
alexandriaEmptyUrl?: string | null
showPaymentAttestationAction?: boolean
},
ref
) => {
@ -949,6 +954,18 @@ const NoteList = forwardRef( @@ -949,6 +954,18 @@ const NoteList = forwardRef(
/** Dedupes layout-time pending sync so a new `events` array reference alone cannot loop setState. */
const lastProfilePrefetchPubkeysKeyRef = useRef('')
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>(
() => ({
@ -2187,6 +2204,96 @@ const NoteList = forwardRef( @@ -2187,6 +2204,96 @@ const NoteList = forwardRef(
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
? RELAY_EXPLORE_LIMIT
: areAlgoRelays
@ -3157,68 +3264,14 @@ const NoteList = forwardRef( @@ -3157,68 +3264,14 @@ const NoteList = forwardRef(
}
}
if (shouldHideEventRef.current(event)) return
if ((pubkey && event.pubkey === pubkey) || eventMatchesProfileTimelineRequest(event)) {
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
}
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)
})
}
const route: 'profile' | 'home' | 'pending' =
(pubkey && event.pubkey === pubkey) || eventMatchesProfileTimelineRequest(event)
? 'profile'
: hostPrimaryPageNameRef.current === 'feed'
? 'home'
: 'pending'
liveOnNewPendingRef.current.push({ event, route })
scheduleLiveOnNewFlush()
},
},
{
@ -3277,6 +3330,11 @@ const NoteList = forwardRef( @@ -3277,6 +3330,11 @@ const NoteList = forwardRef(
const snapshotKeyForCleanup = sessionSnapshotIdentityKey
return () => {
effectActive = false
if (liveOnNewFlushTimerRef.current != null) {
clearTimeout(liveOnNewFlushTimerRef.current)
liveOnNewFlushTimerRef.current = null
}
liveOnNewPendingRef.current = []
profileLocalPrimingPendingRef.current = false
timelineMergeBootstrapRef.current = null
setProgressiveLayersSearching(false)
@ -3519,47 +3577,14 @@ const NoteList = forwardRef( @@ -3519,47 +3577,14 @@ const NoteList = forwardRef(
}
}
if (shouldHideEventRef.current(event)) return
if ((pubkey && event.pubkey === pubkey) || eventMatchesProfileDeltaRequest(event)) {
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
}
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)
})
}
const route: 'profile' | 'home' | 'pending' =
(pubkey && event.pubkey === pubkey) || eventMatchesProfileDeltaRequest(event)
? 'profile'
: hostPrimaryPageNameRef.current === 'feed'
? 'home'
: 'pending'
liveOnNewPendingRef.current.push({ event, route })
scheduleLiveOnNewFlush()
}
},
{
@ -4536,7 +4561,8 @@ const NoteList = forwardRef( @@ -4536,7 +4561,8 @@ const NoteList = forwardRef(
const reqs = subRequestsRef.current.filter((req) => req.reasonLabel && req.reasonLabel.trim().length > 0)
if (!reqs.length || !clientFilteredEvents.length) return 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[] = []
for (const req of reqs) {
if (!eventMatchesSubRequestFilter(event, req.filter as Filter)) continue
@ -4554,7 +4580,7 @@ const NoteList = forwardRef( @@ -4554,7 +4580,7 @@ const NoteList = forwardRef(
}
}
return map
}, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick])
}, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick, showCount])
const list = (
<div className="min-h-0 w-full">
@ -4585,6 +4611,7 @@ const NoteList = forwardRef( @@ -4585,6 +4611,7 @@ const NoteList = forwardRef(
bottomNoteLabel={eventReasonLabelMap.get(event.id)}
deferAuthorAvatar
seenOnAllowlist={homeFeedActiveSeenOnAllowlist}
showPaymentAttestationAction={showPaymentAttestationAction}
/>
))
)}

4
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -28,7 +28,7 @@ import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage' @@ -28,7 +28,7 @@ import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage'
import { Button } from '@/components/ui/button'
/** 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. */
const FOLLOWING_CALENDAR_AUTHORS_CAP = 200
const FOLLOWING_CALENDAR_AUTHORS_CHUNK = 80
@ -37,7 +37,7 @@ const FOLLOWING_CALENDAR_CHUNK_LIMIT = 350 @@ -37,7 +37,7 @@ const FOLLOWING_CALENDAR_CHUNK_LIMIT = 350
const LIST_MAX_HEIGHT_PX = 240
const SIDEBAR_CALENDAR_MAX_RELAYS = 24
/** 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() {
const { t } = useTranslation()

2
src/constants.ts

@ -945,7 +945,7 @@ export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter( @@ -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). */
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. */
export const PROFILE_PUBLICATIONS_TAB_KINDS: readonly number[] = [

75
src/hooks/usePaymentAttestationStatus.tsx

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

2
src/i18n/locales/en.ts

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

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

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

4
src/providers/LiveActivitiesProvider.tsx

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

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

@ -281,6 +281,27 @@ export class QueryService { @@ -281,6 +281,27 @@ export class QueryService {
* feed / prefetch / replaceable fetches yield to search and publish.
*/
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
@ -493,7 +514,19 @@ export class QueryService { @@ -493,7 +514,19 @@ export class QueryService {
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 cancelAbortRegistrations: Array<() => void> = []
const abortHttp = new AbortController()
@ -767,6 +800,17 @@ export class QueryService { @@ -767,6 +800,17 @@ export class QueryService {
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