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