diff --git a/src/components/EventPowLabel/index.tsx b/src/components/EventPowLabel/index.tsx index d9c0b21b..a41d7914 100644 --- a/src/components/EventPowLabel/index.tsx +++ b/src/components/EventPowLabel/index.tsx @@ -30,7 +30,7 @@ export default function EventPowLabel({ title={t('Proof of Work')} > - {t('POW: difficulty {{difficulty}}', { difficulty })} + {t('POW {{difficulty}}', { difficulty })} ) } diff --git a/src/components/Note/Superchat.tsx b/src/components/Note/Superchat.tsx index 9d8f79ad..e49586e8 100644 --- a/src/components/Note/Superchat.tsx +++ b/src/components/Note/Superchat.tsx @@ -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({ hasMetaLine && 'mt-1' )} > - - {t('Superchat')} + + {t('Superchat')} {comment ? ( ) : null} - + {showAttestationAction ? ( + + ) : null} ) } diff --git a/src/components/Note/SuperchatCommentMarkdown.tsx b/src/components/Note/SuperchatCommentMarkdown.tsx index e615581f..73b9c6f4 100644 --- a/src/components/Note/SuperchatCommentMarkdown.tsx +++ b/src/components/Note/SuperchatCommentMarkdown.tsx @@ -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 )} /> diff --git a/src/components/Note/SuperchatPaymentMethodLabel.tsx b/src/components/Note/SuperchatPaymentMethodLabel.tsx index d6e44830..55d0455a 100644 --- a/src/components/Note/SuperchatPaymentMethodLabel.tsx +++ b/src/components/Note/SuperchatPaymentMethodLabel.tsx @@ -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({ className )} > - + {label} ) diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index 075504ed..cf6cd10d 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -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({ hasMetaLine && 'mt-1' )} > - - {t('Superchat')} + + {t('Superchat')} {amount != null ? ( - + {formatAmount(amount)} {t('sats')} ) : null} @@ -123,7 +129,9 @@ export default function Zap({ {comment ? ( ) : null} - + {showAttestationAction ? ( + + ) : null} ) } diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 9d02837e..3f074608 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -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({ 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({ 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({ } else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { content = renderEventContent({ hideMetadata: true }) } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { - content = + content = ( + + ) } else if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { - content = + content = ( + + ) } else if (event.kind === ExtendedKind.FOLLOW_PACK) { content = } else if ( diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 452079bf..7a134a6e 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -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' 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({ fetchNoteStatsIfMissing = true, deferAuthorAvatar = false, searchListPreview = false, - seenOnAllowlist + seenOnAllowlist, + showPaymentAttestationAction = false }: { event: Event 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). */ searchListPreview?: boolean seenOnAllowlist?: readonly string[] + showPaymentAttestationAction?: boolean }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() @@ -123,6 +146,8 @@ export default function MainNoteCard({ hideParentNotePreview={hideParentNotePreview} showFull={showFull} deferAuthorAvatar={deferAuthorAvatar} + skipEmbedPrefetch={deferAuthorAvatar} + showPaymentAttestationAction={showPaymentAttestationAction} pinned={pinned} /> diff --git a/src/components/NoteCard/index.tsx b/src/components/NoteCard/index.tsx index 6021c00d..b2bf6025 100644 --- a/src/components/NoteCard/index.tsx +++ b/src/components/NoteCard/index.tsx @@ -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({ deferAuthorAvatar?: boolean searchListPreview?: boolean seenOnAllowlist?: readonly string[] + showPaymentAttestationAction?: boolean }) { const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() @@ -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({ prevProps.fetchNoteStatsIfMissing === nextProps.fetchNoteStatsIfMissing && prevProps.seenOnAllowlist === nextProps.seenOnAllowlist && prevProps.deferAuthorAvatar === nextProps.deferAuthorAvatar && - prevProps.searchListPreview === nextProps.searchListPreview + prevProps.searchListPreview === nextProps.searchListPreview && + prevProps.showPaymentAttestationAction === nextProps.showPaymentAttestationAction ) }) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 47297901..bf506a60 100644 --- a/src/components/NoteList/index.tsx +++ b/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 /** 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 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( * 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( 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( /** 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(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( () => ({ @@ -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( } } 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( 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( } } 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( const reqs = subRequestsRef.current.filter((req) => req.reasonLabel && req.reasonLabel.trim().length > 0) if (!reqs.length || !clientFilteredEvents.length) return new Map() const map = new Map() - 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( } } return map - }, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick]) + }, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick, showCount]) const list = (
@@ -4585,6 +4611,7 @@ const NoteList = forwardRef( bottomNoteLabel={eventReasonLabelMap.get(event.id)} deferAuthorAvatar seenOnAllowlist={homeFeedActiveSeenOnAllowlist} + showPaymentAttestationAction={showPaymentAttestationAction} /> )) )} diff --git a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx index a67972c9..a5b0ed7a 100644 --- a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -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 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() diff --git a/src/constants.ts b/src/constants.ts index a440e136..58a4cc58 100644 --- a/src/constants.ts +++ b/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). */ -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[] = [ diff --git a/src/hooks/usePaymentAttestationStatus.tsx b/src/hooks/usePaymentAttestationStatus.tsx index 9de3a8f8..c3ee212c 100644 --- a/src/hooks/usePaymentAttestationStatus.tsx +++ b/src/hooks/usePaymentAttestationStatus.tsx @@ -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) { } } -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(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) [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(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) 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) 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 { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6c6047d7..0d352b3e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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", diff --git a/src/lib/payment-attestation-cache.ts b/src/lib/payment-attestation-cache.ts new file mode 100644 index 00000000..6614c7e2 --- /dev/null +++ b/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() +const relayFetchByTargetKey = new Map>() + +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 { + 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 { + 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) +} diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 2d1a6866..02ce0da0 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -1108,6 +1108,7 @@ const SpellsPage = forwardRef(function SpellsPage( hideUntrustedNotes={ selectedFauxSpell === 'notifications' ? hideUntrustedNotifications : false } + showPaymentAttestationAction={selectedFauxSpell === 'notifications'} />
diff --git a/src/providers/LiveActivitiesProvider.tsx b/src/providers/LiveActivitiesProvider.tsx index beb87aa0..fbcb6f6a 100644 --- a/src/providers/LiveActivitiesProvider.tsx +++ b/src/providers/LiveActivitiesProvider.tsx @@ -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) diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index f247a5c6..b1cf68e4 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -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>() + + 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 { const foreground = options?.foreground === true - return await new Promise((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((resolve) => { const events: NEvent[] = [] const cancelAbortRegistrations: Array<() => void> = [] const abortHttp = new AbortController() @@ -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 } /**