18 changed files with 684 additions and 303 deletions
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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 } |
||||||
|
} |
||||||
@ -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') |
||||||
|
}) |
||||||
|
}) |
||||||
Loading…
Reference in new issue