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({
} }
if (variant === 'compact') { if (variant === 'compact') {
const hasMetaLine =
(recipientPubkey && recipientPubkey !== senderPubkey) || hasTarget
return ( return (
<div className={cn('text-sm text-muted-foreground', className)}> <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} /> <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 ? ( {recipientPubkey && recipientPubkey !== senderPubkey ? (
<span className="text-xs"> <span>
<span>{t('to')}</span>{' '} <span>{t('to')}</span>{' '}
<Username <Username
userId={recipientPubkey} userId={recipientPubkey}
@ -88,14 +94,15 @@ export default function Superchat({
<button <button
type="button" type="button"
onClick={openTarget} 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')} {hasThreadTarget ? t('Superchat thread') : t('Superchat profile')}
</button> </button>
) : null} ) : null}
</div> </div>
) : null}
{comment ? ( {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} {comment}
</p> </p>
) : null} ) : null}
@ -122,27 +129,27 @@ export default function Superchat({
<div className="flex items-start gap-3 pb-10 pr-2 sm:pr-36"> <div className="flex items-start gap-3 pb-10 pr-2 sm:pr-36">
<div className="mt-1 shrink-0"> <div className="mt-1 shrink-0">
<SuperchatPaymentMethodLabel paytoType={paytoType} className="text-sm" /> <SuperchatPaymentMethodLabel paytoType={paytoType} className="text-base" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{!omitSenderHeading && ( {!omitSenderHeading && (
<div className="mb-3 flex flex-wrap items-center gap-2"> <div className="mb-3 flex flex-wrap items-center gap-2">
<UserAvatar userId={senderPubkey} size="small" /> <UserAvatar userId={senderPubkey} size="small" />
<Username userId={senderPubkey} className="font-semibold text-foreground" /> <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 && ( {recipientPubkey && recipientPubkey !== senderPubkey && (
<> <span className="w-full basis-full flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground">{t('to')}</span> <span>{t('to')}</span>
<UserAvatar userId={recipientPubkey} size="small" /> <UserAvatar userId={recipientPubkey} size="small" />
<Username userId={recipientPubkey} className="font-semibold text-foreground" /> <Username userId={recipientPubkey} className="font-semibold text-foreground" />
</> </span>
)} )}
</div> </div>
)} )}
{comment ? ( {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"> <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} {comment}
</p> </p>
</div> </div>

27
src/components/Note/SuperchatPaymentMethodLabel.tsx

@ -1,12 +1,6 @@
import { import { getCanonicalPaytoType, getPaytoEditorTypeLabel } from '@/lib/payto'
getCanonicalPaytoType, import PaytoTypeIcon from '@/components/PaytoTypeIcon'
getPaytoEditorTypeLabel,
getPaytoIconChar,
getPaytoLogoPath,
isLightningPaytoType
} from '@/lib/payto'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Zap as ZapIcon } from 'lucide-react'
export default function SuperchatPaymentMethodLabel({ export default function SuperchatPaymentMethodLabel({
paytoType, paytoType,
@ -18,27 +12,16 @@ export default function SuperchatPaymentMethodLabel({
}) { }) {
const canonical = getCanonicalPaytoType(paytoType) const canonical = getCanonicalPaytoType(paytoType)
const label = getPaytoEditorTypeLabel(canonical) const label = getPaytoEditorTypeLabel(canonical)
const logoPath = getPaytoLogoPath(canonical)
const iconChar = getPaytoIconChar(canonical)
const isLightning = isLightningPaytoType(canonical)
return ( return (
<span <span
className={cn( className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-md border border-border/60 bg-muted/40', 'inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border/60 bg-muted/40',
'px-1.5 py-0.5 text-xs font-medium leading-none text-muted-foreground', 'px-2 py-1 text-sm font-semibold leading-none text-muted-foreground',
className className
)} )}
> >
{logoPath ? ( <PaytoTypeIcon type={paytoType} />
<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}
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
</span> </span>
) )

35
src/components/Note/Zap.tsx

@ -2,9 +2,9 @@ import { useFetchEvent } from '@/hooks'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
import { formatAmount } from '@/lib/lightning' import { formatAmount } from '@/lib/lightning'
import { getSuperchatPaytoType } from '@/lib/superchat'
import { toNote, toProfile } from '@/lib/link' import { toNote, toProfile } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Zap as ZapIcon } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, type MouseEvent } from 'react' import { useMemo, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -77,6 +77,7 @@ export default function Zap({
}, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey]) }, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey])
const { senderPubkey, recipientPubkey, amount, comment } = zapInfo const { senderPubkey, recipientPubkey, amount, comment } = zapInfo
const paytoType = useMemo(() => getSuperchatPaytoType(event), [event])
const openZapTarget = (e: MouseEvent<HTMLButtonElement>) => { const openZapTarget = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation() e.stopPropagation()
@ -92,13 +93,19 @@ export default function Zap({
} }
if (variant === 'compact') { if (variant === 'compact') {
const hasMetaLine =
(recipientPubkey && recipientPubkey !== senderPubkey) || isEventZap || isProfileZap
return ( return (
<div className={cn('text-sm text-muted-foreground', className)}> <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="lightning" /> <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 && ( {recipientPubkey && recipientPubkey !== senderPubkey && (
<span className="text-xs"> <span>
<span>{t('zapped')}</span>{' '} <span>{t('zapped')}</span>{' '}
<Username <Username
userId={recipientPubkey} userId={recipientPubkey}
@ -110,7 +117,7 @@ export default function Zap({
<button <button
type="button" type="button"
onClick={openZapTarget} 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 {isEventZap
? t('Zapped note') ? t('Zapped note')
@ -120,8 +127,9 @@ export default function Zap({
</button> </button>
)} )}
</div> </div>
) : null}
{comment ? ( {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} {comment}
</p> </p>
) : null} ) : null}
@ -156,25 +164,28 @@ export default function Zap({
</button> </button>
<div className="flex items-start gap-3 pb-10 pr-2 sm:pr-36"> <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"> <div className="min-w-0 flex-1">
{!omitSenderHeading && ( {!omitSenderHeading && (
<div className="mb-3 flex flex-wrap items-center gap-2"> <div className="mb-3 flex flex-wrap items-center gap-2">
<UserAvatar userId={senderPubkey} size="small" /> <UserAvatar userId={senderPubkey} size="small" />
<Username userId={senderPubkey} className="font-semibold text-foreground" /> <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 && ( {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" /> <UserAvatar userId={recipientPubkey} size="small" />
<Username userId={recipientPubkey} className="font-semibold text-foreground" /> <Username userId={recipientPubkey} className="font-semibold text-foreground" />
</> </span>
)} )}
</div> </div>
)} )}
{comment ? ( {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"> <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} {comment}
</p> </p>
</div> </div>

10
src/components/NoteOptions/RawEventDialog.tsx

@ -16,13 +16,17 @@ import logger from '@/lib/logger'
export default function RawEventDialog({ export default function RawEventDialog({
event, event,
isOpen, isOpen,
onClose onClose,
title
}: { }: {
event: Event event: Event
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
/** Dialog title; defaults to “Raw Event”. */
title?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const dialogTitle = title ?? t('Raw Event')
const [wordWrapEnabled, setWordWrapEnabled] = useState(true) const [wordWrapEnabled, setWordWrapEnabled] = useState(true)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@ -37,12 +41,12 @@ export default function RawEventDialog({
} }
return ( 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"> <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"> <DialogHeader className="shrink-0 pr-8">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<DialogTitle>Raw Event</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription className="sr-only">View the raw event data</DialogDescription> <DialogDescription className="sr-only">View the raw event data</DialogDescription>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">

34
src/components/NoteOptions/index.tsx

@ -1,7 +1,12 @@
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Ellipsis } from 'lucide-react' import { Ellipsis } from 'lucide-react'
import { Event } from 'nostr-tools' 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 { DesktopMenu } from './DesktopMenu'
import EditOrCloneEventDialog, { type TEditOrCloneMode } from './EditOrCloneEventDialog' import EditOrCloneEventDialog, { type TEditOrCloneMode } from './EditOrCloneEventDialog'
import { MobileMenu } from './MobileMenu' import { MobileMenu } from './MobileMenu'
@ -41,8 +46,11 @@ export default function NoteOptions({
/** Default content when opening the editor (e.g. call invite URL). */ /** Default content when opening the editor (e.g. call invite URL). */
initialDefaultContent?: string | null initialDefaultContent?: string | null
}) { }) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false) const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
const [isAttestationDialogOpen, setIsAttestationDialogOpen] = useState(false)
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false) const [isReportDialogOpen, setIsReportDialogOpen] = useState(false)
const [editCloneOpen, setEditCloneOpen] = useState(false) const [editCloneOpen, setEditCloneOpen] = useState(false)
const [editCloneMode, setEditCloneMode] = useState<TEditOrCloneMode>('clone') const [editCloneMode, setEditCloneMode] = useState<TEditOrCloneMode>('clone')
@ -74,6 +82,15 @@ export default function NoteOptions({
setShowSubMenu(true) 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({ const menuActions = useMenuActions({
event, event,
closeDrawer, closeDrawer,
@ -87,7 +104,12 @@ export default function NoteOptions({
setEditCloneMode(mode) setEditCloneMode(mode)
setEditCloneOpen(true) setEditCloneOpen(true)
}, },
pinned pinned,
onViewAttestation: canViewAttestation
? () => {
queueMicrotask(() => setIsAttestationDialogOpen(true))
}
: undefined
}) })
const trigger = useMemo( const trigger = useMemo(
@ -126,6 +148,14 @@ export default function NoteOptions({
isOpen={isRawEventDialogOpen} isOpen={isRawEventDialogOpen}
onClose={() => setIsRawEventDialogOpen(false)} onClose={() => setIsRawEventDialogOpen(false)}
/> />
{attestationEvent ? (
<RawEventDialog
event={attestationEvent}
isOpen={isAttestationDialogOpen}
onClose={() => setIsAttestationDialogOpen(false)}
title={t('Payment attestation')}
/>
) : null}
<ReportDialog <ReportDialog
event={event} event={event}
isOpen={isReportDialogOpen} isOpen={isReportDialogOpen}

21
src/components/NoteOptions/useMenuActions.tsx

@ -48,6 +48,7 @@ import {
Pin, Pin,
SatelliteDish, SatelliteDish,
Send, Send,
Sparkles,
Trash2, Trash2,
TriangleAlert, TriangleAlert,
Video, Video,
@ -118,6 +119,8 @@ interface UseMenuActionsProps {
onOpenEditOrClone?: (mode: TEditOrCloneMode) => void onOpenEditOrClone?: (mode: TEditOrCloneMode) => void
/** When the feed already marks this note pinned (e.g. profile pin section). */ /** When the feed already marks this note pinned (e.g. profile pin section). */
pinned?: boolean pinned?: boolean
/** Opens JSON viewer for the kind 9741 attestation of this payment or zap receipt. */
onViewAttestation?: () => void
} }
export function useMenuActions({ export function useMenuActions({
@ -130,7 +133,8 @@ export function useMenuActions({
onOpenPublicMessage, onOpenPublicMessage,
onOpenCallInvite, onOpenCallInvite,
onOpenEditOrClone, onOpenEditOrClone,
pinned: pinnedInFeed = false pinned: pinnedInFeed = false,
onViewAttestation
}: UseMenuActionsProps) { }: UseMenuActionsProps) {
const { t } = useTranslation() const { t } = useTranslation()
// Use useContext directly to avoid error if provider is not available // Use useContext directly to avoid error if provider is not available
@ -1062,8 +1066,20 @@ export function useMenuActions({
closeDrawer() closeDrawer()
setIsRawEventDialogOpen(true) setIsRawEventDialogOpen(true)
}, },
separator: !onViewAttestation
})
if (onViewAttestation) {
actions.push({
icon: Sparkles,
label: t('View attestation'),
onClick: () => {
closeDrawer()
onViewAttestation()
},
separator: true separator: true
}) })
}
// Add export options for article-type events // Add export options for article-type events
if (isArticleType) { if (isArticleType) {
@ -1258,7 +1274,8 @@ export function useMenuActions({
canSignEvents, canSignEvents,
profile, profile,
noteTranslationFromMenu, noteTranslationFromMenu,
translateMenuOptions translateMenuOptions,
onViewAttestation
]) ])
return menuActions return menuActions

25
src/components/PaytoLink/index.tsx

@ -2,15 +2,13 @@ import { ZAP_SENDING_ENABLED } from '@/constants'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import PaytoTypeIcon from '@/components/PaytoTypeIcon'
import { import {
parsePaytoUri, parsePaytoUri,
buildPaytoUri, buildPaytoUri,
getCanonicalPaytoType, getCanonicalPaytoType,
getPaytoTypeInfo, getPaytoTypeInfo,
getPaytoIconChar,
getPaytoLogoPath,
isKnownPaytoType, isKnownPaytoType,
isLightningPaytoType,
isZappableLightningPaytoType, isZappableLightningPaytoType,
flattenPaytoLinkChildText, flattenPaytoLinkChildText,
formatPaytoLinkDisplayText, formatPaytoLinkDisplayText,
@ -18,7 +16,6 @@ import {
} from '@/lib/payto' } from '@/lib/payto'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import PaytoDialog from '@/components/PaytoDialog' import PaytoDialog from '@/components/PaytoDialog'
import { HelpCircle } from 'lucide-react'
import { URI_LINK_CLASS } from '@/lib/link-styles' import { URI_LINK_CLASS } from '@/lib/link-styles'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { PostPaymentContext } from '@/lib/post-payment-context' import type { PostPaymentContext } from '@/lib/post-payment-context'
@ -76,7 +73,6 @@ export default function PaytoLink({
const { type, authority, raw } = parsed const { type, authority, raw } = parsed
const info = getPaytoTypeInfo(type) const info = getPaytoTypeInfo(type)
const known = isKnownPaytoType(type) const known = isKnownPaytoType(type)
const isLightning = isLightningPaytoType(type)
const canZap = const canZap =
ZAP_SENDING_ENABLED && isZappableLightningPaytoType(type) && !!pubkey && !!onOpenZap ZAP_SENDING_ENABLED && isZappableLightningPaytoType(type) && !!pubkey && !!onOpenZap
@ -102,8 +98,6 @@ export default function PaytoLink({
if (c === 'bitcoin-layer') return 'Bitcoin layer' if (c === 'bitcoin-layer') return 'Bitcoin layer'
return c.charAt(0).toUpperCase() + c.slice(1) return c.charAt(0).toUpperCase() + c.slice(1)
})() })()
const logoPath = getPaytoLogoPath(type)
const iconChar = getPaytoIconChar(type)
const childText = flattenPaytoLinkChildText(children) const childText = flattenPaytoLinkChildText(children)
const useCompactDisplay = const useCompactDisplay =
displayFormat === 'compact' && displayFormat === 'compact' &&
@ -123,22 +117,7 @@ export default function PaytoLink({
: `${displayLabel}: ${t('Click to open payment options')}` : `${displayLabel}: ${t('Click to open payment options')}`
: t('Click to copy address') : t('Click to copy address')
const iconEl = ( const iconEl = <PaytoTypeIcon type={type} className="w-4 h-4 text-[1rem]" />
<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>
)
return ( return (
<> <>

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

10
src/components/Profile/ProfileWallSuperchats.tsx

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

112
src/components/ReplyNoteList/index.tsx

@ -55,7 +55,7 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' import { eventReferencesThreadTarget } from '@/lib/op-reference-tags'
import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match'
import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' 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 { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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_BATCH_DEBOUNCE_MS = 120
const THREAD_PROFILE_CHUNK = 80 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[]) { function replyFeedZapsFirst(sortedNonZapReplies: NEvent[], superchats: NEvent[]) {
return replyFeedSuperchatsFirst(sortedNonZapReplies, superchats) return replyFeedSuperchatsFirst(sortedNonZapReplies, superchats)
} }
@ -354,6 +425,7 @@ function ReplyNoteList({
const { pubkey: userPubkey } = useNostr() const { pubkey: userPubkey } = useNostr()
const { zapReplyThreshold } = useZap() const { zapReplyThreshold } = useZap()
const [attestedPaymentIds, setAttestedPaymentIds] = useState<Set<string>>(() => new Set()) const [attestedPaymentIds, setAttestedPaymentIds] = useState<Set<string>>(() => new Set())
const threadRelayUrlsRef = useRef<string[]>([])
const { blockedRelays, favoriteRelays } = useFavoriteRelays() const { blockedRelays, favoriteRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const relayAuthoritativeRead = const relayAuthoritativeRead =
@ -386,10 +458,18 @@ function ReplyNoteList({
next.add(targetId) next.add(targetId)
return next return next
}) })
void client
.fetchEvent(targetId, { relayHints: threadRelayUrlsRef.current })
.then((target) => {
if (target) addReplies([target])
})
.catch(() => {
/* optional */
})
} }
client.addEventListener('newEvent', handleAttestation) client.addEventListener('newEvent', handleAttestation)
return () => client.removeEventListener('newEvent', handleAttestation) return () => client.removeEventListener('newEvent', handleAttestation)
}, [event.pubkey]) }, [event.pubkey, addReplies])
const replies = useMemo(() => { const replies = useMemo(() => {
const replyIdSet = new Set<string>() const replyIdSet = new Set<string>()
@ -1163,25 +1243,21 @@ function ReplyNoteList({
addReplies(mergedForUi) addReplies(mergedForUi)
const recipientPubkey = event.pubkey const recipientPubkey = event.pubkey
if (recipientPubkey && relayUrlsForThreadReq.length > 0) { threadRelayUrlsRef.current = relayUrlsForThreadReq
void client if (recipientPubkey) {
.fetchEvents( void fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, {
relayUrlsForThreadReq,
{
kinds: [ExtendedKind.PAYMENT_ATTESTATION],
authors: [recipientPubkey],
limit: 500
},
{
cache: true,
eoseTimeout: 4500,
globalTimeout: 12_000,
foreground: statsForeground 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 if (fetchGeneration !== replyFetchGenRef.current) return
setAttestedPaymentIds(buildAttestedPaymentIdSet(attestations, recipientPubkey)) if (targets.length > 0) addReplies(targets)
}) })
.catch(() => { .catch(() => {
/* attestations optional */ /* attestations optional */

42
src/components/TurnIntoSuperchatButton/index.tsx

@ -4,9 +4,12 @@ import { LoginRequiredError } from '@/lib/nostr-errors'
import { showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { import {
getSuperchatAttestationTargetKindValue, getSuperchatAttestationTargetKindValue,
isAttestableSuperchatPayment getSuperchatPaymentRecipientPubkey,
isAttestableSuperchatPayment,
isIncomingPaymentNotificationOrZapReceipt
} from '@/lib/superchat' } from '@/lib/superchat'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { requestProfileWallRefresh } from '@/hooks/useProfileWall'
import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus' import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
@ -25,17 +28,41 @@ export default function TurnIntoSuperchatButton({
/** Full-width call-to-action styling for note cards. */ /** Full-width call-to-action styling for note cards. */
prominent?: boolean prominent?: boolean
}) { }) {
const { t } = useTranslation() const { pubkey } = useNostr()
const { pubkey, publish, checkLogin } = useNostr()
const { attested, checking, recipientPubkey } = usePaymentAttestationStatus(event)
const [publishing, setPublishing] = useState(false)
if (!isAttestableSuperchatPayment(event) || !getSuperchatAttestationTargetKindValue(event)) { if (
!isAttestableSuperchatPayment(event) ||
!getSuperchatAttestationTargetKindValue(event) ||
!pubkey ||
!isIncomingPaymentNotificationOrZapReceipt(event, pubkey)
) {
return null 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 return null
} }
if (attested) { if (attested) {
return ( return (
<p <p
@ -56,6 +83,7 @@ export default function TurnIntoSuperchatButton({
try { try {
const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true }) const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true })
await publish(draft, { disableFallbacks: true }) await publish(draft, { disableFallbacks: true })
requestProfileWallRefresh(recipientPubkey)
showSimplePublishSuccess(t('Superchat attested')) showSimplePublishSuccess(t('Superchat attested'))
} catch (error) { } catch (error) {
if (error instanceof LoginRequiredError) return if (error instanceof LoginRequiredError) return

12
src/hooks/usePaymentAttestationStatus.tsx

@ -1,6 +1,6 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import {
buildAttestedPaymentIdSet, findPaymentAttestationForTarget,
getPaymentAttestationTargetId, getPaymentAttestationTargetId,
getSuperchatPaymentRecipientPubkey getSuperchatPaymentRecipientPubkey
} from '@/lib/superchat' } from '@/lib/superchat'
@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'
export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) { export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) {
const [attested, setAttested] = useState(false) const [attested, setAttested] = useState(false)
const [attestationEvent, setAttestationEvent] = useState<NostrEvent | null>(null)
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null
@ -17,6 +18,7 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
useEffect(() => { useEffect(() => {
setAttested(false) setAttested(false)
setAttestationEvent(null)
if (!targetEvent?.id || !recipientPubkey) return if (!targetEvent?.id || !recipientPubkey) return
let cancelled = false let cancelled = false
@ -35,8 +37,9 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
) )
.then((attestations) => { .then((attestations) => {
if (cancelled) return if (cancelled) return
const ids = buildAttestedPaymentIdSet(attestations, recipientPubkey) const match = findPaymentAttestationForTarget(attestations, targetEvent.id, recipientPubkey)
setAttested(ids.has(targetEvent.id.toLowerCase())) setAttestationEvent(match ?? null)
setAttested(Boolean(match))
}) })
.catch(() => { .catch(() => {
/* optional */ /* optional */
@ -60,6 +63,7 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
const attestedId = getPaymentAttestationTargetId(evt) const attestedId = getPaymentAttestationTargetId(evt)
if (attestedId?.toLowerCase() === targetEvent.id.toLowerCase()) { if (attestedId?.toLowerCase() === targetEvent.id.toLowerCase()) {
setAttested(true) setAttested(true)
setAttestationEvent(evt)
} }
} }
@ -67,5 +71,5 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
return () => client.removeEventListener('newEvent', handleAttestation) return () => client.removeEventListener('newEvent', handleAttestation)
}, [targetEvent?.id, recipientPubkey]) }, [targetEvent?.id, recipientPubkey])
return { attested, checking, recipientPubkey } return { attested, attestationEvent, checking, recipientPubkey }
} }

79
src/hooks/useProfileWall.tsx

@ -19,10 +19,11 @@ import {
type ResolvedProfileBadge type ResolvedProfileBadge
} from '@/lib/nip58-profile-badges' } from '@/lib/nip58-profile-badges'
import { isDirectProfileWallComment } from '@/lib/profile-wall-comments' 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 { isValidPubkey, userIdToPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import type { TSubRequestFilter } from '@/types'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import client, { replaceableEventService } from '@/services/client.service' import client, { replaceableEventService } from '@/services/client.service'
import { ReplaceableEventService } from '@/services/client-replaceable-events.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 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 { export function requestProfileWallRefresh(pubkey: string): void {
const pk = normalizeWallRefreshPubkey(pubkey) const pk = normalizeWallRefreshPubkey(pubkey)
if (!pk) return 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) const listeners = wallRefreshListenersByPubkey.get(pk)
if (!listeners?.size) return if (!listeners?.size) return
for (const listener of listeners) listener() for (const listener of listeners) listener()
@ -116,7 +122,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
const cached = wallCacheByKey.get(cacheKey) const cached = wallCacheByKey.get(cacheKey)
const hasUsefulWallCache = const hasUsefulWallCache =
!!cached && !!cached &&
cached.badges.length > 0 && (cached.badges.length > 0 || (cached.superchats?.length ?? 0) > 0) &&
Date.now() - cached.lastUpdated < CACHE_DURATION Date.now() - cached.lastUpdated < CACHE_DURATION
const pkNormForHydrate = useMemo(() => userIdToPubkey(pubkey) || pubkey, [pubkey]) const pkNormForHydrate = useMemo(() => userIdToPubkey(pubkey) || pubkey, [pubkey])
@ -178,6 +184,23 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
const pk = normalizeWallRefreshPubkey(pkNormForHydrate) const pk = normalizeWallRefreshPubkey(pkNormForHydrate)
if (!pk) return 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() const listeners = wallRefreshListenersByPubkey.get(pk) ?? new Set()
listeners.add(scheduleManualWallRefetch) listeners.add(scheduleManualWallRefetch)
wallRefreshListenersByPubkey.set(pk, listeners) wallRefreshListenersByPubkey.set(pk, listeners)
@ -192,6 +215,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
onAuthorReplaceablesRefreshed onAuthorReplaceablesRefreshed
) )
return () => { return () => {
client.removeEventListener('newEvent', onWallPaymentEvent)
listeners.delete(scheduleManualWallRefetch) listeners.delete(scheduleManualWallRefetch)
if (listeners.size === 0) { if (listeners.size === 0) {
wallRefreshListenersByPubkey.delete(pk) 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). // Do not reuse empty cache (transient abort when secondary panel opens used to cache [] for 5m).
if ( if (
mem && mem &&
mem.badges.length > 0 && (mem.badges.length > 0 || (mem.superchats?.length ?? 0) > 0) &&
Date.now() - mem.lastUpdated < CACHE_DURATION && Date.now() - mem.lastUpdated < CACHE_DURATION &&
refreshToken === 0 refreshToken === 0
) { ) {
@ -299,21 +323,41 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
} }
setIsLoading(false) 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 wallComments: Event[] = []
let wallSuperchats: Event[] = [] let wallSuperchats: Event[] = []
const profileId = profileEventId?.trim().toLowerCase() const profileId =
if (profileId && /^[0-9a-f]{64}$/.test(profileId) && relayUrls.length > 0) { 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 profileCoord = getReplaceableCoordinate(kinds.Metadata, pkNorm, '')
const filters: Filter[] = [ 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], '#p': [pkNorm], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#e': [profileId], limit: 200 }, { kinds: [kinds.Zap], '#p': [pkNorm], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#a': [profileCoord], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pkNorm], limit: 500 } { 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>() 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 { try {
const rows = await Promise.all( const rows = await Promise.all(
filters.map((filter) => filters.map((filter) =>
@ -332,6 +376,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
/* ignore */ /* ignore */
} }
if (profileId) {
wallComments = [...pool.values()] wallComments = [...pool.values()]
.filter( .filter(
(e) => (e) =>
@ -340,18 +385,20 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
isDirectProfileWallComment(e, profileId, pkNorm) isDirectProfileWallComment(e, profileId, pkNorm)
) )
.sort((a, b) => b.created_at - a.created_at) .sort((a, b) => b.created_at - a.created_at)
}
const paymentNotifications = [...pool.values()].filter( const paymentEvents = [...pool.values()].filter(
(e) => (e) =>
e.kind === ExtendedKind.PAYMENT_NOTIFICATION && (e.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
!isEventDeletedRef.current(e) && e.kind === kinds.Zap ||
isProfileWallPaymentNotification(e, pkNorm, profileId) e.kind === ExtendedKind.ZAP_RECEIPT) &&
!isEventDeletedRef.current(e)
) )
const attestations = [...pool.values()].filter( const attestations = [...pool.values()].filter(
(e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION (e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION
) )
wallSuperchats = filterAttestedProfileWallSuperchats( wallSuperchats = filterAttestedProfileWallSuperchats(
paymentNotifications, paymentEvents,
attestations, attestations,
pkNorm, pkNorm,
profileId profileId

3
src/i18n/locales/en.ts

@ -84,6 +84,9 @@ export default {
"Copy user ID": "Copy user ID", "Copy user ID": "Copy user ID",
"Send public message": "Send public message", "Send public message": "Send public message",
"View raw event": "View raw event", "View raw event": "View raw event",
"View attestation": "View attestation",
"Payment attestation": "Payment attestation",
"Raw Event": "Raw Event",
"Edit this event": "Edit this event", "Edit this event": "Edit this event",
"Clone or fork this event": "Clone or fork this event", "Clone or fork this event": "Clone or fork this event",
"Event kind": "Event kind", "Event kind": "Event kind",

14
src/lib/event.ts

@ -209,8 +209,12 @@ export function getParentETag(event?: Event) {
return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E')) 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`) // Kind 9735 / 9740: referenced note id is on `e` / `E` (or addressable target on `a` / `A`).
if (event.kind === kinds.Zap) { if (
event.kind === kinds.Zap ||
event.kind === ExtendedKind.ZAP_RECEIPT ||
event.kind === ExtendedKind.PAYMENT_NOTIFICATION
) {
const firstHex = getFirstHexEventIdFromETags(event.tags) const firstHex = getFirstHexEventIdFromETags(event.tags)
if (firstHex) { if (firstHex) {
return ( return (
@ -242,7 +246,11 @@ export function getParentETag(event?: Event) {
export function getParentATag(event?: Event) { export function getParentATag(event?: Event) {
if (!event) return undefined 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')) return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A'))
} }
if ( if (

76
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()
}
}

81
src/lib/superchat.test.ts

@ -7,6 +7,7 @@ import {
getSuperchatPaytoType, getSuperchatPaytoType,
getSuperchatReferenceFetchId, getSuperchatReferenceFetchId,
isProfileWallPaymentNotification, isProfileWallPaymentNotification,
isProfileWallZapReceipt,
partitionAttestedSuperchats partitionAttestedSuperchats
} from '@/lib/superchat' } from '@/lib/superchat'
import { parsePaytoTagType } from '@/lib/payto' import { parsePaytoTagType } from '@/lib/payto'
@ -118,6 +119,35 @@ describe('partitionAttestedSuperchats', () => {
expect(superchats.map((e) => e.id)).toEqual([payment.id, zapAttested.id]) expect(superchats.map((e) => e.id)).toEqual([payment.id, zapAttested.id])
expect(rest).toEqual([comment]) 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', () => { describe('getPaymentNotificationInfo', () => {
@ -226,4 +256,55 @@ describe('profile wall payment notifications', () => {
expect(out).toHaveLength(1) expect(out).toHaveLength(1)
expect(out[0]!.id).toBe(paymentId) 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(
return out 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 { export function getPaymentNotificationInfo(event: Event): PaymentNotificationInfo | null {
if (event.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return null if (event.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return null
@ -170,18 +188,14 @@ export function sortSuperchatsByAmountDesc(events: Event[]): Event[] {
export function partitionAttestedSuperchats( export function partitionAttestedSuperchats(
items: Event[], items: Event[],
attestedIds: Set<string>, attestedIds: Set<string>,
zapReplyThreshold: number _zapReplyThreshold: number
): { superchats: Event[]; rest: Event[] } { ): { superchats: Event[]; rest: Event[] } {
const superchats: Event[] = [] const superchats: Event[] = []
const rest: Event[] = [] const rest: Event[] = []
for (const e of items) { for (const e of items) {
if (e.kind === kinds.Zap) { if (e.kind === kinds.Zap || e.kind === ExtendedKind.ZAP_RECEIPT) {
if ( if (isAttestedSuperchat(e, attestedIds) && getZapInfoFromEvent(e)) {
isAttestedSuperchat(e, attestedIds) &&
getZapInfoFromEvent(e) &&
getSuperchatAmountSats(e) >= zapReplyThreshold
) {
superchats.push(e) superchats.push(e)
} }
continue continue
@ -202,27 +216,23 @@ export function replyFeedSuperchatsFirst(sortedNonSuperchatReplies: Event[], sup
return [...superchats, ...sortedNonSuperchatReplies] return [...superchats, ...sortedNonSuperchatReplies]
} }
/** Kind 9740 on a profile wall: `p` is the profile owner and there is no note/thread reference. */ function isProfileWallThreadReference(
export function isProfileWallPaymentNotification( referencedEventId: string | undefined,
event: Event, referencedCoordinate: string | undefined,
profilePubkey: string, profilePubkey: string,
profileEventId?: string profileEventId?: string
): boolean { ): boolean {
if (event.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return false if (referencedEventId) {
const info = getPaymentNotificationInfo(event)
if (!info || info.recipientPubkey.toLowerCase() !== profilePubkey.toLowerCase()) return false
if (info.referencedEventId) {
const profileId = profileEventId?.trim().toLowerCase() const profileId = profileEventId?.trim().toLowerCase()
if (profileId && info.referencedEventId === profileId) return true if (profileId && referencedEventId.toLowerCase() === profileId) return true
return false return false
} }
if (info.referencedCoordinate) { if (referencedCoordinate) {
const profileCoord = normalizeReplaceableCoordinateString( const profileCoord = normalizeReplaceableCoordinateString(
getReplaceableCoordinate(kinds.Metadata, profilePubkey, '') getReplaceableCoordinate(kinds.Metadata, profilePubkey, '')
) )
if (normalizeReplaceableCoordinateString(info.referencedCoordinate) === profileCoord) { if (normalizeReplaceableCoordinateString(referencedCoordinate) === profileCoord) {
return true return true
} }
return false return false
@ -231,18 +241,62 @@ export function isProfileWallPaymentNotification(
return true 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( export function filterAttestedProfileWallSuperchats(
paymentNotifications: Event[], paymentEvents: Event[],
attestations: Event[], attestations: Event[],
profilePubkey: string, profilePubkey: string,
profileEventId?: string profileEventId?: string
): Event[] { ): Event[] {
const attestedIds = buildAttestedPaymentIdSet(attestations, profilePubkey) const attestedIds = buildAttestedPaymentIdSet(attestations, profilePubkey)
return sortSuperchatsByAmountDesc( return sortSuperchatsByAmountDesc(
paymentNotifications.filter( paymentEvents.filter((e) => {
(e) => if (e.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
return (
isProfileWallPaymentNotification(e, profilePubkey, profileEventId) && isProfileWallPaymentNotification(e, profilePubkey, profileEventId) &&
isAttestedSuperchat(e, attestedIds) 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 }) {
logger.warn('[Publish] Calendar RSVP IndexedDB persist failed', { err }) 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) client.emitNewEvent(event)
// Replaceable list events (pins, cache relays, …) must hit IndexedDB + DataLoader, not only RAM // Replaceable list events (pins, cache relays, …) must hit IndexedDB + DataLoader, not only RAM
void replaceableEventService.updateReplaceableEventCache(event).catch(() => {}) void replaceableEventService.updateReplaceableEventCache(event).catch(() => {})

20
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). */ /** 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'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query' import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag'
import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter' import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter'
import { getPaymentAttestationTargetId } from '@/lib/superchat'
import { import {
buildPublicMessagePublishRelayUrls, buildPublicMessagePublishRelayUrls,
collectRecipientInboxUrls, collectRecipientInboxUrls,
@ -1257,6 +1258,48 @@ class ClientService extends EventTarget {
return pubRelays 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[] let relays: string[]
if (specifiedRelayUrls?.length) { if (specifiedRelayUrls?.length) {
relays = specifiedRelayUrls relays = specifiedRelayUrls
@ -2224,18 +2267,22 @@ class ClientService extends EventTarget {
add(this.eventService.getSessionEventsMatchingFilters(filters, maxMatches)) 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[]), this.getTimelineDiskSnapshotEvents(subRequests).catch(() => [] as NEvent[]),
indexedDb indexedDb
.scanEventArchiveByFilters(filters, { maxRowsScanned, maxMatches }) .scanEventArchiveByFilters(filters, { maxRowsScanned, maxMatches })
.catch(() => [] as NEvent[]), .catch(() => [] as NEvent[]),
indexedDb indexedDb
.scanPublicationEventsByFilters(filters, { maxRowsScanned: Math.min(maxRowsScanned, 16_000), maxMatches }) .scanPublicationEventsByFilters(filters, { maxRowsScanned: Math.min(maxRowsScanned, 16_000), maxMatches })
.catch(() => [] as NEvent[]),
indexedDb
.getPaymentSuperchatEventsMatchingFilters(filters, maxMatches)
.catch(() => [] as NEvent[]) .catch(() => [] as NEvent[])
]) ])
add(timelineRows) add(timelineRows)
add(archiveRows) add(archiveRows)
add(publicationRows) add(publicationRows)
add(paymentSuperchatRows)
return [...byId.values()] return [...byId.values()]
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) .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 {
function shouldSkipArchiving(ev: Event): boolean { function shouldSkipArchiving(ev: Event): boolean {
if (shouldDropEventOnIngest(ev)) return true if (shouldDropEventOnIngest(ev)) return true
if (isNip52CalendarCardKind(ev.kind) || ev.kind === ExtendedKind.CALENDAR_EVENT_RSVP) 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)) { if (isReplaceableEvent(ev.kind) && indexedDb.hasReplaceableEventStoreForKind(ev.kind)) {
return true return true
} }

253
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 { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-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' import type { Filter } from 'nostr-tools'
/** Hot archive row in {@link StoreNames.EVENT_ARCHIVE}. */ /** 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`. */ /** NIP-52 calendar notes (31922/31923). Key: {@link replaceableEventDedupeKey}. Index: `occurrenceStartMs`. */
CALENDAR_EVENTS: 'calendarEvents', CALENDAR_EVENTS: 'calendarEvents',
/** NIP-52 calendar RSVPs (31925). Key: event id. Index: `parentCoordinate` (`a` tag). */ /** 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}. */ /** Row shape for {@link StoreNames.CALENDAR_EVENTS}. */
@ -173,7 +183,9 @@ const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet<string> = new Set(
StoreNames.MUTE_DECRYPTED_TAGS, StoreNames.MUTE_DECRYPTED_TAGS,
StoreNames.FAVORITE_RELAYS, StoreNames.FAVORITE_RELAYS,
StoreNames.CALENDAR_EVENTS, 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([
const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37' const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37'
/** Schema version we expect. When adding stores or migrations, bump this. */ /** 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). */ /** Hint age for profile/payment reads (stale rows still returned; background refresh). */
const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000 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) { } else if (storeName === StoreNames.CALENDAR_RSVP_EVENTS) {
const rsvp = db.createObjectStore(storeName, { keyPath: 'key' }) const rsvp = db.createObjectStore(storeName, { keyPath: 'key' })
rsvp.createIndex('parentCoordinate', 'parentCoordinate', { unique: false }) 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 { } else {
db.createObjectStore(storeName, { keyPath: 'key' }) db.createObjectStore(storeName, { keyPath: 'key' })
} }
@ -474,6 +495,19 @@ class IndexedDbService {
if (event.oldVersion < 37) { if (event.oldVersion < 37) {
// v37: drop legacy object stores; calendar notes purged from EVENT_ARCHIVE post-open // 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) ensureMissingObjectStores(db)
} }
} }
@ -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() const instance = IndexedDbService.getInstance()

Loading…
Cancel
Save