Browse Source

Implement superchats

imwald
Silberengel 3 weeks ago
parent
commit
3db38eed30
  1. 116
      src/components/AdvancedEventLab/AdvancedEventLabTagsEditor.tsx
  2. 11
      src/components/ContentPreview/index.tsx
  3. 36
      src/components/EventPowLabel/index.tsx
  4. 153
      src/components/Note/Superchat.tsx
  5. 45
      src/components/Note/SuperchatPaymentMethodLabel.tsx
  6. 10
      src/components/Note/UnknownNote.tsx
  7. 10
      src/components/Note/Zap.tsx
  8. 9
      src/components/Note/index.tsx
  9. 32
      src/components/NoteStats/ZapButton.tsx
  10. 12
      src/components/PaymentMethodsSection/index.tsx
  11. 22
      src/components/PaytoDialog/LightningInvoiceSection.tsx
  12. 107
      src/components/PaytoDialog/index.tsx
  13. 12
      src/components/PaytoLink/index.tsx
  14. 68
      src/components/Profile/ProfileBadges.tsx
  15. 37
      src/components/Profile/ProfileWallSuperchats.tsx
  16. 27
      src/components/ReplyNote/index.tsx
  17. 134
      src/components/ReplyNoteList/index.tsx
  18. 182
      src/components/ZapDialog/PostPaymentMessagePrompt.tsx
  19. 123
      src/components/ZapDialog/PublicMessageForm.tsx
  20. 129
      src/components/ZapDialog/SuperchatRequestForm.tsx
  21. 130
      src/components/ZapDialog/index.tsx
  22. 4
      src/constants.ts
  23. 42
      src/hooks/useProfileWall.tsx
  24. 32
      src/i18n/locales/en.ts
  25. 23
      src/lib/composer-extra-tags.test.ts
  26. 29
      src/lib/composer-extra-tags.ts
  27. 42
      src/lib/draft-event.ts
  28. 76
      src/lib/event-pow.test.ts
  29. 34
      src/lib/event-pow.ts
  30. 8
      src/lib/event.ts
  31. 4
      src/lib/kind-description.ts
  32. 1
      src/lib/note-renderable-kinds.ts
  33. 13
      src/lib/payto.ts
  34. 55
      src/lib/post-payment-context.ts
  35. 229
      src/lib/superchat.test.ts
  36. 212
      src/lib/superchat.ts
  37. 6
      src/lib/thread-interaction-req.ts
  38. 25
      src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx
  39. 5
      src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx
  40. 45
      src/services/lightning.service.ts

116
src/components/AdvancedEventLab/AdvancedEventLabTagsEditor.tsx

