Browse Source

bug-fixes

persist attestations and payment notifications to cache
imwald
Silberengel 3 weeks ago
parent
commit
371e6c8f5f
  1. 29
      src/components/Note/Superchat.tsx
  2. 27
      src/components/Note/SuperchatPaymentMethodLabel.tsx
  3. 35
      src/components/Note/Zap.tsx
  4. 10
      src/components/NoteOptions/RawEventDialog.tsx
  5. 34
      src/components/NoteOptions/index.tsx
  6. 21
      src/components/NoteOptions/useMenuActions.tsx
  7. 25
      src/components/PaytoLink/index.tsx
  8. 42
      src/components/PaytoTypeIcon/index.tsx
  9. 9
      src/components/Profile/ProfileBadges.tsx
  10. 10
      src/components/Profile/ProfileWallSuperchats.tsx
  11. 112
      src/components/ReplyNoteList/index.tsx
  12. 42
      src/components/TurnIntoSuperchatButton/index.tsx
  13. 12
      src/hooks/usePaymentAttestationStatus.tsx
  14. 79
      src/hooks/useProfileWall.tsx
  15. 3
      src/i18n/locales/en.ts
  16. 14
      src/lib/event.ts
  17. 76
      src/lib/payment-superchat-idb.ts
  18. 81
      src/lib/superchat.test.ts
  19. 96
      src/lib/superchat.ts
  20. 14
      src/providers/NostrProvider/index.tsx
  21. 20
      src/services/client-events.service.ts
  22. 49
      src/services/client.service.ts
  23. 6
      src/services/event-archive.service.ts
  24. 253
      src/services/indexed-db.service.ts

29
src/components/Note/Superchat.tsx

@ -70,13 +70,19 @@ export default function Superchat({ @@ -70,13 +70,19 @@ export default function Superchat({
}
if (variant === 'compact') {
const hasMetaLine =
(recipientPubkey && recipientPubkey !== senderPubkey) || hasTarget
return (
<div className={cn('text-sm text-muted-foreground', className)}>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<SuperchatPaymentMethodLabel paytoType={paytoType} />
<span className="text-xs font-medium text-yellow-400/90">{t('Superchat')}</span>
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span>
</div>
{hasMetaLine ? (
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm">
{recipientPubkey && recipientPubkey !== senderPubkey ? (
<span className="text-xs">
<span>
<span>{t('to')}</span>{' '}
<Username
userId={recipientPubkey}
@ -88,14 +94,15 @@ export default function Superchat({ @@ -88,14 +94,15 @@ export default function Superchat({
<button
type="button"
onClick={openTarget}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{hasThreadTarget ? t('Superchat thread') : t('Superchat profile')}
</button>
) : null}
</div>
) : null}
{comment ? (
<p className="mt-1.5 text-sm leading-snug text-foreground/90 whitespace-pre-wrap break-words">
<p className="mt-2 text-base font-medium leading-snug text-foreground whitespace-pre-wrap break-words">
{comment}
</p>
) : null}
@ -122,27 +129,27 @@ export default function Superchat({ @@ -122,27 +129,27 @@ export default function Superchat({
<div className="flex items-start gap-3 pb-10 pr-2 sm:pr-36">
<div className="mt-1 shrink-0">
<SuperchatPaymentMethodLabel paytoType={paytoType} className="text-sm" />
<SuperchatPaymentMethodLabel paytoType={paytoType} className="text-base" />
</div>
<div className="min-w-0 flex-1">
{!omitSenderHeading && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<UserAvatar userId={senderPubkey} size="small" />
<Username userId={senderPubkey} className="font-semibold text-foreground" />
<span className="text-sm font-medium text-yellow-400/90">{t('Superchat')}</span>
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<>
<span className="text-sm text-muted-foreground">{t('to')}</span>
<span className="w-full basis-full flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t('to')}</span>
<UserAvatar userId={recipientPubkey} size="small" />
<Username userId={recipientPubkey} className="font-semibold text-foreground" />
</>
</span>
)}
</div>
)}
{comment ? (
<div className="rounded-r-md border-l-[3px] border-yellow-400 bg-muted/40 py-2.5 pl-3 pr-2 dark:bg-muted/25">
<p className="text-lg font-semibold leading-snug tracking-tight text-foreground whitespace-pre-wrap break-words">
<p className="text-xl font-semibold leading-snug tracking-tight text-foreground whitespace-pre-wrap break-words">
{comment}
</p>
</div>

27
src/components/Note/SuperchatPaymentMethodLabel.tsx

@ -1,12 +1,6 @@ @@ -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({ @@ -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 (
<span
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-md border border-border/60 bg-muted/40',
'px-1.5 py-0.5 text-xs font-medium leading-none text-muted-foreground',
'inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border/60 bg-muted/40',
'px-2 py-1 text-sm font-semibold leading-none text-muted-foreground',
className
)}
>
{logoPath ? (
<img src={logoPath} alt="" className="size-3.5 shrink-0 object-contain" aria-hidden />
) : isLightning ? (
<ZapIcon className="size-3.5 shrink-0 text-yellow-400" strokeWidth={2} aria-hidden />
) : iconChar ? (
<span className="shrink-0 text-[0.65rem] leading-none" aria-hidden>
{iconChar}
</span>
) : null}
<PaytoTypeIcon type={paytoType} />
<span className="truncate">{label}</span>
</span>
)

35
src/components/Note/Zap.tsx

@ -2,9 +2,9 @@ import { useFetchEvent } from '@/hooks' @@ -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({ @@ -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<HTMLButtonElement>) => {
e.stopPropagation()
@ -92,13 +93,19 @@ export default function Zap({ @@ -92,13 +93,19 @@ export default function Zap({
}
if (variant === 'compact') {
const hasMetaLine =
(recipientPubkey && recipientPubkey !== senderPubkey) || isEventZap || isProfileZap
return (
<div className={cn('text-sm text-muted-foreground', className)}>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<SuperchatPaymentMethodLabel paytoType="lightning" />
<span className="text-xs font-medium text-yellow-400/90">{t('Superchat')}</span>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<SuperchatPaymentMethodLabel paytoType={paytoType} />
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span>
</div>
{hasMetaLine ? (
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm">
{recipientPubkey && recipientPubkey !== senderPubkey && (
<span className="text-xs">
<span>
<span>{t('zapped')}</span>{' '}
<Username
userId={recipientPubkey}
@ -110,7 +117,7 @@ export default function Zap({ @@ -110,7 +117,7 @@ export default function Zap({
<button
type="button"
onClick={openZapTarget}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{isEventZap
? t('Zapped note')
@ -120,8 +127,9 @@ export default function Zap({ @@ -120,8 +127,9 @@ export default function Zap({
</button>
)}
</div>
) : null}
{comment ? (
<p className="mt-1.5 text-sm leading-snug text-foreground/90 whitespace-pre-wrap break-words">
<p className="mt-2 text-base font-medium leading-snug text-foreground whitespace-pre-wrap break-words">
{comment}
</p>
) : null}
@ -156,25 +164,28 @@ export default function Zap({ @@ -156,25 +164,28 @@ export default function Zap({
</button>
<div className="flex items-start gap-3 pb-10 pr-2 sm:pr-36">
<ZapIcon size={28} className="mt-0.5 shrink-0 text-primary" strokeWidth={2} />
<div className="mt-1 shrink-0">
<SuperchatPaymentMethodLabel paytoType={paytoType} className="text-base" />
</div>
<div className="min-w-0 flex-1">
{!omitSenderHeading && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<UserAvatar userId={senderPubkey} size="small" />
<Username userId={senderPubkey} className="font-semibold text-foreground" />
<span className="text-sm text-muted-foreground">{t('zapped')}</span>
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<>
<span className="w-full basis-full flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t('zapped')}</span>
<UserAvatar userId={recipientPubkey} size="small" />
<Username userId={recipientPubkey} className="font-semibold text-foreground" />
</>
</span>
)}
</div>
)}
{comment ? (
<div className="mb-3 rounded-r-md border-l-[3px] border-primary bg-muted/40 py-2.5 pl-3 pr-2 dark:bg-muted/25">
<p className="text-lg font-semibold leading-snug tracking-tight text-foreground whitespace-pre-wrap break-words">
<p className="text-xl font-semibold leading-snug tracking-tight text-foreground whitespace-pre-wrap break-words">
{comment}
</p>
</div>

10
src/components/NoteOptions/RawEventDialog.tsx

@ -16,13 +16,17 @@ import logger from '@/lib/logger' @@ -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({ @@ -37,12 +41,12 @@ export default function RawEventDialog({
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose() }}>
<DialogContent className="h-[60vh] w-[95vw] max-w-[400px] sm:w-[90vw] sm:max-w-[600px] md:w-[85vw] md:max-w-[800px] lg:w-[80vw] lg:max-w-[1000px] xl:w-[75vw] xl:max-w-[1200px] 2xl:w-[70vw] 2xl:max-w-[1400px] flex flex-col overflow-hidden">
<DialogHeader className="shrink-0 pr-8">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<DialogTitle>Raw Event</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription className="sr-only">View the raw event data</DialogDescription>
</div>
<div className="flex items-center gap-1 shrink-0">

34
src/components/NoteOptions/index.tsx

@ -1,7 +1,12 @@ @@ -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({ @@ -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<TEditOrCloneMode>('clone')
@ -74,6 +82,15 @@ export default function NoteOptions({ @@ -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({ @@ -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({ @@ -126,6 +148,14 @@ export default function NoteOptions({
isOpen={isRawEventDialogOpen}
onClose={() => setIsRawEventDialogOpen(false)}
/>
{attestationEvent ? (
<RawEventDialog
event={attestationEvent}
isOpen={isAttestationDialogOpen}
onClose={() => setIsAttestationDialogOpen(false)}
title={t('Payment attestation')}
/>
) : null}
<ReportDialog
event={event}
isOpen={isReportDialogOpen}

21
src/components/NoteOptions/useMenuActions.tsx

@ -48,6 +48,7 @@ import { @@ -48,6 +48,7 @@ import {
Pin,
SatelliteDish,
Send,
Sparkles,
Trash2,
TriangleAlert,
Video,
@ -118,6 +119,8 @@ interface UseMenuActionsProps { @@ -118,6 +119,8 @@ interface UseMenuActionsProps {
onOpenEditOrClone?: (mode: TEditOrCloneMode) => 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({ @@ -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,8 +1066,20 @@ export function useMenuActions({ @@ -1062,8 +1066,20 @@ export function useMenuActions({
closeDrawer()
setIsRawEventDialogOpen(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) {
@ -1258,7 +1274,8 @@ export function useMenuActions({ @@ -1258,7 +1274,8 @@ export function useMenuActions({
canSignEvents,
profile,
noteTranslationFromMenu,
translateMenuOptions
translateMenuOptions,
onViewAttestation
])
return menuActions

25
src/components/PaytoLink/index.tsx

@ -2,15 +2,13 @@ import { ZAP_SENDING_ENABLED } from '@/constants' @@ -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 { @@ -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({ @@ -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({ @@ -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({ @@ -123,22 +117,7 @@ export default function PaytoLink({
: `${displayLabel}: ${t('Click to open payment options')}`
: t('Click to copy address')
const iconEl = (
<span className="shrink-0 flex items-center justify-center w-4 h-4 text-[1rem] leading-none" aria-hidden>
{logoPath ? (
<img src={logoPath} alt="" className="size-4 object-contain" />
) : iconChar != null ? (
<span className={cn(
'inline-flex items-center justify-center',
isLightning && 'text-yellow-400'
)}>
{iconChar}
</span>
) : (
<HelpCircle className="size-3.5 text-muted-foreground" />
)}
</span>
)
const iconEl = <PaytoTypeIcon type={type} className="w-4 h-4 text-[1rem]" />
return (
<>

42
src/components/PaytoTypeIcon/index.tsx

@ -0,0 +1,42 @@ @@ -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 (
<span
className={cn('inline-flex shrink-0 items-center justify-center leading-none', className)}
aria-hidden
>
{isLightning ? (
<ZapIcon className={cn('size-4 shrink-0 text-yellow-400', imgClassName)} strokeWidth={2} />
) : logoPath ? (
<img src={logoPath} alt="" className={cn('size-4 object-contain', imgClassName)} />
) : iconChar != null ? (
<span className="inline-flex items-center justify-center text-[1rem] leading-none">
{iconChar}
</span>
) : (
<HelpCircle className={cn('size-3.5 text-muted-foreground', imgClassName)} />
)}
</span>
)
}

9
src/components/Profile/ProfileBadges.tsx

@ -17,11 +17,10 @@ export default function ProfileBadges({ @@ -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({ @@ -37,11 +36,13 @@ export default function ProfileBadges({
return (
<div className="mt-3 min-w-0">
{badges.length > 0 ? (
<section className="min-w-0" aria-label={t('Badges')}>
{badges.length > 0 || superchats.length > 0 ? (
<div className="mb-1 flex items-center justify-end gap-2">
<RefreshButton onClick={handleRefresh} onLongPress={null} />
</div>
) : null}
{badges.length > 0 ? (
<section className="min-w-0" aria-label={t('Badges')}>
<div className="flex flex-wrap gap-2">
{badges.map((badge) => (
<div

10
src/components/Profile/ProfileWallSuperchats.tsx

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
import Superchat from '@/components/Note/Superchat'
import Zap from '@/components/Note/Zap'
import { ExtendedKind } from '@/constants'
import { Skeleton } from '@/components/ui/skeleton'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
@ -28,9 +30,13 @@ export default function ProfileWallSuperchats({ @@ -28,9 +30,13 @@ export default function ProfileWallSuperchats({
{t('Superchats')}
</h3>
<div className="space-y-2">
{superchats.map((event) => (
{superchats.map((event) =>
event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
<Superchat key={event.id} event={event} variant="compact" />
))}
) : (
<Zap key={event.id} event={event} variant="compact" />
)
)}
</div>
</section>
)

112
src/components/ReplyNoteList/index.tsx

@ -55,7 +55,7 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' @@ -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 @@ -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<string>,
relayUrls: string[]
): Promise<NEvent[]> {
const ids = [...attestedIds].filter((id) => /^[0-9a-f]{64}$/i.test(id))
if (ids.length === 0) return []
const byId = new Map<string, NEvent>()
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<NEvent[]> {
const filter: Filter = {
kinds: [ExtendedKind.PAYMENT_ATTESTATION],
authors: [recipientPubkey],
limit: 500
}
const byId = new Map<string, NEvent>()
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({ @@ -354,6 +425,7 @@ function ReplyNoteList({
const { pubkey: userPubkey } = useNostr()
const { zapReplyThreshold } = useZap()
const [attestedPaymentIds, setAttestedPaymentIds] = useState<Set<string>>(() => new Set())
const threadRelayUrlsRef = useRef<string[]>([])
const { blockedRelays, favoriteRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const relayAuthoritativeRead =
@ -386,10 +458,18 @@ function ReplyNoteList({ @@ -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<string>()
@ -1163,25 +1243,21 @@ function ReplyNoteList({ @@ -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,
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
)
.then((attestations) => {
if (fetchGeneration !== replyFetchGenRef.current) return
setAttestedPaymentIds(buildAttestedPaymentIdSet(attestations, recipientPubkey))
if (targets.length > 0) addReplies(targets)
})
.catch(() => {
/* attestations optional */

42
src/components/TurnIntoSuperchatButton/index.tsx

@ -4,9 +4,12 @@ import { LoginRequiredError } from '@/lib/nostr-errors' @@ -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({ @@ -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 (
<TurnIntoSuperchatButtonInner event={event} className={className} prominent={prominent} />
)
}
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 (
<p
@ -56,6 +83,7 @@ export default function TurnIntoSuperchatButton({ @@ -56,6 +83,7 @@ export default function TurnIntoSuperchatButton({
try {
const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true })
await publish(draft, { disableFallbacks: true })
requestProfileWallRefresh(recipientPubkey)
showSimplePublishSuccess(t('Superchat attested'))
} catch (error) {
if (error instanceof LoginRequiredError) return

12
src/hooks/usePaymentAttestationStatus.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { ExtendedKind } from '@/constants'
import {
buildAttestedPaymentIdSet,
findPaymentAttestationForTarget,
getPaymentAttestationTargetId,
getSuperchatPaymentRecipientPubkey
} from '@/lib/superchat'
@ -10,6 +10,7 @@ import { useEffect, useState } from 'react' @@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'
export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) {
const [attested, setAttested] = useState(false)
const [attestationEvent, setAttestationEvent] = useState<NostrEvent | null>(null)
const [checking, setChecking] = useState(false)
const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null
@ -17,6 +18,7 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) @@ -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) @@ -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) @@ -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) @@ -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 }
}

79
src/hooks/useProfileWall.tsx

@ -19,10 +19,11 @@ import { @@ -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 { @@ -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 @@ -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 @@ -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<Event>).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 @@ -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 @@ -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 @@ -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<string, Event>()
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,6 +376,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine @@ -332,6 +376,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
/* ignore */
}
if (profileId) {
wallComments = [...pool.values()]
.filter(
(e) =>
@ -340,18 +385,20 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine @@ -340,18 +385,20 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
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

3
src/i18n/locales/en.ts

@ -84,6 +84,9 @@ export default { @@ -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",

14
src/lib/event.ts

@ -209,8 +209,12 @@ export function getParentETag(event?: Event) { @@ -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) { @@ -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 (

76
src/lib/payment-superchat-idb.ts

@ -0,0 +1,76 @@ @@ -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()
}
}

81
src/lib/superchat.test.ts

@ -7,6 +7,7 @@ import { @@ -7,6 +7,7 @@ import {
getSuperchatPaytoType,
getSuperchatReferenceFetchId,
isProfileWallPaymentNotification,
isProfileWallZapReceipt,
partitionAttestedSuperchats
} from '@/lib/superchat'
import { parsePaytoTagType } from '@/lib/payto'
@ -118,6 +119,35 @@ describe('partitionAttestedSuperchats', () => { @@ -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', () => { @@ -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)
})
})

96
src/lib/superchat.ts

@ -62,6 +62,24 @@ export function buildAttestedPaymentIdSet( @@ -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[] { @@ -170,18 +188,14 @@ export function sortSuperchatsByAmountDesc(events: Event[]): Event[] {
export function partitionAttestedSuperchats(
items: Event[],
attestedIds: Set<string>,
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 @@ -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( @@ -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) =>
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
})
)
}

14
src/providers/NostrProvider/index.tsx

@ -1653,6 +1653,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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(() => {})

20
src/services/client-events.service.ts

@ -730,6 +730,26 @@ export class EventService { @@ -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). */

49
src/services/client.service.ts

@ -138,6 +138,7 @@ import { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search' @@ -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 { @@ -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 { @@ -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))

6
src/services/event-archive.service.ts

@ -48,6 +48,12 @@ function archiveTierForEvent(ev: Event): number { @@ -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
}

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

@ -28,6 +28,12 @@ import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' @@ -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 = { @@ -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<string> = new Set( @@ -173,7 +183,9 @@ const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet<string> = 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<string> = new Set([ @@ -214,7 +226,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet<string> = 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 { @@ -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 { @@ -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 { @@ -3847,6 +3881,219 @@ class IndexedDbService {
}
})
}
async putPaymentNotificationRow(ev: Event): Promise<void> {
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<void> {
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<Event[]> {
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<Event[]> {
return this.getIndexedEventsByField(
StoreNames.PAYMENT_NOTIFICATION_EVENTS,
'recipientPubkey',
recipientPubkey,
limit
)
}
async getPaymentNotificationsForReferencedEvent(eventId: string, limit = 200): Promise<Event[]> {
return this.getIndexedEventsByField(
StoreNames.PAYMENT_NOTIFICATION_EVENTS,
'referencedEventId',
eventId,
limit
)
}
async getPaymentNotificationsForReferencedCoordinate(coordinate: string, limit = 200): Promise<Event[]> {
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<Event[]> {
return this.getIndexedEventsByField(
StoreNames.PAYMENT_ATTESTATION_EVENTS,
'authorPubkey',
authorPubkey,
limit
)
}
async getPaymentAttestationsForTargetEvent(targetEventId: string, limit = 20): Promise<Event[]> {
return this.getIndexedEventsByField(
StoreNames.PAYMENT_ATTESTATION_EVENTS,
'targetEventId',
targetEventId,
limit
)
}
async getPaymentSuperchatEventsMatchingFilters(filters: Filter[], maxMatches: number): Promise<Event[]> {
const out: Event[] = []
const seen = new Set<string>()
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<Event | undefined>((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<Event | undefined>((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()

Loading…
Cancel
Save