From 371e6c8f5f9d1821ff60576ee6afa117347c45ca Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 23 May 2026 12:59:20 +0200 Subject: [PATCH] bug-fixes persist attestations and payment notifications to cache --- src/components/Note/Superchat.tsx | 61 +++-- .../Note/SuperchatPaymentMethodLabel.tsx | 27 +- src/components/Note/Zap.tsx | 75 +++--- src/components/NoteOptions/RawEventDialog.tsx | 10 +- src/components/NoteOptions/index.tsx | 34 ++- src/components/NoteOptions/useMenuActions.tsx | 23 +- src/components/PaytoLink/index.tsx | 25 +- src/components/PaytoTypeIcon/index.tsx | 42 +++ src/components/Profile/ProfileBadges.tsx | 11 +- .../Profile/ProfileWallSuperchats.tsx | 12 +- src/components/ReplyNoteList/index.tsx | 116 ++++++-- .../TurnIntoSuperchatButton/index.tsx | 42 ++- src/hooks/usePaymentAttestationStatus.tsx | 12 +- src/hooks/useProfileWall.tsx | 95 +++++-- src/i18n/locales/en.ts | 3 + src/lib/event.ts | 14 +- src/lib/payment-superchat-idb.ts | 76 ++++++ src/lib/superchat.test.ts | 81 ++++++ src/lib/superchat.ts | 102 +++++-- src/providers/NostrProvider/index.tsx | 14 + src/services/client-events.service.ts | 20 ++ src/services/client.service.ts | 49 +++- src/services/event-archive.service.ts | 6 + src/services/indexed-db.service.ts | 253 +++++++++++++++++- 24 files changed, 997 insertions(+), 206 deletions(-) create mode 100644 src/components/PaytoTypeIcon/index.tsx create mode 100644 src/lib/payment-superchat-idb.ts diff --git a/src/components/Note/Superchat.tsx b/src/components/Note/Superchat.tsx index 88afcf6d..6ba8710b 100644 --- a/src/components/Note/Superchat.tsx +++ b/src/components/Note/Superchat.tsx @@ -70,32 +70,39 @@ export default function Superchat({ } if (variant === 'compact') { + const hasMetaLine = + (recipientPubkey && recipientPubkey !== senderPubkey) || hasTarget + return (
-
+
- {t('Superchat')} - {recipientPubkey && recipientPubkey !== senderPubkey ? ( - - {t('to')}{' '} - - - ) : null} - {hasTarget ? ( - - ) : null} + {t('Superchat')}
+ {hasMetaLine ? ( +
+ {recipientPubkey && recipientPubkey !== senderPubkey ? ( + + {t('to')}{' '} + + + ) : null} + {hasTarget ? ( + + ) : null} +
+ ) : null} {comment ? ( -

+

{comment}

) : null} @@ -122,27 +129,27 @@ export default function Superchat({
- +
{!omitSenderHeading && (
- {t('Superchat')} + {t('Superchat')} {recipientPubkey && recipientPubkey !== senderPubkey && ( - <> - {t('to')} + + {t('to')} - + )}
)} {comment ? (
-

+

{comment}

diff --git a/src/components/Note/SuperchatPaymentMethodLabel.tsx b/src/components/Note/SuperchatPaymentMethodLabel.tsx index 016fad23..d6e44830 100644 --- a/src/components/Note/SuperchatPaymentMethodLabel.tsx +++ b/src/components/Note/SuperchatPaymentMethodLabel.tsx @@ -1,12 +1,6 @@ -import { - getCanonicalPaytoType, - getPaytoEditorTypeLabel, - getPaytoIconChar, - getPaytoLogoPath, - isLightningPaytoType -} from '@/lib/payto' +import { getCanonicalPaytoType, getPaytoEditorTypeLabel } from '@/lib/payto' +import PaytoTypeIcon from '@/components/PaytoTypeIcon' import { cn } from '@/lib/utils' -import { Zap as ZapIcon } from 'lucide-react' export default function SuperchatPaymentMethodLabel({ paytoType, @@ -18,27 +12,16 @@ export default function SuperchatPaymentMethodLabel({ }) { const canonical = getCanonicalPaytoType(paytoType) const label = getPaytoEditorTypeLabel(canonical) - const logoPath = getPaytoLogoPath(canonical) - const iconChar = getPaytoIconChar(canonical) - const isLightning = isLightningPaytoType(canonical) return ( - {logoPath ? ( - - ) : isLightning ? ( - - ) : iconChar ? ( - - {iconChar} - - ) : null} + {label} ) diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index 6fbc7b0d..eb19a3e9 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -2,9 +2,9 @@ import { useFetchEvent } from '@/hooks' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { shouldHideInteractions } from '@/lib/event-filtering' import { formatAmount } from '@/lib/lightning' +import { getSuperchatPaytoType } from '@/lib/superchat' import { toNote, toProfile } from '@/lib/link' import { cn } from '@/lib/utils' -import { Zap as ZapIcon } from 'lucide-react' import { Event } from 'nostr-tools' import { useMemo, type MouseEvent } from 'react' import { useTranslation } from 'react-i18next' @@ -77,6 +77,7 @@ export default function Zap({ }, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey]) const { senderPubkey, recipientPubkey, amount, comment } = zapInfo + const paytoType = useMemo(() => getSuperchatPaytoType(event), [event]) const openZapTarget = (e: MouseEvent) => { e.stopPropagation() @@ -92,36 +93,43 @@ export default function Zap({ } if (variant === 'compact') { + const hasMetaLine = + (recipientPubkey && recipientPubkey !== senderPubkey) || isEventZap || isProfileZap + return (
-
- - {t('Superchat')} - {recipientPubkey && recipientPubkey !== senderPubkey && ( - - {t('zapped')}{' '} - - - )} - {(isEventZap || isProfileZap) && ( - - )} +
+ + {t('Superchat')}
+ {hasMetaLine ? ( +
+ {recipientPubkey && recipientPubkey !== senderPubkey && ( + + {t('zapped')}{' '} + + + )} + {(isEventZap || isProfileZap) && ( + + )} +
+ ) : null} {comment ? ( -

+

{comment}

) : null} @@ -156,25 +164,28 @@ export default function Zap({
- +
+ +
{!omitSenderHeading && (
- {t('zapped')} + {t('Superchat')} {recipientPubkey && recipientPubkey !== senderPubkey && ( - <> + + {t('zapped')} - + )}
)} {comment ? (
-

+

{comment}

diff --git a/src/components/NoteOptions/RawEventDialog.tsx b/src/components/NoteOptions/RawEventDialog.tsx index 08ff34ba..d7263956 100644 --- a/src/components/NoteOptions/RawEventDialog.tsx +++ b/src/components/NoteOptions/RawEventDialog.tsx @@ -16,13 +16,17 @@ import logger from '@/lib/logger' export default function RawEventDialog({ event, isOpen, - onClose + onClose, + title }: { event: Event isOpen: boolean onClose: () => void + /** Dialog title; defaults to “Raw Event”. */ + title?: string }) { const { t } = useTranslation() + const dialogTitle = title ?? t('Raw Event') const [wordWrapEnabled, setWordWrapEnabled] = useState(true) const [copied, setCopied] = useState(false) @@ -37,12 +41,12 @@ export default function RawEventDialog({ } return ( - + { if (!open) onClose() }}>
- Raw Event + {dialogTitle} View the raw event data
diff --git a/src/components/NoteOptions/index.tsx b/src/components/NoteOptions/index.tsx index ad34629a..7bfdced3 100644 --- a/src/components/NoteOptions/index.tsx +++ b/src/components/NoteOptions/index.tsx @@ -1,7 +1,12 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' import { Ellipsis } from 'lucide-react' import { Event } from 'nostr-tools' -import { useState, useMemo } from 'react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus' +import { hexPubkeysEqual } from '@/lib/pubkey' +import { isAttestableSuperchatPayment } from '@/lib/superchat' +import { useNostr } from '@/providers/NostrProvider' import { DesktopMenu } from './DesktopMenu' import EditOrCloneEventDialog, { type TEditOrCloneMode } from './EditOrCloneEventDialog' import { MobileMenu } from './MobileMenu' @@ -41,8 +46,11 @@ export default function NoteOptions({ /** Default content when opening the editor (e.g. call invite URL). */ initialDefaultContent?: string | null }) { + const { t } = useTranslation() + const { pubkey } = useNostr() const { isSmallScreen } = useScreenSize() const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false) + const [isAttestationDialogOpen, setIsAttestationDialogOpen] = useState(false) const [isReportDialogOpen, setIsReportDialogOpen] = useState(false) const [editCloneOpen, setEditCloneOpen] = useState(false) const [editCloneMode, setEditCloneMode] = useState('clone') @@ -74,6 +82,15 @@ export default function NoteOptions({ setShowSubMenu(true) } + const attestableEvent = isAttestableSuperchatPayment(event) ? event : undefined + const { attested, attestationEvent, recipientPubkey } = usePaymentAttestationStatus(attestableEvent) + const canViewAttestation = + attested && + attestationEvent != null && + pubkey != null && + recipientPubkey != null && + hexPubkeysEqual(pubkey, recipientPubkey) + const menuActions = useMenuActions({ event, closeDrawer, @@ -87,7 +104,12 @@ export default function NoteOptions({ setEditCloneMode(mode) setEditCloneOpen(true) }, - pinned + pinned, + onViewAttestation: canViewAttestation + ? () => { + queueMicrotask(() => setIsAttestationDialogOpen(true)) + } + : undefined }) const trigger = useMemo( @@ -126,6 +148,14 @@ export default function NoteOptions({ isOpen={isRawEventDialogOpen} onClose={() => setIsRawEventDialogOpen(false)} /> + {attestationEvent ? ( + setIsAttestationDialogOpen(false)} + title={t('Payment attestation')} + /> + ) : null} void /** When the feed already marks this note pinned (e.g. profile pin section). */ pinned?: boolean + /** Opens JSON viewer for the kind 9741 attestation of this payment or zap receipt. */ + onViewAttestation?: () => void } export function useMenuActions({ @@ -130,7 +133,8 @@ export function useMenuActions({ onOpenPublicMessage, onOpenCallInvite, onOpenEditOrClone, - pinned: pinnedInFeed = false + pinned: pinnedInFeed = false, + onViewAttestation }: UseMenuActionsProps) { const { t } = useTranslation() // Use useContext directly to avoid error if provider is not available @@ -1062,9 +1066,21 @@ export function useMenuActions({ closeDrawer() setIsRawEventDialogOpen(true) }, - separator: true + separator: !onViewAttestation }) + if (onViewAttestation) { + actions.push({ + icon: Sparkles, + label: t('View attestation'), + onClick: () => { + closeDrawer() + onViewAttestation() + }, + separator: true + }) + } + // Add export options for article-type events if (isArticleType) { const isMarkdownFormat = event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.NOSTR_SPECIFICATION @@ -1258,7 +1274,8 @@ export function useMenuActions({ canSignEvents, profile, noteTranslationFromMenu, - translateMenuOptions + translateMenuOptions, + onViewAttestation ]) return menuActions diff --git a/src/components/PaytoLink/index.tsx b/src/components/PaytoLink/index.tsx index 15b6bf76..80d4a059 100644 --- a/src/components/PaytoLink/index.tsx +++ b/src/components/PaytoLink/index.tsx @@ -2,15 +2,13 @@ import { ZAP_SENDING_ENABLED } from '@/constants' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import PaytoTypeIcon from '@/components/PaytoTypeIcon' import { parsePaytoUri, buildPaytoUri, getCanonicalPaytoType, getPaytoTypeInfo, - getPaytoIconChar, - getPaytoLogoPath, isKnownPaytoType, - isLightningPaytoType, isZappableLightningPaytoType, flattenPaytoLinkChildText, formatPaytoLinkDisplayText, @@ -18,7 +16,6 @@ import { } from '@/lib/payto' import { NostrEvent } from 'nostr-tools' import PaytoDialog from '@/components/PaytoDialog' -import { HelpCircle } from 'lucide-react' import { URI_LINK_CLASS } from '@/lib/link-styles' import { cn } from '@/lib/utils' import type { PostPaymentContext } from '@/lib/post-payment-context' @@ -76,7 +73,6 @@ export default function PaytoLink({ const { type, authority, raw } = parsed const info = getPaytoTypeInfo(type) const known = isKnownPaytoType(type) - const isLightning = isLightningPaytoType(type) const canZap = ZAP_SENDING_ENABLED && isZappableLightningPaytoType(type) && !!pubkey && !!onOpenZap @@ -102,8 +98,6 @@ export default function PaytoLink({ if (c === 'bitcoin-layer') return 'Bitcoin layer' return c.charAt(0).toUpperCase() + c.slice(1) })() - const logoPath = getPaytoLogoPath(type) - const iconChar = getPaytoIconChar(type) const childText = flattenPaytoLinkChildText(children) const useCompactDisplay = displayFormat === 'compact' && @@ -123,22 +117,7 @@ export default function PaytoLink({ : `${displayLabel}: ${t('Click to open payment options')}` : t('Click to copy address') - const iconEl = ( - - {logoPath ? ( - - ) : iconChar != null ? ( - - {iconChar} - - ) : ( - - )} - - ) + const iconEl = return ( <> diff --git a/src/components/PaytoTypeIcon/index.tsx b/src/components/PaytoTypeIcon/index.tsx new file mode 100644 index 00000000..b3ad8023 --- /dev/null +++ b/src/components/PaytoTypeIcon/index.tsx @@ -0,0 +1,42 @@ +import { + getCanonicalPaytoType, + getPaytoIconChar, + getPaytoLogoPath, + isLightningPaytoType +} from '@/lib/payto' +import { cn } from '@/lib/utils' +import { HelpCircle, Zap as ZapIcon } from 'lucide-react' + +export default function PaytoTypeIcon({ + type, + className, + imgClassName +}: { + type: string + className?: string + imgClassName?: string +}) { + const canonical = getCanonicalPaytoType(type) + const logoPath = getPaytoLogoPath(canonical) + const iconChar = getPaytoIconChar(canonical) + const isLightning = isLightningPaytoType(canonical) + + return ( + + {isLightning ? ( + + ) : logoPath ? ( + + ) : iconChar != null ? ( + + {iconChar} + + ) : ( + + )} + + ) +} diff --git a/src/components/Profile/ProfileBadges.tsx b/src/components/Profile/ProfileBadges.tsx index 3e5fea6f..2241329f 100644 --- a/src/components/Profile/ProfileBadges.tsx +++ b/src/components/Profile/ProfileBadges.tsx @@ -17,11 +17,10 @@ export default function ProfileBadges({ const { t } = useTranslation() const { badges, superchats, isLoading, refresh } = useProfileWall(pubkey, profileEventId) const handleRefresh = () => { + refresh() if (onRefresh) { void onRefresh() - return } - refresh() } if (isLoading && badges.length === 0 && superchats.length === 0) { @@ -37,11 +36,13 @@ export default function ProfileBadges({ return (
+ {badges.length > 0 || superchats.length > 0 ? ( +
+ +
+ ) : null} {badges.length > 0 ? (
-
- -
{badges.map((badge) => (
- {superchats.map((event) => ( - - ))} + {superchats.map((event) => + event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? ( + + ) : ( + + ) + )}
) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 8f3701f1..a3bd1990 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -55,7 +55,7 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' -import type { TProfile } from '@/types' +import type { TProfile, TSubRequestFilter } from '@/types' import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -78,6 +78,77 @@ const MAX_PARENT_IDS_PER_NESTED_REQ = 64 const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 120 const THREAD_PROFILE_CHUNK = 80 +async function hydrateAttestedSuperchatTargets( + attestedIds: ReadonlySet, + relayUrls: string[] +): Promise { + const ids = [...attestedIds].filter((id) => /^[0-9a-f]{64}$/i.test(id)) + if (ids.length === 0) return [] + + const byId = new Map() + try { + const local = await client.getLocalFeedEvents( + [{ urls: [], filter: { ids, limit: ids.length } }], + { maxMatches: ids.length } + ) + for (const e of local) byId.set(e.id.toLowerCase(), e) + } catch { + /* optional */ + } + + const missing = ids.filter((id) => !byId.has(id.toLowerCase())) + if (missing.length > 0 && relayUrls.length > 0) { + try { + const fetched = await client.fetchEvents( + relayUrls, + { ids: missing, limit: missing.length }, + { cache: true, eoseTimeout: 4500, globalTimeout: 12_000 } + ) + for (const e of fetched) byId.set(e.id.toLowerCase(), e) + } catch { + /* optional */ + } + } + + return [...byId.values()] +} + +async function fetchPaymentAttestationsForRecipient( + recipientPubkey: string, + relayUrls: string[], + options: { foreground?: boolean } = {} +): Promise { + const filter: Filter = { + kinds: [ExtendedKind.PAYMENT_ATTESTATION], + authors: [recipientPubkey], + limit: 500 + } + const byId = new Map() + try { + const local = await client.getLocalFeedEvents( + [{ urls: [], filter: filter as TSubRequestFilter }], + { maxMatches: 500 } + ) + for (const e of local) byId.set(e.id, e) + } catch { + /* optional */ + } + if (relayUrls.length > 0) { + try { + const rows = await client.fetchEvents(relayUrls, filter, { + cache: true, + eoseTimeout: 4500, + globalTimeout: 12_000, + foreground: options.foreground + }) + for (const e of rows) byId.set(e.id, e) + } catch { + /* optional */ + } + } + return [...byId.values()] +} + function replyFeedZapsFirst(sortedNonZapReplies: NEvent[], superchats: NEvent[]) { return replyFeedSuperchatsFirst(sortedNonZapReplies, superchats) } @@ -354,6 +425,7 @@ function ReplyNoteList({ const { pubkey: userPubkey } = useNostr() const { zapReplyThreshold } = useZap() const [attestedPaymentIds, setAttestedPaymentIds] = useState>(() => new Set()) + const threadRelayUrlsRef = useRef([]) const { blockedRelays, favoriteRelays } = useFavoriteRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays() const relayAuthoritativeRead = @@ -386,10 +458,18 @@ function ReplyNoteList({ next.add(targetId) return next }) + void client + .fetchEvent(targetId, { relayHints: threadRelayUrlsRef.current }) + .then((target) => { + if (target) addReplies([target]) + }) + .catch(() => { + /* optional */ + }) } client.addEventListener('newEvent', handleAttestation) return () => client.removeEventListener('newEvent', handleAttestation) - }, [event.pubkey]) + }, [event.pubkey, addReplies]) const replies = useMemo(() => { const replyIdSet = new Set() @@ -1163,25 +1243,21 @@ function ReplyNoteList({ addReplies(mergedForUi) const recipientPubkey = event.pubkey - if (recipientPubkey && relayUrlsForThreadReq.length > 0) { - void client - .fetchEvents( - relayUrlsForThreadReq, - { - kinds: [ExtendedKind.PAYMENT_ATTESTATION], - authors: [recipientPubkey], - limit: 500 - }, - { - cache: true, - eoseTimeout: 4500, - globalTimeout: 12_000, - foreground: statsForeground - } - ) - .then((attestations) => { + threadRelayUrlsRef.current = relayUrlsForThreadReq + if (recipientPubkey) { + void fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, { + foreground: statsForeground + }) + .then(async (attestations) => { + if (fetchGeneration !== replyFetchGenRef.current) return + const attestedIds = buildAttestedPaymentIdSet(attestations, recipientPubkey) + setAttestedPaymentIds(attestedIds) + const targets = await hydrateAttestedSuperchatTargets( + attestedIds, + relayUrlsForThreadReq + ) if (fetchGeneration !== replyFetchGenRef.current) return - setAttestedPaymentIds(buildAttestedPaymentIdSet(attestations, recipientPubkey)) + if (targets.length > 0) addReplies(targets) }) .catch(() => { /* attestations optional */ diff --git a/src/components/TurnIntoSuperchatButton/index.tsx b/src/components/TurnIntoSuperchatButton/index.tsx index 16f761be..7537ac42 100644 --- a/src/components/TurnIntoSuperchatButton/index.tsx +++ b/src/components/TurnIntoSuperchatButton/index.tsx @@ -4,9 +4,12 @@ import { LoginRequiredError } from '@/lib/nostr-errors' import { showSimplePublishSuccess } from '@/lib/publishing-feedback' import { getSuperchatAttestationTargetKindValue, - isAttestableSuperchatPayment + getSuperchatPaymentRecipientPubkey, + isAttestableSuperchatPayment, + isIncomingPaymentNotificationOrZapReceipt } from '@/lib/superchat' import { cn } from '@/lib/utils' +import { requestProfileWallRefresh } from '@/hooks/useProfileWall' import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus' import { useNostr } from '@/providers/NostrProvider' import { Sparkles } from 'lucide-react' @@ -25,17 +28,41 @@ export default function TurnIntoSuperchatButton({ /** Full-width call-to-action styling for note cards. */ prominent?: boolean }) { - const { t } = useTranslation() - const { pubkey, publish, checkLogin } = useNostr() - const { attested, checking, recipientPubkey } = usePaymentAttestationStatus(event) - const [publishing, setPublishing] = useState(false) + const { pubkey } = useNostr() - if (!isAttestableSuperchatPayment(event) || !getSuperchatAttestationTargetKindValue(event)) { + if ( + !isAttestableSuperchatPayment(event) || + !getSuperchatAttestationTargetKindValue(event) || + !pubkey || + !isIncomingPaymentNotificationOrZapReceipt(event, pubkey) + ) { return null } - if (!pubkey || !recipientPubkey || recipientPubkey.toLowerCase() !== pubkey.toLowerCase()) { + + return ( + + ) +} + +function TurnIntoSuperchatButtonInner({ + event, + className, + prominent = false +}: { + event: Event + className?: string + prominent?: boolean +}) { + const { t } = useTranslation() + const { publish, checkLogin } = useNostr() + const recipientPubkey = getSuperchatPaymentRecipientPubkey(event) + const { attested, checking } = usePaymentAttestationStatus(event) + const [publishing, setPublishing] = useState(false) + + if (!recipientPubkey) { return null } + if (attested) { return (

(null) const [checking, setChecking] = useState(false) const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null @@ -17,6 +18,7 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) useEffect(() => { setAttested(false) + setAttestationEvent(null) if (!targetEvent?.id || !recipientPubkey) return let cancelled = false @@ -35,8 +37,9 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) ) .then((attestations) => { if (cancelled) return - const ids = buildAttestedPaymentIdSet(attestations, recipientPubkey) - setAttested(ids.has(targetEvent.id.toLowerCase())) + const match = findPaymentAttestationForTarget(attestations, targetEvent.id, recipientPubkey) + setAttestationEvent(match ?? null) + setAttested(Boolean(match)) }) .catch(() => { /* optional */ @@ -60,6 +63,7 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) const attestedId = getPaymentAttestationTargetId(evt) if (attestedId?.toLowerCase() === targetEvent.id.toLowerCase()) { setAttested(true) + setAttestationEvent(evt) } } @@ -67,5 +71,5 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) return () => client.removeEventListener('newEvent', handleAttestation) }, [targetEvent?.id, recipientPubkey]) - return { attested, checking, recipientPubkey } + return { attested, attestationEvent, checking, recipientPubkey } } diff --git a/src/hooks/useProfileWall.tsx b/src/hooks/useProfileWall.tsx index bf60e8e2..ae60e22e 100644 --- a/src/hooks/useProfileWall.tsx +++ b/src/hooks/useProfileWall.tsx @@ -19,10 +19,11 @@ import { type ResolvedProfileBadge } from '@/lib/nip58-profile-badges' import { isDirectProfileWallComment } from '@/lib/profile-wall-comments' -import { filterAttestedProfileWallSuperchats, isProfileWallPaymentNotification } from '@/lib/superchat' +import { filterAttestedProfileWallSuperchats, getPaymentAttestationTargetId } from '@/lib/superchat' import { isValidPubkey, userIdToPubkey } from '@/lib/pubkey' import { normalizeAnyRelayUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import type { TSubRequestFilter } from '@/types' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import client, { replaceableEventService } from '@/services/client.service' import { ReplaceableEventService } from '@/services/client-replaceable-events.service' @@ -90,10 +91,15 @@ function normalizeWallRefreshPubkey(pubkey: string): string | null { return /^[0-9a-f]{64}$/.test(pk) ? pk : null } -/** Invalidate in-memory wall cache and schedule a badge re-fetch (avoids sync window events during React updates). */ +/** Invalidate in-memory wall cache and schedule a re-fetch when a profile wall hook is mounted. */ export function requestProfileWallRefresh(pubkey: string): void { const pk = normalizeWallRefreshPubkey(pubkey) if (!pk) return + for (const key of wallCacheByKey.keys()) { + if (key.startsWith(`${pk}-`) || key.startsWith(`${pubkey.trim().toLowerCase()}-`)) { + wallCacheByKey.delete(key) + } + } const listeners = wallRefreshListenersByPubkey.get(pk) if (!listeners?.size) return for (const listener of listeners) listener() @@ -116,7 +122,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine const cached = wallCacheByKey.get(cacheKey) const hasUsefulWallCache = !!cached && - cached.badges.length > 0 && + (cached.badges.length > 0 || (cached.superchats?.length ?? 0) > 0) && Date.now() - cached.lastUpdated < CACHE_DURATION const pkNormForHydrate = useMemo(() => userIdToPubkey(pubkey) || pubkey, [pubkey]) @@ -178,6 +184,23 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine const pk = normalizeWallRefreshPubkey(pkNormForHydrate) if (!pk) return + const onWallPaymentEvent = (data: globalThis.Event) => { + const evt = (data as CustomEvent).detail + if (!evt) return + if (evt.kind === ExtendedKind.PAYMENT_ATTESTATION) { + if (evt.pubkey.toLowerCase() !== pk) return + if (!getPaymentAttestationTargetId(evt)) return + } else if (evt.kind === ExtendedKind.PAYMENT_NOTIFICATION) { + const recipient = evt.tags.find((t) => t[0] === 'p')?.[1] + if (!recipient || recipient.toLowerCase() !== pk) return + } else { + return + } + bumpWallRefetch() + } + + client.addEventListener('newEvent', onWallPaymentEvent) + const listeners = wallRefreshListenersByPubkey.get(pk) ?? new Set() listeners.add(scheduleManualWallRefetch) wallRefreshListenersByPubkey.set(pk, listeners) @@ -192,6 +215,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine onAuthorReplaceablesRefreshed ) return () => { + client.removeEventListener('newEvent', onWallPaymentEvent) listeners.delete(scheduleManualWallRefetch) if (listeners.size === 0) { wallRefreshListenersByPubkey.delete(pk) @@ -218,7 +242,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine // Do not reuse empty cache (transient abort when secondary panel opens used to cache [] for 5m). if ( mem && - mem.badges.length > 0 && + (mem.badges.length > 0 || (mem.superchats?.length ?? 0) > 0) && Date.now() - mem.lastUpdated < CACHE_DURATION && refreshToken === 0 ) { @@ -299,21 +323,41 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine } setIsLoading(false) - // --- Wall comments (kind 1111) and attested superchats (kind 9740) --- + // --- Wall comments (kind 1111) and attested superchats (9735 / 9740 + 9741) --- let wallComments: Event[] = [] let wallSuperchats: Event[] = [] - const profileId = profileEventId?.trim().toLowerCase() - if (profileId && /^[0-9a-f]{64}$/.test(profileId) && relayUrls.length > 0) { + const profileId = + profileEventId?.trim().toLowerCase() && /^[0-9a-f]{64}$/.test(profileEventId.trim()) + ? profileEventId.trim().toLowerCase() + : undefined + if (relayUrls.length > 0) { const profileCoord = getReplaceableCoordinate(kinds.Metadata, pkNorm, '') const filters: Filter[] = [ - { kinds: [ExtendedKind.COMMENT], '#e': [profileId], limit: 200 }, - { kinds: [ExtendedKind.COMMENT], '#a': [profileCoord], limit: 200 }, { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#p': [pkNorm], limit: 200 }, - { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#e': [profileId], limit: 200 }, - { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#a': [profileCoord], limit: 200 }, + { kinds: [kinds.Zap], '#p': [pkNorm], limit: 200 }, { kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pkNorm], limit: 500 } ] + if (profileId) { + filters.unshift( + { kinds: [ExtendedKind.COMMENT], '#e': [profileId], limit: 200 }, + { kinds: [ExtendedKind.COMMENT], '#a': [profileCoord], limit: 200 } + ) + filters.push( + { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#e': [profileId], limit: 200 }, + { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#a': [profileCoord], limit: 200 }, + { kinds: [kinds.Zap], '#e': [profileId], limit: 200 } + ) + } const pool = new Map() + try { + const localMatches = await client.getLocalFeedEvents( + filters.map((filter) => ({ urls: [], filter: filter as TSubRequestFilter })), + { maxMatches: 800 } + ) + for (const e of localMatches) pool.set(e.id, e) + } catch { + /* ignore */ + } try { const rows = await Promise.all( filters.map((filter) => @@ -332,26 +376,29 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine /* ignore */ } - wallComments = [...pool.values()] - .filter( - (e) => - e.kind === ExtendedKind.COMMENT && - !isEventDeletedRef.current(e) && - isDirectProfileWallComment(e, profileId, pkNorm) - ) - .sort((a, b) => b.created_at - a.created_at) + if (profileId) { + wallComments = [...pool.values()] + .filter( + (e) => + e.kind === ExtendedKind.COMMENT && + !isEventDeletedRef.current(e) && + isDirectProfileWallComment(e, profileId, pkNorm) + ) + .sort((a, b) => b.created_at - a.created_at) + } - const paymentNotifications = [...pool.values()].filter( + const paymentEvents = [...pool.values()].filter( (e) => - e.kind === ExtendedKind.PAYMENT_NOTIFICATION && - !isEventDeletedRef.current(e) && - isProfileWallPaymentNotification(e, pkNorm, profileId) + (e.kind === ExtendedKind.PAYMENT_NOTIFICATION || + e.kind === kinds.Zap || + e.kind === ExtendedKind.ZAP_RECEIPT) && + !isEventDeletedRef.current(e) ) const attestations = [...pool.values()].filter( (e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION ) wallSuperchats = filterAttestedProfileWallSuperchats( - paymentNotifications, + paymentEvents, attestations, pkNorm, profileId diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 441d3a14..6c6047d7 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -84,6 +84,9 @@ export default { "Copy user ID": "Copy user ID", "Send public message": "Send public message", "View raw event": "View raw event", + "View attestation": "View attestation", + "Payment attestation": "Payment attestation", + "Raw Event": "Raw Event", "Edit this event": "Edit this event", "Clone or fork this event": "Clone or fork this event", "Event kind": "Event kind", diff --git a/src/lib/event.ts b/src/lib/event.ts index 86454ab7..fc0baeb9 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -209,8 +209,12 @@ export function getParentETag(event?: Event) { return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E')) } - // Kind 9735: zapped note id is on `e` / `E` (or addressable target on `a` / `A`) - if (event.kind === kinds.Zap) { + // Kind 9735 / 9740: referenced note id is on `e` / `E` (or addressable target on `a` / `A`). + if ( + event.kind === kinds.Zap || + event.kind === ExtendedKind.ZAP_RECEIPT || + event.kind === ExtendedKind.PAYMENT_NOTIFICATION + ) { const firstHex = getFirstHexEventIdFromETags(event.tags) if (firstHex) { return ( @@ -242,7 +246,11 @@ export function getParentETag(event?: Event) { export function getParentATag(event?: Event) { if (!event) return undefined - if (event.kind === kinds.Zap) { + if ( + event.kind === kinds.Zap || + event.kind === ExtendedKind.ZAP_RECEIPT || + event.kind === ExtendedKind.PAYMENT_NOTIFICATION + ) { return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A')) } if ( diff --git a/src/lib/payment-superchat-idb.ts b/src/lib/payment-superchat-idb.ts new file mode 100644 index 00000000..caf56e87 --- /dev/null +++ b/src/lib/payment-superchat-idb.ts @@ -0,0 +1,76 @@ +import { ExtendedKind } from '@/constants' +import { normalizeReplaceableCoordinateString } from '@/lib/event' +import { getPaymentAttestationTargetId, getPaymentNotificationInfo } from '@/lib/superchat' +import type { Event } from 'nostr-tools' + +export type PaymentNotificationIdbRow = { + key: string + value: Event + addedAt: number + recipientPubkey: string + referencedEventId: string + referencedCoordinate: string +} + +export type PaymentAttestationIdbRow = { + key: string + value: Event + addedAt: number + authorPubkey: string + targetEventId: string +} + +function normalizeHexId(id: string | undefined): string { + const t = id?.trim().toLowerCase() ?? '' + return /^[0-9a-f]{64}$/.test(t) ? t : '' +} + +function normalizePubkey(pk: string | undefined): string { + const t = pk?.trim().toLowerCase() ?? '' + return /^[0-9a-f]{64}$/.test(t) ? t : '' +} + +export function paymentNotificationIdbRowFromEvent(ev: Event): PaymentNotificationIdbRow | null { + if (ev.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return null + const info = getPaymentNotificationInfo(ev) + if (!info?.recipientPubkey) return null + const key = normalizeHexId(ev.id) + if (!key) return null + + const clean = { ...ev } as Event + delete (clean as { relayStatuses?: unknown }).relayStatuses + clean.id = key + + return { + key, + value: clean, + addedAt: Date.now(), + recipientPubkey: normalizePubkey(info.recipientPubkey), + referencedEventId: normalizeHexId(info.referencedEventId), + referencedCoordinate: info.referencedCoordinate + ? normalizeReplaceableCoordinateString(info.referencedCoordinate) + : '' + } +} + +export function paymentAttestationIdbRowFromEvent(ev: Event): PaymentAttestationIdbRow | null { + if (ev.kind !== ExtendedKind.PAYMENT_ATTESTATION) return null + const targetEventId = getPaymentAttestationTargetId(ev) + if (!targetEventId) return null + const authorPubkey = normalizePubkey(ev.pubkey) + if (!authorPubkey) return null + const key = normalizeHexId(ev.id) + if (!key) return null + + const clean = { ...ev } as Event + delete (clean as { relayStatuses?: unknown }).relayStatuses + clean.id = key + + return { + key, + value: clean, + addedAt: Date.now(), + authorPubkey, + targetEventId: targetEventId.toLowerCase() + } +} diff --git a/src/lib/superchat.test.ts b/src/lib/superchat.test.ts index ab1917ec..ef20e08c 100644 --- a/src/lib/superchat.test.ts +++ b/src/lib/superchat.test.ts @@ -7,6 +7,7 @@ import { getSuperchatPaytoType, getSuperchatReferenceFetchId, isProfileWallPaymentNotification, + isProfileWallZapReceipt, partitionAttestedSuperchats } from '@/lib/superchat' import { parsePaytoTagType } from '@/lib/payto' @@ -118,6 +119,35 @@ describe('partitionAttestedSuperchats', () => { expect(superchats.map((e) => e.id)).toEqual([payment.id, zapAttested.id]) expect(rest).toEqual([comment]) }) + + it('includes attested zaps below the reply threshold at the top', () => { + const attested = new Set([ZAP_ID]) + const microZap = fakeEvent({ + id: ZAP_ID, + kind: kinds.Zap, + tags: [ + ['P', SENDER], + ['p', RECIPIENT], + ['bolt11', 'lnbc1n1p0fake'], + [ + 'description', + JSON.stringify({ + pubkey: SENDER, + content: 'tiny', + tags: [['p', RECIPIENT], ['amount', '1000']] + }) + ] + ] + }) + const comment = fakeEvent({ + id: '1'.repeat(64), + kind: ExtendedKind.COMMENT, + tags: [['e', '2'.repeat(64)]] + }) + const { superchats, rest } = partitionAttestedSuperchats([microZap, comment], attested, 21) + expect(superchats.map((e) => e.id)).toEqual([ZAP_ID]) + expect(rest).toEqual([comment]) + }) }) describe('getPaymentNotificationInfo', () => { @@ -226,4 +256,55 @@ describe('profile wall payment notifications', () => { expect(out).toHaveLength(1) expect(out[0]!.id).toBe(paymentId) }) + + it('accepts profile-only zap receipt without thread reference', () => { + const evt = fakeEvent({ + kind: kinds.Zap, + tags: [ + ['P', SENDER], + ['p', RECIPIENT], + ['bolt11', 'lnbc210n1p0fake'], + [ + 'description', + JSON.stringify({ + pubkey: SENDER, + content: 'Zap!', + tags: [['p', RECIPIENT], ['amount', '21000']] + }) + ] + ] + }) + expect(isProfileWallZapReceipt(evt, RECIPIENT)).toBe(true) + }) + + it('filters to attested profile wall zap receipts', () => { + const zap = fakeEvent({ + id: ZAP_ID, + kind: kinds.Zap, + tags: [ + ['P', SENDER], + ['p', RECIPIENT], + ['bolt11', 'lnbc210n1p0fake'], + [ + 'description', + JSON.stringify({ + pubkey: SENDER, + content: 'Wall zap', + tags: [['p', RECIPIENT], ['amount', '21000']] + }) + ] + ] + }) + const attestation = fakeEvent({ + kind: ExtendedKind.PAYMENT_ATTESTATION, + pubkey: RECIPIENT, + tags: [ + ['e', ZAP_ID], + ['k', '9735'] + ] + }) + const out = filterAttestedProfileWallSuperchats([zap], [attestation], RECIPIENT) + expect(out).toHaveLength(1) + expect(out[0]!.id).toBe(ZAP_ID) + }) }) diff --git a/src/lib/superchat.ts b/src/lib/superchat.ts index 84a66c2c..217a76c4 100644 --- a/src/lib/superchat.ts +++ b/src/lib/superchat.ts @@ -62,6 +62,24 @@ export function buildAttestedPaymentIdSet( return out } +/** Kind 9741 attestation from `recipientPubkey` for payment event `targetEventId`, if any. */ +export function findPaymentAttestationForTarget( + attestations: Event[], + targetEventId: string, + recipientPubkey: string +): Event | undefined { + const target = targetEventId.trim().toLowerCase() + const recipient = recipientPubkey.trim().toLowerCase() + for (const attestation of attestations) { + if (attestation.pubkey.toLowerCase() !== recipient) continue + const attestedId = getPaymentAttestationTargetId(attestation) + const targetKind = getPaymentAttestationTargetKind(attestation) + if (!attestedId || !targetKind) continue + if (attestedId.toLowerCase() === target) return attestation + } + return undefined +} + export function getPaymentNotificationInfo(event: Event): PaymentNotificationInfo | null { if (event.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return null @@ -170,18 +188,14 @@ export function sortSuperchatsByAmountDesc(events: Event[]): Event[] { export function partitionAttestedSuperchats( items: Event[], attestedIds: Set, - zapReplyThreshold: number + _zapReplyThreshold: number ): { superchats: Event[]; rest: Event[] } { const superchats: Event[] = [] const rest: Event[] = [] for (const e of items) { - if (e.kind === kinds.Zap) { - if ( - isAttestedSuperchat(e, attestedIds) && - getZapInfoFromEvent(e) && - getSuperchatAmountSats(e) >= zapReplyThreshold - ) { + if (e.kind === kinds.Zap || e.kind === ExtendedKind.ZAP_RECEIPT) { + if (isAttestedSuperchat(e, attestedIds) && getZapInfoFromEvent(e)) { superchats.push(e) } continue @@ -202,27 +216,23 @@ export function replyFeedSuperchatsFirst(sortedNonSuperchatReplies: Event[], sup return [...superchats, ...sortedNonSuperchatReplies] } -/** Kind 9740 on a profile wall: `p` is the profile owner and there is no note/thread reference. */ -export function isProfileWallPaymentNotification( - event: Event, +function isProfileWallThreadReference( + referencedEventId: string | undefined, + referencedCoordinate: string | undefined, profilePubkey: string, profileEventId?: string ): boolean { - if (event.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return false - const info = getPaymentNotificationInfo(event) - if (!info || info.recipientPubkey.toLowerCase() !== profilePubkey.toLowerCase()) return false - - if (info.referencedEventId) { + if (referencedEventId) { const profileId = profileEventId?.trim().toLowerCase() - if (profileId && info.referencedEventId === profileId) return true + if (profileId && referencedEventId.toLowerCase() === profileId) return true return false } - if (info.referencedCoordinate) { + if (referencedCoordinate) { const profileCoord = normalizeReplaceableCoordinateString( getReplaceableCoordinate(kinds.Metadata, profilePubkey, '') ) - if (normalizeReplaceableCoordinateString(info.referencedCoordinate) === profileCoord) { + if (normalizeReplaceableCoordinateString(referencedCoordinate) === profileCoord) { return true } return false @@ -231,18 +241,62 @@ export function isProfileWallPaymentNotification( return true } +/** Kind 9740 on a profile wall: `p` is the profile owner and there is no note/thread reference. */ +export function isProfileWallPaymentNotification( + event: Event, + profilePubkey: string, + profileEventId?: string +): boolean { + if (event.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return false + const info = getPaymentNotificationInfo(event) + if (!info || !hexPubkeysEqual(info.recipientPubkey, profilePubkey)) return false + + return isProfileWallThreadReference( + info.referencedEventId, + info.referencedCoordinate, + profilePubkey, + profileEventId + ) +} + +/** Kind 9735 profile zap on a wall: `p` is the profile owner and there is no note/thread reference. */ +export function isProfileWallZapReceipt( + event: Event, + profilePubkey: string, + profileEventId?: string +): boolean { + if (event.kind !== kinds.Zap && event.kind !== ExtendedKind.ZAP_RECEIPT) return false + const zapInfo = getZapInfoFromEvent(event) + if (!zapInfo?.recipientPubkey || !hexPubkeysEqual(zapInfo.recipientPubkey, profilePubkey)) { + return false + } + + const referencedEventId = zapInfo.originalEventId?.trim().toLowerCase() + return isProfileWallThreadReference(referencedEventId, undefined, profilePubkey, profileEventId) +} + export function filterAttestedProfileWallSuperchats( - paymentNotifications: Event[], + paymentEvents: Event[], attestations: Event[], profilePubkey: string, profileEventId?: string ): Event[] { const attestedIds = buildAttestedPaymentIdSet(attestations, profilePubkey) return sortSuperchatsByAmountDesc( - paymentNotifications.filter( - (e) => - isProfileWallPaymentNotification(e, profilePubkey, profileEventId) && - isAttestedSuperchat(e, attestedIds) - ) + paymentEvents.filter((e) => { + if (e.kind === ExtendedKind.PAYMENT_NOTIFICATION) { + return ( + isProfileWallPaymentNotification(e, profilePubkey, profileEventId) && + isAttestedSuperchat(e, attestedIds) + ) + } + if (e.kind === kinds.Zap || e.kind === ExtendedKind.ZAP_RECEIPT) { + return ( + isProfileWallZapReceipt(e, profilePubkey, profileEventId) && + attestedIds.has(e.id.toLowerCase()) + ) + } + return false + }) ) } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 3ff2697b..e0900d9f 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1653,6 +1653,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { logger.warn('[Publish] Calendar RSVP IndexedDB persist failed', { err }) } } + if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { + try { + await indexedDb.putPaymentNotificationRow(event) + } catch (err) { + logger.warn('[Publish] Payment notification IndexedDB persist failed', { err }) + } + } + if (event.kind === ExtendedKind.PAYMENT_ATTESTATION) { + try { + await indexedDb.putPaymentAttestationRow(event) + } catch (err) { + logger.warn('[Publish] Payment attestation IndexedDB persist failed', { err }) + } + } client.emitNewEvent(event) // Replaceable list events (pins, cache relays, …) must hit IndexedDB + DataLoader, not only RAM void replaceableEventService.updateReplaceableEventCache(event).catch(() => {}) diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index dd24b33b..f2c65d39 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -730,6 +730,26 @@ export class EventService { }) }) } + if (cleanEvent.kind === ExtendedKind.PAYMENT_NOTIFICATION) { + void indexedDb.putPaymentNotificationRow(cleanEvent as NEvent).catch((error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)) + logger.debug('[EventService] Payment notification IndexedDB persist failed', { + kind: cleanEvent.kind, + eventId: id, + errorMessage: err.message + }) + }) + } + if (cleanEvent.kind === ExtendedKind.PAYMENT_ATTESTATION) { + void indexedDb.putPaymentAttestationRow(cleanEvent as NEvent).catch((error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)) + logger.debug('[EventService] Payment attestation IndexedDB persist failed', { + kind: cleanEvent.kind, + eventId: id, + errorMessage: err.message + }) + }) + } } /** Apply {@link StorageKey.SESSION_EVENT_LRU_MAX} without reload (copies entries into a new LRU). */ diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 9251ee14..9a22ca07 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -138,6 +138,7 @@ import { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' +import { getPaymentAttestationTargetId } from '@/lib/superchat' import { buildPublicMessagePublishRelayUrls, collectRecipientInboxUrls, @@ -1257,6 +1258,48 @@ class ClientService extends EventTarget { return pubRelays } + // Payment attestations (9741): attester outbox + attester read inboxes (profile wall REQ) + + // payment sender inboxes + relays that carried the attested payment. + if (event.kind === ExtendedKind.PAYMENT_ATTESTATION) { + const targetEventId = getPaymentAttestationTargetId(event) + const paymentSenderPubkey = event.tags.find(([name]) => name === 'e')?.[3]?.trim() + const senderPubkeys = + paymentSenderPubkey && isValidPubkey(paymentSenderPubkey) ? [paymentSenderPubkey] : [] + const [authorRelayList, senderRelayLists] = await Promise.all([ + this.fetchRelayListWithPublishTimeout(event.pubkey), + senderPubkeys.length > 0 + ? this.fetchRelayListsWithPublishTimeout(senderPubkeys) + : Promise.resolve([] as TRelayList[]) + ]) + const authorWrite = collectSenderOutboxUrls(authorRelayList) + const authorRead = collectRecipientInboxUrls(authorRelayList) + const senderInboxes = dedupeNormalizeRelayUrlsOrdered( + senderRelayLists.flatMap((rl) => collectRecipientInboxUrls(rl)) + ) + const seenRelays = targetEventId ? this.getSeenEventRelayUrls(targetEventId) : [] + const attestationRelays = this.filterPublishingRelays( + buildPrioritizedWriteRelayUrls({ + userWriteRelays: authorWrite, + authorReadRelays: dedupeNormalizeRelayUrlsOrdered([...authorRead, ...senderInboxes]), + favoriteRelays: favoriteRelayUrls ?? [], + extraRelays: seenRelays, + maxRelays: MAX_PUBLISH_RELAYS, + includeGlobalFastWriteReadTails: useGlobalRelayDefaults, + ...writeRelayPubOpts + }), + event + ) + logger.debug('[DetermineTargetRelays] Payment attestation: outbox + inboxes + seen relays', { + kind: event.kind, + relayCount: attestationRelays.length, + authorWriteCount: authorWrite.length, + authorReadCount: authorRead.length, + senderInboxCount: senderInboxes.length, + seenRelayCount: seenRelays.length + }) + return attestationRelays + } + let relays: string[] if (specifiedRelayUrls?.length) { relays = specifiedRelayUrls @@ -2224,18 +2267,22 @@ class ClientService extends EventTarget { add(this.eventService.getSessionEventsMatchingFilters(filters, maxMatches)) - const [timelineRows, archiveRows, publicationRows] = await Promise.all([ + const [timelineRows, archiveRows, publicationRows, paymentSuperchatRows] = await Promise.all([ this.getTimelineDiskSnapshotEvents(subRequests).catch(() => [] as NEvent[]), indexedDb .scanEventArchiveByFilters(filters, { maxRowsScanned, maxMatches }) .catch(() => [] as NEvent[]), indexedDb .scanPublicationEventsByFilters(filters, { maxRowsScanned: Math.min(maxRowsScanned, 16_000), maxMatches }) + .catch(() => [] as NEvent[]), + indexedDb + .getPaymentSuperchatEventsMatchingFilters(filters, maxMatches) .catch(() => [] as NEvent[]) ]) add(timelineRows) add(archiveRows) add(publicationRows) + add(paymentSuperchatRows) return [...byId.values()] .sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) diff --git a/src/services/event-archive.service.ts b/src/services/event-archive.service.ts index e1347d14..eb1d1875 100644 --- a/src/services/event-archive.service.ts +++ b/src/services/event-archive.service.ts @@ -48,6 +48,12 @@ function archiveTierForEvent(ev: Event): number { function shouldSkipArchiving(ev: Event): boolean { if (shouldDropEventOnIngest(ev)) return true if (isNip52CalendarCardKind(ev.kind) || ev.kind === ExtendedKind.CALENDAR_EVENT_RSVP) return true + if ( + ev.kind === ExtendedKind.PAYMENT_NOTIFICATION || + ev.kind === ExtendedKind.PAYMENT_ATTESTATION + ) { + return true + } if (isReplaceableEvent(ev.kind) && indexedDb.hasReplaceableEventStoreForKind(ev.kind)) { return true } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index a5adf419..efed79b1 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -28,6 +28,12 @@ import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' +import { + paymentAttestationIdbRowFromEvent, + paymentNotificationIdbRowFromEvent, + type PaymentAttestationIdbRow, + type PaymentNotificationIdbRow +} from '@/lib/payment-superchat-idb' import type { Filter } from 'nostr-tools' /** Hot archive row in {@link StoreNames.EVENT_ARCHIVE}. */ @@ -138,7 +144,11 @@ export const StoreNames = { /** NIP-52 calendar notes (31922/31923). Key: {@link replaceableEventDedupeKey}. Index: `occurrenceStartMs`. */ CALENDAR_EVENTS: 'calendarEvents', /** NIP-52 calendar RSVPs (31925). Key: event id. Index: `parentCoordinate` (`a` tag). */ - CALENDAR_RSVP_EVENTS: 'calendarRsvpEvents' + CALENDAR_RSVP_EVENTS: 'calendarRsvpEvents', + /** Kind 9740 payment notifications. Key: event id. Indexes: recipient, referenced event/coordinate. */ + PAYMENT_NOTIFICATION_EVENTS: 'paymentNotificationEvents', + /** Kind 9741 payment attestations. Key: event id. Indexes: author (attester), target payment id. */ + PAYMENT_ATTESTATION_EVENTS: 'paymentAttestationEvents' } /** Row shape for {@link StoreNames.CALENDAR_EVENTS}. */ @@ -173,7 +183,9 @@ const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet = new Set( StoreNames.MUTE_DECRYPTED_TAGS, StoreNames.FAVORITE_RELAYS, StoreNames.CALENDAR_EVENTS, - StoreNames.CALENDAR_RSVP_EVENTS + StoreNames.CALENDAR_RSVP_EVENTS, + StoreNames.PAYMENT_NOTIFICATION_EVENTS, + StoreNames.PAYMENT_ATTESTATION_EVENTS ]) /** @@ -214,7 +226,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet = new Set([ const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37' /** Schema version we expect. When adding stores or migrations, bump this. */ -const DB_VERSION = 38 +const DB_VERSION = 39 /** Hint age for profile/payment reads (stale rows still returned; background refresh). */ const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000 @@ -247,6 +259,15 @@ function ensureMissingObjectStores(db: IDBDatabase): void { } else if (storeName === StoreNames.CALENDAR_RSVP_EVENTS) { const rsvp = db.createObjectStore(storeName, { keyPath: 'key' }) rsvp.createIndex('parentCoordinate', 'parentCoordinate', { unique: false }) + } else if (storeName === StoreNames.PAYMENT_NOTIFICATION_EVENTS) { + const pn = db.createObjectStore(storeName, { keyPath: 'key' }) + pn.createIndex('recipientPubkey', 'recipientPubkey', { unique: false }) + pn.createIndex('referencedEventId', 'referencedEventId', { unique: false }) + pn.createIndex('referencedCoordinate', 'referencedCoordinate', { unique: false }) + } else if (storeName === StoreNames.PAYMENT_ATTESTATION_EVENTS) { + const pa = db.createObjectStore(storeName, { keyPath: 'key' }) + pa.createIndex('authorPubkey', 'authorPubkey', { unique: false }) + pa.createIndex('targetEventId', 'targetEventId', { unique: false }) } else { db.createObjectStore(storeName, { keyPath: 'key' }) } @@ -474,6 +495,19 @@ class IndexedDbService { if (event.oldVersion < 37) { // v37: drop legacy object stores; calendar notes purged from EVENT_ARCHIVE post-open } + if (event.oldVersion < 39) { + if (!db.objectStoreNames.contains(StoreNames.PAYMENT_NOTIFICATION_EVENTS)) { + const pn = db.createObjectStore(StoreNames.PAYMENT_NOTIFICATION_EVENTS, { keyPath: 'key' }) + pn.createIndex('recipientPubkey', 'recipientPubkey', { unique: false }) + pn.createIndex('referencedEventId', 'referencedEventId', { unique: false }) + pn.createIndex('referencedCoordinate', 'referencedCoordinate', { unique: false }) + } + if (!db.objectStoreNames.contains(StoreNames.PAYMENT_ATTESTATION_EVENTS)) { + const pa = db.createObjectStore(StoreNames.PAYMENT_ATTESTATION_EVENTS, { keyPath: 'key' }) + pa.createIndex('authorPubkey', 'authorPubkey', { unique: false }) + pa.createIndex('targetEventId', 'targetEventId', { unique: false }) + } + } ensureMissingObjectStores(db) } } @@ -3847,6 +3881,219 @@ class IndexedDbService { } }) } + + async putPaymentNotificationRow(ev: Event): Promise { + const row = paymentNotificationIdbRowFromEvent(ev) + if (!row) return + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.PAYMENT_NOTIFICATION_EVENTS)) return + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.PAYMENT_NOTIFICATION_EVENTS, 'readwrite') + const store = tx.objectStore(StoreNames.PAYMENT_NOTIFICATION_EVENTS) + const getReq = store.get(row.key) + getReq.onerror = (e) => reject(idbEventToError(e)) + getReq.onsuccess = () => { + const prev = getReq.result as PaymentNotificationIdbRow | undefined + if (prev?.value?.created_at != null && prev.value.created_at > ev.created_at) { + resolve() + return + } + const putReq = store.put(row) + putReq.onerror = (e) => reject(idbEventToError(e)) + putReq.onsuccess = () => resolve() + } + }) + } + + async putPaymentAttestationRow(ev: Event): Promise { + const row = paymentAttestationIdbRowFromEvent(ev) + if (!row) return + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.PAYMENT_ATTESTATION_EVENTS)) return + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.PAYMENT_ATTESTATION_EVENTS, 'readwrite') + const store = tx.objectStore(StoreNames.PAYMENT_ATTESTATION_EVENTS) + const getReq = store.get(row.key) + getReq.onerror = (e) => reject(idbEventToError(e)) + getReq.onsuccess = () => { + const prev = getReq.result as PaymentAttestationIdbRow | undefined + if (prev?.value?.created_at != null && prev.value.created_at > ev.created_at) { + resolve() + return + } + const putReq = store.put(row) + putReq.onerror = (e) => reject(idbEventToError(e)) + putReq.onsuccess = () => resolve() + } + }) + } + + private async getIndexedEventsByField( + storeName: string, + indexName: string, + fieldValue: string, + limit: number + ): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(storeName)) return [] + const key = fieldValue.trim().toLowerCase() + if (!key) return [] + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(storeName, 'readonly') + const store = tx.objectStore(storeName) + let index: IDBIndex + try { + index = store.index(indexName) + } catch { + resolve([]) + return + } + const req = index.getAll(IDBKeyRange.only(key)) + req.onerror = (e) => reject(idbEventToError(e)) + req.onsuccess = () => { + const rows = (req.result as { value: Event }[]) ?? [] + const events = rows.map((r) => r.value).filter(Boolean) + events.sort((a, b) => b.created_at - a.created_at) + resolve(events.slice(0, limit)) + } + }) + } + + async getPaymentNotificationsForRecipient(recipientPubkey: string, limit = 200): Promise { + return this.getIndexedEventsByField( + StoreNames.PAYMENT_NOTIFICATION_EVENTS, + 'recipientPubkey', + recipientPubkey, + limit + ) + } + + async getPaymentNotificationsForReferencedEvent(eventId: string, limit = 200): Promise { + return this.getIndexedEventsByField( + StoreNames.PAYMENT_NOTIFICATION_EVENTS, + 'referencedEventId', + eventId, + limit + ) + } + + async getPaymentNotificationsForReferencedCoordinate(coordinate: string, limit = 200): Promise { + const norm = normalizeReplaceableCoordinateString(coordinate.trim()) + if (!norm) return [] + return this.getIndexedEventsByField( + StoreNames.PAYMENT_NOTIFICATION_EVENTS, + 'referencedCoordinate', + norm, + limit + ) + } + + async getPaymentAttestationsForAuthor(authorPubkey: string, limit = 500): Promise { + return this.getIndexedEventsByField( + StoreNames.PAYMENT_ATTESTATION_EVENTS, + 'authorPubkey', + authorPubkey, + limit + ) + } + + async getPaymentAttestationsForTargetEvent(targetEventId: string, limit = 20): Promise { + return this.getIndexedEventsByField( + StoreNames.PAYMENT_ATTESTATION_EVENTS, + 'targetEventId', + targetEventId, + limit + ) + } + + async getPaymentSuperchatEventsMatchingFilters(filters: Filter[], maxMatches: number): Promise { + const out: Event[] = [] + const seen = new Set() + const push = (events: Event[]) => { + for (const ev of events) { + if (shouldDropEventOnIngest(ev)) continue + if (seen.has(ev.id)) continue + seen.add(ev.id) + out.push(ev) + } + } + + for (const filter of filters) { + const kindsList = filter.kinds + const want9740 = + !kindsList?.length || + kindsList.includes(ExtendedKind.PAYMENT_NOTIFICATION) + const want9741 = !kindsList?.length || kindsList.includes(ExtendedKind.PAYMENT_ATTESTATION) + const limit = Math.min(filter.limit ?? maxMatches, maxMatches) + + if (want9740) { + const pTags = filter['#p'] + if (Array.isArray(pTags)) { + for (const p of pTags) { + if (typeof p !== 'string') continue + push(await this.getPaymentNotificationsForRecipient(p, limit)) + } + } + const eTags = filter['#e'] + if (Array.isArray(eTags)) { + for (const eid of eTags) { + if (typeof eid !== 'string') continue + push(await this.getPaymentNotificationsForReferencedEvent(eid, limit)) + } + } + const aTags = filter['#a'] + if (Array.isArray(aTags)) { + for (const coord of aTags) { + if (typeof coord !== 'string') continue + push(await this.getPaymentNotificationsForReferencedCoordinate(coord, limit)) + } + } + } + + if (want9741 && Array.isArray(filter.authors)) { + for (const author of filter.authors) { + if (typeof author !== 'string') continue + push(await this.getPaymentAttestationsForAuthor(author, limit)) + } + } + + if (Array.isArray(filter.ids)) { + await this.initPromise + for (const id of filter.ids) { + if (typeof id !== 'string' || !/^[0-9a-f]{64}$/i.test(id)) continue + const hex = id.toLowerCase() + if (this.db?.objectStoreNames.contains(StoreNames.PAYMENT_NOTIFICATION_EVENTS)) { + const ev = await new Promise((resolve) => { + const tx = this.db!.transaction(StoreNames.PAYMENT_NOTIFICATION_EVENTS, 'readonly') + const req = tx.objectStore(StoreNames.PAYMENT_NOTIFICATION_EVENTS).get(hex) + req.onsuccess = () => resolve((req.result as PaymentNotificationIdbRow | undefined)?.value) + req.onerror = () => resolve(undefined) + }) + if (ev) push([ev]) + } + if (this.db?.objectStoreNames.contains(StoreNames.PAYMENT_ATTESTATION_EVENTS)) { + const ev = await new Promise((resolve) => { + const tx = this.db!.transaction(StoreNames.PAYMENT_ATTESTATION_EVENTS, 'readonly') + const req = tx.objectStore(StoreNames.PAYMENT_ATTESTATION_EVENTS).get(hex) + req.onsuccess = () => resolve((req.result as PaymentAttestationIdbRow | undefined)?.value) + req.onerror = () => resolve(undefined) + }) + if (ev) push([ev]) + } + } + } + + if (out.length >= maxMatches) break + } + + return out + .filter((ev) => eventMatchesAnyLocalFeedFilter(ev, filters)) + .sort((a, b) => b.created_at - a.created_at) + .slice(0, maxMatches) + } } const instance = IndexedDbService.getInstance()