@ -8,20 +8,20 @@ import {
CollapsibleTrigger CollapsibleTrigger
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { import {
formatComposerTagValuesInput, composerTagRowFromNostrTag,
newComposerTagRow, newComposerTagRow,
normalizeComposerExtraTags, normalizeComposerExtraTags,
parseComposerTagValuesInput,
type ComposerExtraTagRow type ComposerExtraTagRow
} from '@/lib/composer-extra-tags' } from '@/lib/composer-extra-tags'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ChevronDown, Plus, Trash2 } from 'lucide-react' import { ChevronDown, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export function labTagsToEditableRows(tags: string[][]): ComposerExtraTagRow[] { export function labTagsToEditableRows(tags: string[][]): ComposerExtraTagRow[] {
const normalized = tags.filter((t) => Array.isArray(t) && String(t[0] ?? '').trim()) const normalized = tags.filter((t) => Array.isArray(t) && String(t[0] ?? '').trim())
if (!normalized.length) return [newComposerTagRow()] if (!normalized.length) return [newComposerTagRow()]
return normalized.map((tag) => newComposerTagRow([...tag])) return normalized.map((tag) => composerTagRowFromNostrTag([...tag]))
} }
export function editableRowsToLabTags(rows: ComposerExtraTagRow[]): string[][] { export function editableRowsToLabTags(rows: ComposerExtraTagRow[]): string[][] {
@ -38,21 +38,15 @@ export default function AdvancedEventLabTagsEditor({
className?: string className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const filledCount = rows.filter((r) => r.name.trim()).length
const [open, setOpen] = useState(filledCount > 0)
const updateRow = (id: string, patch: Partial<{ name: string; valuesRaw: string }>) => { useEffect(() => {
onChange( if (filledCount > 0) setOpen(true)
rows.map((row) => { }, [filledCount])
if (row.id !== id) return row
const name = patch.name !== undefined ? patch.name : (row.tag[0] ?? '') const updateRow = (id: string, patch: Partial<Pick<ComposerExtraTagRow, 'name' | 'valuesRaw'>>) => {
const valuesRaw = onChange(rows.map((row) => (row.id === id ? { ...row, ...patch } : row)))
patch.valuesRaw !== undefined ? patch.valuesRaw : formatComposerTagValuesInput(row.tag)
const vals = parseComposerTagValuesInput(valuesRaw)
return {
...row,
tag: name.trim() ? [name.trim(), ...vals] : vals.length ? ['', ...vals] : ['', '']
}
})
)
} }
const removeRow = (id: string) => { const removeRow = (id: string) => {
@ -62,15 +56,11 @@ export default function AdvancedEventLabTagsEditor({
const addRow = () => { const addRow = () => {
onChange([...rows, newComposerTagRow()]) onChange([...rows, newComposerTagRow()])
setOpen(true)
} }
const filledCount = rows.filter((r) => (r.tag[0] ?? '').trim()).length
return ( return (
<Collapsible <Collapsible open={open} onOpenChange={setOpen} className={cn('rounded-lg border bg-muted/30', className)}>
defaultOpen={filledCount > 0}
className={cn('rounded-lg border bg-muted/30', className)}
>
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm font-medium hover:bg-muted/50 rounded-lg"> <CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm font-medium hover:bg-muted/50 rounded-lg">
<ChevronDown className="h-4 w-4 shrink-0 transition-transform [[data-state=open]_&]:rotate-180" /> <ChevronDown className="h-4 w-4 shrink-0 transition-transform [[data-state=open]_&]:rotate-180" />
<span className="flex-1">{t('advancedLabTagsTitle', { defaultValue: 'Event tags' })}</span> <span className="flex-1">{t('advancedLabTagsTitle', { defaultValue: 'Event tags' })}</span>
@ -89,49 +79,45 @@ export default function AdvancedEventLabTagsEditor({
})} })}
</p> </p>
<div className="space-y-2 max-h-[min(40vh,20rem)] overflow-y-auto pr-1"> <div className="space-y-2 max-h-[min(40vh,20rem)] overflow-y-auto pr-1">
{rows.map((row) => { {rows.map((row) => (
const name = row.tag[0] ?? '' <div
const valuesRaw = formatComposerTagValuesInput(row.tag) key={row.id}
return ( className="flex flex-wrap gap-2 items-start min-w-0 rounded-md border border-border/60 bg-background/50 p-2"
<div >
key={row.id} <div className="w-full min-w-[5rem] sm:w-28 shrink-0">
className="flex flex-wrap gap-2 items-start min-w-0 rounded-md border border-border/60 bg-background/50 p-2" <Label className="sr-only">{t('Tag name')}</Label>
> <Input
<div className="w-full min-w-[5rem] sm:w-28 shrink-0"> value={row.name}
<Label className="sr-only">{t('Tag name')}</Label> placeholder={t('Tag name')}
<Input className="font-mono text-xs h-8"
value={name} onChange={(e) => updateRow(row.id, { name: e.target.value })}
placeholder={t('Tag name')} />
className="font-mono text-xs h-8"
onChange={(e) => updateRow(row.id, { name: e.target.value })}
/>
</div>
<div className="flex-1 min-w-[8rem]">
<Label className="sr-only">{t('Values')}</Label>
<Textarea
value={valuesRaw}
placeholder={t('advancedLabTagValuesPlaceholder', {
defaultValue: 'One value per line'
})}
className="font-mono text-xs min-h-[2.25rem] resize-y"
rows={2}
onChange={(e) => updateRow(row.id, { valuesRaw: e.target.value })}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive mt-0.5"
disabled={rows.length <= 1}
onClick={() => removeRow(row.id)}
aria-label={t('Remove')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
) <div className="flex-1 min-w-[8rem]">
})} <Label className="sr-only">{t('Values')}</Label>
<Textarea
value={row.valuesRaw}
placeholder={t('advancedLabTagValuesPlaceholder', {
defaultValue: 'One value per line'
})}
className="font-mono text-xs min-h-[2.25rem] resize-y"
rows={2}
onChange={(e) => updateRow(row.id, { valuesRaw: e.target.value })}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive mt-0.5"
disabled={rows.length <= 1}
onClick={() => removeRow(row.id)}
aria-label={t('Remove')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div> </div>
<Button type="button" variant="outline" size="sm" className="gap-1" onClick={addRow}> <Button type="button" variant="outline" size="sm" className="gap-1" onClick={addRow}>
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />

11
src/components/ContentPreview/index.tsx

@ -37,6 +37,7 @@ import ApplicationHandlerRecommendation from '../ApplicationHandlerRecommendatio
import FollowPackPreview from './FollowPackPreview' import FollowPackPreview from './FollowPackPreview'
import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay' import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay'
import NoteKindLabel from '../Note/NoteKindLabel' import NoteKindLabel from '../Note/NoteKindLabel'
import EventPowLabel from '../EventPowLabel'
import Zap from '../Note/Zap' import Zap from '../Note/Zap'
import GitRepublicEventCard from '../Note/GitRepublicEventCard' import GitRepublicEventCard from '../Note/GitRepublicEventCard'
@ -123,7 +124,10 @@ export default function ContentPreview({
const withKindRow = (node: React.ReactNode) => ( const withKindRow = (node: React.ReactNode) => (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}> <div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}>
<NoteKindLabel kind={previewEvent.kind} event={previewEvent} size="small" /> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<NoteKindLabel kind={previewEvent.kind} event={previewEvent} size="small" />
<EventPowLabel event={previewEvent} />
</div>
<div className={cn('min-w-0', previewBody)}>{node}</div> <div className={cn('min-w-0', previewBody)}>{node}</div>
</div> </div>
) )
@ -164,7 +168,10 @@ export default function ContentPreview({
} }
return ( return (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}> <div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}>
<NoteKindLabel kind={previewEvent.kind} event={previewEvent} size="small" /> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<NoteKindLabel kind={previewEvent.kind} event={previewEvent} size="small" />
<EventPowLabel event={previewEvent} />
</div>
<div className={cn('min-w-0', previewBody)}> <div className={cn('min-w-0', previewBody)}>
<DiscussionNote event={previewEvent} size="small" /> <DiscussionNote event={previewEvent} size="small" />
</div> </div>

36
src/components/EventPowLabel/index.tsx

@ -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>
)
}

153
src/components/Note/Superchat.tsx

@ -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>
)
}

45
src/components/Note/SuperchatPaymentMethodLabel.tsx

@ -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>
)
}

10
src/components/Note/UnknownNote.tsx

@ -7,6 +7,7 @@ import { ExtendedKind } from '@/constants'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { getKindDescription } from '@/lib/kind-description' import { getKindDescription } from '@/lib/kind-description'
import NoteKindLabel from './NoteKindLabel' import NoteKindLabel from './NoteKindLabel'
import EventPowLabel from '../EventPowLabel'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import EventViewer from './EventViewer' import EventViewer from './EventViewer'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -34,8 +35,8 @@ const ELEVATED_TAG_NAMES = new Set([
'pubkey' 'pubkey'
]) ])
/** e / p / q / a: thread & pubkey refs — noisy in preview; show under Technical details only. */ /** e / p / q / a / nonce: thread refs & PoW — noisy in preview; show under Technical details only. */
const TECHNICAL_ONLY_TAG_NAMES = new Set(['e', 'p', 'q', 'a']) const TECHNICAL_ONLY_TAG_NAMES = new Set(['e', 'p', 'q', 'a', 'nonce'])
function truncatePreview(text: string, max: number): string { function truncatePreview(text: string, max: number): string {
const t = text.trim() const t = text.trim()
@ -241,7 +242,10 @@ export default function UnknownNote({
<div> <div>
<h3 className="text-sm font-semibold leading-tight text-foreground">{headline}</h3> <h3 className="text-sm font-semibold leading-tight text-foreground">{headline}</h3>
{!omitKindLabel ? ( {!omitKindLabel ? (
<NoteKindLabel kind={event.kind} event={event} size="small" className="mt-0.5" /> <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1">
<NoteKindLabel kind={event.kind} event={event} size="small" />
<EventPowLabel event={event} />
</div>
) : null} ) : null}
{elevated.title?.trim() && !omitKindLabel ? ( {elevated.title?.trim() && !omitKindLabel ? (
<p className="mt-0.5 text-[11px] text-muted-foreground leading-snug"> <p className="mt-0.5 text-[11px] text-muted-foreground leading-snug">

10
src/components/Note/Zap.tsx

@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next'
import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager' import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager'
import Username from '../Username' import Username from '../Username'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel'
export default function Zap({ export default function Zap({
event, event,
@ -42,7 +43,7 @@ export default function Zap({
const secondaryPage = useSecondaryPageOptional() const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) { if (!zapInfo || !zapInfo.senderPubkey || (variant === 'default' && !zapInfo.amount)) {
return ( return (
<div <div
className={cn( className={cn(
@ -93,9 +94,8 @@ export default function Zap({
return ( return (
<div className={cn('text-sm text-muted-foreground', className)}> <div className={cn('text-sm text-muted-foreground', className)}>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5"> <div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<ZapIcon className="size-3.5 shrink-0 opacity-70" strokeWidth={2} aria-hidden /> <SuperchatPaymentMethodLabel paytoType="lightning" />
<span className="tabular-nums font-medium text-foreground/90">{formatAmount(amount)}</span> <span className="text-xs font-medium text-yellow-400/90">{t('Superchat')}</span>
<span>{t('sats')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && ( {recipientPubkey && recipientPubkey !== senderPubkey && (
<span className="text-xs"> <span className="text-xs">
<span>{t('zapped')}</span>{' '} <span>{t('zapped')}</span>{' '}
@ -120,7 +120,7 @@ export default function Zap({
)} )}
</div> </div>
{comment ? ( {comment ? (
<p className="mt-1.5 pl-5 text-sm leading-snug text-muted-foreground whitespace-pre-wrap break-words"> <p className="mt-1.5 text-sm leading-snug text-foreground/90 whitespace-pre-wrap break-words">
{comment} {comment}
</p> </p>
) : null} ) : null}

9
src/components/Note/index.tsx

@ -48,6 +48,7 @@ import SelectionHighlightTrigger from './SelectionHighlightTrigger'
import AudioPlayer from '../AudioPlayer' import AudioPlayer from '../AudioPlayer'
import WebPreview from '../WebPreview' import WebPreview from '../WebPreview'
import ClientTag from '../ClientTag' import ClientTag from '../ClientTag'
import EventPowLabel from '../EventPowLabel'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05' import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions' import NoteOptions from '../NoteOptions'
@ -78,6 +79,7 @@ import NoteKindLabel from './NoteKindLabel'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import VideoNote from './VideoNote' import VideoNote from './VideoNote'
import RelayReview from './RelayReview' import RelayReview from './RelayReview'
import Superchat from './Superchat'
import Zap from './Zap' import Zap from './Zap'
import CitationCard from '@/components/CitationCard' import CitationCard from '@/components/CitationCard'
import FollowPackPreview from '../ContentPreview/FollowPackPreview' import FollowPackPreview from '../ContentPreview/FollowPackPreview'
@ -557,6 +559,8 @@ export default function Note({
content = renderEventContent({ hideMetadata: true }) content = renderEventContent({ hideMetadata: true })
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content = <Zap className="mt-2" event={displayEvent} /> content = <Zap className="mt-2" event={displayEvent} />
} else if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
content = <Superchat className="mt-2" event={displayEvent} />
} else if (event.kind === ExtendedKind.FOLLOW_PACK) { } else if (event.kind === ExtendedKind.FOLLOW_PACK) {
content = <FollowPackPreview className="mt-2" event={displayEvent} /> content = <FollowPackPreview className="mt-2" event={displayEvent} />
} else if ( } else if (
@ -742,7 +746,10 @@ export default function Note({
)} )}
</div> </div>
</div> </div>
<NoteKindLabel kind={event.kind} event={event} size={size} className="mt-1" /> <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<NoteKindLabel kind={event.kind} event={event} size={size} />
<EventPowLabel event={event} />
</div>
{webReactionParentUrl ? ( {webReactionParentUrl ? (
<div className="mt-2 not-prose max-w-full" data-parent-note-preview> <div className="mt-2 not-prose max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" /> <WebPreview url={webReactionParentUrl} className="w-full" />

32
src/components/NoteStats/ZapButton.tsx

@ -27,7 +27,8 @@ import { MouseEvent, TouchEvent, useCallback, useEffect, useMemo, useRef, useSta
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import ZapDialog from '../ZapDialog' import ZapDialog from '../ZapDialog'
import TipPublicMessagePrompt from '../ZapDialog/TipPublicMessagePrompt' import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt'
import { buildPostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
type ZapButtonProps = { type ZapButtonProps = {
event: Event event: Event
@ -267,10 +268,11 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
const { t } = useTranslation() const { t } = useTranslation()
const { checkLogin, pubkey } = useNostr() const { checkLogin, pubkey } = useNostr()
const { defaultZapSats, defaultZapComment, quickZap, includePublicZapReceipt } = useZap() const { defaultZapSats, defaultZapComment, quickZap } = useZap()
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null) const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
const [openZapDialog, setOpenZapDialog] = useState(false) const [openZapDialog, setOpenZapDialog] = useState(false)
const [tipNoticeOpen, setTipNoticeOpen] = useState(false) const [postPaymentOpen, setPostPaymentOpen] = useState(false)
const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null)
const [zapping, setZapping] = useState(false) const [zapping, setZapping] = useState(false)
const statsLoaded = noteStats?.updatedAt != null const statsLoaded = noteStats?.updatedAt != null
const { zapAmount, hasZapped } = useMemo(() => { const { zapAmount, hasZapped } = useMemo(() => {
@ -366,15 +368,25 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
if (zapping) return if (zapping) return
setZapping(true) setZapping(true)
const paymentDetails = { amountMsat: defaultZapSats * 1000 }
const zapResult = await lightning.zap( const zapResult = await lightning.zap(
pubkey, pubkey,
event, event,
defaultZapSats, defaultZapSats,
defaultZapComment, defaultZapComment,
undefined, undefined,
includePublicZapReceipt () => {
if (event.pubkey === pubkey) return
setPostPaymentContext(
buildPostPaymentContext({
recipientPubkey: event.pubkey,
amountMsat: paymentDetails.amountMsat,
referencedEvent: event
})
)
setPostPaymentOpen(true)
}
) )
// user canceled
if (!zapResult) { if (!zapResult) {
return return
} }
@ -385,9 +397,6 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
defaultZapSats, defaultZapSats,
defaultZapComment defaultZapComment
) )
if (event.pubkey !== pubkey && !includePublicZapReceipt) {
setTipNoticeOpen(true)
}
} catch (error) { } catch (error) {
toast.error(`${t('Zap failed')}: ${(error as Error).message}`) toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
} finally { } finally {
@ -510,10 +519,11 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
event={event} event={event}
prefetchedPayment={tipPaymentData} prefetchedPayment={tipPaymentData}
/> />
<TipPublicMessagePrompt <PostPaymentMessagePrompt
open={tipNoticeOpen} open={postPaymentOpen}
onOpenChange={setTipNoticeOpen} onOpenChange={setPostPaymentOpen}
recipientPubkey={event.pubkey} recipientPubkey={event.pubkey}
paymentContext={postPaymentContext}
/> />
</> </>
) )

12
src/components/PaymentMethodsSection/index.tsx

@ -7,11 +7,16 @@ import { Copy } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { NostrEvent } from 'nostr-tools'
import type { PostPaymentContext } from '@/lib/post-payment-context'
export default function PaymentMethodsSection({ export default function PaymentMethodsSection({
groups, groups,
recipientPubkey, recipientPubkey,
onOpenZap, onOpenZap,
referencedEvent,
offerTipNoticeOnClose = true, offerTipNoticeOnClose = true,
onPostPaymentRequest,
title, title,
className, className,
headerHelpText headerHelpText
@ -20,8 +25,11 @@ export default function PaymentMethodsSection({
recipientPubkey?: string recipientPubkey?: string
/** When set, lightning rows open the zap flow with that address as the default. */ /** When set, lightning rows open the zap flow with that address as the default. */
onOpenZap?: (lightningAuthority: string) => void onOpenZap?: (lightningAuthority: string) => void
/** When false, PaytoDialog defer tip notice to parent (e.g. ZapDialog). */ /** Thread context passed to PaytoDialog for superchat requests. */
referencedEvent?: NostrEvent
/** When false, PaytoDialog defer post-payment prompt to parent. */
offerTipNoticeOnClose?: boolean offerTipNoticeOnClose?: boolean
onPostPaymentRequest?: (context: PostPaymentContext) => void
title?: string title?: string
className?: string className?: string
/** Prominent note above the list (e.g. on-chain Bitcoin eligibility in zap dialog). */ /** Prominent note above the list (e.g. on-chain Bitcoin eligibility in zap dialog). */
@ -71,6 +79,8 @@ export default function PaymentMethodsSection({
: undefined : undefined
} }
offerTipNoticeOnClose={offerTipNoticeOnClose} offerTipNoticeOnClose={offerTipNoticeOnClose}
onPostPaymentRequest={onPostPaymentRequest}
referencedEvent={referencedEvent}
className={cn(PRIMARY_LINK_HOVER_CLASS, 'break-all min-w-0 flex-1')} className={cn(PRIMARY_LINK_HOVER_CLASS, 'break-all min-w-0 flex-1')}
> >
{method.authority} {method.authority}

22
src/components/PaytoDialog/LightningInvoiceSection.tsx

@ -12,6 +12,7 @@ import {
getAmountFromInvoice, getAmountFromInvoice,
parseGroupedIntegerInput parseGroupedIntegerInput
} from '@/lib/lightning' } from '@/lib/lightning'
import { buildPaytoUri, formatPaytoTagValue } from '@/lib/payto'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import lightning from '@/services/lightning.service' import lightning from '@/services/lightning.service'
@ -35,7 +36,7 @@ export default function LightningInvoiceSection({
paytoUri, paytoUri,
onBolt11InvoiceChange, onBolt11InvoiceChange,
onRequestClose, onRequestClose,
onPaymentSuccess onPaymentFlowComplete
}: { }: {
lightningAddress: string lightningAddress: string
paytoUri: string paytoUri: string
@ -43,8 +44,8 @@ export default function LightningInvoiceSection({
onBolt11InvoiceChange?: (invoice: string | null) => void onBolt11InvoiceChange?: (invoice: string | null) => void
/** Close the payto dialog before opening an external wallet / Bitcoin Connect UI. */ /** Close the payto dialog before opening an external wallet / Bitcoin Connect UI. */
onRequestClose?: () => void onRequestClose?: () => void
/** After a successful in-app or external wallet payment (kind-24 tip notice). */ /** After the payment modal closes (success or cancel). */
onPaymentSuccess?: () => void onPaymentFlowComplete?: (details?: { amountMsat: number; payto: string }) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { defaultZapSats, isWalletConnected } = useZap() const { defaultZapSats, isWalletConnected } = useZap()
@ -135,17 +136,28 @@ export default function LightningInvoiceSection({
} }
} }
const paymentDetails = useMemo(
() => ({
amountMsat: clampZapSats(sats) * 1000,
payto: formatPaytoTagValue(buildPaytoUri('lightning', lightningAddress))
}),
[sats, lightningAddress]
)
const handlePay = async () => { const handlePay = async () => {
if (!invoice) return if (!invoice) return
try { try {
setPaying(true) setPaying(true)
const result = await lightning.payInvoice(invoice, onRequestClose) const result = await lightning.payInvoice(
invoice,
onRequestClose,
() => onPaymentFlowComplete?.(paymentDetails)
)
if (!mountedRef.current) return if (!mountedRef.current) return
if (result) { if (result) {
toast.success(t('Payment sent')) toast.success(t('Payment sent'))
setInvoice(null) setInvoice(null)
setInvoiceDescription(null) setInvoiceDescription(null)
onPaymentSuccess?.()
} }
} catch (error) { } catch (error) {
if (mountedRef.current) { if (mountedRef.current) {

107
src/components/PaytoDialog/index.tsx

@ -1,4 +1,4 @@
import TipPublicMessagePrompt from '@/components/ZapDialog/TipPublicMessagePrompt' import PostPaymentMessagePrompt from '@/components/ZapDialog/PostPaymentMessagePrompt'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -32,6 +32,8 @@ import {
} from '@/lib/payto' } from '@/lib/payto'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { buildPostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
import { NostrEvent } from 'nostr-tools'
import LightningInvoiceSection from './LightningInvoiceSection' import LightningInvoiceSection from './LightningInvoiceSection'
export default function PaytoDialog({ export default function PaytoDialog({
@ -41,22 +43,29 @@ export default function PaytoDialog({
authority, authority,
paytoUri, paytoUri,
recipientPubkey, recipientPubkey,
offerTipNoticeOnClose = true referencedEvent,
offerTipNoticeOnClose = true,
onPostPaymentRequest
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
type: string type: string
authority: string authority: string
paytoUri: string paytoUri: string
/** When set, closing the dialog offers a kind-24 tip notice to this pubkey. */ /** When set, closing the dialog offers a post-payment message prompt to this pubkey. */
recipientPubkey?: string recipientPubkey?: string
/** When false, a parent (e.g. ZapDialog) will offer the tip notice on its own close. */ /** Note or profile context for superchat placement (kind 9740). */
referencedEvent?: NostrEvent
/** When false, a parent handles the post-payment prompt itself. */
offerTipNoticeOnClose?: boolean offerTipNoticeOnClose?: boolean
/** Parent-owned post-payment UI (e.g. ZapDialog). When set, internal prompt is skipped. */
onPostPaymentRequest?: (context: PostPaymentContext) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: selfPubkey } = useNostr() const { pubkey: selfPubkey } = useNostr()
const [tipNoticeOpen, setTipNoticeOpen] = useState(false) const [postPaymentOpen, setPostPaymentOpen] = useState(false)
const skipTipNoticeOnCloseRef = useRef(false) const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null)
const skipPostPaymentOnCloseRef = useRef(false)
const info = getPaytoTypeInfo(type) const info = getPaytoTypeInfo(type)
const label = info?.label ?? type const label = info?.label ?? type
const isLightning = type.toLowerCase() === 'lightning' const isLightning = type.toLowerCase() === 'lightning'
@ -73,10 +82,52 @@ export default function PaytoDialog({
}, [open]) }, [open])
const closeForWalletFlow = useCallback(() => { const closeForWalletFlow = useCallback(() => {
skipTipNoticeOnCloseRef.current = true skipPostPaymentOnCloseRef.current = true
onOpenChange(false) onOpenChange(false)
}, [onOpenChange]) }, [onOpenChange])
const openPostPaymentPrompt = useCallback(
(context?: Partial<PostPaymentContext>) => {
if (!recipientPubkey) return
if (selfPubkey && recipientPubkey === selfPubkey) return
const built = buildPostPaymentContext({
recipientPubkey,
paytoUri,
paytoType: type,
paytoAuthority: authority,
referencedEvent,
...context
})
if (onPostPaymentRequest) {
onPostPaymentRequest(built)
return
}
if (!offerTipNoticeOnClose) return
setPostPaymentContext(built)
setPostPaymentOpen(true)
},
[
offerTipNoticeOnClose,
onPostPaymentRequest,
recipientPubkey,
selfPubkey,
paytoUri,
type,
authority,
referencedEvent
]
)
/** Run after the payto dialog has closed so nested modals (e.g. inside ZapDialog) do not dismiss the prompt. */
const schedulePostPaymentPrompt = useCallback(
(context?: Partial<PostPaymentContext>) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => openPostPaymentPrompt(context))
})
},
[openPostPaymentPrompt]
)
const openHandlers = useMemo( const openHandlers = useMemo(
() => () =>
filterPaytoPaymentOpenHandlersForDevice( filterPaytoPaymentOpenHandlersForDevice(
@ -122,26 +173,23 @@ export default function PaytoDialog({
handleDialogOpenChange(false) handleDialogOpenChange(false)
} }
const maybeOfferTipNotice = useCallback(() => { const maybeOfferPostPaymentOnClose = () => {
if (!offerTipNoticeOnClose) return if (skipPostPaymentOnCloseRef.current) return
if (!recipientPubkey) return schedulePostPaymentPrompt()
if (selfPubkey && recipientPubkey === selfPubkey) return
setTipNoticeOpen(true)
}, [offerTipNoticeOnClose, recipientPubkey, selfPubkey])
const maybeOfferTipNoticeOnClose = () => {
if (skipTipNoticeOnCloseRef.current) return
maybeOfferTipNotice()
} }
const handleDialogOpenChange = (next: boolean) => { const handleDialogOpenChange = (next: boolean) => {
if (!next) { if (!next) {
maybeOfferTipNoticeOnClose() const skipped = skipPostPaymentOnCloseRef.current
skipTipNoticeOnCloseRef.current = false skipPostPaymentOnCloseRef.current = false
onOpenChange(next)
if (!skipped) {
maybeOfferPostPaymentOnClose()
}
} else { } else {
skipTipNoticeOnCloseRef.current = false skipPostPaymentOnCloseRef.current = false
onOpenChange(next)
} }
onOpenChange(next)
} }
return ( return (
@ -175,7 +223,13 @@ export default function PaytoDialog({
paytoUri={paytoUri} paytoUri={paytoUri}
onBolt11InvoiceChange={setBolt11Invoice} onBolt11InvoiceChange={setBolt11Invoice}
onRequestClose={closeForWalletFlow} onRequestClose={closeForWalletFlow}
onPaymentSuccess={maybeOfferTipNotice} onPaymentFlowComplete={(details) => {
onOpenChange(false)
schedulePostPaymentPrompt({
amountMsat: details?.amountMsat,
payto: details?.payto
})
}}
/> />
) : isLightning ? null : ( ) : isLightning ? null : (
<> <>
@ -276,11 +330,12 @@ export default function PaytoDialog({
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{recipientPubkey ? ( {recipientPubkey && !onPostPaymentRequest ? (
<TipPublicMessagePrompt <PostPaymentMessagePrompt
open={tipNoticeOpen} open={postPaymentOpen}
onOpenChange={setTipNoticeOpen} onOpenChange={setPostPaymentOpen}
recipientPubkey={recipientPubkey} recipientPubkey={recipientPubkey}
paymentContext={postPaymentContext}
/> />
) : null} ) : null}
</> </>

12
src/components/PaytoLink/index.tsx

@ -16,10 +16,12 @@ import {
formatPaytoLinkDisplayText, formatPaytoLinkDisplayText,
paytoLinkChildTextLooksLikeAuthority paytoLinkChildTextLooksLikeAuthority
} from '@/lib/payto' } from '@/lib/payto'
import { NostrEvent } from 'nostr-tools'
import PaytoDialog from '@/components/PaytoDialog' import PaytoDialog from '@/components/PaytoDialog'
import { HelpCircle } from 'lucide-react' import { HelpCircle } from 'lucide-react'
import { URI_LINK_CLASS } from '@/lib/link-styles' import { URI_LINK_CLASS } from '@/lib/link-styles'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { PostPaymentContext } from '@/lib/post-payment-context'
export default function PaytoLink({ export default function PaytoLink({
paytoUri, paytoUri,
@ -28,6 +30,8 @@ export default function PaytoLink({
pubkey, pubkey,
onOpenZap, onOpenZap,
offerTipNoticeOnClose = true, offerTipNoticeOnClose = true,
onPostPaymentRequest,
referencedEvent,
className, className,
children, children,
/** `compact`: `47R4Npvudm... (Monero)` for notes/markup; `full`: show authority as-is (e.g. zap dialog). */ /** `compact`: `47R4Npvudm... (Monero)` for notes/markup; `full`: show authority as-is (e.g. zap dialog). */
@ -41,8 +45,12 @@ export default function PaytoLink({
/** When set with lightning type, clicking can open Zap dialog via onOpenZap */ /** When set with lightning type, clicking can open Zap dialog via onOpenZap */
pubkey?: string pubkey?: string
onOpenZap?: (pubkey: string, lightningAuthority: string) => void onOpenZap?: (pubkey: string, lightningAuthority: string) => void
/** Passed to PaytoDialog; set false when a parent already offers the tip notice on close. */ /** Passed to PaytoDialog; set false when a parent already offers the post-payment prompt. */
offerTipNoticeOnClose?: boolean offerTipNoticeOnClose?: boolean
/** Parent-owned post-payment prompt (e.g. ZapDialog). */
onPostPaymentRequest?: (context: PostPaymentContext) => void
/** Thread context for superchat requests (kind 9740). */
referencedEvent?: NostrEvent
className?: string className?: string
children?: React.ReactNode children?: React.ReactNode
displayFormat?: 'compact' | 'full' displayFormat?: 'compact' | 'full'
@ -156,6 +164,8 @@ export default function PaytoLink({
paytoUri={raw} paytoUri={raw}
recipientPubkey={pubkey} recipientPubkey={pubkey}
offerTipNoticeOnClose={offerTipNoticeOnClose} offerTipNoticeOnClose={offerTipNoticeOnClose}
onPostPaymentRequest={onPostPaymentRequest}
referencedEvent={referencedEvent}
/> />
)} )}
</> </>

68
src/components/Profile/ProfileBadges.tsx

@ -2,6 +2,7 @@ import { RefreshButton } from '@/components/RefreshButton'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useProfileWall } from '@/hooks/useProfileWall' import { useProfileWall } from '@/hooks/useProfileWall'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ProfileWallSuperchats from './ProfileWallSuperchats'
export default function ProfileBadges({ export default function ProfileBadges({
pubkey, pubkey,
@ -14,7 +15,7 @@ export default function ProfileBadges({
onRefresh?: () => void | Promise<void> onRefresh?: () => void | Promise<void>
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { badges, isLoading, refresh } = useProfileWall(pubkey, profileEventId) const { badges, superchats, isLoading, refresh } = useProfileWall(pubkey, profileEventId)
const handleRefresh = () => { const handleRefresh = () => {
if (onRefresh) { if (onRefresh) {
void onRefresh() void onRefresh()
@ -23,7 +24,7 @@ export default function ProfileBadges({
refresh() refresh()
} }
if (isLoading && badges.length === 0) { if (isLoading && badges.length === 0 && superchats.length === 0) {
return ( return (
<div className="mt-3 flex flex-wrap gap-2" aria-hidden> <div className="mt-3 flex flex-wrap gap-2" aria-hidden>
<Skeleton className="h-14 w-14 rounded-lg" /> <Skeleton className="h-14 w-14 rounded-lg" />
@ -32,39 +33,46 @@ export default function ProfileBadges({
) )
} }
if (badges.length === 0) return null if (badges.length === 0 && superchats.length === 0) return null
return ( return (
<section className="mt-3 min-w-0" aria-label={t('Badges')}> <div className="mt-3 min-w-0">
<div className="mb-1 flex items-center justify-end gap-2"> {badges.length > 0 ? (
<RefreshButton onClick={handleRefresh} onLongPress={null} /> <section className="min-w-0" aria-label={t('Badges')}>
</div> <div className="mb-1 flex items-center justify-end gap-2">
<div className="flex flex-wrap gap-2"> <RefreshButton onClick={handleRefresh} onLongPress={null} />
{badges.map((badge) => ( </div>
<div <div className="flex flex-wrap gap-2">
key={`${badge.definitionCoordinate}:${badge.awardEventId}`} {badges.map((badge) => (
className="flex max-w-[5.5rem] flex-col items-center gap-0.5"
title={badge.description ?? badge.name}
>
{badge.imageUrl ? (
<img
src={badge.imageUrl}
alt={badge.name}
className="h-14 w-14 rounded-lg border border-border object-cover"
loading="lazy"
/>
) : (
<div <div
className="flex h-14 w-14 items-center justify-center rounded-lg border border-border bg-muted px-1 text-center text-[10px] font-medium leading-tight" key={`${badge.definitionCoordinate}:${badge.awardEventId}`}
aria-hidden className="flex max-w-[5.5rem] flex-col items-center gap-0.5"
title={badge.description ?? badge.name}
> >
{badge.name} {badge.imageUrl ? (
<img
src={badge.imageUrl}
alt={badge.name}
className="h-14 w-14 rounded-lg border border-border object-cover"
loading="lazy"
/>
) : (
<div
className="flex h-14 w-14 items-center justify-center rounded-lg border border-border bg-muted px-1 text-center text-[10px] font-medium leading-tight"
aria-hidden
>
{badge.name}
</div>
)}
<span className="w-full truncate text-center text-[10px] text-muted-foreground">
{badge.name}
</span>
</div> </div>
)} ))}
<span className="w-full truncate text-center text-[10px] text-muted-foreground">{badge.name}</span>
</div> </div>
))} </section>
</div> ) : null}
</section> <ProfileWallSuperchats superchats={superchats} isLoading={isLoading && superchats.length === 0} />
</div>
) )
} }

37
src/components/Profile/ProfileWallSuperchats.tsx

@ -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>
)
}

27
src/components/ReplyNote/index.tsx

@ -25,6 +25,7 @@ import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ClientTag from '../ClientTag' import ClientTag from '../ClientTag'
import EventPowLabel from '../EventPowLabel'
import Collapsible from '../Collapsible' import Collapsible from '../Collapsible'
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle' import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle'
import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay' import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay'
@ -37,6 +38,7 @@ import WebPreview from '../WebPreview'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
import NoteKindLabel from '../Note/NoteKindLabel' import NoteKindLabel from '../Note/NoteKindLabel'
import Superchat from '../Note/Superchat'
import Zap from '../Note/Zap' import Zap from '../Note/Zap'
export default function ReplyNote({ export default function ReplyNote({
@ -143,15 +145,20 @@ export default function ReplyNote({
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" /> <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div> </div>
</div> </div>
<NoteKindLabel <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1">
kind={event.kind} <NoteKindLabel
event={event} kind={event.kind}
size="small" event={event}
className={cn( size="small"
'mt-0.5', className={cn(
(isNip25ReactionKind(event.kind) || event.kind === kinds.Zap) && 'opacity-60' (isNip25ReactionKind(event.kind) ||
)} event.kind === kinds.Zap ||
/> event.kind === ExtendedKind.PAYMENT_NOTIFICATION) &&
'opacity-60'
)}
/>
<EventPowLabel event={event} />
</div>
{webReactionParentUrl ? ( {webReactionParentUrl ? (
<div className="mt-1.5 not-prose max-w-full" data-parent-note-preview> <div className="mt-1.5 not-prose max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" /> <WebPreview url={webReactionParentUrl} className="w-full" />
@ -195,6 +202,8 @@ export default function ReplyNote({
</div> </div>
) : event.kind === kinds.Zap ? ( ) : event.kind === kinds.Zap ? (
<Zap className="mt-1.5" event={event} omitSenderHeading variant="compact" /> <Zap className="mt-1.5" event={event} omitSenderHeading variant="compact" />
) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
<Superchat className="mt-1.5" event={event} omitSenderHeading variant="compact" />
) : isNip18RepostKind(event.kind) ? null : ( ) : isNip18RepostKind(event.kind) ? null : (
<MarkdownArticle <MarkdownArticle
className="mt-2" className="mt-2"

134
src/components/ReplyNoteList/index.tsx

@ -17,7 +17,12 @@ import {
resolveDeclaredThreadRootEventHex resolveDeclaredThreadRootEventHex
} from '@/lib/event' } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import {
buildAttestedPaymentIdSet,
getPaymentAttestationTargetId,
partitionAttestedSuperchats,
replyFeedSuperchatsFirst
} from '@/lib/superchat'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
@ -73,32 +78,8 @@ const MAX_PARENT_IDS_PER_NESTED_REQ = 64
const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 120 const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 120
const THREAD_PROFILE_CHUNK = 80 const THREAD_PROFILE_CHUNK = 80
function partitionZapReceipts(items: NEvent[]) { function replyFeedZapsFirst(sortedNonZapReplies: NEvent[], superchats: NEvent[]) {
const zaps: NEvent[] = [] return replyFeedSuperchatsFirst(sortedNonZapReplies, superchats)
const nonZaps: NEvent[] = []
for (const e of items) {
if (e.kind === kinds.Zap) zaps.push(e)
else nonZaps.push(e)
}
return { zaps, nonZaps }
}
function filterZapReceiptsByReplyThreshold(zaps: NEvent[], thresholdSats: number): NEvent[] {
return zaps.filter((z) => shouldIncludeZapReceiptAtReplyThreshold(z, thresholdSats))
}
/** Zap receipts (9735) at top of reply feeds: largest sats first */
function sortZapReceiptsBySatsDesc(zaps: NEvent[]) {
return [...zaps].sort((a, b) => {
const sa = getZapInfoFromEvent(a)?.amount ?? 0
const sb = getZapInfoFromEvent(b)?.amount ?? 0
if (sb !== sa) return sb - sa
return b.created_at - a.created_at
})
}
function replyFeedZapsFirst(sortedNonZapReplies: NEvent[], zaps: NEvent[]) {
return [...sortZapReceiptsBySatsDesc(zaps), ...sortedNonZapReplies]
} }
type TBacklinkSubsection = 'primary' | 'bookmark' | 'list' | 'report' type TBacklinkSubsection = 'primary' | 'bookmark' | 'list' | 'report'
@ -287,6 +268,13 @@ function replyMatchesThreadForList(
) { ) {
return true return true
} }
if (
evt.kind === ExtendedKind.PAYMENT_NOTIFICATION &&
(rootInfo.type === 'E' || rootInfo.type === 'A') &&
eventReferencesThreadTarget(evt, rootInfo)
) {
return true
}
if ( if (
(rootInfo.type === 'E' || rootInfo.type === 'A') && (rootInfo.type === 'E' || rootInfo.type === 'A') &&
evt.kind !== kinds.ShortTextNote && evt.kind !== kinds.ShortTextNote &&
@ -365,6 +353,7 @@ function ReplyNoteList({
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { pubkey: userPubkey } = useNostr() const { pubkey: userPubkey } = useNostr()
const { zapReplyThreshold } = useZap() const { zapReplyThreshold } = useZap()
const [attestedPaymentIds, setAttestedPaymentIds] = useState<Set<string>>(() => new Set())
const { blockedRelays, favoriteRelays } = useFavoriteRelays() const { blockedRelays, favoriteRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const relayAuthoritativeRead = const relayAuthoritativeRead =
@ -379,6 +368,29 @@ function ReplyNoteList({
return out.length ? out : undefined return out.length ? out : undefined
}, [duplicateWebPreviewCleanedUrlHints, rootInfo]) }, [duplicateWebPreviewCleanedUrlHints, rootInfo])
useEffect(() => {
setAttestedPaymentIds(new Set())
}, [event.id])
useEffect(() => {
const handleAttestation = (data: Event) => {
const ce = data as CustomEvent<NEvent>
const evt = ce.detail
if (!evt || evt.kind !== ExtendedKind.PAYMENT_ATTESTATION) return
if (evt.pubkey.toLowerCase() !== event.pubkey.toLowerCase()) return
const targetId = getPaymentAttestationTargetId(evt)
if (!targetId) return
setAttestedPaymentIds((prev) => {
if (prev.has(targetId)) return prev
const next = new Set(prev)
next.add(targetId)
return next
})
}
client.addEventListener('newEvent', handleAttestation)
return () => client.removeEventListener('newEvent', handleAttestation)
}, [event.pubkey])
const replies = useMemo(() => { const replies = useMemo(() => {
const replyIdSet = new Set<string>() const replyIdSet = new Set<string>()
const replyEvents: NEvent[] = [] const replyEvents: NEvent[] = []
@ -444,8 +456,12 @@ function ReplyNoteList({
const { zaps: zapsPartitioned, nonZaps } = partitionZapReceipts(replyEvents) const { superchats, rest: nonZaps } = partitionAttestedSuperchats(
const zaps = filterZapReceiptsByReplyThreshold(zapsPartitioned, zapReplyThreshold) replyEvents,
attestedPaymentIds,
zapReplyThreshold
)
const zaps = superchats
const replyScoreById = const replyScoreById =
sort === 'top' || sort === 'controversial' || sort === 'most-zapped' sort === 'top' || sort === 'controversial' || sort === 'most-zapped'
? new Map( ? new Map(
@ -536,6 +552,7 @@ function ReplyNoteList({
hideContentMentioningMutedUsers, hideContentMentioningMutedUsers,
sort, sort,
zapReplyThreshold, zapReplyThreshold,
attestedPaymentIds,
isDiscussionRoot, isDiscussionRoot,
event.kind event.kind
]) ])
@ -559,20 +576,26 @@ function ReplyNoteList({
const mergedFeed = useMemo(() => { const mergedFeed = useMemo(() => {
/** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */
const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => {
const { zaps, nonZaps } = partitionZapReceipts(merged) const { superchats, rest: nonZaps } = partitionAttestedSuperchats(
const zapsShown = zaps merged,
attestedPaymentIds,
zapReplyThreshold
)
const sortedNon = [...nonZaps].sort((a, b) => const sortedNon = [...nonZaps].sort((a, b) =>
direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at
) )
return moveReportsToEndPreserveOrder(replyFeedZapsFirst(sortedNon, zapsShown)) return moveReportsToEndPreserveOrder(replyFeedSuperchatsFirst(sortedNon, superchats))
} }
if (!showQuotes) return replies if (!showQuotes) return replies
// E/A: zaps (sats desc) → thread replies (1 / 1111 / 1244, excluding #q-only) → tail (quotes, highlights, long-form refs) // E/A: zaps (sats desc) → thread replies (1 / 1111 / 1244, excluding #q-only) → tail (quotes, highlights, long-form refs)
if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { if (rootInfo?.type === 'E' || rootInfo?.type === 'A') {
const { zaps, nonZaps } = partitionZapReceipts(replies) const { superchats, rest: nonZaps } = partitionAttestedSuperchats(
const zapsShown = zaps replies,
attestedPaymentIds,
zapReplyThreshold
)
const middle = nonZaps.filter((e) => !isEaThreadTailBacklinkCandidate(e, rootInfo)) const middle = nonZaps.filter((e) => !isEaThreadTailBacklinkCandidate(e, rootInfo))
const tailFromReplies = nonZaps.filter((e) => isEaThreadTailBacklinkCandidate(e, rootInfo)) const tailFromReplies = nonZaps.filter((e) => isEaThreadTailBacklinkCandidate(e, rootInfo))
const tailSeen = new Set<string>() const tailSeen = new Set<string>()
@ -584,13 +607,16 @@ function ReplyNoteList({
} }
for (const e of tailFromReplies) pushTail(e) for (const e of tailFromReplies) pushTail(e)
const tailSorted = partitionAndSortBacklinkTail(tail) const tailSorted = partitionAndSortBacklinkTail(tail)
return [...replyFeedZapsFirst(middle, zapsShown), ...tailSorted] return [...replyFeedSuperchatsFirst(middle, superchats), ...tailSorted]
} }
// Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A // Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A
if (rootInfo?.type === 'I') { if (rootInfo?.type === 'I') {
const { zaps, nonZaps } = partitionZapReceipts(replies) const { superchats, rest: nonZaps } = partitionAttestedSuperchats(
const zapsShownI = zaps replies,
attestedPaymentIds,
zapReplyThreshold
)
const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind)) const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind))
const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind)) const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind))
const tailSeen = new Set<string>() const tailSeen = new Set<string>()
@ -602,7 +628,7 @@ function ReplyNoteList({
} }
for (const e of tailFromReplies) pushTail(e) for (const e of tailFromReplies) pushTail(e)
const tailSorted = partitionAndSortBacklinkTail(tail) const tailSorted = partitionAndSortBacklinkTail(tail)
return [...replyFeedZapsFirst(middle, zapsShownI), ...tailSorted] return [...replyFeedSuperchatsFirst(middle, superchats), ...tailSorted]
} }
const merged = [...replies] const merged = [...replies]
@ -612,7 +638,7 @@ function ReplyNoteList({
return [...replies] return [...replies]
} }
return zapsThenTimeSorted(merged, 'desc') return zapsThenTimeSorted(merged, 'desc')
}, [replies, showQuotes, sort, replyIdSet, rootInfo, event.kind]) }, [replies, showQuotes, sort, replyIdSet, rootInfo, event.kind, attestedPaymentIds, zapReplyThreshold])
const parentNoteFeed = useNoteFeedProfileContext() const parentNoteFeed = useNoteFeedProfileContext()
const threadProfileLoadedRef = useRef<Set<string>>(new Set()) const threadProfileLoadedRef = useRef<Set<string>>(new Set())
@ -1136,6 +1162,32 @@ function ReplyNoteList({
const repliesForStatsPrime = mergedForUi const repliesForStatsPrime = mergedForUi
addReplies(mergedForUi) addReplies(mergedForUi)
const recipientPubkey = event.pubkey
if (recipientPubkey && relayUrlsForThreadReq.length > 0) {
void client
.fetchEvents(
relayUrlsForThreadReq,
{
kinds: [ExtendedKind.PAYMENT_ATTESTATION],
authors: [recipientPubkey],
limit: 500
},
{
cache: true,
eoseTimeout: 4500,
globalTimeout: 12_000,
foreground: statsForeground
}
)
.then((attestations) => {
if (fetchGeneration !== replyFetchGenRef.current) return
setAttestedPaymentIds(buildAttestedPaymentIdSet(attestations, recipientPubkey))
})
.catch(() => {
/* attestations optional */
})
}
const statsBatch = mergedCachedReplies !== null && mergedCachedReplies.length > 0 ? mergedCachedReplies : regularReplies const statsBatch = mergedCachedReplies !== null && mergedCachedReplies.length > 0 ? mergedCachedReplies : regularReplies
if (statsBatch.length > 0) { if (statsBatch.length > 0) {
noteStatsService.updateNoteStatsByEvents(statsBatch, event.pubkey, { noteStatsService.updateNoteStatsByEvents(statsBatch, event.pubkey, {
@ -1382,8 +1434,8 @@ function ReplyNoteList({
return false return false
} }
const isQuote = quoteUiIdSet.has(item.id) const isQuote = quoteUiIdSet.has(item.id)
// Zap receipts are public payment records — always show when they passed mute filters. // Attested superchats are public payment records — always show when they passed mute filters.
if (item.kind === kinds.Zap) return true if (item.kind === kinds.Zap || item.kind === ExtendedKind.PAYMENT_NOTIFICATION) return true
// Backlink rows (quotes, highlights, …): show even when author is not in the trust list. // Backlink rows (quotes, highlights, …): show even when author is not in the trust list.
if (isQuote) return true if (isQuote) return true
if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) { if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) {

182
src/components/ZapDialog/PostPaymentMessagePrompt.tsx

@ -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>
)
}

123
src/components/ZapDialog/TipPublicMessagePrompt.tsx → src/components/ZapDialog/PublicMessageForm.tsx

@ -1,35 +1,18 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import { DialogFooter } from '@/components/ui/dialog'
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
Drawer,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle
} from '@/components/ui/drawer'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { createPublicMessageDraftEvent } from '@/lib/draft-event' import { createPublicMessageDraftEvent } from '@/lib/draft-event'
import { createFakeEvent } from '@/lib/event' import { createFakeEvent } from '@/lib/event'
import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { LoginRequiredError } from '@/lib/nostr-errors' import { LoginRequiredError } from '@/lib/nostr-errors'
import { pubkeyToNpub } from '@/lib/pubkey' import { pubkeyToNpub } from '@/lib/pubkey'
import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle' import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
const TIP_NOTICE_DEFAULT_KEY = 'I just sent you a tip!' const TIP_NOTICE_DEFAULT_KEY = 'I just sent you a tip!'
@ -38,32 +21,28 @@ function defaultTipNoticeMessage(recipientPubkey: string, tipText: string): stri
return `nostr:${npub} ${tipText}` return `nostr:${npub} ${tipText}`
} }
export default function TipPublicMessagePrompt({ export default function PublicMessageForm({
open, recipientPubkey,
onOpenChange, onBack,
recipientPubkey onDone
}: { }: {
open: boolean recipientPubkey: string
onOpenChange: (open: boolean) => void onBack: () => void
recipientPubkey: string | null onDone: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { publish, checkLogin, pubkey: selfPubkey } = useNostr() const { publish, checkLogin, pubkey: selfPubkey } = useNostr()
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const cancelRef = useRef<HTMLButtonElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const tipText = t(TIP_NOTICE_DEFAULT_KEY) const tipText = t(TIP_NOTICE_DEFAULT_KEY)
useEffect(() => { useEffect(() => {
if (!open || !recipientPubkey) return
setMessage(defaultTipNoticeMessage(recipientPubkey, tipText)) setMessage(defaultTipNoticeMessage(recipientPubkey, tipText))
}, [open, recipientPubkey, tipText]) }, [recipientPubkey, tipText])
useEffect(() => { useEffect(() => {
if (!open) return
const id = requestAnimationFrame(() => { const id = requestAnimationFrame(() => {
textareaRef.current?.focus() textareaRef.current?.focus()
textareaRef.current?.setSelectionRange( textareaRef.current?.setSelectionRange(
@ -72,10 +51,9 @@ export default function TipPublicMessagePrompt({
) )
}) })
return () => cancelAnimationFrame(id) return () => cancelAnimationFrame(id)
}, [open]) }, [])
const previewEvent = useMemo(() => { const previewEvent = useMemo(() => {
if (!recipientPubkey) return null
return createFakeEvent({ return createFakeEvent({
kind: ExtendedKind.PUBLIC_MESSAGE, kind: ExtendedKind.PUBLIC_MESSAGE,
pubkey: selfPubkey ?? '', pubkey: selfPubkey ?? '',
@ -85,12 +63,11 @@ export default function TipPublicMessagePrompt({
}, [message, recipientPubkey, selfPubkey]) }, [message, recipientPubkey, selfPubkey])
const handleSend = () => { const handleSend = () => {
if (!recipientPubkey) return
const trimmed = message.trim() const trimmed = message.trim()
if (!trimmed) return if (!trimmed) return
checkLogin(async () => { checkLogin(async () => {
if (selfPubkey === recipientPubkey) { if (selfPubkey === recipientPubkey) {
onOpenChange(false) onDone()
return return
} }
setSending(true) setSending(true)
@ -100,7 +77,7 @@ export default function TipPublicMessagePrompt({
}) })
await publish(draft, { disableFallbacks: true }) await publish(draft, { disableFallbacks: true })
showSimplePublishSuccess(t('Tip notice sent')) showSimplePublishSuccess(t('Tip notice sent'))
onOpenChange(false) onDone()
} catch (error) { } catch (error) {
if (error instanceof LoginRequiredError) return if (error instanceof LoginRequiredError) return
toast.error( toast.error(
@ -114,9 +91,9 @@ export default function TipPublicMessagePrompt({
}) })
} }
const body = ( return (
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-foreground">{t('Tip notice success only note')}</p> <p className="text-sm text-muted-foreground">{t('Tip notice prompt description')}</p>
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={message} value={message}
@ -139,68 +116,14 @@ export default function TipPublicMessagePrompt({
</div> </div>
</div> </div>
) : null} ) : 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')}
</Button>
</DialogFooter>
</div> </div>
) )
const actions = (
<>
<Button
ref={cancelRef}
type="button"
variant="default"
onClick={() => onOpenChange(false)}
disabled={sending}
>
{t('Cancel')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleSend}
disabled={sending || !recipientPubkey || !message.trim()}
>
{t('Send')}
</Button>
</>
)
if (!recipientPubkey) return 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 className="flex min-w-0 items-center gap-2">
<span className="shrink-0">{t('Tip notice prompt title')}</span>
<UserAvatar size="small" userId={recipientPubkey} className="shrink-0" />
<Username userId={recipientPubkey} className="min-w-0 flex-1 truncate" />
</DrawerTitle>
</DrawerHeader>
<div className="px-0 pb-4">{body}</div>
<DrawerFooter className="flex-row justify-end gap-2 pt-2">{actions}</DrawerFooter>
</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 className="flex min-w-0 items-center gap-2">
<span className="shrink-0">{t('Tip notice prompt title')}</span>
<UserAvatar size="small" userId={recipientPubkey} className="shrink-0" />
<Username userId={recipientPubkey} className="min-w-0 flex-1 truncate" />
</DialogTitle>
<DialogDescription>{t('Tip notice prompt description')}</DialogDescription>
</DialogHeader>
{body}
<DialogFooter className="gap-2 sm:gap-2">{actions}</DialogFooter>
</DialogContent>
</Dialog>
)
} }

129
src/components/ZapDialog/SuperchatRequestForm.tsx

@ -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>
)
}

130
src/components/ZapDialog/index.tsx

@ -17,7 +17,6 @@ import {
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
@ -31,6 +30,8 @@ import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react' import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { buildPostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
import { buildPaytoUri } from '@/lib/payto'
import { import {
buildOrderedZapLightningAddresses, buildOrderedZapLightningAddresses,
groupPaymentMethodsByDisplayType, groupPaymentMethodsByDisplayType,
@ -52,7 +53,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import TipPublicMessagePrompt from './TipPublicMessagePrompt' import PostPaymentMessagePrompt from './PostPaymentMessagePrompt'
import ZapSatsAmountInput from './ZapSatsAmountInput' import ZapSatsAmountInput from './ZapSatsAmountInput'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
@ -82,8 +83,15 @@ export default function ZapDialog({
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const drawerContentRef = useRef<HTMLDivElement | null>(null) const drawerContentRef = useRef<HTMLDivElement | null>(null)
const { pubkey: selfPubkey } = useNostr() const { pubkey: selfPubkey } = useNostr()
const [tipNoticeOpen, setTipNoticeOpen] = useState(false) const [postPaymentOpen, setPostPaymentOpen] = useState(false)
const skipTipNoticeOnCloseRef = useRef(false) const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null)
const openPostPaymentPrompt = (context?: PostPaymentContext | null) => {
if (selfPubkey && pubkey === selfPubkey) return
setPostPaymentContext(context ?? buildPostPaymentContext({ recipientPubkey: pubkey, referencedEvent: event }))
setPostPaymentOpen(true)
setOpen(false)
}
const fetchedPayment = useRecipientZapPaymentData(pubkey, open) const fetchedPayment = useRecipientZapPaymentData(pubkey, open)
const recipientPayment = useMemo( const recipientPayment = useMemo(
@ -118,22 +126,7 @@ export default function ZapDialog({
? t('Send a Lightning payment to this user') ? t('Send a Lightning payment to this user')
: t('Send a payment to this user') : t('Send a payment to this user')
const maybeOfferTipNoticeOnClose = () => { const handleZapDialogOpenChange: Dispatch<SetStateAction<boolean>> = setOpen
if (skipTipNoticeOnCloseRef.current) return
if (selfPubkey && pubkey === selfPubkey) return
setTipNoticeOpen(true)
}
const handleZapDialogOpenChange: Dispatch<SetStateAction<boolean>> = (next) => {
const willOpen = typeof next === 'function' ? next(open) : next
if (!willOpen) {
maybeOfferTipNoticeOnClose()
skipTipNoticeOnCloseRef.current = false
} else {
skipTipNoticeOnCloseRef.current = false
}
setOpen(next)
}
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
@ -192,19 +185,24 @@ export default function ZapDialog({
recipientPayment={recipientPayment} recipientPayment={recipientPayment}
lightningAddressOptions={lightningAddressOptions} lightningAddressOptions={lightningAddressOptions}
canLightningZap={canLightningZap} canLightningZap={canLightningZap}
onBeforeZapDialogClose={ onPaymentFlowComplete={(_result, paymentDetails) => {
paymentsOnly openPostPaymentPrompt(
? undefined buildPostPaymentContext({
: (withPublicReceipt) => { recipientPubkey: pubkey,
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true amountMsat: paymentDetails?.amountMsat,
} paytoUri: paymentDetails?.paytoUri,
} referencedEvent: event
})
)
}}
onPostPaymentRequest={openPostPaymentPrompt}
/> />
</DrawerContent> </DrawerContent>
<TipPublicMessagePrompt <PostPaymentMessagePrompt
open={tipNoticeOpen} open={postPaymentOpen}
onOpenChange={setTipNoticeOpen} onOpenChange={setPostPaymentOpen}
recipientPubkey={pubkey} recipientPubkey={pubkey}
paymentContext={postPaymentContext}
/> />
</Drawer> </Drawer>
) )
@ -232,20 +230,25 @@ export default function ZapDialog({
recipientPayment={recipientPayment} recipientPayment={recipientPayment}
lightningAddressOptions={lightningAddressOptions} lightningAddressOptions={lightningAddressOptions}
canLightningZap={canLightningZap} canLightningZap={canLightningZap}
onBeforeZapDialogClose={ onPaymentFlowComplete={(_result, paymentDetails) => {
paymentsOnly openPostPaymentPrompt(
? undefined buildPostPaymentContext({
: (withPublicReceipt) => { recipientPubkey: pubkey,
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true amountMsat: paymentDetails?.amountMsat,
} paytoUri: paymentDetails?.paytoUri,
} referencedEvent: event
})
)
}}
onPostPaymentRequest={openPostPaymentPrompt}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<TipPublicMessagePrompt <PostPaymentMessagePrompt
open={tipNoticeOpen} open={postPaymentOpen}
onOpenChange={setTipNoticeOpen} onOpenChange={setPostPaymentOpen}
recipientPubkey={pubkey} recipientPubkey={pubkey}
paymentContext={postPaymentContext}
/> />
</> </>
) )
@ -261,7 +264,8 @@ function ZapDialogContent({
recipientPayment, recipientPayment,
lightningAddressOptions, lightningAddressOptions,
canLightningZap, canLightningZap,
onBeforeZapDialogClose onPaymentFlowComplete,
onPostPaymentRequest
}: { }: {
open: boolean open: boolean
setOpen: Dispatch<SetStateAction<boolean>> setOpen: Dispatch<SetStateAction<boolean>>
@ -272,14 +276,16 @@ function ZapDialogContent({
recipientPayment: RecipientZapPaymentData recipientPayment: RecipientZapPaymentData
lightningAddressOptions: string[] lightningAddressOptions: string[]
canLightningZap: boolean canLightningZap: boolean
/** Runs before the zap dialog closes (e.g. after payment); skip tip notice if a public receipt was sent. */ onPaymentFlowComplete?: (
onBeforeZapDialogClose?: (withPublicReceipt: boolean) => void result: import('@/services/lightning.service').PaymentFlowResult,
paymentDetails?: { amountMsat?: number; paytoUri?: string }
) => void
onPostPaymentRequest?: (context: PostPaymentContext) => void
}) { }) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const paymentsOnly = !ZAP_SENDING_ENABLED const paymentsOnly = !ZAP_SENDING_ENABLED
const { defaultZapSats, defaultZapComment, includePublicZapReceipt, updateIncludePublicZapReceipt } = const { defaultZapSats, defaultZapComment } = useZap()
useZap()
const allPaymentGroups = useMemo(() => { const allPaymentGroups = useMemo(() => {
if (!paymentsOnly) return [] if (!paymentsOnly) return []
@ -303,7 +309,9 @@ function ZapDialogContent({
<PaymentMethodsSection <PaymentMethodsSection
groups={allPaymentGroups} groups={allPaymentGroups}
recipientPubkey={recipient} recipientPubkey={recipient}
referencedEvent={event}
offerTipNoticeOnClose={false} offerTipNoticeOnClose={false}
onPostPaymentRequest={onPostPaymentRequest}
title={t('Payment methods')} title={t('Payment methods')}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0" className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/> />
@ -383,23 +391,24 @@ function ZapDialogContent({
throw new Error('You need to be logged in to zap') throw new Error('You need to be logged in to zap')
} }
setZapping(true) setZapping(true)
const closeZapDialog = () => { const paytoUri = selectedLightning ? buildPaytoUri('lightning', selectedLightning) : undefined
onBeforeZapDialogClose?.(includePublicZapReceipt) const paymentDetails = {
setOpen(false) amountMsat: clampedSats * 1000,
paytoUri
} }
const closeZapDialog = () => setOpen(false)
const zapResult = await lightning.zap( const zapResult = await lightning.zap(
pubkey, pubkey,
event ?? recipient, event ?? recipient,
clampedSats, clampedSats,
comment, comment,
closeZapDialog, closeZapDialog,
includePublicZapReceipt, (result) => onPaymentFlowComplete?.(result, paymentDetails),
{ {
address: selectedLightning || undefined, address: selectedLightning || undefined,
candidates: lightningAddressOptions.length > 0 ? lightningAddressOptions : undefined candidates: lightningAddressOptions.length > 0 ? lightningAddressOptions : undefined
} }
) )
// user canceled
if (!zapResult) { if (!zapResult) {
return return
} }
@ -423,7 +432,9 @@ function ZapDialogContent({
<PaymentMethodsSection <PaymentMethodsSection
groups={zapAlternativePayments.groups} groups={zapAlternativePayments.groups}
recipientPubkey={recipient} recipientPubkey={recipient}
referencedEvent={event}
offerTipNoticeOnClose={false} offerTipNoticeOnClose={false}
onPostPaymentRequest={onPostPaymentRequest}
title={t('Payment methods')} title={t('Payment methods')}
headerHelpText={ headerHelpText={
zapAlternativePayments.showBitcoinOnChainHint zapAlternativePayments.showBitcoinOnChainHint
@ -482,8 +493,9 @@ function ZapDialogContent({
{/* Comment input */} {/* Comment input */}
<div className="px-4"> <div className="px-4">
<Label htmlFor="comment">{t('zapComment')}</Label> <Label htmlFor="comment">{t('Zap lnurl comment label')}</Label>
<Input id="comment" value={comment} onChange={(e) => setComment(e.target.value)} /> <Input id="comment" value={comment} onChange={(e) => setComment(e.target.value)} />
<p className="mt-1 text-xs text-muted-foreground">{t('Zap lnurl comment hint')}</p>
</div> </div>
</div> </div>
@ -491,19 +503,7 @@ function ZapDialogContent({
className="space-y-3 border-t border-border bg-background px-4 pt-3" className="space-y-3 border-t border-border bg-background px-4 pt-3"
style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}
> >
<div className="flex items-center justify-between gap-3"> <p className="text-xs leading-relaxed text-muted-foreground">{t('Zap superchat flow hint')}</p>
<Label htmlFor="zap-include-receipt" className="flex-1 cursor-pointer">
<div className="text-sm font-medium">{t('Include public zap receipt')}</div>
<div className="text-xs text-muted-foreground font-normal">
{t('When off, your zap may still succeed but a public receipt may not be published to relays')}
</div>
</Label>
<Switch
id="zap-include-receipt"
checked={includePublicZapReceipt}
onCheckedChange={updateIncludePublicZapReceipt}
/>
</div>
<div className="min-w-0 space-y-1.5"> <div className="min-w-0 space-y-1.5">
<Label <Label
@ -562,7 +562,9 @@ function ZapDialogContent({
<PaymentMethodsSection <PaymentMethodsSection
groups={zapAlternativePayments.groups} groups={zapAlternativePayments.groups}
recipientPubkey={recipient} recipientPubkey={recipient}
referencedEvent={event}
offerTipNoticeOnClose={false} offerTipNoticeOnClose={false}
onPostPaymentRequest={onPostPaymentRequest}
title={t('Other payment methods')} title={t('Other payment methods')}
headerHelpText={ headerHelpText={
zapAlternativePayments.showBitcoinOnChainHint zapAlternativePayments.showBitcoinOnChainHint

4
src/constants.ts

@ -552,6 +552,10 @@ export const ExtendedKind = {
FOLLOW_SET: 30000, FOLLOW_SET: 30000,
ZAP_REQUEST: 9734, ZAP_REQUEST: 9734,
ZAP_RECEIPT: 9735, ZAP_RECEIPT: 9735,
/** Payment Superchats: sender notifies recipient of a payto/zap payment (kind 9740). */
PAYMENT_NOTIFICATION: 9740,
/** Payment Superchats: recipient attests receipt of kind 9740 or 9735 (kind 9741). */
PAYMENT_ATTESTATION: 9741,
PUBLICATION: 30040, PUBLICATION: 30040,
WIKI_ARTICLE: 30818, WIKI_ARTICLE: 30818,
/** NIP/spec document (Markdown) for relay publication instead of GitHub; kind 30817. */ /** NIP/spec document (Markdown) for relay publication instead of GitHub; kind 30817. */

42
src/hooks/useProfileWall.tsx

@ -19,6 +19,7 @@ import {
type ResolvedProfileBadge type ResolvedProfileBadge
} from '@/lib/nip58-profile-badges' } from '@/lib/nip58-profile-badges'
import { isDirectProfileWallComment } from '@/lib/profile-wall-comments' import { isDirectProfileWallComment } from '@/lib/profile-wall-comments'
import { filterAttestedProfileWallSuperchats, isProfileWallPaymentNotification } from '@/lib/superchat'
import { isValidPubkey, userIdToPubkey } from '@/lib/pubkey' import { isValidPubkey, userIdToPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -77,7 +78,10 @@ async function fetchBadgeDefinitionOnRelays(
} }
const CACHE_DURATION = 5 * 60 * 1000 const CACHE_DURATION = 5 * 60 * 1000
const wallCacheByKey = new Map<string, { badges: ResolvedProfileBadge[]; comments: Event[]; lastUpdated: number }>() const wallCacheByKey = new Map<
string,
{ badges: ResolvedProfileBadge[]; comments: Event[]; superchats: Event[]; lastUpdated: number }
>()
const wallRefreshListenersByPubkey = new Map<string, Set<() => void>>() const wallRefreshListenersByPubkey = new Map<string, Set<() => void>>()
@ -120,6 +124,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
hasUsefulWallCache ? cached!.badges : [] hasUsefulWallCache ? cached!.badges : []
) )
const [comments, setComments] = useState<Event[]>(hasUsefulWallCache ? cached!.comments : []) const [comments, setComments] = useState<Event[]>(hasUsefulWallCache ? cached!.comments : [])
const [superchats, setSuperchats] = useState<Event[]>(hasUsefulWallCache ? (cached!.superchats ?? []) : [])
const [isLoading, setIsLoading] = useState(!hasUsefulWallCache) const [isLoading, setIsLoading] = useState(!hasUsefulWallCache)
const [refreshToken, setRefreshToken] = useState(0) const [refreshToken, setRefreshToken] = useState(0)
@ -220,6 +225,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
if (runGen === runGenRef.current) { if (runGen === runGenRef.current) {
setBadges((prev) => (prev === mem.badges ? prev : mem.badges)) setBadges((prev) => (prev === mem.badges ? prev : mem.badges))
setComments((prev) => (prev === mem.comments ? prev : mem.comments)) setComments((prev) => (prev === mem.comments ? prev : mem.comments))
setSuperchats((prev) => (prev === (mem.superchats ?? []) ? prev : (mem.superchats ?? [])))
setIsLoading((prev) => (prev ? false : prev)) setIsLoading((prev) => (prev ? false : prev))
} }
return return
@ -249,7 +255,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
authorRl, authorRl,
false, false,
false, false,
[ExtendedKind.COMMENT, ExtendedKind.PROFILE_BADGES_LIST, ExtendedKind.BADGE_DEFINITION], [ExtendedKind.COMMENT, ExtendedKind.PROFILE_BADGES_LIST, ExtendedKind.BADGE_DEFINITION, ExtendedKind.PAYMENT_NOTIFICATION, ExtendedKind.PAYMENT_ATTESTATION],
useGlobalRelayBootstrapRef.current useGlobalRelayBootstrapRef.current
) )
@ -293,14 +299,19 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
} }
setIsLoading(false) setIsLoading(false)
// --- Wall comments (kind 1111): after badges so payment UI is not blocked --- // --- Wall comments (kind 1111) and attested superchats (kind 9740) ---
let wallComments: Event[] = [] let wallComments: Event[] = []
let wallSuperchats: Event[] = []
const profileId = profileEventId?.trim().toLowerCase() const profileId = profileEventId?.trim().toLowerCase()
if (profileId && /^[0-9a-f]{64}$/.test(profileId) && relayUrls.length > 0) { if (profileId && /^[0-9a-f]{64}$/.test(profileId) && relayUrls.length > 0) {
const profileCoord = getReplaceableCoordinate(kinds.Metadata, pkNorm, '') const profileCoord = getReplaceableCoordinate(kinds.Metadata, pkNorm, '')
const filters: Filter[] = [ const filters: Filter[] = [
{ kinds: [ExtendedKind.COMMENT], '#e': [profileId], limit: 200 }, { kinds: [ExtendedKind.COMMENT], '#e': [profileId], limit: 200 },
{ kinds: [ExtendedKind.COMMENT], '#a': [profileCoord], limit: 200 } { kinds: [ExtendedKind.COMMENT], '#a': [profileCoord], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#p': [pkNorm], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#e': [profileId], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#a': [profileCoord], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pkNorm], limit: 500 }
] ]
const pool = new Map<string, Event>() const pool = new Map<string, Event>()
try { try {
@ -324,18 +335,37 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
wallComments = [...pool.values()] wallComments = [...pool.values()]
.filter( .filter(
(e) => (e) =>
e.kind === ExtendedKind.COMMENT &&
!isEventDeletedRef.current(e) && !isEventDeletedRef.current(e) &&
isDirectProfileWallComment(e, profileId, pkNorm) isDirectProfileWallComment(e, profileId, pkNorm)
) )
.sort((a, b) => b.created_at - a.created_at) .sort((a, b) => b.created_at - a.created_at)
const paymentNotifications = [...pool.values()].filter(
(e) =>
e.kind === ExtendedKind.PAYMENT_NOTIFICATION &&
!isEventDeletedRef.current(e) &&
isProfileWallPaymentNotification(e, pkNorm, profileId)
)
const attestations = [...pool.values()].filter(
(e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION
)
wallSuperchats = filterAttestedProfileWallSuperchats(
paymentNotifications,
attestations,
pkNorm,
profileId
)
} }
if (cancelled) return if (cancelled) return
setComments(wallComments) setComments(wallComments)
if (resolvedBadges.length > 0 || wallComments.length > 0) { setSuperchats(wallSuperchats)
if (resolvedBadges.length > 0 || wallComments.length > 0 || wallSuperchats.length > 0) {
wallCacheByKey.set(cacheKey, { wallCacheByKey.set(cacheKey, {
badges: resolvedBadges, badges: resolvedBadges,
comments: wallComments, comments: wallComments,
superchats: wallSuperchats,
lastUpdated: Date.now() lastUpdated: Date.now()
}) })
} else { } else {
@ -357,5 +387,5 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
scheduleManualWallRefetch() scheduleManualWallRefetch()
}, [scheduleManualWallRefetch]) }, [scheduleManualWallRefetch])
return { badges, comments, isLoading, refresh } return { badges, comments, superchats, isLoading, refresh }
} }

32
src/i18n/locales/en.ts

@ -194,6 +194,37 @@ export default {
"Tips above 10k sats can use Bitcoin on-chain.": "Tips above 10k sats can use Bitcoin on-chain.", "Tips above 10k sats can use Bitcoin on-chain.": "Tips above 10k sats can use Bitcoin on-chain.",
"Zap dialog other payment hint": "Tap a link to open PayPal or copy an address. Lightning tips use the button below.", "Zap dialog other payment hint": "Tap a link to open PayPal or copy an address. Lightning tips use the button below.",
"Tip notice prompt title": "Let them know?", "Tip notice prompt title": "Let them know?",
"Send them a message": "Send them a message",
"Post payment prompt label": "If you have successfully completed a payment, you can:",
"Send them a public message": "Send them a public message",
"Post payment public message hint":
"This is not encrypted, but it goes to their notifications, instead of the feeds.",
"Request a superchat": "Request a superchat",
"Post payment superchat hint":
"This sends an unencrypted payment notification to them, which can appear as a superchat in the related thread or wall, if they attest to receiving the payment.",
"Superchat request prompt description":
"Publish a payment notification (kind 9740). The recipient can attest to receiving your payment so this message may appear as a superchat.",
"Superchat message": "Superchat message",
"Superchat message placeholder": "Thank you for this post!",
"Send superchat request": "Send superchat request",
"Superchat request sent": "Superchat request sent",
"Failed to send superchat request": "Failed to send superchat request: {{error}}",
"Superchat": "Superchat",
"Superchats": "Superchats",
"Profile wall superchats": "Profile wall superchats",
"Invalid superchat": "Invalid superchat",
"Superchat thread": "View thread",
"Superchat profile": "View profile",
"View thread": "View thread",
"View profile": "View profile",
"to": "to",
"Payment target": "Payment target",
"Zap lnurl comment label": "Wallet comment (optional)",
"Zap lnurl comment hint": "Shown on the Lightning invoice only. To appear in a thread, request a superchat after paying.",
"Zap superchat flow hint":
"After you pay, you can send a kind 24 public message or request a superchat. Superchats use kind 9740; the recipient attests receipt before they appear in threads.",
"Zap superchat wallet hint":
"After paying, you can send a public message or request a superchat so the recipient can attest your payment.",
"Tip notice success only note": "Only if you already sent a tip successfully (Lightning or another payment method).", "Tip notice success only note": "Only if you already sent a tip successfully (Lightning or another payment method).",
"Tip notice prompt description": "Send a public message (kind 24) so they know you tipped.", "Tip notice prompt description": "Send a public message (kind 24) so they know you tipped.",
"I just sent you a tip!": "I just sent you a tip!", "I just sent you a tip!": "I just sent you a tip!",
@ -1330,6 +1361,7 @@ export default {
"Submit Relay": "Submit Relay", "Submit Relay": "Submit Relay",
Homepage: "Homepage", Homepage: "Homepage",
"Proof of Work (difficulty {{minPow}})": "Proof of Work (difficulty {{minPow}})", "Proof of Work (difficulty {{minPow}})": "Proof of Work (difficulty {{minPow}})",
"POW: difficulty {{difficulty}}": "POW: difficulty {{difficulty}}",
"via {{client}}": "via {{client}}", "via {{client}}": "via {{client}}",
"Auto-load media": "Auto-load media", "Auto-load media": "Auto-load media",
Always: "Always", Always: "Always",

23
src/lib/composer-extra-tags.test.ts

@ -1,21 +1,28 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
composerTagRowFromNostrTag,
normalizeComposerExtraTags, normalizeComposerExtraTags,
parseComposerTagValuesInput, parseComposerTagValuesInput,
type ComposerExtraTagRow type ComposerExtraTagRow
} from './composer-extra-tags' } from './composer-extra-tags'
function row(tag: string[]): ComposerExtraTagRow { function row(name: string, valuesRaw = ''): ComposerExtraTagRow {
return { id: '1', tag } return { id: '1', name, valuesRaw }
} }
describe('normalizeComposerExtraTags', () => { describe('normalizeComposerExtraTags', () => {
it('drops rows without a tag name', () => { it('drops rows without a tag name', () => {
expect(normalizeComposerExtraTags([row(['', 'x']), row(['t', 'a'])])).toEqual([['t', 'a']]) expect(normalizeComposerExtraTags([row('', 'x'), row('t', 'a')])).toEqual([['t', 'a']])
}) })
it('trims tag name and values', () => { it('trims tag name and values', () => {
expect(normalizeComposerExtraTags([row([' k ', ' 1 ', '2 '])])).toEqual([['k', '1', '2']]) expect(normalizeComposerExtraTags([row(' k ', ' 1 \n2 ')])).toEqual([['k', '1', '2']])
})
it('builds from nostr tag arrays', () => {
expect(normalizeComposerExtraTags([composerTagRowFromNostrTag(['t', 'a', 'b'])])).toEqual([
['t', 'a', 'b']
])
}) })
}) })
@ -24,3 +31,11 @@ describe('parseComposerTagValuesInput', () => {
expect(parseComposerTagValuesInput('a\n\n b \n')).toEqual(['a', 'b']) expect(parseComposerTagValuesInput('a\n\n b \n')).toEqual(['a', 'b'])
}) })
}) })
describe('composerTagRowFromNostrTag', () => {
it('preserves multiline values in raw form', () => {
const editable = composerTagRowFromNostrTag(['summary', 'line one', 'line two'])
expect(editable.name).toBe('summary')
expect(editable.valuesRaw).toBe('line one\nline two')
})
})

29
src/lib/composer-extra-tags.ts

@ -1,18 +1,33 @@
export type ComposerExtraTagRow = { id: string; tag: string[] } export type ComposerExtraTagRow = {
id: string
name: string
/** Raw textarea content; parsed only when exporting tags. */
valuesRaw: string
}
export function newComposerTagRow(): ComposerExtraTagRow {
return { id: crypto.randomUUID(), name: '', valuesRaw: '' }
}
export function newComposerTagRow(tag: string[] = ['', '']): ComposerExtraTagRow { export function composerTagRowFromNostrTag(tag: string[]): ComposerExtraTagRow {
return { id: crypto.randomUUID(), tag: [...tag] } return {
id: crypto.randomUUID(),
name: String(tag[0] ?? ''),
valuesRaw: formatComposerTagValuesInput(tag)
}
} }
/** Normalize user rows into valid Nostr tag arrays (non-empty tag name). */ /** Normalize user rows into valid Nostr tag arrays (non-empty tag name). */
export function normalizeComposerExtraTags(rows: ComposerExtraTagRow[]): string[][] { export function normalizeComposerExtraTags(rows: ComposerExtraTagRow[]): string[][] {
return rows return rows
.map((row) => row.tag) .filter((row) => row.name.trim())
.filter((tag) => Array.isArray(tag) && String(tag[0] ?? '').trim()) .map((row) => {
.map((tag) => [String(tag[0]).trim(), ...tag.slice(1).map((v) => String(v ?? '').trim())]) const vals = parseComposerTagValuesInput(row.valuesRaw)
return [row.name.trim(), ...vals]
})
} }
/** Join tag[1..] for a single-line editor (commas in values are preserved via multiline instead). */ /** Join tag[1..] for the values textarea (one value per line). */
export function formatComposerTagValuesInput(tag: string[]): string { export function formatComposerTagValuesInput(tag: string[]): string {
return tag.slice(1).join('\n') return tag.slice(1).join('\n')
} }

42
src/lib/draft-event.ts

@ -551,6 +551,48 @@ export async function createPublicMessageDraftEvent(
return setDraftEventCache(baseDraft) return setDraftEventCache(baseDraft)
} }
export async function createPaymentNotificationDraftEvent(
content: string,
recipientPubkey: string,
options: {
amountMsat?: number
payto?: string
referencedEvent?: Event
addClientTag?: boolean
} = {}
): Promise<TDraftEvent> {
const trimmed = content.trim()
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(trimmed)
const hashtags = extractHashtags(transformedEmojisContent)
const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(buildPTag(recipientPubkey))
if (options.amountMsat != null && options.amountMsat > 0) {
tags.push(['amount', String(Math.round(options.amountMsat))])
}
if (options.payto?.trim()) {
tags.push(['payto', options.payto.trim()])
}
if (options.referencedEvent) {
if (isReplaceableEvent(options.referencedEvent.kind)) {
tags.push(buildATag(options.referencedEvent))
} else {
tags.push(buildETag(options.referencedEvent.id, options.referencedEvent.pubkey))
}
tags.push(['k', String(options.referencedEvent.kind)])
}
const baseDraft = {
kind: ExtendedKind.PAYMENT_NOTIFICATION,
content: transformedEmojisContent,
tags
}
return setDraftEventCache(baseDraft)
}
const SECONDS_PER_DAY = 86400 const SECONDS_PER_DAY = 86400
/** /**

76
src/lib/event-pow.test.ts

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { getPow } from 'nostr-tools/nip13' import { getPow } from 'nostr-tools/nip13'
import { minePow } from '@/lib/event' import { minePow } from '@/lib/event'
import {
getEventNoncePowDifficulty,
isEventNoncePowVerified
} from '@/lib/event-pow'
describe('minePow', () => { describe('minePow', () => {
it('uses NIP-13 nonce tag and meets requested difficulty', async () => { it('uses NIP-13 nonce tag and meets requested difficulty', async () => {
@ -17,3 +21,75 @@ describe('minePow', () => {
expect(getPow(mined.id)).toBeGreaterThanOrEqual(1) expect(getPow(mined.id)).toBeGreaterThanOrEqual(1)
}) })
}) })
describe('getEventNoncePowDifficulty', () => {
it('reads difficulty from the first verified nonce tag only', async () => {
const unsigned = {
kind: 1,
content: 'pow test',
tags: [] as string[][],
created_at: 1_700_000_000,
pubkey: 'a'.repeat(64)
}
const mined = await minePow(unsigned, 12)
const withExtra = {
...mined,
tags: [
...mined.tags,
['nonce', '999', '99'],
['p', 'b'.repeat(64)]
] as string[][]
}
expect(getEventNoncePowDifficulty(mined)).toBe(12)
expect(isEventNoncePowVerified(mined)).toBe(true)
expect(getEventNoncePowDifficulty(withExtra)).toBe(12)
})
it('returns null when no nonce tag', () => {
expect(
getEventNoncePowDifficulty({
id: 'e'.repeat(64),
tags: []
})
).toBeNull()
})
it('rejects nonce tag when event id does not meet committed difficulty', async () => {
const unsigned = {
kind: 1,
content: 'pow test',
tags: [] as string[][],
created_at: 1_700_000_000,
pubkey: 'a'.repeat(64)
}
const mined = await minePow(unsigned, 5)
const nonce = mined.tags.find((t) => t[0] === 'nonce')!
const forged = {
...mined,
tags: [['nonce', nonce[1]!, '99']] as string[][]
}
expect(getPow(forged.id)).toBeLessThan(99)
expect(getEventNoncePowDifficulty(forged)).toBeNull()
expect(isEventNoncePowVerified(forged)).toBe(false)
})
it('rejects nonce tag without a nonce value', () => {
expect(
getEventNoncePowDifficulty({
id: '00000' + 'e'.repeat(59),
tags: [['nonce', '', '20']]
})
).toBeNull()
})
it('accepts when id exceeds committed difficulty (lucky hash)', () => {
const id = '00000' + 'f'.repeat(59)
expect(getPow(id)).toBeGreaterThanOrEqual(20)
expect(
getEventNoncePowDifficulty({
id,
tags: [['nonce', '1', '20']]
})
).toBe(20)
})
})

34
src/lib/event-pow.ts

@ -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
}

8
src/lib/event.ts

@ -6,7 +6,7 @@ import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import { Event, getEventHash, kinds, nip19, UnsignedEvent } from 'nostr-tools' import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { minePow as nip13MinePow } from 'nostr-tools/nip13' import { minePow as nip13MinePow } from 'nostr-tools/nip13'
import { hexPubkeysEqual, normalizeHexPubkey } from './pubkey' import { hexPubkeysEqual, normalizeHexPubkey } from './pubkey'
import { import {
@ -135,9 +135,9 @@ export function isReplyNoteEvent(event: Event) {
return true return true
} }
// Zap receipts are considered replies if they have an 'e' tag (zapping a note) or 'a' tag (zapping an addressable event) // Zap receipts and payment notifications are thread replies when they reference a note or addressable event.
if (event.kind === kinds.Zap) { if (event.kind === kinds.Zap || event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
return event.tags.some(tag => tag[0] === 'e' || tag[0] === 'a') return event.tags.some((tag) => tag[0] === 'e' || tag[0] === 'a')
} }
if (event.kind !== kinds.ShortTextNote) return false if (event.kind !== kinds.ShortTextNote) return false

4
src/lib/kind-description.ts

@ -82,6 +82,10 @@ export function getKindDescription(
return { number: 9734, description: 'Zap request' } return { number: 9734, description: 'Zap request' }
case ExtendedKind.ZAP_RECEIPT: case ExtendedKind.ZAP_RECEIPT:
return { number: 9735, description: 'Zap receipt' } return { number: 9735, description: 'Zap receipt' }
case ExtendedKind.PAYMENT_NOTIFICATION:
return { number: 9740, description: 'Payment notification' }
case ExtendedKind.PAYMENT_ATTESTATION:
return { number: 9741, description: 'Payment attestation' }
case ExtendedKind.RELAY_REVIEW: case ExtendedKind.RELAY_REVIEW:
return { number: 31987, description: 'Relay review' } return { number: 31987, description: 'Relay review' }
case ExtendedKind.PUBLICATION: case ExtendedKind.PUBLICATION:

1
src/lib/note-renderable-kinds.ts

@ -16,6 +16,7 @@ const RENDERABLE_NOTE_KINDS = new Set<number>([
ExtendedKind.PUBLIC_MESSAGE, ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.ZAP_REQUEST, ExtendedKind.ZAP_REQUEST,
ExtendedKind.ZAP_RECEIPT, ExtendedKind.ZAP_RECEIPT,
ExtendedKind.PAYMENT_NOTIFICATION,
ExtendedKind.PUBLICATION_CONTENT, ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.FOLLOW_PACK, ExtendedKind.FOLLOW_PACK,
ExtendedKind.CITATION_INTERNAL, ExtendedKind.CITATION_INTERNAL,

13
src/lib/payto.ts

@ -63,6 +63,19 @@ export function buildPaytoUri(type: string, authority: string): string {
return `payto://${t}/${a}` return `payto://${t}/${a}`
} }
/** Kind 9740 `payto` tag: scheme prefix stripped (e.g. `lightning/user%40domain`). */
export function formatPaytoTagValue(paytoUriOrPath: string): string {
return paytoUriOrPath.trim().replace(/^payto:\/\//i, '')
}
/** Payto type from a kind-9740 `payto` tag value (segment before the first `/`). */
export function parsePaytoTagType(paytoTagValue: string): string {
const trimmed = paytoTagValue.trim()
const slash = trimmed.indexOf('/')
const raw = slash <= 0 ? trimmed : trimmed.slice(0, slash)
return getCanonicalPaytoType(raw)
}
export { export {
flattenPaytoLinkChildText, flattenPaytoLinkChildText,
formatPaytoLinkDisplayText, formatPaytoLinkDisplayText,

55
src/lib/post-payment-context.ts

@ -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
}

229
src/lib/superchat.test.ts

@ -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)
})
})

212
src/lib/superchat.ts

@ -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)
)
)
}

6
src/lib/thread-interaction-req.ts

@ -34,13 +34,15 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte
kinds.ShortTextNote, kinds.ShortTextNote,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,
kinds.Zap kinds.Zap,
ExtendedKind.PAYMENT_NOTIFICATION
]) ])
const kindsPrimaryThread = kindsNoteCommentVoiceZap const kindsPrimaryThread = kindsNoteCommentVoiceZap
const kindsUpperEThread = sortedUniqueKinds([ const kindsUpperEThread = sortedUniqueKinds([
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,
kinds.Zap kinds.Zap,
ExtendedKind.PAYMENT_NOTIFICATION
]) ])
const kindsOnETag = sortedUniqueKinds([ const kindsOnETag = sortedUniqueKinds([

25
src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx

@ -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>
)
}

5
src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx

@ -17,7 +17,6 @@ import { useTranslation } from 'react-i18next'
import DefaultZapAmountInput from './DefaultZapAmountInput' import DefaultZapAmountInput from './DefaultZapAmountInput'
import DefaultZapCommentInput from './DefaultZapCommentInput' import DefaultZapCommentInput from './DefaultZapCommentInput'
import QuickZapSwitch from './QuickZapSwitch' import QuickZapSwitch from './QuickZapSwitch'
import IncludePublicZapReceiptSwitch from './IncludePublicZapReceiptSwitch'
import WalletConnectionDetails from './WalletConnectionDetails' import WalletConnectionDetails from './WalletConnectionDetails'
export default function WalletZapSendingSettings() { export default function WalletZapSendingSettings() {
@ -59,7 +58,9 @@ export default function WalletZapSendingSettings() {
<> <>
<DefaultZapCommentInput /> <DefaultZapCommentInput />
<QuickZapSwitch /> <QuickZapSwitch />
<IncludePublicZapReceiptSwitch /> <p className="text-sm text-muted-foreground leading-relaxed">
{t('Zap superchat wallet hint')}
</p>
</> </>
) : null} ) : null}
</> </>

45
src/services/lightning.service.ts

@ -20,8 +20,8 @@ import { Filter, kinds, NostrEvent } from 'nostr-tools'
import { SubCloser } from 'nostr-tools/abstract-pool' import { SubCloser } from 'nostr-tools/abstract-pool'
import { makeZapRequest } from 'nostr-tools/nip57' import { makeZapRequest } from 'nostr-tools/nip57'
import { utf8Decoder } from 'nostr-tools/utils' import { utf8Decoder } from 'nostr-tools/utils'
import client from './client.service' import client from './client.service'
import storage from './local-storage.service'
import { queryService, replaceableEventService } from './client.service' import { queryService, replaceableEventService } from './client.service'
import { getProfileFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent } from '@/lib/event-metadata'
import { clampZapSats } from '@/lib/lightning' import { clampZapSats } from '@/lib/lightning'
@ -33,6 +33,8 @@ import { runAfterReleasingRadixScrollLock } from '@/lib/react-remove-scroll-body
export type TRecentSupporter = { pubkey: string; amount: number; comment?: string } export type TRecentSupporter = { pubkey: string; amount: number; comment?: string }
export type PaymentFlowResult = { preimage: string; invoice: string } | null
/** LNURL-pay limits from the recipient’s `.well-known/lnurlp` metadata. */ /** LNURL-pay limits from the recipient’s `.well-known/lnurlp` metadata. */
export type LnurlPayInvoiceOptions = { export type LnurlPayInvoiceOptions = {
/** Max description length; `0` means the endpoint does not accept comments. */ /** Max description length; `0` means the endpoint does not accept comments. */
@ -69,9 +71,9 @@ class LightningService {
sats: number, sats: number,
comment: string, comment: string,
closeOuterModel?: () => void, closeOuterModel?: () => void,
includePublicReceipt: boolean = storage.getIncludePublicZapReceipt(), onPaymentFlowComplete?: (result: PaymentFlowResult) => void,
zapLightning?: { address?: string; candidates?: string[] } zapLightning?: { address?: string; candidates?: string[] }
): Promise<{ preimage: string; invoice: string } | null> { ): Promise<PaymentFlowResult> {
if (!ZAP_SENDING_ENABLED) { if (!ZAP_SENDING_ENABLED) {
throw new Error('NIP-57 zaps are disabled; use LNURL-pay invoices instead') throw new Error('NIP-57 zaps are disabled; use LNURL-pay invoices instead')
} }
@ -102,13 +104,10 @@ class LightningService {
} }
const { callback, lnurl } = zapEndpoint const { callback, lnurl } = zapEndpoint
const amount = sats * 1000 const amount = sats * 1000
const zapRelays = includePublicReceipt
? senderRelayList.write.slice(0, 4).concat(FAST_READ_RELAY_URLS)
: []
const zapRequestDraft = makeZapRequest({ const zapRequestDraft = makeZapRequest({
...(event ? { event } : { pubkey: recipient }), ...(event ? { event } : { pubkey: recipient }),
amount, amount,
relays: zapRelays, relays: [],
comment comment
}) })
const zapRequest = await client.signer.signEvent(zapRequestDraft) const zapRequest = await client.signer.signEvent(zapRequestDraft)
@ -131,7 +130,9 @@ class LightningService {
try { try {
const { preimage } = await sendWebLNPaymentWithRetry(this.provider, pr) const { preimage } = await sendWebLNPaymentWithRetry(this.provider, pr)
closeOuterModel?.() closeOuterModel?.()
return { preimage, invoice: pr } const result = { preimage, invoice: pr }
onPaymentFlowComplete?.(result)
return result
} catch (error) { } catch (error) {
if (!isNwcWalletServiceInfoError(error)) { if (!isNwcWalletServiceInfoError(error)) {
throw error throw error
@ -144,17 +145,19 @@ class LightningService {
closeModal() closeModal()
let checkPaymentInterval: ReturnType<typeof setInterval> | undefined let checkPaymentInterval: ReturnType<typeof setInterval> | undefined
let subCloser: SubCloser | undefined let subCloser: SubCloser | undefined
const finish = (result: PaymentFlowResult) => {
clearInterval(checkPaymentInterval)
subCloser?.close()
onPaymentFlowComplete?.(result)
resolve(result)
}
const { setPaid } = launchPaymentModal({ const { setPaid } = launchPaymentModal({
invoice: pr, invoice: pr,
onPaid: (response) => { onPaid: (response) => {
clearInterval(checkPaymentInterval) finish({ preimage: response.preimage, invoice: pr })
subCloser?.close()
resolve({ preimage: response.preimage, invoice: pr })
}, },
onCancelled: () => { onCancelled: () => {
clearInterval(checkPaymentInterval) finish(null)
subCloser?.close()
resolve(null)
} }
}) })
@ -199,13 +202,16 @@ class LightningService {
async payInvoice( async payInvoice(
invoice: string, invoice: string,
closeOuterModel?: () => void closeOuterModel?: () => void,
): Promise<{ preimage: string; invoice: string } | null> { onPaymentFlowComplete?: (result: PaymentFlowResult) => void
): Promise<PaymentFlowResult> {
if (this.provider) { if (this.provider) {
try { try {
const { preimage } = await sendWebLNPaymentWithRetry(this.provider, invoice) const { preimage } = await sendWebLNPaymentWithRetry(this.provider, invoice)
closeOuterModel?.() closeOuterModel?.()
return { preimage, invoice } const result = { preimage, invoice }
onPaymentFlowComplete?.(result)
return result
} catch (error) { } catch (error) {
if (!isNwcWalletServiceInfoError(error)) { if (!isNwcWalletServiceInfoError(error)) {
throw error throw error
@ -219,9 +225,12 @@ class LightningService {
launchPaymentModal({ launchPaymentModal({
invoice: invoice, invoice: invoice,
onPaid: (response) => { onPaid: (response) => {
resolve({ preimage: response.preimage, invoice: invoice }) const result = { preimage: response.preimage, invoice: invoice }
onPaymentFlowComplete?.(result)
resolve(result)
}, },
onCancelled: () => { onCancelled: () => {
onPaymentFlowComplete?.(null)
resolve(null) resolve(null)
} }
}) })

Loading…
Cancel
Save