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. 17
      src/components/PaytoDialog/LightningInvoiceSection.tsx
  6. 379
      src/components/PaytoDialog/index.tsx
  7. 92
      src/components/TurnIntoSuperchatButton/index.tsx
  8. 122
      src/components/ZapDialog/PostPaymentMessagePrompt.tsx
  9. 11
      src/components/ZapDialog/PublicMessageForm.tsx
  10. 11
      src/components/ZapDialog/SuperchatRequestForm.tsx
  11. 73
      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 @@ -10,6 +10,7 @@ import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/Page
import Username from '../Username'
import UserAvatar from '../UserAvatar'
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel'
import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
export default function Superchat({
event,
@ -148,6 +149,7 @@ export default function Superchat({ @@ -148,6 +149,7 @@ export default function Superchat({
) : null}
</div>
</div>
<TurnIntoSuperchatButton event={event} prominent className="mt-4" />
</div>
)
}

2
src/components/Note/Zap.tsx

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

47
src/components/NoteStats/ZapButton.tsx

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

2
src/components/PaymentMethodsSection/index.tsx

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

17
src/components/PaytoDialog/LightningInvoiceSection.tsx

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

379
src/components/PaytoDialog/index.tsx

@ -3,6 +3,7 @@ import { @@ -3,6 +3,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
@ -32,7 +33,7 @@ import { @@ -32,7 +33,7 @@ import {
} from '@/lib/payto'
import { cn } from '@/lib/utils'
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 LightningInvoiceSection from './LightningInvoiceSection'
@ -52,7 +53,7 @@ export default function PaytoDialog({ @@ -52,7 +53,7 @@ export default function PaytoDialog({
type: string
authority: 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
/** Note or profile context for superchat placement (kind 9740). */
referencedEvent?: NostrEvent
@ -63,41 +64,54 @@ export default function PaytoDialog({ @@ -63,41 +64,54 @@ export default function PaytoDialog({
}) {
const { t } = useTranslation()
const { pubkey: selfPubkey } = useNostr()
const sendMessageRef = useRef<HTMLButtonElement>(null)
const [postPaymentOpen, setPostPaymentOpen] = useState(false)
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 label = info?.label ?? type
const isLightning = type.toLowerCase() === 'lightning'
const [bolt11Invoice, setBolt11Invoice] = useState<string | null>(null)
const [selectedOpenHandlerId, setSelectedOpenHandlerId] = useState('')
const canOfferPostPayment =
!!recipientPubkey && (!selfPubkey || recipientPubkey !== selfPubkey)
useEffect(() => {
if (!open) {
setBolt11Invoice(null)
setSelectedOpenHandlerId('')
setCompletedPaymentDetails(null)
closeModal()
releaseBodyScrollLocks()
}
}, [open])
useEffect(() => {
if (!open || !canOfferPostPayment) return
const id = requestAnimationFrame(() => sendMessageRef.current?.focus())
return () => cancelAnimationFrame(id)
}, [open, canOfferPostPayment])
const closeForWalletFlow = useCallback(() => {
skipPostPaymentOnCloseRef.current = true
onOpenChange(false)
}, [onOpenChange])
const openPostPaymentPrompt = useCallback(
(context?: Partial<PostPaymentContext>) => {
if (!recipientPubkey) return
if (selfPubkey && recipientPubkey === selfPubkey) return
const built = buildPostPaymentContext({
recipientPubkey,
paytoUri,
paytoType: type,
paytoAuthority: authority,
referencedEvent,
...context
})
if (!canOfferPostPayment) return
const built = mergePostPaymentContext(
{ recipientPubkey: recipientPubkey!, referencedEvent },
{
paytoUri,
paytoType: type,
paytoAuthority: authority,
...completedPaymentDetails,
...context
}
)
if (onPostPaymentRequest) {
onPostPaymentRequest(built)
return
@ -107,26 +121,22 @@ export default function PaytoDialog({ @@ -107,26 +121,22 @@ export default function PaytoDialog({
setPostPaymentOpen(true)
},
[
canOfferPostPayment,
offerTipNoticeOnClose,
onPostPaymentRequest,
recipientPubkey,
selfPubkey,
paytoUri,
type,
authority,
referencedEvent
referencedEvent,
completedPaymentDetails
]
)
/** Run after the payto dialog has closed so nested modals (e.g. inside ZapDialog) do not dismiss the prompt. */
const schedulePostPaymentPrompt = useCallback(
(context?: Partial<PostPaymentContext>) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => openPostPaymentPrompt(context))
})
},
[openPostPaymentPrompt]
)
const handleSendMessage = () => {
onOpenChange(false)
requestAnimationFrame(() => openPostPaymentPrompt())
}
const openHandlers = useMemo(
() =>
@ -170,174 +180,185 @@ export default function PaytoDialog({ @@ -170,174 +180,185 @@ export default function PaytoDialog({
const handleCopy = (text: string, copyLabel?: string) => {
navigator.clipboard.writeText(text)
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 (
<>
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent
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',
'max-h-[min(92dvh,720px)] overflow-x-hidden overflow-y-auto p-0 sm:max-w-md sm:p-0',
'pb-[max(0.75rem,env(safe-area-inset-bottom))]'
)}
>
<DialogHeader className="shrink-0 space-y-1 border-b border-border/60 px-4 pb-3 pt-4 text-left sm:px-5 sm:pt-5">
<DialogTitle className="flex min-w-0 items-center gap-2 pr-8 text-lg sm:text-xl">
{isLightning && <Zap className="size-6 shrink-0 text-yellow-400" />}
<span className="truncate">{label}</span>
</DialogTitle>
<DialogDescription className="text-left text-sm leading-relaxed sm:text-base">
{isLightning
? t('Create a BOLT11 invoice from this Lightning address, then pay in your connected wallet or another app.')
: showPrimaryOpen
? t('Open in your wallet app or copy the address below.')
: t('Payment address – copy to use in your wallet or app')}
</DialogDescription>
</DialogHeader>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
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',
'max-h-[min(92dvh,720px)] overflow-x-hidden overflow-y-auto p-0 sm:max-w-md sm:p-0',
'pb-[max(0.75rem,env(safe-area-inset-bottom))]'
)}
>
<DialogHeader className="shrink-0 space-y-1 border-b border-border/60 px-4 pb-3 pt-4 text-left sm:px-5 sm:pt-5">
<DialogTitle className="flex min-w-0 items-center gap-2 pr-8 text-lg sm:text-xl">
{isLightning && <Zap className="size-6 shrink-0 text-yellow-400" />}
<span className="truncate">{label}</span>
</DialogTitle>
<DialogDescription className="text-left text-sm leading-relaxed sm:text-base">
{isLightning
? t('Create a BOLT11 invoice from this Lightning address, then pay in your connected wallet or another app.')
: showPrimaryOpen
? t('Open in your wallet app or copy the address below.')
: t('Payment address – copy to use in your wallet or app')}
</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-4 px-4 py-4 sm:px-5">
{isLightning && open ? (
<LightningInvoiceSection
lightningAddress={authority}
paytoUri={paytoUri}
onBolt11InvoiceChange={setBolt11Invoice}
onPaymentFlowComplete={(details) => {
setCompletedPaymentDetails({
amountMsat: details?.amountMsat,
payto: details?.payto
})
}}
/>
) : isLightning ? null : (
<>
<div className="min-w-0 rounded-lg bg-muted/40 px-3 py-2.5 ring-1 ring-border/50">
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('Payment address')}
</p>
<p className="break-all font-mono text-base leading-relaxed select-text sm:text-lg">{authority}</p>
</div>
<div className="flex min-w-0 flex-col gap-2">
{showPrimaryOpen && walletOpenUri ? (
<Button
variant="default"
className="h-11 w-full min-w-0 gap-2 text-base"
onClick={handleOpenWallet}
>
<Wallet className="size-5 shrink-0" />
<span className="truncate">
{isPaytoHttpOpenUrl(walletOpenUri)
? t('Open on website')
: t('Open in wallet')}
</span>
</Button>
) : null}
<div className="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2">
<Button
variant={showPrimaryOpen ? 'outline' : 'default'}
className="h-11 w-full min-w-0 gap-2 text-base"
onClick={() => handleCopy(authority, label)}
>
<Copy className="size-5 shrink-0" />
<span className="truncate">{t('Copy address')}</span>
</Button>
<Button
variant={showPrimaryOpen ? 'outline' : 'secondary'}
className="h-11 w-full min-w-0 gap-2 text-base"
onClick={() => handleCopy(paytoUri)}
>
<Copy className="size-5 shrink-0" />
<span className="truncate">{t('Copy payto URI')}</span>
</Button>
</div>
</div>
</>
)}
<div className="min-w-0 space-y-4 px-4 py-4 sm:px-5">
{isLightning && open ? (
<LightningInvoiceSection
lightningAddress={authority}
paytoUri={paytoUri}
onBolt11InvoiceChange={setBolt11Invoice}
onRequestClose={closeForWalletFlow}
onPaymentFlowComplete={(details) => {
onOpenChange(false)
schedulePostPaymentPrompt({
amountMsat: details?.amountMsat,
payto: details?.payto
})
}}
/>
) : isLightning ? null : (
<>
<div className="min-w-0 rounded-lg bg-muted/40 px-3 py-2.5 ring-1 ring-border/50">
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('Payment address')}
{openHandlers.length > 0 && (
<div className="min-w-0 space-y-2.5 border-t border-border/60 pt-4">
<p className="text-sm font-semibold uppercase tracking-wider text-muted-foreground sm:text-base">
{t('Open with')}
</p>
<p className="break-all font-mono text-base leading-relaxed select-text sm:text-lg">{authority}</p>
</div>
<div className="flex min-w-0 flex-col gap-2">
{showPrimaryOpen && walletOpenUri ? (
<Button
variant="default"
className="h-11 w-full min-w-0 gap-2 text-base"
onClick={handleOpenWallet}
<div className="flex min-w-0 items-stretch gap-2">
<Select
value={selectedOpenHandlerId}
onValueChange={setSelectedOpenHandlerId}
>
<Wallet className="size-5 shrink-0" />
<span className="truncate">
{isPaytoHttpOpenUrl(walletOpenUri)
? t('Open on website')
: t('Open in wallet')}
</span>
</Button>
) : null}
<div className="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2">
<SelectTrigger
className="h-11 min-w-0 flex-1 text-base"
aria-label={t('Open with')}
>
<SelectValue
placeholder={t('Choose app', { defaultValue: 'Choose app' })}
/>
</SelectTrigger>
<SelectContent>
{openHandlers.map((handler) => (
<SelectItem key={handler.id} value={handler.id} className="text-base">
{handler.openTargetName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant={showPrimaryOpen ? 'outline' : 'default'}
className="h-11 w-full min-w-0 gap-2 text-base"
onClick={() => handleCopy(authority, label)}
type="button"
variant="outline"
size="icon"
className="h-11 w-11 shrink-0"
disabled={!selectedOpenHandler}
title={
selectedOpenHandler
? t('Open in {{name}}', { name: selectedOpenHandler.openTargetName })
: undefined
}
aria-label={
selectedOpenHandler
? t('Open in {{name}}', { name: selectedOpenHandler.openTargetName })
: t('Open', { defaultValue: 'Open' })
}
onClick={() => {
if (selectedOpenHandler) openPaytoPaymentTarget(selectedOpenHandler)
}}
>
<Copy className="size-5 shrink-0" />
<span className="truncate">{t('Copy address')}</span>
</Button>
<Button
variant={showPrimaryOpen ? 'outline' : 'secondary'}
className="h-11 w-full min-w-0 gap-2 text-base"
onClick={() => handleCopy(paytoUri)}
>
<Copy className="size-5 shrink-0" />
<span className="truncate">{t('Copy payto URI')}</span>
<ArrowRight className="size-5" aria-hidden />
</Button>
</div>
</div>
</>
)}
)}
</div>
{openHandlers.length > 0 && (
<div className="min-w-0 space-y-2.5 border-t border-border/60 pt-4">
<p className="text-sm font-semibold uppercase tracking-wider text-muted-foreground sm:text-base">
{t('Open with')}
</p>
<div className="flex min-w-0 items-stretch gap-2">
<Select
value={selectedOpenHandlerId}
onValueChange={setSelectedOpenHandlerId}
>
<SelectTrigger
className="h-11 min-w-0 flex-1 text-base"
aria-label={t('Open with')}
>
<SelectValue
placeholder={t('Choose app', { defaultValue: 'Choose app' })}
/>
</SelectTrigger>
<SelectContent>
{openHandlers.map((handler) => (
<SelectItem key={handler.id} value={handler.id} className="text-base">
{handler.openTargetName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="icon"
className="h-11 w-11 shrink-0"
disabled={!selectedOpenHandler}
title={
selectedOpenHandler
? t('Open in {{name}}', { name: selectedOpenHandler.openTargetName })
: undefined
}
aria-label={
selectedOpenHandler
? t('Open in {{name}}', { name: selectedOpenHandler.openTargetName })
: t('Open', { defaultValue: 'Open' })
}
onClick={() => {
if (selectedOpenHandler) openPaytoPaymentTarget(selectedOpenHandler)
}}
>
<ArrowRight className="size-5" aria-hidden />
</Button>
</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>
)}
</div>
</DialogContent>
</Dialog>
{recipientPubkey && !onPostPaymentRequest ? (
<PostPaymentMessagePrompt
open={postPaymentOpen}
onOpenChange={setPostPaymentOpen}
recipientPubkey={recipientPubkey}
paymentContext={postPaymentContext}
/>
) : null}
</DialogContent>
</Dialog>
{recipientPubkey && !onPostPaymentRequest ? (
<PostPaymentMessagePrompt
open={postPaymentOpen}
onOpenChange={setPostPaymentOpen}
recipientPubkey={recipientPubkey}
paymentContext={postPaymentContext}
/>
) : null}
</>
)
}

92
src/components/TurnIntoSuperchatButton/index.tsx

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

122
src/components/ZapDialog/PostPaymentMessagePrompt.tsx

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

11
src/components/ZapDialog/PublicMessageForm.tsx

@ -116,11 +116,16 @@ export default function PublicMessageForm({ @@ -116,11 +116,16 @@ export default function PublicMessageForm({
</div>
</div>
) : null}
<DialogFooter className="mt-4 gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onBack} disabled={sending}>
<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" className="w-full min-w-0 sm:w-auto" onClick={onBack} disabled={sending}>
{t('Back')}
</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')}
</Button>
</DialogFooter>

11
src/components/ZapDialog/SuperchatRequestForm.tsx

@ -116,11 +116,16 @@ export default function SuperchatRequestForm({ @@ -116,11 +116,16 @@ export default function SuperchatRequestForm({
</div>
</div>
) : null}
<DialogFooter className="mt-4 gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onBack} disabled={sending}>
<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" className="w-full min-w-0 sm:w-auto" onClick={onBack} disabled={sending}>
{t('Back')}
</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')}
</Button>
</DialogFooter>

73
src/components/ZapDialog/index.tsx

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

71
src/hooks/usePaymentAttestationStatus.tsx

@ -0,0 +1,71 @@ @@ -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 { @@ -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.",
"Tip notice prompt title": "Let them know?",
"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:",
"Send them a public message": "Send them a public message",
"Post payment public message hint":
@ -213,6 +214,9 @@ export default { @@ -213,6 +214,9 @@ export default {
"Superchats": "Superchats",
"Profile wall superchats": "Profile wall superchats",
"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 profile": "View profile",
"View thread": "View thread",

29
src/lib/draft-event.ts

@ -581,6 +581,7 @@ export async function createPaymentNotificationDraftEvent( @@ -581,6 +581,7 @@ export async function createPaymentNotificationDraftEvent(
} else {
tags.push(buildETag(options.referencedEvent.id, options.referencedEvent.pubkey))
}
tags.push(['P', options.referencedEvent.pubkey])
tags.push(['k', String(options.referencedEvent.kind)])
}
@ -593,6 +594,34 @@ export async function createPaymentNotificationDraftEvent( @@ -593,6 +594,34 @@ export async function createPaymentNotificationDraftEvent(
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
/**

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

@ -111,6 +111,7 @@ export function isNotificationThreadInteractionEvent(event: Event): boolean { @@ -111,6 +111,7 @@ export function isNotificationThreadInteractionEvent(event: Event): boolean {
(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.POLL_RESPONSE) return true
if (event.kind === kinds.Highlights) return true

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

@ -0,0 +1,65 @@ @@ -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 = { @@ -13,7 +13,7 @@ export type PostPaymentContext = {
referencedEvent?: NostrEvent
}
export function buildPostPaymentContext(params: {
type BuildPostPaymentContextParams = {
recipientPubkey: string
amountMsat?: number
/** Preformatted kind-9740 payto tag value. */
@ -22,7 +22,9 @@ export function buildPostPaymentContext(params: { @@ -22,7 +22,9 @@ export function buildPostPaymentContext(params: {
paytoType?: string
paytoAuthority?: string
referencedEvent?: NostrEvent
}): PostPaymentContext {
}
export function buildPostPaymentContext(params: BuildPostPaymentContextParams): PostPaymentContext {
const payto =
params.payto ??
(params.paytoUri != null
@ -39,6 +41,22 @@ export function buildPostPaymentContext(params: { @@ -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(
referencedEvent?: NostrEvent
): string[][] {
@ -50,6 +68,7 @@ export function paymentNotificationReferenceTags( @@ -50,6 +68,7 @@ export function paymentNotificationReferenceTags(
} else {
tags.push(buildETag(referencedEvent.id, referencedEvent.pubkey))
}
tags.push(['P', referencedEvent.pubkey])
tags.push(['k', String(referencedEvent.kind)])
return tags
}

36
src/lib/superchat.ts

@ -4,6 +4,7 @@ import { @@ -4,6 +4,7 @@ import {
getReplaceableCoordinate,
normalizeReplaceableCoordinateString
} from '@/lib/event'
import { hexPubkeysEqual } from '@/lib/pubkey'
import { parsePaytoTagType } from '@/lib/payto'
import { generateBech32IdFromATag } from '@/lib/tag'
import { Event, kinds } from 'nostr-tools'
@ -117,6 +118,41 @@ export function isSuperchatKind(kind: number): boolean { @@ -117,6 +118,41 @@ export function isSuperchatKind(kind: number): boolean {
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 {
if (!isSuperchatKind(event.kind)) return false
return attestedIds.has(event.id.toLowerCase())

Loading…
Cancel
Save