40 changed files with 1858 additions and 432 deletions
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
import { getEventNoncePowDifficulty } from '@/lib/event-pow' |
||||
import { cn } from '@/lib/utils' |
||||
import { Pickaxe } from 'lucide-react' |
||||
import type { Event, NostrEvent } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function EventPowLabel({ |
||||
event, |
||||
className |
||||
}: { |
||||
event: Event | NostrEvent |
||||
className?: string |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const difficulty = useMemo(() => getEventNoncePowDifficulty(event), [event]) |
||||
|
||||
if (difficulty == null) return null |
||||
|
||||
return ( |
||||
<span |
||||
className={cn( |
||||
'inline-flex shrink-0 items-center gap-1 rounded-md border-2 border-amber-500/90', |
||||
'bg-gradient-to-r from-amber-400/40 to-yellow-300/30 px-2 py-0.5', |
||||
'text-xs font-bold uppercase tracking-wide text-amber-950 shadow-sm', |
||||
'ring-2 ring-amber-400/35 dark:border-amber-400/80 dark:from-amber-500/30 dark:to-yellow-500/20', |
||||
'dark:text-amber-50 dark:ring-amber-300/25', |
||||
className |
||||
)} |
||||
title={t('Proof of Work')} |
||||
> |
||||
<Pickaxe className="size-3.5 shrink-0" strokeWidth={2.5} aria-hidden /> |
||||
{t('POW: difficulty {{difficulty}}', { difficulty })} |
||||
</span> |
||||
) |
||||
} |
||||
@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
|
||||
import { useFetchEvent } from '@/hooks' |
||||
import { parsePaytoTagType } from '@/lib/payto' |
||||
import { getPaymentNotificationInfo, getSuperchatReferenceFetchId } from '@/lib/superchat' |
||||
import { toNote, toProfile } from '@/lib/link' |
||||
import { cn } from '@/lib/utils' |
||||
import { Event } from 'nostr-tools' |
||||
import { useMemo, type MouseEvent } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager' |
||||
import Username from '../Username' |
||||
import UserAvatar from '../UserAvatar' |
||||
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel' |
||||
|
||||
export default function Superchat({ |
||||
event, |
||||
className, |
||||
omitSenderHeading, |
||||
variant = 'default' |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
omitSenderHeading?: boolean |
||||
variant?: 'default' | 'compact' |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const info = useMemo(() => getPaymentNotificationInfo(event), [event]) |
||||
const paytoType = useMemo( |
||||
() => (info?.payto ? parsePaytoTagType(info.payto) : 'unknown'), |
||||
[info?.payto] |
||||
) |
||||
const { navigateToNote } = useSmartNoteNavigationOptional() |
||||
const secondaryPage = useSecondaryPageOptional() |
||||
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) |
||||
|
||||
const referencedFetchId = useMemo( |
||||
() => (info ? getSuperchatReferenceFetchId(info) : undefined), |
||||
[info] |
||||
) |
||||
|
||||
const { event: targetEvent } = useFetchEvent(referencedFetchId) |
||||
|
||||
if (!info) { |
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'text-sm text-muted-foreground', |
||||
variant === 'compact' ? 'py-0.5' : 'rounded-lg border border-border bg-muted/20 p-4', |
||||
className |
||||
)} |
||||
> |
||||
[{t('Invalid superchat')}] |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
const { senderPubkey, recipientPubkey, comment } = info |
||||
const hasThreadTarget = Boolean(targetEvent || referencedFetchId) |
||||
const hasTarget = hasThreadTarget || Boolean(recipientPubkey) |
||||
|
||||
const openTarget = (e: MouseEvent<HTMLButtonElement>) => { |
||||
e.stopPropagation() |
||||
if (targetEvent) { |
||||
navigateToNote(toNote(targetEvent), targetEvent) |
||||
} else if (referencedFetchId) { |
||||
navigateToNote(toNote(referencedFetchId)) |
||||
} else if (recipientPubkey) { |
||||
push(toProfile(recipientPubkey)) |
||||
} |
||||
} |
||||
|
||||
if (variant === 'compact') { |
||||
return ( |
||||
<div className={cn('text-sm text-muted-foreground', className)}> |
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5"> |
||||
<SuperchatPaymentMethodLabel paytoType={paytoType} /> |
||||
<span className="text-xs font-medium text-yellow-400/90">{t('Superchat')}</span> |
||||
{recipientPubkey && recipientPubkey !== senderPubkey ? ( |
||||
<span className="text-xs"> |
||||
<span>{t('to')}</span>{' '} |
||||
<Username |
||||
userId={recipientPubkey} |
||||
className="inline font-medium text-foreground/85 hover:text-foreground" |
||||
/> |
||||
</span> |
||||
) : null} |
||||
{hasTarget ? ( |
||||
<button |
||||
type="button" |
||||
onClick={openTarget} |
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" |
||||
> |
||||
{hasThreadTarget ? t('Superchat thread') : t('Superchat profile')} |
||||
</button> |
||||
) : null} |
||||
</div> |
||||
{comment ? ( |
||||
<p className="mt-1.5 text-sm leading-snug text-foreground/90 whitespace-pre-wrap break-words"> |
||||
{comment} |
||||
</p> |
||||
) : null} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'relative rounded-lg border border-yellow-400/35 bg-yellow-400/5 p-4 text-card-foreground shadow-sm', |
||||
className |
||||
)} |
||||
> |
||||
{hasTarget ? ( |
||||
<button |
||||
type="button" |
||||
onClick={openTarget} |
||||
className="absolute bottom-3 right-3 flex items-center gap-2 rounded-md border border-border bg-secondary/80 px-2.5 py-1.5 text-xs font-medium text-secondary-foreground shadow-sm transition-colors hover:bg-secondary" |
||||
> |
||||
{hasThreadTarget ? t('View thread') : t('View profile')} |
||||
</button> |
||||
) : null} |
||||
|
||||
<div className="flex items-start gap-3 pb-10 pr-2 sm:pr-36"> |
||||
<div className="mt-1 shrink-0"> |
||||
<SuperchatPaymentMethodLabel paytoType={paytoType} className="text-sm" /> |
||||
</div> |
||||
<div className="min-w-0 flex-1"> |
||||
{!omitSenderHeading && ( |
||||
<div className="mb-3 flex flex-wrap items-center gap-2"> |
||||
<UserAvatar userId={senderPubkey} size="small" /> |
||||
<Username userId={senderPubkey} className="font-semibold text-foreground" /> |
||||
<span className="text-sm font-medium text-yellow-400/90">{t('Superchat')}</span> |
||||
{recipientPubkey && recipientPubkey !== senderPubkey && ( |
||||
<> |
||||
<span className="text-sm text-muted-foreground">{t('to')}</span> |
||||
<UserAvatar userId={recipientPubkey} size="small" /> |
||||
<Username userId={recipientPubkey} className="font-semibold text-foreground" /> |
||||
</> |
||||
)} |
||||
</div> |
||||
)} |
||||
|
||||
{comment ? ( |
||||
<div className="rounded-r-md border-l-[3px] border-yellow-400 bg-muted/40 py-2.5 pl-3 pr-2 dark:bg-muted/25"> |
||||
<p className="text-lg font-semibold leading-snug tracking-tight text-foreground whitespace-pre-wrap break-words"> |
||||
{comment} |
||||
</p> |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
|
||||
import { |
||||
getCanonicalPaytoType, |
||||
getPaytoEditorTypeLabel, |
||||
getPaytoIconChar, |
||||
getPaytoLogoPath, |
||||
isLightningPaytoType |
||||
} from '@/lib/payto' |
||||
import { cn } from '@/lib/utils' |
||||
import { Zap as ZapIcon } from 'lucide-react' |
||||
|
||||
export default function SuperchatPaymentMethodLabel({ |
||||
paytoType, |
||||
className |
||||
}: { |
||||
/** Canonical or alias payto type (`lightning`, `monero`, `geyser`, …). */ |
||||
paytoType: string |
||||
className?: string |
||||
}) { |
||||
const canonical = getCanonicalPaytoType(paytoType) |
||||
const label = getPaytoEditorTypeLabel(canonical) |
||||
const logoPath = getPaytoLogoPath(canonical) |
||||
const iconChar = getPaytoIconChar(canonical) |
||||
const isLightning = isLightningPaytoType(canonical) |
||||
|
||||
return ( |
||||
<span |
||||
className={cn( |
||||
'inline-flex shrink-0 items-center gap-1 rounded-md border border-border/60 bg-muted/40', |
||||
'px-1.5 py-0.5 text-xs font-medium leading-none text-muted-foreground', |
||||
className |
||||
)} |
||||
> |
||||
{logoPath ? ( |
||||
<img src={logoPath} alt="" className="size-3.5 shrink-0 object-contain" aria-hidden /> |
||||
) : isLightning ? ( |
||||
<ZapIcon className="size-3.5 shrink-0 text-yellow-400" strokeWidth={2} aria-hidden /> |
||||
) : iconChar ? ( |
||||
<span className="shrink-0 text-[0.65rem] leading-none" aria-hidden> |
||||
{iconChar} |
||||
</span> |
||||
) : null} |
||||
<span className="truncate">{label}</span> |
||||
</span> |
||||
) |
||||
} |
||||
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
import Superchat from '@/components/Note/Superchat' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { Event } from 'nostr-tools' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function ProfileWallSuperchats({ |
||||
superchats, |
||||
isLoading |
||||
}: { |
||||
superchats: Event[] |
||||
isLoading?: boolean |
||||
}) { |
||||
const { t } = useTranslation() |
||||
|
||||
if (isLoading && superchats.length === 0) { |
||||
return ( |
||||
<div className="mt-3 space-y-2" aria-hidden> |
||||
<Skeleton className="h-24 w-full rounded-lg" /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (superchats.length === 0) return null |
||||
|
||||
return ( |
||||
<section className="mt-4 min-w-0" aria-label={t('Profile wall superchats')}> |
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground"> |
||||
{t('Superchats')} |
||||
</h3> |
||||
<div className="space-y-2"> |
||||
{superchats.map((event) => ( |
||||
<Superchat key={event.id} event={event} variant="compact" /> |
||||
))} |
||||
</div> |
||||
</section> |
||||
) |
||||
} |
||||
@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogDescription, |
||||
DialogFooter, |
||||
DialogHeader, |
||||
DialogTitle |
||||
} from '@/components/ui/dialog' |
||||
import { |
||||
Drawer, |
||||
DrawerContent, |
||||
DrawerFooter, |
||||
DrawerHeader, |
||||
DrawerTitle |
||||
} from '@/components/ui/drawer' |
||||
import { type PostPaymentContext } from '@/lib/post-payment-context' |
||||
import { cn } from '@/lib/utils' |
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||
import { useEffect, useRef, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import UserAvatar from '../UserAvatar' |
||||
import Username from '../Username' |
||||
import PublicMessageForm from './PublicMessageForm' |
||||
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> |
||||
) |
||||
} |
||||
|
||||
export default function PostPaymentMessagePrompt({ |
||||
open, |
||||
onOpenChange, |
||||
recipientPubkey, |
||||
paymentContext |
||||
}: { |
||||
open: boolean |
||||
onOpenChange: (open: boolean) => void |
||||
recipientPubkey: string | null |
||||
paymentContext?: PostPaymentContext | null |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { isSmallScreen } = useScreenSize() |
||||
const closeRef = useRef<HTMLButtonElement>(null) |
||||
const [step, setStep] = useState<Step>('choice') |
||||
|
||||
useEffect(() => { |
||||
if (open) { |
||||
setStep('choice') |
||||
} |
||||
}, [open, recipientPubkey]) |
||||
|
||||
useEffect(() => { |
||||
if (!open || step !== 'choice') return |
||||
const id = requestAnimationFrame(() => closeRef.current?.focus()) |
||||
return () => cancelAnimationFrame(id) |
||||
}, [open, step]) |
||||
|
||||
if (!recipientPubkey) return null |
||||
|
||||
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> |
||||
) |
||||
|
||||
const choiceActions = ( |
||||
<Button ref={closeRef} type="button" variant="default" onClick={handleClose}> |
||||
{t('Close')} |
||||
</Button> |
||||
) |
||||
|
||||
const title = ( |
||||
<span className="flex min-w-0 items-center gap-2"> |
||||
<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> |
||||
) |
||||
|
||||
const body = |
||||
step === 'public-message' ? ( |
||||
<PublicMessageForm |
||||
recipientPubkey={recipientPubkey} |
||||
onBack={() => setStep('choice')} |
||||
onDone={handleClose} |
||||
/> |
||||
) : step === 'superchat' ? ( |
||||
<SuperchatRequestForm |
||||
recipientPubkey={recipientPubkey} |
||||
paymentContext={paymentContext} |
||||
onBack={() => setStep('choice')} |
||||
onDone={handleClose} |
||||
/> |
||||
) : ( |
||||
choiceBody |
||||
) |
||||
|
||||
const footer = |
||||
step === 'choice' ? ( |
||||
isSmallScreen ? ( |
||||
choiceActions |
||||
) : ( |
||||
<DialogFooter className="gap-2 sm:gap-2">{choiceActions}</DialogFooter> |
||||
) |
||||
) : null |
||||
|
||||
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> |
||||
{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} |
||||
</DrawerContent> |
||||
</Drawer> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
<DialogContent |
||||
className="w-[calc(100vw-2rem)] max-w-lg min-w-0 overflow-hidden sm:max-w-lg" |
||||
onOpenAutoFocus={(e) => e.preventDefault()} |
||||
> |
||||
<DialogHeader className="min-w-0"> |
||||
<DialogTitle>{title}</DialogTitle> |
||||
{step === 'choice' ? ( |
||||
<DialogDescription className="sr-only">{t('Post payment prompt label')}</DialogDescription> |
||||
) : null} |
||||
</DialogHeader> |
||||
{body} |
||||
{footer} |
||||
</DialogContent> |
||||
</Dialog> |
||||
) |
||||
} |
||||
@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { DialogFooter } from '@/components/ui/dialog' |
||||
import { Textarea } from '@/components/ui/textarea' |
||||
import { ExtendedKind } from '@/constants' |
||||
import { createPaymentNotificationDraftEvent } from '@/lib/draft-event' |
||||
import { createFakeEvent } from '@/lib/event' |
||||
import { parsePaytoTagType } from '@/lib/payto' |
||||
import { LoginRequiredError } from '@/lib/nostr-errors' |
||||
import { paymentNotificationReferenceTags, type PostPaymentContext } from '@/lib/post-payment-context' |
||||
import { showSimplePublishSuccess } from '@/lib/publishing-feedback' |
||||
import { cn } from '@/lib/utils' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { useEffect, useMemo, useRef, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { toast } from 'sonner' |
||||
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle' |
||||
import SuperchatPaymentMethodLabel from '../Note/SuperchatPaymentMethodLabel' |
||||
|
||||
export default function SuperchatRequestForm({ |
||||
recipientPubkey, |
||||
paymentContext, |
||||
onBack, |
||||
onDone |
||||
}: { |
||||
recipientPubkey: string |
||||
paymentContext?: PostPaymentContext | null |
||||
onBack: () => void |
||||
onDone: () => void |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { publish, checkLogin, pubkey: selfPubkey } = useNostr() |
||||
const [message, setMessage] = useState('') |
||||
const [sending, setSending] = useState(false) |
||||
const textareaRef = useRef<HTMLTextAreaElement>(null) |
||||
|
||||
useEffect(() => { |
||||
const id = requestAnimationFrame(() => textareaRef.current?.focus()) |
||||
return () => cancelAnimationFrame(id) |
||||
}, []) |
||||
|
||||
const previewEvent = useMemo(() => { |
||||
const tags: string[][] = [['p', recipientPubkey]] |
||||
if (paymentContext?.amountMsat) { |
||||
tags.push(['amount', String(paymentContext.amountMsat)]) |
||||
} |
||||
if (paymentContext?.payto) { |
||||
tags.push(['payto', paymentContext.payto]) |
||||
} |
||||
tags.push(...paymentNotificationReferenceTags(paymentContext?.referencedEvent)) |
||||
return createFakeEvent({ |
||||
kind: ExtendedKind.PAYMENT_NOTIFICATION, |
||||
pubkey: selfPubkey ?? '', |
||||
content: message, |
||||
tags |
||||
}) |
||||
}, [message, paymentContext, recipientPubkey, selfPubkey]) |
||||
|
||||
const handleSend = () => { |
||||
const trimmed = message.trim() |
||||
if (!trimmed) return |
||||
checkLogin(async () => { |
||||
setSending(true) |
||||
try { |
||||
const draft = await createPaymentNotificationDraftEvent(trimmed, recipientPubkey, { |
||||
amountMsat: paymentContext?.amountMsat, |
||||
payto: paymentContext?.payto, |
||||
referencedEvent: paymentContext?.referencedEvent, |
||||
addClientTag: true |
||||
}) |
||||
await publish(draft, { disableFallbacks: true }) |
||||
showSimplePublishSuccess(t('Superchat request sent')) |
||||
onDone() |
||||
} catch (error) { |
||||
if (error instanceof LoginRequiredError) return |
||||
toast.error( |
||||
t('Failed to send superchat request', { |
||||
error: error instanceof Error ? error.message : String(error) |
||||
}) |
||||
) |
||||
} finally { |
||||
setSending(false) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
const paytoType = paymentContext?.payto ? parsePaytoTagType(paymentContext.payto) : null |
||||
|
||||
return ( |
||||
<div className="min-w-0"> |
||||
<p className="text-sm text-muted-foreground">{t('Superchat request prompt description')}</p> |
||||
{paytoType ? ( |
||||
<div className="mt-3"> |
||||
<SuperchatPaymentMethodLabel paytoType={paytoType} /> |
||||
</div> |
||||
) : null} |
||||
<Textarea |
||||
ref={textareaRef} |
||||
value={message} |
||||
onChange={(e) => setMessage(e.target.value)} |
||||
disabled={sending} |
||||
rows={5} |
||||
className="mt-3 min-h-[8rem] resize-y text-sm leading-relaxed" |
||||
aria-label={t('Superchat message')} |
||||
placeholder={t('Superchat message placeholder')} |
||||
/> |
||||
{previewEvent && message.trim() ? ( |
||||
<div className="mt-4 min-w-0"> |
||||
<p className="text-xs font-medium text-muted-foreground">{t('Preview')}</p> |
||||
<div |
||||
className={cn( |
||||
'mt-1.5 max-h-48 min-w-0 overflow-y-auto overflow-x-hidden rounded-md border border-border', |
||||
'bg-muted/25 px-3 py-2' |
||||
)} |
||||
> |
||||
<MarkdownArticle event={previewEvent} hideMetadata lazyMedia={false} className="text-sm" /> |
||||
</div> |
||||
</div> |
||||
) : null} |
||||
<DialogFooter className="mt-4 gap-2 sm:justify-end"> |
||||
<Button type="button" variant="outline" onClick={onBack} disabled={sending}> |
||||
{t('Back')} |
||||
</Button> |
||||
<Button type="button" onClick={handleSend} disabled={sending || !message.trim()}> |
||||
{t('Send superchat request')} |
||||
</Button> |
||||
</DialogFooter> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
import { getPow } from 'nostr-tools/nip13' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
/** Fields required for NIP-13 PoW verification (id + tags). */ |
||||
export type PowVerifiableEvent = Pick<Event, 'id' | 'tags'> |
||||
|
||||
function parseFirstNonceTag(event: PowVerifiableEvent): { nonce: string; difficulty: number } | null { |
||||
for (const tag of event.tags) { |
||||
if (tag[0] !== 'nonce') continue |
||||
const nonce = tag[1]?.trim() |
||||
const difficulty = parseInt(String(tag[2] ?? ''), 10) |
||||
if (!nonce || !Number.isFinite(difficulty) || difficulty <= 0) return null |
||||
return { nonce, difficulty } |
||||
} |
||||
return null |
||||
} |
||||
|
||||
/** Whether the event id meets the committed difficulty in its first `nonce` tag (NIP-13). */ |
||||
export function isEventNoncePowVerified(event: PowVerifiableEvent): boolean { |
||||
const parsed = parseFirstNonceTag(event) |
||||
if (!parsed) return false |
||||
return getPow(event.id) >= parsed.difficulty |
||||
} |
||||
|
||||
/** |
||||
* Committed PoW difficulty from the first `nonce` tag, or null if missing or not verified |
||||
* against the event id (NIP-13). |
||||
*/ |
||||
export function getEventNoncePowDifficulty(event: PowVerifiableEvent): number | null { |
||||
const parsed = parseFirstNonceTag(event) |
||||
if (!parsed) return null |
||||
if (getPow(event.id) < parsed.difficulty) return null |
||||
return parsed.difficulty |
||||
} |
||||
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
import { buildPaytoUri, formatPaytoTagValue } from '@/lib/payto' |
||||
import { buildATag, buildETag } from '@/lib/draft-event' |
||||
import { isReplaceableEvent } from '@/lib/event' |
||||
import { NostrEvent } from 'nostr-tools' |
||||
|
||||
export type PostPaymentContext = { |
||||
recipientPubkey: string |
||||
/** Payment amount in millisats. */ |
||||
amountMsat?: number |
||||
/** payto tag value without the `payto://` prefix. */ |
||||
payto?: string |
||||
/** Thread or wall reference for superchat placement. */ |
||||
referencedEvent?: NostrEvent |
||||
} |
||||
|
||||
export function buildPostPaymentContext(params: { |
||||
recipientPubkey: string |
||||
amountMsat?: number |
||||
/** Preformatted kind-9740 payto tag value. */ |
||||
payto?: string |
||||
paytoUri?: string |
||||
paytoType?: string |
||||
paytoAuthority?: string |
||||
referencedEvent?: NostrEvent |
||||
}): PostPaymentContext { |
||||
const payto = |
||||
params.payto ?? |
||||
(params.paytoUri != null |
||||
? formatPaytoTagValue(params.paytoUri) |
||||
: params.paytoType && params.paytoAuthority |
||||
? formatPaytoTagValue(buildPaytoUri(params.paytoType, params.paytoAuthority)) |
||||
: undefined) |
||||
|
||||
return { |
||||
recipientPubkey: params.recipientPubkey, |
||||
amountMsat: params.amountMsat, |
||||
payto, |
||||
referencedEvent: params.referencedEvent |
||||
} |
||||
} |
||||
|
||||
export function paymentNotificationReferenceTags( |
||||
referencedEvent?: NostrEvent |
||||
): string[][] { |
||||
if (!referencedEvent) return [] |
||||
|
||||
const tags: string[][] = [] |
||||
if (isReplaceableEvent(referencedEvent.kind)) { |
||||
tags.push(buildATag(referencedEvent)) |
||||
} else { |
||||
tags.push(buildETag(referencedEvent.id, referencedEvent.pubkey)) |
||||
} |
||||
tags.push(['k', String(referencedEvent.kind)]) |
||||
return tags |
||||
} |
||||
@ -0,0 +1,229 @@
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { ExtendedKind } from '@/constants' |
||||
import { |
||||
buildAttestedPaymentIdSet, |
||||
filterAttestedProfileWallSuperchats, |
||||
getPaymentNotificationInfo, |
||||
getSuperchatPaytoType, |
||||
getSuperchatReferenceFetchId, |
||||
isProfileWallPaymentNotification, |
||||
partitionAttestedSuperchats |
||||
} from '@/lib/superchat' |
||||
import { parsePaytoTagType } from '@/lib/payto' |
||||
import { kinds, type Event } from 'nostr-tools' |
||||
|
||||
const RECIPIENT = 'a'.repeat(64) |
||||
const SENDER = 'b'.repeat(64) |
||||
const ZAP_ID = 'c'.repeat(64) |
||||
const PAYMENT_ID = 'd'.repeat(64) |
||||
|
||||
function fakeEvent(partial: Partial<Event> & Pick<Event, 'kind' | 'tags'>): Event { |
||||
return { |
||||
id: partial.id ?? 'e'.repeat(64), |
||||
pubkey: partial.pubkey ?? SENDER, |
||||
created_at: partial.created_at ?? 1_700_000_000, |
||||
kind: partial.kind, |
||||
tags: partial.tags, |
||||
content: partial.content ?? '', |
||||
sig: partial.sig ?? '' |
||||
} |
||||
} |
||||
|
||||
describe('buildAttestedPaymentIdSet', () => { |
||||
it('collects attested zap and payment notification ids from recipient', () => { |
||||
const attestations = [ |
||||
fakeEvent({ |
||||
kind: ExtendedKind.PAYMENT_ATTESTATION, |
||||
pubkey: RECIPIENT, |
||||
tags: [ |
||||
['e', ZAP_ID], |
||||
['k', '9735'] |
||||
] |
||||
}), |
||||
fakeEvent({ |
||||
kind: ExtendedKind.PAYMENT_ATTESTATION, |
||||
pubkey: RECIPIENT, |
||||
tags: [ |
||||
['e', PAYMENT_ID], |
||||
['k', '9740'] |
||||
] |
||||
}), |
||||
fakeEvent({ |
||||
kind: ExtendedKind.PAYMENT_ATTESTATION, |
||||
pubkey: SENDER, |
||||
tags: [ |
||||
['e', ZAP_ID], |
||||
['k', '9735'] |
||||
] |
||||
}) |
||||
] |
||||
const ids = buildAttestedPaymentIdSet(attestations, RECIPIENT) |
||||
expect(ids.has(ZAP_ID)).toBe(true) |
||||
expect(ids.has(PAYMENT_ID)).toBe(true) |
||||
expect(ids.size).toBe(2) |
||||
}) |
||||
}) |
||||
|
||||
describe('partitionAttestedSuperchats', () => { |
||||
it('drops unattested zaps and keeps attested zaps and payment notifications', () => { |
||||
const attested = new Set([ZAP_ID, PAYMENT_ID]) |
||||
const zapAttested = fakeEvent({ |
||||
id: ZAP_ID, |
||||
kind: kinds.Zap, |
||||
tags: [ |
||||
['P', SENDER], |
||||
['p', RECIPIENT], |
||||
['bolt11', 'lnbc210n1p0fake'], |
||||
[ |
||||
'description', |
||||
JSON.stringify({ |
||||
pubkey: SENDER, |
||||
content: 'Zap!', |
||||
tags: [['p', RECIPIENT], ['amount', '21000']] |
||||
}) |
||||
] |
||||
] |
||||
}) |
||||
const zapUnattested = fakeEvent({ |
||||
id: 'f'.repeat(64), |
||||
kind: kinds.Zap, |
||||
tags: [ |
||||
['P', SENDER], |
||||
['p', RECIPIENT], |
||||
['amount', '42000'], |
||||
['bolt11', 'lnbc2'] |
||||
] |
||||
}) |
||||
const payment = fakeEvent({ |
||||
id: PAYMENT_ID, |
||||
kind: ExtendedKind.PAYMENT_NOTIFICATION, |
||||
content: 'Thanks!', |
||||
tags: [ |
||||
['p', RECIPIENT], |
||||
['amount', '100000'] |
||||
] |
||||
}) |
||||
const comment = fakeEvent({ |
||||
id: '1'.repeat(64), |
||||
kind: ExtendedKind.COMMENT, |
||||
tags: [['e', '2'.repeat(64)]] |
||||
}) |
||||
|
||||
const { superchats, rest } = partitionAttestedSuperchats( |
||||
[zapAttested, zapUnattested, payment, comment], |
||||
attested, |
||||
1 |
||||
) |
||||
|
||||
expect(superchats.map((e) => e.id)).toEqual([payment.id, zapAttested.id]) |
||||
expect(rest).toEqual([comment]) |
||||
}) |
||||
}) |
||||
|
||||
describe('getPaymentNotificationInfo', () => { |
||||
it('uses only the first p, e, and a tags', () => { |
||||
const evt = fakeEvent({ |
||||
kind: ExtendedKind.PAYMENT_NOTIFICATION, |
||||
tags: [ |
||||
['p', RECIPIENT], |
||||
['p', SENDER], |
||||
['e', '1'.repeat(64)], |
||||
['e', '2'.repeat(64)], |
||||
['a', '30023:' + RECIPIENT + ':'], |
||||
['a', '30023:' + SENDER + ':'], |
||||
['payto', 'monero/primary'], |
||||
['payto', 'lightning/user'], |
||||
['amount', '100000'], |
||||
['amount', '200000'] |
||||
] |
||||
}) |
||||
const info = getPaymentNotificationInfo(evt) |
||||
expect(info?.recipientPubkey).toBe(RECIPIENT) |
||||
expect(info?.referencedEventId).toBe('1'.repeat(64)) |
||||
expect(info?.referencedCoordinate).toBe('30023:' + RECIPIENT + ':') |
||||
expect(info?.payto).toBe('monero/primary') |
||||
expect(info?.amountSats).toBe(100) |
||||
}) |
||||
}) |
||||
|
||||
describe('getSuperchatPaytoType', () => { |
||||
it('returns lightning for zap receipts', () => { |
||||
const zap = fakeEvent({ kind: kinds.Zap, tags: [['p', RECIPIENT]] }) |
||||
expect(getSuperchatPaytoType(zap)).toBe('lightning') |
||||
}) |
||||
|
||||
it('parses payto type from payment notification', () => { |
||||
const evt = fakeEvent({ |
||||
kind: ExtendedKind.PAYMENT_NOTIFICATION, |
||||
tags: [ |
||||
['p', RECIPIENT], |
||||
['payto', 'geyser/project123'] |
||||
] |
||||
}) |
||||
expect(getSuperchatPaytoType(evt)).toBe('geyser') |
||||
expect(parsePaytoTagType('lightning/user%40example.com')).toBe('lightning') |
||||
}) |
||||
}) |
||||
|
||||
describe('getSuperchatReferenceFetchId', () => { |
||||
it('prefers event id over coordinate', () => { |
||||
const info = { |
||||
senderPubkey: SENDER, |
||||
recipientPubkey: RECIPIENT, |
||||
amountSats: 0, |
||||
referencedEventId: '1'.repeat(64), |
||||
referencedCoordinate: `30023:${RECIPIENT}:article` |
||||
} |
||||
expect(getSuperchatReferenceFetchId(info)).toBe('1'.repeat(64)) |
||||
}) |
||||
|
||||
it('returns naddr for replaceable coordinate when no e tag', () => { |
||||
const info = { |
||||
senderPubkey: SENDER, |
||||
recipientPubkey: RECIPIENT, |
||||
amountSats: 0, |
||||
referencedCoordinate: `30023:${RECIPIENT}:my-article` |
||||
} |
||||
const id = getSuperchatReferenceFetchId(info) |
||||
expect(id).toBeTruthy() |
||||
expect(id).toMatch(/^naddr1/) |
||||
}) |
||||
}) |
||||
|
||||
describe('profile wall payment notifications', () => { |
||||
it('accepts profile-only 9740 without thread reference', () => { |
||||
const evt = fakeEvent({ |
||||
kind: ExtendedKind.PAYMENT_NOTIFICATION, |
||||
tags: [ |
||||
['p', RECIPIENT], |
||||
['amount', '50000'] |
||||
] |
||||
}) |
||||
expect(isProfileWallPaymentNotification(evt, RECIPIENT)).toBe(true) |
||||
expect(getPaymentNotificationInfo(evt)?.amountSats).toBe(50) |
||||
}) |
||||
|
||||
it('filters to attested profile wall superchats', () => { |
||||
const paymentId = PAYMENT_ID |
||||
const payment = fakeEvent({ |
||||
id: paymentId, |
||||
kind: ExtendedKind.PAYMENT_NOTIFICATION, |
||||
content: 'Wall tip', |
||||
tags: [ |
||||
['p', RECIPIENT], |
||||
['amount', '21000'] |
||||
] |
||||
}) |
||||
const attestation = fakeEvent({ |
||||
kind: ExtendedKind.PAYMENT_ATTESTATION, |
||||
pubkey: RECIPIENT, |
||||
tags: [ |
||||
['e', paymentId], |
||||
['k', '9740'] |
||||
] |
||||
}) |
||||
const out = filterAttestedProfileWallSuperchats([payment], [attestation], RECIPIENT) |
||||
expect(out).toHaveLength(1) |
||||
expect(out[0]!.id).toBe(paymentId) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,212 @@
@@ -0,0 +1,212 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { getZapInfoFromEvent } from '@/lib/event-metadata' |
||||
import { |
||||
getReplaceableCoordinate, |
||||
normalizeReplaceableCoordinateString |
||||
} from '@/lib/event' |
||||
import { parsePaytoTagType } from '@/lib/payto' |
||||
import { generateBech32IdFromATag } from '@/lib/tag' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
|
||||
export const PAYMENT_ATTESTATION_TARGET_KINDS = new Set(['9735', '9740']) |
||||
|
||||
export type PaymentNotificationInfo = { |
||||
senderPubkey: string |
||||
recipientPubkey: string |
||||
amountSats: number |
||||
payto?: string |
||||
comment?: string |
||||
referencedEventId?: string |
||||
referencedCoordinate?: string |
||||
} |
||||
|
||||
/** First matching tag value only (duplicate `p` / `e` / `a` tags are ignored). */ |
||||
function firstTagValue(tags: string[][], names: readonly string[]): string | undefined { |
||||
for (const tag of tags) { |
||||
const name = tag[0] |
||||
const value = tag[1]?.trim() |
||||
if (value && names.includes(name)) return value |
||||
} |
||||
return undefined |
||||
} |
||||
|
||||
export function getPaymentAttestationTargetId(attestation: Event): string | undefined { |
||||
if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return undefined |
||||
const tag = attestation.tags.find(([name]) => name === 'e' || name === 'E') |
||||
const id = tag?.[1]?.trim().toLowerCase() |
||||
return id && /^[0-9a-f]{64}$/.test(id) ? id : undefined |
||||
} |
||||
|
||||
export function getPaymentAttestationTargetKind(attestation: Event): string | undefined { |
||||
if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return undefined |
||||
const tag = attestation.tags.find(([name]) => name === 'k') |
||||
const k = tag?.[1]?.trim() |
||||
return k && PAYMENT_ATTESTATION_TARGET_KINDS.has(k) ? k : undefined |
||||
} |
||||
|
||||
/** Event ids (lowercase hex) the recipient has attested as received payment. */ |
||||
export function buildAttestedPaymentIdSet( |
||||
attestations: Event[], |
||||
recipientPubkey: string |
||||
): Set<string> { |
||||
const recipient = recipientPubkey.trim().toLowerCase() |
||||
const out = new Set<string>() |
||||
for (const attestation of attestations) { |
||||
if (attestation.pubkey.toLowerCase() !== recipient) continue |
||||
const targetId = getPaymentAttestationTargetId(attestation) |
||||
const targetKind = getPaymentAttestationTargetKind(attestation) |
||||
if (!targetId || !targetKind) continue |
||||
out.add(targetId) |
||||
} |
||||
return out |
||||
} |
||||
|
||||
export function getPaymentNotificationInfo(event: Event): PaymentNotificationInfo | null { |
||||
if (event.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return null |
||||
|
||||
const recipientPubkey = firstTagValue(event.tags, ['p']) |
||||
if (!recipientPubkey) return null |
||||
|
||||
const amountTag = firstTagValue(event.tags, ['amount']) |
||||
const amountSats = amountTag ? Math.floor(parseInt(amountTag, 10) / 1000) : 0 |
||||
const payto = firstTagValue(event.tags, ['payto']) |
||||
const referencedEventId = firstTagValue(event.tags, ['e', 'E'])?.toLowerCase() |
||||
const referencedCoordinate = firstTagValue(event.tags, ['a', 'A']) |
||||
|
||||
return { |
||||
senderPubkey: event.pubkey, |
||||
recipientPubkey, |
||||
amountSats, |
||||
payto, |
||||
comment: event.content?.trim() || undefined, |
||||
referencedEventId, |
||||
referencedCoordinate |
||||
} |
||||
} |
||||
|
||||
/** Payment category for superchat display (9735 → lightning). */ |
||||
export function getSuperchatPaytoType(event: Event): string { |
||||
if (event.kind === kinds.Zap) return 'lightning' |
||||
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { |
||||
const payto = getPaymentNotificationInfo(event)?.payto |
||||
return payto ? parsePaytoTagType(payto) : 'unknown' |
||||
} |
||||
return 'unknown' |
||||
} |
||||
|
||||
/** Hex event id or `naddr` bech32 for fetching / navigating the superchat target (9740 `e` or `a`). */ |
||||
export function getSuperchatReferenceFetchId(info: PaymentNotificationInfo): string | undefined { |
||||
if (info.referencedEventId) return info.referencedEventId |
||||
if (info.referencedCoordinate) { |
||||
return generateBech32IdFromATag(['a', info.referencedCoordinate]) ?? undefined |
||||
} |
||||
return undefined |
||||
} |
||||
|
||||
export function getSuperchatAmountSats(event: Event): number { |
||||
if (event.kind === kinds.Zap) { |
||||
return getZapInfoFromEvent(event)?.amount ?? 0 |
||||
} |
||||
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { |
||||
return getPaymentNotificationInfo(event)?.amountSats ?? 0 |
||||
} |
||||
return 0 |
||||
} |
||||
|
||||
export function isSuperchatKind(kind: number): boolean { |
||||
return kind === kinds.Zap || kind === ExtendedKind.PAYMENT_NOTIFICATION |
||||
} |
||||
|
||||
export function isAttestedSuperchat(event: Event, attestedIds: Set<string>): boolean { |
||||
if (!isSuperchatKind(event.kind)) return false |
||||
return attestedIds.has(event.id.toLowerCase()) |
||||
} |
||||
|
||||
export function sortSuperchatsByAmountDesc(events: Event[]): Event[] { |
||||
return [...events].sort((a, b) => { |
||||
const sa = getSuperchatAmountSats(a) |
||||
const sb = getSuperchatAmountSats(b) |
||||
if (sb !== sa) return sb - sa |
||||
return b.created_at - a.created_at |
||||
}) |
||||
} |
||||
|
||||
export function partitionAttestedSuperchats( |
||||
items: Event[], |
||||
attestedIds: Set<string>, |
||||
zapReplyThreshold: number |
||||
): { superchats: Event[]; rest: Event[] } { |
||||
const superchats: Event[] = [] |
||||
const rest: Event[] = [] |
||||
|
||||
for (const e of items) { |
||||
if (e.kind === kinds.Zap) { |
||||
if ( |
||||
isAttestedSuperchat(e, attestedIds) && |
||||
getZapInfoFromEvent(e) && |
||||
getSuperchatAmountSats(e) >= zapReplyThreshold |
||||
) { |
||||
superchats.push(e) |
||||
} |
||||
continue |
||||
} |
||||
if (e.kind === ExtendedKind.PAYMENT_NOTIFICATION) { |
||||
if (isAttestedSuperchat(e, attestedIds) && getPaymentNotificationInfo(e)) { |
||||
superchats.push(e) |
||||
} |
||||
continue |
||||
} |
||||
rest.push(e) |
||||
} |
||||
|
||||
return { superchats: sortSuperchatsByAmountDesc(superchats), rest } |
||||
} |
||||
|
||||
export function replyFeedSuperchatsFirst(sortedNonSuperchatReplies: Event[], superchats: Event[]) { |
||||
return [...superchats, ...sortedNonSuperchatReplies] |
||||
} |
||||
|
||||
/** Kind 9740 on a profile wall: `p` is the profile owner and there is no note/thread reference. */ |
||||
export function isProfileWallPaymentNotification( |
||||
event: Event, |
||||
profilePubkey: string, |
||||
profileEventId?: string |
||||
): boolean { |
||||
if (event.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return false |
||||
const info = getPaymentNotificationInfo(event) |
||||
if (!info || info.recipientPubkey.toLowerCase() !== profilePubkey.toLowerCase()) return false |
||||
|
||||
if (info.referencedEventId) { |
||||
const profileId = profileEventId?.trim().toLowerCase() |
||||
if (profileId && info.referencedEventId === profileId) return true |
||||
return false |
||||
} |
||||
|
||||
if (info.referencedCoordinate) { |
||||
const profileCoord = normalizeReplaceableCoordinateString( |
||||
getReplaceableCoordinate(kinds.Metadata, profilePubkey, '') |
||||
) |
||||
if (normalizeReplaceableCoordinateString(info.referencedCoordinate) === profileCoord) { |
||||
return true |
||||
} |
||||
return false |
||||
} |
||||
|
||||
return true |
||||
} |
||||
|
||||
export function filterAttestedProfileWallSuperchats( |
||||
paymentNotifications: Event[], |
||||
attestations: Event[], |
||||
profilePubkey: string, |
||||
profileEventId?: string |
||||
): Event[] { |
||||
const attestedIds = buildAttestedPaymentIdSet(attestations, profilePubkey) |
||||
return sortSuperchatsByAmountDesc( |
||||
paymentNotifications.filter( |
||||
(e) => |
||||
isProfileWallPaymentNotification(e, profilePubkey, profileEventId) && |
||||
isAttestedSuperchat(e, attestedIds) |
||||
) |
||||
) |
||||
} |
||||
@ -1,25 +0,0 @@
@@ -1,25 +0,0 @@
|
||||
import { Label } from '@/components/ui/label' |
||||
import { Switch } from '@/components/ui/switch' |
||||
import { useZap } from '@/providers/ZapProvider' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function IncludePublicZapReceiptSwitch() { |
||||
const { t } = useTranslation() |
||||
const { includePublicZapReceipt, updateIncludePublicZapReceipt } = useZap() |
||||
|
||||
return ( |
||||
<div className="w-full flex justify-between items-center gap-3"> |
||||
<Label htmlFor="include-public-zap-receipt-switch" className="flex-1"> |
||||
<div className="text-base font-medium">{t('Include public zap receipt')}</div> |
||||
<div className="text-muted-foreground text-sm font-normal"> |
||||
{t('When off, your zap may still succeed but a public receipt may not be published to relays')} |
||||
</div> |
||||
</Label> |
||||
<Switch |
||||
id="include-public-zap-receipt-switch" |
||||
checked={includePublicZapReceipt} |
||||
onCheckedChange={updateIncludePublicZapReceipt} |
||||
/> |
||||
</div> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue