diff --git a/src/components/Note/Superchat.tsx b/src/components/Note/Superchat.tsx index dc8771fc..88afcf6d 100644 --- a/src/components/Note/Superchat.tsx +++ b/src/components/Note/Superchat.tsx @@ -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({ ) : null} + ) } diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index 9c9d01c0..6fbc7b0d 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -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({ + ) } diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index c82502a2..5b950f19 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/NoteStats/ZapButton.tsx @@ -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 const [disable, setDisable] = useState(true) const [tipPaymentData, setTipPaymentData] = useState(null) + const [postPaymentOpen, setPostPaymentOpen] = useState(false) + const [postPaymentContext, setPostPaymentContext] = useState(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 pubkey={event.pubkey} event={event} prefetchedPayment={tipPaymentData} + onPostPaymentRequest={handlePostPaymentRequest} + /> + ) @@ -274,6 +294,17 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB const [postPaymentOpen, setPostPaymentOpen] = useState(false) const [postPaymentContext, setPostPaymentContext] = useState(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 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 pubkey={event.pubkey} event={event} prefetchedPayment={tipPaymentData} + onPostPaymentRequest={handlePostPaymentRequest} /> onOpenZap(authority) diff --git a/src/components/PaytoDialog/LightningInvoiceSection.tsx b/src/components/PaytoDialog/LightningInvoiceSection.tsx index d4a8c8f8..469ac478 100644 --- a/src/components/PaytoDialog/LightningInvoiceSection.tsx +++ b/src/components/PaytoDialog/LightningInvoiceSection.tsx @@ -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({ 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({ role="img" aria-label={t('Scan to pay with a Lightning wallet')} > - +
+ +

{t('Scan to pay with a Lightning wallet')}

diff --git a/src/components/PaytoDialog/index.tsx b/src/components/PaytoDialog/index.tsx index 34164303..27546e0e 100644 --- a/src/components/PaytoDialog/index.tsx +++ b/src/components/PaytoDialog/index.tsx @@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -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({ 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({ }) { const { t } = useTranslation() const { pubkey: selfPubkey } = useNostr() + const sendMessageRef = useRef(null) const [postPaymentOpen, setPostPaymentOpen] = useState(false) const [postPaymentContext, setPostPaymentContext] = useState(null) - const skipPostPaymentOnCloseRef = useRef(false) + const [completedPaymentDetails, setCompletedPaymentDetails] = useState< + Partial> | null + >(null) const info = getPaytoTypeInfo(type) const label = info?.label ?? type const isLightning = type.toLowerCase() === 'lightning' const [bolt11Invoice, setBolt11Invoice] = useState(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) => { - 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({ 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) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => openPostPaymentPrompt(context)) - }) - }, - [openPostPaymentPrompt] - ) + const handleSendMessage = () => { + onOpenChange(false) + requestAnimationFrame(() => openPostPaymentPrompt()) + } const openHandlers = useMemo( () => @@ -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 ( <> - - - - - {isLightning && } - {label} - - - {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')} - - + + + + + {isLightning && } + {label} + + + {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')} + + + +
+ {isLightning && open ? ( + { + setCompletedPaymentDetails({ + amountMsat: details?.amountMsat, + payto: details?.payto + }) + }} + /> + ) : isLightning ? null : ( + <> +
+

+ {t('Payment address')} +

+

{authority}

+
+
+ {showPrimaryOpen && walletOpenUri ? ( + + ) : null} +
+ + +
+
+ + )} -
- {isLightning && open ? ( - { - onOpenChange(false) - schedulePostPaymentPrompt({ - amountMsat: details?.amountMsat, - payto: details?.payto - }) - }} - /> - ) : isLightning ? null : ( - <> -
-

- {t('Payment address')} + {openHandlers.length > 0 && ( +

+

+ {t('Open with')}

-

{authority}

-
-
- {showPrimaryOpen && walletOpenUri ? ( - -
- - )} + )} +
- {openHandlers.length > 0 && ( -
-

- {t('Open with')} -

-
- - -
-
+ {canOfferPostPayment ? ( + + + + + ) : ( + + + )} -
-
-
- {recipientPubkey && !onPostPaymentRequest ? ( - - ) : null} +
+
+ {recipientPubkey && !onPostPaymentRequest ? ( + + ) : null} ) } diff --git a/src/components/TurnIntoSuperchatButton/index.tsx b/src/components/TurnIntoSuperchatButton/index.tsx new file mode 100644 index 00000000..16f761be --- /dev/null +++ b/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 ( +

+ {t('Superchat attested')} +

+ ) + } + + 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 ( + + ) +} diff --git a/src/components/ZapDialog/PostPaymentMessagePrompt.tsx b/src/components/ZapDialog/PostPaymentMessagePrompt.tsx index 8db3b19d..e5615ba8 100644 --- a/src/components/ZapDialog/PostPaymentMessagePrompt.tsx +++ b/src/components/ZapDialog/PostPaymentMessagePrompt.tsx @@ -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' type Step = 'choice' | 'public-message' | 'superchat' -function ChoiceButton({ - title, - hint, - onClick, - disabled -}: { - title: string - hint: string - onClick: () => void - disabled?: boolean -}) { - return ( - - ) -} +const footerButtonClass = 'w-full min-w-0 sm:w-auto' export default function PostPaymentMessagePrompt({ open, @@ -67,7 +41,7 @@ export default function PostPaymentMessagePrompt({ }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() - const closeRef = useRef(null) + const superchatRef = useRef(null) const [step, setStep] = useState('choice') useEffect(() => { @@ -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({ const handleClose = () => onOpenChange(false) const choiceBody = ( -
-

{t('Post payment prompt label')}

-
- setStep('public-message')} - /> - setStep('superchat')} - /> -
-
+

{t('Post payment prompt label')}

) const choiceActions = ( - + <> + + + + ) const title = ( - + {t('Send them a message')} - - + + + + ) @@ -136,27 +117,28 @@ export default function PostPaymentMessagePrompt({ choiceBody ) - const footer = - step === 'choice' ? ( - isSmallScreen ? ( - choiceActions - ) : ( - {choiceActions} - ) - ) : 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 ( - e.preventDefault()}> - - {title} + e.preventDefault()} + > + + {title} {step === 'choice' ? ( {t('Post payment prompt label')} ) : null} -
{body}
- {footer ? {footer} : null} +
{body}
+ {step === 'choice' ? ( + {choiceActions} + ) : null}
) @@ -165,17 +147,19 @@ export default function PostPaymentMessagePrompt({ return ( e.preventDefault()} > - - {title} + + {title} {step === 'choice' ? ( {t('Post payment prompt label')} ) : null} - {body} - {footer} +
{body}
+ {step === 'choice' ? ( + {choiceActions} + ) : null}
) diff --git a/src/components/ZapDialog/PublicMessageForm.tsx b/src/components/ZapDialog/PublicMessageForm.tsx index 9d95ecb8..09291248 100644 --- a/src/components/ZapDialog/PublicMessageForm.tsx +++ b/src/components/ZapDialog/PublicMessageForm.tsx @@ -116,11 +116,16 @@ export default function PublicMessageForm({ ) : null} - - - diff --git a/src/components/ZapDialog/SuperchatRequestForm.tsx b/src/components/ZapDialog/SuperchatRequestForm.tsx index 853c2018..c6d82ff8 100644 --- a/src/components/ZapDialog/SuperchatRequestForm.tsx +++ b/src/components/ZapDialog/SuperchatRequestForm.tsx @@ -116,11 +116,16 @@ export default function SuperchatRequestForm({ ) : null} - - - diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx index a93a9bca..e8c6cf13 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -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({ defaultAmount, defaultComment, defaultLightningAddress, - prefetchedPayment = null + prefetchedPayment = null, + onPostPaymentRequest }: { open: boolean setOpen: Dispatch> 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({ 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({ 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({ 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} /> - + {!onPostPaymentRequest ? ( + + ) : null} ) } @@ -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} /> - + {!onPostPaymentRequest ? ( + + ) : null} ) } diff --git a/src/hooks/usePaymentAttestationStatus.tsx b/src/hooks/usePaymentAttestationStatus.tsx new file mode 100644 index 00000000..2fb8bcab --- /dev/null +++ b/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).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 } +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 503ca28c..441d3a14 100644 --- a/src/i18n/locales/en.ts +++ b/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.", "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 { "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", diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 10f2136d..d9b25452 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -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( return setDraftEventCache(baseDraft) } +export async function createPaymentAttestationDraftEvent( + targetEvent: Event, + _options: { addClientTag?: boolean } = {} +): Promise { + 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 /** diff --git a/src/lib/notification-thread-watch.ts b/src/lib/notification-thread-watch.ts index c08ac667..c2e15be6 100644 --- a/src/lib/notification-thread-watch.ts +++ b/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' ) } + 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 diff --git a/src/lib/post-payment-context.test.ts b/src/lib/post-payment-context.test.ts new file mode 100644 index 00000000..6a581cac --- /dev/null +++ b/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 & Pick): 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') + }) +}) diff --git a/src/lib/post-payment-context.ts b/src/lib/post-payment-context.ts index 69cd7799..c8fa9abb 100644 --- a/src/lib/post-payment-context.ts +++ b/src/lib/post-payment-context.ts @@ -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: { 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: { } } +/** + * 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, + partial?: Partial | 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( } else { tags.push(buildETag(referencedEvent.id, referencedEvent.pubkey)) } + tags.push(['P', referencedEvent.pubkey]) tags.push(['k', String(referencedEvent.kind)]) return tags } diff --git a/src/lib/superchat.ts b/src/lib/superchat.ts index 16a0b1db..84a66c2c 100644 --- a/src/lib/superchat.ts +++ b/src/lib/superchat.ts @@ -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 { 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): boolean { if (!isSuperchatKind(event.kind)) return false return attestedIds.has(event.id.toLowerCase())