40 changed files with 1858 additions and 432 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
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