Browse Source

make sending a message more intentional

imwald
Silberengel 3 weeks ago
parent
commit
524725204d
  1. 2
      src/components/Note/Superchat.tsx
  2. 2
      src/components/Note/Zap.tsx
  3. 47
      src/components/NoteStats/ZapButton.tsx
  4. 2
      src/components/PaymentMethodsSection/index.tsx
  5. 15
      src/components/PaytoDialog/LightningInvoiceSection.tsx
  6. 111
      src/components/PaytoDialog/index.tsx
  7. 92
      src/components/TurnIntoSuperchatButton/index.tsx
  8. 114
      src/components/ZapDialog/PostPaymentMessagePrompt.tsx
  9. 11
      src/components/ZapDialog/PublicMessageForm.tsx
  10. 11
      src/components/ZapDialog/SuperchatRequestForm.tsx
  11. 45
      src/components/ZapDialog/index.tsx
  12. 71
      src/hooks/usePaymentAttestationStatus.tsx
  13. 4
      src/i18n/locales/en.ts
  14. 29
      src/lib/draft-event.ts
  15. 1
      src/lib/notification-thread-watch.ts
  16. 65
      src/lib/post-payment-context.test.ts
  17. 23
      src/lib/post-payment-context.ts
  18. 36
      src/lib/superchat.ts

2
src/components/Note/Superchat.tsx

@ -10,6 +10,7 @@ import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/Page
import Username from '../Username' import Username from '../Username'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel' import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel'
import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
export default function Superchat({ export default function Superchat({
event, event,
@ -148,6 +149,7 @@ export default function Superchat({
) : null} ) : null}
</div> </div>
</div> </div>
<TurnIntoSuperchatButton event={event} prominent className="mt-4" />
</div> </div>
) )
} }

2
src/components/Note/Zap.tsx

@ -12,6 +12,7 @@ import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/Page
import Username from '../Username' import Username from '../Username'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel' import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel'
import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
export default function Zap({ export default function Zap({
event, event,
@ -187,6 +188,7 @@ export default function Zap({
</div> </div>
</div> </div>
</div> </div>
<TurnIntoSuperchatButton event={event} prominent className="mt-4" />
</div> </div>
) )
} }

47
src/components/NoteStats/ZapButton.tsx

@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import ZapDialog from '../ZapDialog' import ZapDialog from '../ZapDialog'
import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt' import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt'
import { buildPostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context' import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
type ZapButtonProps = { type ZapButtonProps = {
event: Event event: Event
@ -156,6 +156,19 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
const [disable, setDisable] = useState(true) const [disable, setDisable] = useState(true)
const [tipPaymentData, setTipPaymentData] = useState<RecipientZapPaymentData | null>(null) const [tipPaymentData, setTipPaymentData] = useState<RecipientZapPaymentData | null>(null)
const [postPaymentOpen, setPostPaymentOpen] = useState(false)
const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null)
const handlePostPaymentRequest = useCallback(
(context: PostPaymentContext) => {
if (event.pubkey === pubkey) return
setPostPaymentContext(
mergePostPaymentContext({ recipientPubkey: event.pubkey, referencedEvent: event }, context)
)
setPostPaymentOpen(true)
},
[event, pubkey]
)
const applyTipAvailability = useCallback( const applyTipAvailability = useCallback(
( (
@ -256,6 +269,13 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
pubkey={event.pubkey} pubkey={event.pubkey}
event={event} event={event}
prefetchedPayment={tipPaymentData} prefetchedPayment={tipPaymentData}
onPostPaymentRequest={handlePostPaymentRequest}
/>
<PostPaymentMessagePrompt
open={postPaymentOpen}
onOpenChange={setPostPaymentOpen}
recipientPubkey={event.pubkey}
paymentContext={postPaymentContext}
/> />
</> </>
) )
@ -274,6 +294,17 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
const [postPaymentOpen, setPostPaymentOpen] = useState(false) const [postPaymentOpen, setPostPaymentOpen] = useState(false)
const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null) const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null)
const [zapping, setZapping] = useState(false) const [zapping, setZapping] = useState(false)
const handlePostPaymentRequest = useCallback(
(context: PostPaymentContext) => {
if (event.pubkey === pubkey) return
setPostPaymentContext(
mergePostPaymentContext({ recipientPubkey: event.pubkey, referencedEvent: event }, context)
)
setPostPaymentOpen(true)
},
[event, pubkey]
)
const statsLoaded = noteStats?.updatedAt != null const statsLoaded = noteStats?.updatedAt != null
const { zapAmount, hasZapped } = useMemo(() => { const { zapAmount, hasZapped } = useMemo(() => {
return { return {
@ -376,15 +407,12 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
defaultZapComment, defaultZapComment,
undefined, undefined,
() => { () => {
if (event.pubkey === pubkey) return handlePostPaymentRequest(
setPostPaymentContext( mergePostPaymentContext(
buildPostPaymentContext({ { recipientPubkey: event.pubkey, referencedEvent: event },
recipientPubkey: event.pubkey, { amountMsat: paymentDetails.amountMsat }
amountMsat: paymentDetails.amountMsat, )
referencedEvent: event
})
) )
setPostPaymentOpen(true)
} }
) )
if (!zapResult) { if (!zapResult) {
@ -518,6 +546,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
pubkey={event.pubkey} pubkey={event.pubkey}
event={event} event={event}
prefetchedPayment={tipPaymentData} prefetchedPayment={tipPaymentData}
onPostPaymentRequest={handlePostPaymentRequest}
/> />
<PostPaymentMessagePrompt <PostPaymentMessagePrompt
open={postPaymentOpen} open={postPaymentOpen}

2
src/components/PaymentMethodsSection/index.tsx

@ -72,7 +72,7 @@ export default function PaymentMethodsSection({
authority={method.authority} authority={method.authority}
paytoUri={method.payto} paytoUri={method.payto}
displayFormat="full" displayFormat="full"
pubkey={isZappableLightningPaytoType(method.type) ? recipientPubkey : undefined} pubkey={recipientPubkey}
onOpenZap={ onOpenZap={
isZappableLightningPaytoType(method.type) && onOpenZap isZappableLightningPaytoType(method.type) && onOpenZap
? (_pk, authority) => onOpenZap(authority) ? (_pk, authority) => onOpenZap(authority)

15
src/components/PaytoDialog/LightningInvoiceSection.tsx

@ -35,16 +35,13 @@ export default function LightningInvoiceSection({
lightningAddress, lightningAddress,
paytoUri, paytoUri,
onBolt11InvoiceChange, onBolt11InvoiceChange,
onRequestClose,
onPaymentFlowComplete onPaymentFlowComplete
}: { }: {
lightningAddress: string lightningAddress: string
paytoUri: string paytoUri: string
/** Fired when a BOLT11 invoice is created or cleared (for Phoenix / external wallet links). */ /** Fired when a BOLT11 invoice is created or cleared (for Phoenix / external wallet links). */
onBolt11InvoiceChange?: (invoice: string | null) => void onBolt11InvoiceChange?: (invoice: string | null) => void
/** Close the payto dialog before opening an external wallet / Bitcoin Connect UI. */ /** After a wallet payment succeeds (dialog stays open for the user to choose next steps). */
onRequestClose?: () => void
/** After the payment modal closes (success or cancel). */
onPaymentFlowComplete?: (details?: { amountMsat: number; payto: string }) => void onPaymentFlowComplete?: (details?: { amountMsat: number; payto: string }) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -148,11 +145,9 @@ export default function LightningInvoiceSection({
if (!invoice) return if (!invoice) return
try { try {
setPaying(true) setPaying(true)
const result = await lightning.payInvoice( const result = await lightning.payInvoice(invoice, undefined, (flowResult) => {
invoice, if (flowResult) onPaymentFlowComplete?.(paymentDetails)
onRequestClose, })
() => onPaymentFlowComplete?.(paymentDetails)
)
if (!mountedRef.current) return if (!mountedRef.current) return
if (result) { if (result) {
toast.success(t('Payment sent')) toast.success(t('Payment sent'))
@ -312,7 +307,9 @@ export default function LightningInvoiceSection({
role="img" role="img"
aria-label={t('Scan to pay with a Lightning wallet')} aria-label={t('Scan to pay with a Lightning wallet')}
> >
<div className="w-full max-w-[min(100%,240px)]">
<QrCode value={invoiceQrValue} size={240} /> <QrCode value={invoiceQrValue} size={240} />
</div>
<p className="text-center text-sm text-muted-foreground sm:text-base"> <p className="text-center text-sm text-muted-foreground sm:text-base">
{t('Scan to pay with a Lightning wallet')} {t('Scan to pay with a Lightning wallet')}
</p> </p>

111
src/components/PaytoDialog/index.tsx

@ -3,6 +3,7 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle DialogTitle
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
@ -32,7 +33,7 @@ import {
} from '@/lib/payto' } from '@/lib/payto'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { buildPostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context' import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import LightningInvoiceSection from './LightningInvoiceSection' import LightningInvoiceSection from './LightningInvoiceSection'
@ -52,7 +53,7 @@ export default function PaytoDialog({
type: string type: string
authority: string authority: string
paytoUri: string paytoUri: string
/** When set, closing the dialog offers a post-payment message prompt to this pubkey. */ /** When set, the dialog offers a post-payment message prompt to this pubkey. */
recipientPubkey?: string recipientPubkey?: string
/** Note or profile context for superchat placement (kind 9740). */ /** Note or profile context for superchat placement (kind 9740). */
referencedEvent?: NostrEvent referencedEvent?: NostrEvent
@ -63,41 +64,54 @@ export default function PaytoDialog({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: selfPubkey } = useNostr() const { pubkey: selfPubkey } = useNostr()
const sendMessageRef = useRef<HTMLButtonElement>(null)
const [postPaymentOpen, setPostPaymentOpen] = useState(false) const [postPaymentOpen, setPostPaymentOpen] = useState(false)
const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null) const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null)
const skipPostPaymentOnCloseRef = useRef(false) const [completedPaymentDetails, setCompletedPaymentDetails] = useState<
Partial<Pick<PostPaymentContext, 'amountMsat' | 'payto'>> | null
>(null)
const info = getPaytoTypeInfo(type) const info = getPaytoTypeInfo(type)
const label = info?.label ?? type const label = info?.label ?? type
const isLightning = type.toLowerCase() === 'lightning' const isLightning = type.toLowerCase() === 'lightning'
const [bolt11Invoice, setBolt11Invoice] = useState<string | null>(null) const [bolt11Invoice, setBolt11Invoice] = useState<string | null>(null)
const [selectedOpenHandlerId, setSelectedOpenHandlerId] = useState('') const [selectedOpenHandlerId, setSelectedOpenHandlerId] = useState('')
const canOfferPostPayment =
!!recipientPubkey && (!selfPubkey || recipientPubkey !== selfPubkey)
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setBolt11Invoice(null) setBolt11Invoice(null)
setSelectedOpenHandlerId('') setSelectedOpenHandlerId('')
setCompletedPaymentDetails(null)
closeModal() closeModal()
releaseBodyScrollLocks() releaseBodyScrollLocks()
} }
}, [open]) }, [open])
useEffect(() => {
if (!open || !canOfferPostPayment) return
const id = requestAnimationFrame(() => sendMessageRef.current?.focus())
return () => cancelAnimationFrame(id)
}, [open, canOfferPostPayment])
const closeForWalletFlow = useCallback(() => { const closeForWalletFlow = useCallback(() => {
skipPostPaymentOnCloseRef.current = true
onOpenChange(false) onOpenChange(false)
}, [onOpenChange]) }, [onOpenChange])
const openPostPaymentPrompt = useCallback( const openPostPaymentPrompt = useCallback(
(context?: Partial<PostPaymentContext>) => { (context?: Partial<PostPaymentContext>) => {
if (!recipientPubkey) return if (!canOfferPostPayment) return
if (selfPubkey && recipientPubkey === selfPubkey) return const built = mergePostPaymentContext(
const built = buildPostPaymentContext({ { recipientPubkey: recipientPubkey!, referencedEvent },
recipientPubkey, {
paytoUri, paytoUri,
paytoType: type, paytoType: type,
paytoAuthority: authority, paytoAuthority: authority,
referencedEvent, ...completedPaymentDetails,
...context ...context
}) }
)
if (onPostPaymentRequest) { if (onPostPaymentRequest) {
onPostPaymentRequest(built) onPostPaymentRequest(built)
return return
@ -107,26 +121,22 @@ export default function PaytoDialog({
setPostPaymentOpen(true) setPostPaymentOpen(true)
}, },
[ [
canOfferPostPayment,
offerTipNoticeOnClose, offerTipNoticeOnClose,
onPostPaymentRequest, onPostPaymentRequest,
recipientPubkey, recipientPubkey,
selfPubkey,
paytoUri, paytoUri,
type, type,
authority, authority,
referencedEvent referencedEvent,
completedPaymentDetails
] ]
) )
/** Run after the payto dialog has closed so nested modals (e.g. inside ZapDialog) do not dismiss the prompt. */ const handleSendMessage = () => {
const schedulePostPaymentPrompt = useCallback( onOpenChange(false)
(context?: Partial<PostPaymentContext>) => { requestAnimationFrame(() => openPostPaymentPrompt())
requestAnimationFrame(() => { }
requestAnimationFrame(() => openPostPaymentPrompt(context))
})
},
[openPostPaymentPrompt]
)
const openHandlers = useMemo( const openHandlers = useMemo(
() => () =>
@ -170,31 +180,11 @@ export default function PaytoDialog({
const handleCopy = (text: string, copyLabel?: string) => { const handleCopy = (text: string, copyLabel?: string) => {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
toast.success(copyLabel ? t('Copied {{label}} address', { label: copyLabel }) : t('Copied to clipboard')) toast.success(copyLabel ? t('Copied {{label}} address', { label: copyLabel }) : t('Copied to clipboard'))
handleDialogOpenChange(false)
}
const maybeOfferPostPaymentOnClose = () => {
if (skipPostPaymentOnCloseRef.current) return
schedulePostPaymentPrompt()
}
const handleDialogOpenChange = (next: boolean) => {
if (!next) {
const skipped = skipPostPaymentOnCloseRef.current
skipPostPaymentOnCloseRef.current = false
onOpenChange(next)
if (!skipped) {
maybeOfferPostPaymentOnClose()
}
} else {
skipPostPaymentOnCloseRef.current = false
onOpenChange(next)
}
} }
return ( return (
<> <>
<Dialog open={open} onOpenChange={handleDialogOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
className={cn( className={cn(
'left-[50%] top-[50%] flex w-[calc(100vw-1.25rem)] max-w-md translate-x-[-50%] translate-y-[-50%] flex-col gap-0', 'left-[50%] top-[50%] flex w-[calc(100vw-1.25rem)] max-w-md translate-x-[-50%] translate-y-[-50%] flex-col gap-0',
@ -222,10 +212,8 @@ export default function PaytoDialog({
lightningAddress={authority} lightningAddress={authority}
paytoUri={paytoUri} paytoUri={paytoUri}
onBolt11InvoiceChange={setBolt11Invoice} onBolt11InvoiceChange={setBolt11Invoice}
onRequestClose={closeForWalletFlow}
onPaymentFlowComplete={(details) => { onPaymentFlowComplete={(details) => {
onOpenChange(false) setCompletedPaymentDetails({
schedulePostPaymentPrompt({
amountMsat: details?.amountMsat, amountMsat: details?.amountMsat,
payto: details?.payto payto: details?.payto
}) })
@ -328,6 +316,39 @@ export default function PaytoDialog({
</div> </div>
)} )}
</div> </div>
{canOfferPostPayment ? (
<DialogFooter className="flex shrink-0 flex-col-reverse gap-2 border-t border-border/60 px-4 py-3 sm:flex-row sm:justify-end sm:px-5">
<Button
type="button"
variant="outline"
className="w-full min-w-0 sm:w-auto"
onClick={() => onOpenChange(false)}
>
{t('Close')}
</Button>
<Button
ref={sendMessageRef}
type="button"
variant="default"
className="w-full min-w-0 sm:w-auto"
onClick={handleSendMessage}
>
{t('Send a message')}
</Button>
</DialogFooter>
) : (
<DialogFooter className="shrink-0 border-t border-border/60 px-4 py-3 sm:px-5">
<Button
type="button"
variant="outline"
className="w-full min-w-0 sm:ml-auto sm:w-auto"
onClick={() => onOpenChange(false)}
>
{t('Close')}
</Button>
</DialogFooter>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{recipientPubkey && !onPostPaymentRequest ? ( {recipientPubkey && !onPostPaymentRequest ? (

92
src/components/TurnIntoSuperchatButton/index.tsx

@ -0,0 +1,92 @@
import { Button } from '@/components/ui/button'
import { createPaymentAttestationDraftEvent } from '@/lib/draft-event'
import { LoginRequiredError } from '@/lib/nostr-errors'
import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
import {
getSuperchatAttestationTargetKindValue,
isAttestableSuperchatPayment
} from '@/lib/superchat'
import { cn } from '@/lib/utils'
import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus'
import { useNostr } from '@/providers/NostrProvider'
import { Sparkles } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export default function TurnIntoSuperchatButton({
event,
className,
prominent = false
}: {
event: Event
className?: string
/** 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)
if (!isAttestableSuperchatPayment(event) || !getSuperchatAttestationTargetKindValue(event)) {
return null
}
if (!pubkey || !recipientPubkey || recipientPubkey.toLowerCase() !== pubkey.toLowerCase()) {
return null
}
if (attested) {
return (
<p
className={cn(
'text-sm font-medium text-yellow-400/90',
prominent && 'rounded-md border border-yellow-400/40 bg-yellow-400/10 px-3 py-2 text-center',
className
)}
>
{t('Superchat attested')}
</p>
)
}
const handleAttest = () => {
checkLogin(async () => {
setPublishing(true)
try {
const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true })
await publish(draft, { disableFallbacks: true })
showSimplePublishSuccess(t('Superchat attested'))
} catch (error) {
if (error instanceof LoginRequiredError) return
toast.error(
t('Failed to attest superchat', {
error: error instanceof Error ? error.message : String(error)
})
)
} finally {
setPublishing(false)
}
})
}
return (
<Button
type="button"
variant={prominent ? 'default' : 'secondary'}
className={cn(
prominent &&
'h-auto min-h-11 w-full gap-2 border-yellow-400/50 bg-yellow-400/20 py-2.5 text-base font-semibold text-yellow-100 shadow-[0_0_16px_rgba(250,204,21,0.25)] hover:bg-yellow-400/30',
className
)}
disabled={checking || publishing}
onClick={(e) => {
e.stopPropagation()
handleAttest()
}}
>
<Sparkles className={cn('shrink-0', prominent ? 'size-5' : 'size-4')} aria-hidden />
{t('Turn this into a superchat!')}
</Button>
)
}

114
src/components/ZapDialog/PostPaymentMessagePrompt.tsx

@ -14,8 +14,8 @@ import {
DrawerHeader, DrawerHeader,
DrawerTitle DrawerTitle
} from '@/components/ui/drawer' } from '@/components/ui/drawer'
import { type PostPaymentContext } from '@/lib/post-payment-context'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { type PostPaymentContext } from '@/lib/post-payment-context'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -26,33 +26,7 @@ import SuperchatRequestForm from './SuperchatRequestForm'
type Step = 'choice' | 'public-message' | 'superchat' type Step = 'choice' | 'public-message' | 'superchat'
function ChoiceButton({ const footerButtonClass = 'w-full min-w-0 sm:w-auto'
title,
hint,
onClick,
disabled
}: {
title: string
hint: string
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
'w-full rounded-lg border border-border bg-muted/30 px-4 py-3 text-left transition-colors',
'hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'disabled:pointer-events-none disabled:opacity-50'
)}
>
<span className="block text-sm font-medium text-foreground">{title}</span>
<span className="mt-1 block text-xs leading-relaxed text-muted-foreground">{hint}</span>
</button>
)
}
export default function PostPaymentMessagePrompt({ export default function PostPaymentMessagePrompt({
open, open,
@ -67,7 +41,7 @@ export default function PostPaymentMessagePrompt({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const closeRef = useRef<HTMLButtonElement>(null) const superchatRef = useRef<HTMLButtonElement>(null)
const [step, setStep] = useState<Step>('choice') const [step, setStep] = useState<Step>('choice')
useEffect(() => { useEffect(() => {
@ -78,7 +52,7 @@ export default function PostPaymentMessagePrompt({
useEffect(() => { useEffect(() => {
if (!open || step !== 'choice') return if (!open || step !== 'choice') return
const id = requestAnimationFrame(() => closeRef.current?.focus()) const id = requestAnimationFrame(() => superchatRef.current?.focus())
return () => cancelAnimationFrame(id) return () => cancelAnimationFrame(id)
}, [open, step]) }, [open, step])
@ -87,34 +61,41 @@ export default function PostPaymentMessagePrompt({
const handleClose = () => onOpenChange(false) const handleClose = () => onOpenChange(false)
const choiceBody = ( const choiceBody = (
<div className="min-w-0 space-y-3"> <p className="min-w-0 text-sm leading-relaxed text-muted-foreground">{t('Post payment prompt label')}</p>
<p className="text-sm font-medium text-foreground">{t('Post payment prompt label')}</p>
<div className="space-y-2">
<ChoiceButton
title={t('Send them a public message')}
hint={t('Post payment public message hint')}
onClick={() => setStep('public-message')}
/>
<ChoiceButton
title={t('Request a superchat')}
hint={t('Post payment superchat hint')}
onClick={() => setStep('superchat')}
/>
</div>
</div>
) )
const choiceActions = ( const choiceActions = (
<Button ref={closeRef} type="button" variant="default" onClick={handleClose}> <>
<Button type="button" variant="outline" className={footerButtonClass} onClick={handleClose}>
{t('Close')} {t('Close')}
</Button> </Button>
<Button
type="button"
variant="secondary"
className={footerButtonClass}
onClick={() => setStep('public-message')}
>
{t('Send them a public message')}
</Button>
<Button
ref={superchatRef}
type="button"
variant="default"
className={footerButtonClass}
onClick={() => setStep('superchat')}
>
{t('Request a superchat')}
</Button>
</>
) )
const title = ( const title = (
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="shrink-0">{t('Send them a message')}</span> <span className="shrink-0">{t('Send them a message')}</span>
<span className="inline-flex min-w-0 max-w-full items-center gap-2">
<UserAvatar size="small" userId={recipientPubkey} className="shrink-0" /> <UserAvatar size="small" userId={recipientPubkey} className="shrink-0" />
<Username userId={recipientPubkey} className="min-w-0 flex-1 truncate" /> <Username userId={recipientPubkey} className="min-w-0 truncate" />
</span>
</span> </span>
) )
@ -136,27 +117,28 @@ export default function PostPaymentMessagePrompt({
choiceBody choiceBody
) )
const footer = const choiceFooterClass = cn(
step === 'choice' ? ( 'flex w-full min-w-0 flex-col-reverse gap-2',
isSmallScreen ? ( !isSmallScreen && 'sm:flex-row sm:flex-wrap sm:justify-end'
choiceActions
) : (
<DialogFooter className="gap-2 sm:gap-2">{choiceActions}</DialogFooter>
) )
) : null
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Drawer open={open} onOpenChange={onOpenChange}> <Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="min-w-0 overflow-hidden px-4 pb-6" onOpenAutoFocus={(e) => e.preventDefault()}> <DrawerContent
<DrawerHeader> className="max-h-[92dvh] min-w-0 overflow-hidden px-4 pb-[max(1rem,env(safe-area-inset-bottom))]"
<DrawerTitle>{title}</DrawerTitle> onOpenAutoFocus={(e) => e.preventDefault()}
>
<DrawerHeader className="min-w-0 text-left">
<DrawerTitle className="min-w-0 break-words">{title}</DrawerTitle>
{step === 'choice' ? ( {step === 'choice' ? (
<DialogDescription className="sr-only">{t('Post payment prompt label')}</DialogDescription> <DialogDescription className="sr-only">{t('Post payment prompt label')}</DialogDescription>
) : null} ) : null}
</DrawerHeader> </DrawerHeader>
<div className="px-0 pb-4">{body}</div> <div className="min-w-0 overflow-x-hidden px-0 pb-4">{body}</div>
{footer ? <DrawerFooter className="flex-row justify-end gap-2 pt-2">{footer}</DrawerFooter> : null} {step === 'choice' ? (
<DrawerFooter className={choiceFooterClass}>{choiceActions}</DrawerFooter>
) : null}
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
) )
@ -165,17 +147,19 @@ export default function PostPaymentMessagePrompt({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
className="w-[calc(100vw-2rem)] max-w-lg min-w-0 overflow-hidden sm:max-w-lg" className="flex w-[calc(100vw-1.25rem)] max-w-lg min-w-0 flex-col gap-4 overflow-hidden sm:max-w-lg"
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<DialogHeader className="min-w-0"> <DialogHeader className="min-w-0 shrink-0">
<DialogTitle>{title}</DialogTitle> <DialogTitle className="min-w-0 break-words">{title}</DialogTitle>
{step === 'choice' ? ( {step === 'choice' ? (
<DialogDescription className="sr-only">{t('Post payment prompt label')}</DialogDescription> <DialogDescription className="sr-only">{t('Post payment prompt label')}</DialogDescription>
) : null} ) : null}
</DialogHeader> </DialogHeader>
{body} <div className="min-w-0 overflow-x-hidden">{body}</div>
{footer} {step === 'choice' ? (
<DialogFooter className={choiceFooterClass}>{choiceActions}</DialogFooter>
) : null}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )

11
src/components/ZapDialog/PublicMessageForm.tsx

@ -116,11 +116,16 @@ export default function PublicMessageForm({
</div> </div>
</div> </div>
) : null} ) : null}
<DialogFooter className="mt-4 gap-2 sm:justify-end"> <DialogFooter className="mt-4 flex w-full min-w-0 flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={onBack} disabled={sending}> <Button type="button" variant="outline" className="w-full min-w-0 sm:w-auto" onClick={onBack} disabled={sending}>
{t('Back')} {t('Back')}
</Button> </Button>
<Button type="button" onClick={handleSend} disabled={sending || !message.trim()}> <Button
type="button"
className="w-full min-w-0 sm:w-auto"
onClick={handleSend}
disabled={sending || !message.trim()}
>
{t('Send')} {t('Send')}
</Button> </Button>
</DialogFooter> </DialogFooter>

11
src/components/ZapDialog/SuperchatRequestForm.tsx

@ -116,11 +116,16 @@ export default function SuperchatRequestForm({
</div> </div>
</div> </div>
) : null} ) : null}
<DialogFooter className="mt-4 gap-2 sm:justify-end"> <DialogFooter className="mt-4 flex w-full min-w-0 flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={onBack} disabled={sending}> <Button type="button" variant="outline" className="w-full min-w-0 sm:w-auto" onClick={onBack} disabled={sending}>
{t('Back')} {t('Back')}
</Button> </Button>
<Button type="button" onClick={handleSend} disabled={sending || !message.trim()}> <Button
type="button"
className="w-full min-w-0 sm:w-auto"
onClick={handleSend}
disabled={sending || !message.trim()}
>
{t('Send superchat request')} {t('Send superchat request')}
</Button> </Button>
</DialogFooter> </DialogFooter>

45
src/components/ZapDialog/index.tsx

@ -30,7 +30,7 @@ import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react' import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { buildPostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context' import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
import { buildPaytoUri } from '@/lib/payto' import { buildPaytoUri } from '@/lib/payto'
import { import {
buildOrderedZapLightningAddresses, buildOrderedZapLightningAddresses,
@ -66,11 +66,13 @@ export default function ZapDialog({
defaultAmount, defaultAmount,
defaultComment, defaultComment,
defaultLightningAddress, defaultLightningAddress,
prefetchedPayment = null prefetchedPayment = null,
onPostPaymentRequest
}: { }: {
open: boolean open: boolean
setOpen: Dispatch<SetStateAction<boolean>> setOpen: Dispatch<SetStateAction<boolean>>
pubkey: string pubkey: string
/** When set, kind 9740 superchats reference this note (e/a + k + author). Omit for profile tips. */
event?: NostrEvent event?: NostrEvent
defaultAmount?: number defaultAmount?: number
defaultComment?: string defaultComment?: string
@ -78,6 +80,8 @@ export default function ZapDialog({
defaultLightningAddress?: string | null defaultLightningAddress?: string | null
/** Profile/feed snapshot shown immediately; relay fetch while open may enrich this. */ /** Profile/feed snapshot shown immediately; relay fetch while open may enrich this. */
prefetchedPayment?: RecipientZapPaymentData | null prefetchedPayment?: RecipientZapPaymentData | null
/** Parent-owned post-payment prompt (e.g. note ZapButton). Skips internal prompt when set. */
onPostPaymentRequest?: (context: PostPaymentContext) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
@ -88,7 +92,16 @@ export default function ZapDialog({
const openPostPaymentPrompt = (context?: PostPaymentContext | null) => { const openPostPaymentPrompt = (context?: PostPaymentContext | null) => {
if (selfPubkey && pubkey === selfPubkey) return if (selfPubkey && pubkey === selfPubkey) return
setPostPaymentContext(context ?? buildPostPaymentContext({ recipientPubkey: pubkey, referencedEvent: event })) const built = mergePostPaymentContext(
{ recipientPubkey: pubkey, referencedEvent: event },
context ?? undefined
)
if (onPostPaymentRequest) {
onPostPaymentRequest(built)
setOpen(false)
return
}
setPostPaymentContext(built)
setPostPaymentOpen(true) setPostPaymentOpen(true)
setOpen(false) setOpen(false)
} }
@ -187,23 +200,26 @@ export default function ZapDialog({
canLightningZap={canLightningZap} canLightningZap={canLightningZap}
onPaymentFlowComplete={(_result, paymentDetails) => { onPaymentFlowComplete={(_result, paymentDetails) => {
openPostPaymentPrompt( openPostPaymentPrompt(
buildPostPaymentContext({ mergePostPaymentContext(
recipientPubkey: pubkey, { recipientPubkey: pubkey, referencedEvent: event },
{
amountMsat: paymentDetails?.amountMsat, amountMsat: paymentDetails?.amountMsat,
paytoUri: paymentDetails?.paytoUri, paytoUri: paymentDetails?.paytoUri
referencedEvent: event }
}) )
) )
}} }}
onPostPaymentRequest={openPostPaymentPrompt} onPostPaymentRequest={openPostPaymentPrompt}
/> />
</DrawerContent> </DrawerContent>
{!onPostPaymentRequest ? (
<PostPaymentMessagePrompt <PostPaymentMessagePrompt
open={postPaymentOpen} open={postPaymentOpen}
onOpenChange={setPostPaymentOpen} onOpenChange={setPostPaymentOpen}
recipientPubkey={pubkey} recipientPubkey={pubkey}
paymentContext={postPaymentContext} paymentContext={postPaymentContext}
/> />
) : null}
</Drawer> </Drawer>
) )
} }
@ -232,24 +248,27 @@ export default function ZapDialog({
canLightningZap={canLightningZap} canLightningZap={canLightningZap}
onPaymentFlowComplete={(_result, paymentDetails) => { onPaymentFlowComplete={(_result, paymentDetails) => {
openPostPaymentPrompt( openPostPaymentPrompt(
buildPostPaymentContext({ mergePostPaymentContext(
recipientPubkey: pubkey, { recipientPubkey: pubkey, referencedEvent: event },
{
amountMsat: paymentDetails?.amountMsat, amountMsat: paymentDetails?.amountMsat,
paytoUri: paymentDetails?.paytoUri, paytoUri: paymentDetails?.paytoUri
referencedEvent: event }
}) )
) )
}} }}
onPostPaymentRequest={openPostPaymentPrompt} onPostPaymentRequest={openPostPaymentPrompt}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{!onPostPaymentRequest ? (
<PostPaymentMessagePrompt <PostPaymentMessagePrompt
open={postPaymentOpen} open={postPaymentOpen}
onOpenChange={setPostPaymentOpen} onOpenChange={setPostPaymentOpen}
recipientPubkey={pubkey} recipientPubkey={pubkey}
paymentContext={postPaymentContext} paymentContext={postPaymentContext}
/> />
) : null}
</> </>
) )
} }

71
src/hooks/usePaymentAttestationStatus.tsx

@ -0,0 +1,71 @@
import { ExtendedKind } from '@/constants'
import {
buildAttestedPaymentIdSet,
getPaymentAttestationTargetId,
getSuperchatPaymentRecipientPubkey
} from '@/lib/superchat'
import client from '@/services/client.service'
import { Event as NostrEvent } from 'nostr-tools'
import { useEffect, useState } from 'react'
export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) {
const [attested, setAttested] = useState(false)
const [checking, setChecking] = useState(false)
const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null
const targetId = targetEvent?.id?.toLowerCase()
useEffect(() => {
setAttested(false)
if (!targetEvent?.id || !recipientPubkey) return
let cancelled = false
setChecking(true)
void client
.fetchEvents(
[],
{
kinds: [ExtendedKind.PAYMENT_ATTESTATION],
authors: [recipientPubkey],
'#e': [targetEvent.id],
limit: 5
},
{ cache: true, eoseTimeout: 4000, globalTimeout: 10_000 }
)
.then((attestations) => {
if (cancelled) return
const ids = buildAttestedPaymentIdSet(attestations, recipientPubkey)
setAttested(ids.has(targetEvent.id.toLowerCase()))
})
.catch(() => {
/* optional */
})
.finally(() => {
if (!cancelled) setChecking(false)
})
return () => {
cancelled = true
}
}, [targetEvent, recipientPubkey, targetId])
useEffect(() => {
if (!targetEvent?.id || !recipientPubkey) return
const handleAttestation = (data: globalThis.Event) => {
const evt = (data as CustomEvent<NostrEvent>).detail
if (!evt || evt.kind !== ExtendedKind.PAYMENT_ATTESTATION) return
if (evt.pubkey.toLowerCase() !== recipientPubkey.toLowerCase()) return
const attestedId = getPaymentAttestationTargetId(evt)
if (attestedId?.toLowerCase() === targetEvent.id.toLowerCase()) {
setAttested(true)
}
}
client.addEventListener('newEvent', handleAttestation)
return () => client.removeEventListener('newEvent', handleAttestation)
}, [targetEvent?.id, recipientPubkey])
return { attested, checking, recipientPubkey }
}

4
src/i18n/locales/en.ts

@ -195,6 +195,7 @@ export default {
"Zap dialog other payment hint": "Tap a link to open PayPal or copy an address. Lightning tips use the button below.", "Zap dialog other payment hint": "Tap a link to open PayPal or copy an address. Lightning tips use the button below.",
"Tip notice prompt title": "Let them know?", "Tip notice prompt title": "Let them know?",
"Send them a message": "Send them a message", "Send them a message": "Send them a message",
"Send a message": "Send a message",
"Post payment prompt label": "If you have successfully completed a payment, you can:", "Post payment prompt label": "If you have successfully completed a payment, you can:",
"Send them a public message": "Send them a public message", "Send them a public message": "Send them a public message",
"Post payment public message hint": "Post payment public message hint":
@ -213,6 +214,9 @@ export default {
"Superchats": "Superchats", "Superchats": "Superchats",
"Profile wall superchats": "Profile wall superchats", "Profile wall superchats": "Profile wall superchats",
"Invalid superchat": "Invalid superchat", "Invalid superchat": "Invalid superchat",
"Turn this into a superchat!": "Turn this into a superchat!",
"Superchat attested": "Superchat attested",
"Failed to attest superchat": "Failed to attest superchat: {{error}}",
"Superchat thread": "View thread", "Superchat thread": "View thread",
"Superchat profile": "View profile", "Superchat profile": "View profile",
"View thread": "View thread", "View thread": "View thread",

29
src/lib/draft-event.ts

@ -581,6 +581,7 @@ export async function createPaymentNotificationDraftEvent(
} else { } else {
tags.push(buildETag(options.referencedEvent.id, options.referencedEvent.pubkey)) tags.push(buildETag(options.referencedEvent.id, options.referencedEvent.pubkey))
} }
tags.push(['P', options.referencedEvent.pubkey])
tags.push(['k', String(options.referencedEvent.kind)]) tags.push(['k', String(options.referencedEvent.kind)])
} }
@ -593,6 +594,34 @@ export async function createPaymentNotificationDraftEvent(
return setDraftEventCache(baseDraft) return setDraftEventCache(baseDraft)
} }
export async function createPaymentAttestationDraftEvent(
targetEvent: Event,
_options: { addClientTag?: boolean } = {}
): Promise<TDraftEvent> {
const targetKind =
targetEvent.kind === ExtendedKind.PAYMENT_NOTIFICATION
? String(ExtendedKind.PAYMENT_NOTIFICATION)
: targetEvent.kind === kinds.Zap || targetEvent.kind === ExtendedKind.ZAP_RECEIPT
? String(ExtendedKind.ZAP_RECEIPT)
: null
if (!targetKind) {
throw new Error('Only zap receipts and payment notifications can be attested')
}
const tags: string[][] = [
buildETag(targetEvent.id, targetEvent.pubkey),
['k', targetKind]
]
const baseDraft = {
kind: ExtendedKind.PAYMENT_ATTESTATION,
content: '',
tags
}
return setDraftEventCache(baseDraft)
}
const SECONDS_PER_DAY = 86400 const SECONDS_PER_DAY = 86400
/** /**

1
src/lib/notification-thread-watch.ts

@ -111,6 +111,7 @@ export function isNotificationThreadInteractionEvent(event: Event): boolean {
(t) => t[0] === 'e' || t[0] === 'E' || t[0] === 'a' || t[0] === 'A' (t) => t[0] === 'e' || t[0] === 'E' || t[0] === 'a' || t[0] === 'A'
) )
} }
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) return true
if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) return true if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) return true
if (event.kind === ExtendedKind.POLL_RESPONSE) return true if (event.kind === ExtendedKind.POLL_RESPONSE) return true
if (event.kind === kinds.Highlights) return true if (event.kind === kinds.Highlights) return true

65
src/lib/post-payment-context.test.ts

@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { kinds, type Event } from 'nostr-tools'
import {
mergePostPaymentContext,
paymentNotificationReferenceTags
} from './post-payment-context'
const AUTHOR = 'a'.repeat(64)
const RECIPIENT = 'b'.repeat(64)
const EVENT_ID = 'c'.repeat(64)
function fakeEvent(partial: Partial<Event> & Pick<Event, 'kind' | 'tags'>): Event {
return {
id: partial.id ?? EVENT_ID,
pubkey: partial.pubkey ?? AUTHOR,
created_at: partial.created_at ?? 1_700_000_000,
kind: partial.kind,
tags: partial.tags,
content: partial.content ?? '',
sig: partial.sig ?? ''
}
}
describe('paymentNotificationReferenceTags', () => {
it('returns e, P, and k for regular notes', () => {
const note = fakeEvent({ kind: kinds.ShortTextNote, tags: [] })
const tags = paymentNotificationReferenceTags(note)
expect(tags.some((t) => t[0] === 'e' && t[1] === EVENT_ID && t[3] === AUTHOR)).toBe(true)
expect(tags).toContainEqual(['P', AUTHOR])
expect(tags).toContainEqual(['k', String(kinds.ShortTextNote)])
})
it('returns a, P, and k for replaceable events', () => {
const article = fakeEvent({
kind: kinds.LongFormArticle,
tags: [['d', 'my-article']]
})
const tags = paymentNotificationReferenceTags(article)
expect(tags.some((t) => t[0] === 'a' && t[1]?.includes(AUTHOR))).toBe(true)
expect(tags).toContainEqual(['P', AUTHOR])
expect(tags).toContainEqual(['k', String(kinds.LongFormArticle)])
})
it('returns no tags when reference is omitted (profile wall default)', () => {
expect(paymentNotificationReferenceTags(undefined)).toEqual([])
})
})
describe('mergePostPaymentContext', () => {
it('keeps note reference from base when partial omits it', () => {
const note = fakeEvent({ kind: kinds.ShortTextNote, tags: [] })
const ctx = mergePostPaymentContext(
{ recipientPubkey: RECIPIENT, referencedEvent: note },
{ amountMsat: 21_000 }
)
expect(ctx.referencedEvent).toBe(note)
expect(ctx.amountMsat).toBe(21_000)
})
it('omits reference for profile tips', () => {
const ctx = mergePostPaymentContext({ recipientPubkey: RECIPIENT }, { payto: 'monero/addr' })
expect(ctx.referencedEvent).toBeUndefined()
expect(ctx.payto).toBe('monero/addr')
})
})

23
src/lib/post-payment-context.ts

@ -13,7 +13,7 @@ export type PostPaymentContext = {
referencedEvent?: NostrEvent referencedEvent?: NostrEvent
} }
export function buildPostPaymentContext(params: { type BuildPostPaymentContextParams = {
recipientPubkey: string recipientPubkey: string
amountMsat?: number amountMsat?: number
/** Preformatted kind-9740 payto tag value. */ /** Preformatted kind-9740 payto tag value. */
@ -22,7 +22,9 @@ export function buildPostPaymentContext(params: {
paytoType?: string paytoType?: string
paytoAuthority?: string paytoAuthority?: string
referencedEvent?: NostrEvent referencedEvent?: NostrEvent
}): PostPaymentContext { }
export function buildPostPaymentContext(params: BuildPostPaymentContextParams): PostPaymentContext {
const payto = const payto =
params.payto ?? params.payto ??
(params.paytoUri != null (params.paytoUri != null
@ -39,6 +41,22 @@ export function buildPostPaymentContext(params: {
} }
} }
/**
* Merge payment details with a default thread reference.
* Profile tips omit `referencedEvent` so kind 9740 defaults to the profile wall.
*/
export function mergePostPaymentContext(
base: Pick<BuildPostPaymentContextParams, 'recipientPubkey' | 'referencedEvent'>,
partial?: Partial<BuildPostPaymentContextParams> | null
): PostPaymentContext {
return buildPostPaymentContext({
...partial,
recipientPubkey: partial?.recipientPubkey ?? base.recipientPubkey,
referencedEvent: partial?.referencedEvent ?? base.referencedEvent
})
}
/** Kind 9740 thread tags: `e` or `a`, referenced kind (`k`), and author pubkey (`P` or in `e`). */
export function paymentNotificationReferenceTags( export function paymentNotificationReferenceTags(
referencedEvent?: NostrEvent referencedEvent?: NostrEvent
): string[][] { ): string[][] {
@ -50,6 +68,7 @@ export function paymentNotificationReferenceTags(
} else { } else {
tags.push(buildETag(referencedEvent.id, referencedEvent.pubkey)) tags.push(buildETag(referencedEvent.id, referencedEvent.pubkey))
} }
tags.push(['P', referencedEvent.pubkey])
tags.push(['k', String(referencedEvent.kind)]) tags.push(['k', String(referencedEvent.kind)])
return tags return tags
} }

36
src/lib/superchat.ts

@ -4,6 +4,7 @@ import {
getReplaceableCoordinate, getReplaceableCoordinate,
normalizeReplaceableCoordinateString normalizeReplaceableCoordinateString
} from '@/lib/event' } from '@/lib/event'
import { hexPubkeysEqual } from '@/lib/pubkey'
import { parsePaytoTagType } from '@/lib/payto' import { parsePaytoTagType } from '@/lib/payto'
import { generateBech32IdFromATag } from '@/lib/tag' import { generateBech32IdFromATag } from '@/lib/tag'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@ -117,6 +118,41 @@ export function isSuperchatKind(kind: number): boolean {
return kind === kinds.Zap || kind === ExtendedKind.PAYMENT_NOTIFICATION return kind === kinds.Zap || kind === ExtendedKind.PAYMENT_NOTIFICATION
} }
/** Recipient pubkey for a kind 9735 or 9740 payment the user may attest to. */
export function getSuperchatPaymentRecipientPubkey(event: Event): string | null {
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
return getPaymentNotificationInfo(event)?.recipientPubkey ?? null
}
if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) {
return getZapInfoFromEvent(event)?.recipientPubkey ?? null
}
return null
}
/** Target `k` tag value for a kind 9741 attestation pointing at this event. */
export function getSuperchatAttestationTargetKindValue(event: Event): string | null {
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
return String(ExtendedKind.PAYMENT_NOTIFICATION)
}
if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) {
return String(ExtendedKind.ZAP_RECEIPT)
}
return null
}
export function isAttestableSuperchatPayment(event: Event): boolean {
return getSuperchatAttestationTargetKindValue(event) != null
}
/** Incoming payment notification or zap receipt addressed to `userPubkey`. */
export function isIncomingPaymentNotificationOrZapReceipt(
event: Event,
userPubkey: string
): boolean {
const recipient = getSuperchatPaymentRecipientPubkey(event)
return recipient != null && hexPubkeysEqual(recipient, userPubkey)
}
export function isAttestedSuperchat(event: Event, attestedIds: Set<string>): boolean { export function isAttestedSuperchat(event: Event, attestedIds: Set<string>): boolean {
if (!isSuperchatKind(event.kind)) return false if (!isSuperchatKind(event.kind)) return false
return attestedIds.has(event.id.toLowerCase()) return attestedIds.has(event.id.toLowerCase())

Loading…
Cancel
Save