Browse Source

prefer common payment targets

remove zaps entirely
add conversion display
imwald
Silberengel 3 weeks ago
parent
commit
3ba7223c50
  1. 340
      src/components/NoteStats/ZapButton.tsx
  2. 32
      src/components/PaymentMethodsSection/index.tsx
  3. 4
      src/components/PaytoDialog/LightningInvoiceSection.tsx
  4. 13
      src/components/PaytoLink/index.tsx
  5. 13
      src/components/Profile/index.tsx
  6. 38
      src/components/SatsAmountEquivalents/index.tsx
  7. 4
      src/components/ZapDialog/SuperchatRequestForm.tsx
  8. 65
      src/components/ZapDialog/ZapSatsAmountInput.tsx
  9. 569
      src/components/ZapDialog/index.tsx
  10. 3
      src/constants.ts
  11. 14
      src/hooks/useRecipientAlternativePayments.test.ts
  12. 67
      src/hooks/useRecipientAlternativePayments.ts
  13. 25
      src/hooks/useSatsFiatRates.ts
  14. 32
      src/hooks/useSenderPaytoTypes.ts
  15. 1
      src/i18n/locales/cs.ts
  16. 1
      src/i18n/locales/de.ts
  17. 1
      src/i18n/locales/en.ts
  18. 1
      src/i18n/locales/es.ts
  19. 1
      src/i18n/locales/fr.ts
  20. 1
      src/i18n/locales/nl.ts
  21. 1
      src/i18n/locales/pl.ts
  22. 1
      src/i18n/locales/ru.ts
  23. 1
      src/i18n/locales/tr.ts
  24. 1
      src/i18n/locales/zh.ts
  25. 161
      src/lib/merge-payment-methods.test.ts
  26. 181
      src/lib/merge-payment-methods.ts
  27. 23
      src/lib/sats-fiat.test.ts
  28. 41
      src/lib/sats-fiat.ts
  29. 23
      src/lib/xmr-usd-rate.ts
  30. 12
      src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx
  31. 194
      src/services/lightning.service.ts

340
src/components/NoteStats/ZapButton.tsx

@ -1,31 +1,21 @@
import { ZAP_SENDING_ENABLED } from '@/constants'
import { Skeleton } from '@/components/ui/skeleton'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { recipientHasAnyPaymentOptions } from '@/lib/merge-payment-methods'
import { import {
buildOrderedZapLightningAddresses, buildRecipientPaymentData,
recipientHasAnyPaymentOptions mergeRecipientPaymentData,
} from '@/lib/merge-payment-methods' type RecipientPaymentData
import {
buildRecipientZapPaymentData,
mergeRecipientZapPaymentData,
type RecipientZapPaymentData
} from '@/hooks/useRecipientAlternativePayments' } from '@/hooks/useRecipientAlternativePayments'
import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata'
import { shouldDeferPerPubkeyProfileNetwork } from '@/lib/profile-batch-coordinator' import { shouldDeferPerPubkeyProfileNetwork } from '@/lib/profile-batch-coordinator'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useZap } from '@/providers/ZapProvider'
import client, { replaceableEventService } from '@/services/client.service' import client, { replaceableEventService } from '@/services/client.service'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
import { kinds, type Event } from 'nostr-tools' import { kinds, type Event } from 'nostr-tools'
import lightning from '@/services/lightning.service'
import noteStatsService from '@/services/note-stats.service'
import type { TNoteStats } from '@/services/note-stats.service'
import { Zap } from 'lucide-react' import { Zap } from 'lucide-react'
import { MouseEvent, TouchEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import ZapDialog from '../ZapDialog' import ZapDialog from '../ZapDialog'
import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt' import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt'
import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context' import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
@ -34,7 +24,7 @@ import { ZapCountHover } from './NoteStatsCountHover'
type ZapButtonProps = { type ZapButtonProps = {
event: Event event: Event
hideCount?: boolean hideCount?: boolean
noteStats?: Partial<TNoteStats> noteStats?: Partial<import('@/services/note-stats.service').TNoteStats>
} }
function formatAmount(amount: number) { function formatAmount(amount: number) {
@ -43,13 +33,13 @@ function formatAmount(amount: number) {
return `${Math.round(amount / 100000) / 10}M` return `${Math.round(amount / 100000) / 10}M`
} }
type ZapRecipientResolveResult = { type RecipientResolveResult = {
profile: TProfile | null profile: TProfile | null
profileEvent: Event | undefined profileEvent: Event | undefined
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null
} }
const zapRecipientResolveByPubkey = new Map<string, Promise<ZapRecipientResolveResult>>() const recipientResolveByPubkey = new Map<string, Promise<RecipientResolveResult>>()
function feedProfileRowSyncKey( function feedProfileRowSyncKey(
profile: TProfile | undefined | null, profile: TProfile | undefined | null,
@ -64,28 +54,27 @@ function feedProfileRowSyncKey(
].join('\x1e') ].join('\x1e')
} }
/** Avoid one metadata + payment REQ per visible note while feed profile batch runs. */ async function resolveRecipientPaymentData(
async function resolveZapRecipientData(
authorPubkey: string, authorPubkey: string,
feedProfile: TProfile | undefined | null feedProfile: TProfile | undefined | null
): Promise<ZapRecipientResolveResult> { ): Promise<RecipientResolveResult> {
const pk = authorPubkey.toLowerCase() const pk = authorPubkey.toLowerCase()
const inFlight = zapRecipientResolveByPubkey.get(pk) const inFlight = recipientResolveByPubkey.get(pk)
if (inFlight) return inFlight if (inFlight) return inFlight
const run = resolveZapRecipientDataBody(pk, feedProfile).finally(() => { const run = resolveRecipientPaymentDataBody(pk, feedProfile).finally(() => {
if (zapRecipientResolveByPubkey.get(pk) === run) { if (recipientResolveByPubkey.get(pk) === run) {
zapRecipientResolveByPubkey.delete(pk) recipientResolveByPubkey.delete(pk)
} }
}) })
zapRecipientResolveByPubkey.set(pk, run) recipientResolveByPubkey.set(pk, run)
return run return run
} }
async function resolveZapRecipientDataBody( async function resolveRecipientPaymentDataBody(
authorPubkey: string, authorPubkey: string,
feedProfile: TProfile | undefined | null feedProfile: TProfile | undefined | null
): Promise<ZapRecipientResolveResult> { ): Promise<RecipientResolveResult> {
const cachedFeed = const cachedFeed =
feedProfile && !feedProfile.batchPlaceholder ? feedProfile : null feedProfile && !feedProfile.batchPlaceholder ? feedProfile : null
const deferNetwork = shouldDeferPerPubkeyProfileNetwork(authorPubkey) const deferNetwork = shouldDeferPerPubkeyProfileNetwork(authorPubkey)
@ -131,8 +120,7 @@ async function resolveZapRecipientDataBody(
} }
} }
/** Zap tally + payment-methods dialog when {@link ZAP_SENDING_ENABLED} is false. */ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapButtonProps) {
function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapButtonProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const [openPaymentDialog, setOpenPaymentDialog] = useState(false) const [openPaymentDialog, setOpenPaymentDialog] = useState(false)
@ -156,7 +144,7 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
) )
const [disable, setDisable] = useState(true) const [disable, setDisable] = useState(true)
const [tipPaymentData, setTipPaymentData] = useState<RecipientZapPaymentData | null>(null) const [tipPaymentData, setTipPaymentData] = useState<RecipientPaymentData | null>(null)
const [postPaymentOpen, setPostPaymentOpen] = useState(false) const [postPaymentOpen, setPostPaymentOpen] = useState(false)
const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null) const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null)
@ -184,8 +172,8 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
) )
setDisable(!canTip) setDisable(!canTip)
setTipPaymentData((prev) => setTipPaymentData((prev) =>
mergeRecipientZapPaymentData( mergeRecipientPaymentData(
buildRecipientZapPaymentData(paymentInfo, profile, profileEvent ?? null), buildRecipientPaymentData(paymentInfo, profile, profileEvent ?? null),
prev prev
) )
) )
@ -210,7 +198,7 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
setTipPaymentData(null) setTipPaymentData(null)
let cancelled = false let cancelled = false
void resolveZapRecipientData(authorPubkey, feedProfileRef.current).then( void resolveRecipientPaymentData(authorPubkey, feedProfileRef.current).then(
({ profile, profileEvent, paymentInfo }) => { ({ profile, profileEvent, paymentInfo }) => {
if (cancelled) return if (cancelled) return
applyTipAvailability(profile, profileEvent ?? null, paymentInfo) applyTipAvailability(profile, profileEvent ?? null, paymentInfo)
@ -288,290 +276,6 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
) )
} }
export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapButtonProps) {
if (!ZAP_SENDING_ENABLED) {
return <ZapPaymentMethodsButton event={event} hideCount={hideCount} noteStats={noteStats} />
}
const { t } = useTranslation()
const { checkLogin, pubkey } = useNostr()
const { defaultZapSats, defaultZapComment, quickZap } = useZap()
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
const [openZapDialog, setOpenZapDialog] = useState(false)
const [postPaymentOpen, setPostPaymentOpen] = useState(false)
const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null)
const [zapping, setZapping] = useState(false)
const handlePostPaymentRequest = useCallback(
(context: PostPaymentContext) => {
if (event.pubkey === pubkey) return
setPostPaymentContext(
mergePostPaymentContext({ recipientPubkey: event.pubkey, referencedEvent: event }, context)
)
setPostPaymentOpen(true)
},
[event, pubkey]
)
const statsLoaded = noteStats?.updatedAt != null
const { zapAmount, hasZapped } = useMemo(() => {
return {
zapAmount: noteStats?.zaps?.reduce((acc, zap) => acc + zap.amount, 0),
hasZapped: pubkey ? noteStats?.zaps?.some((zap) => zap.pubkey === pubkey) : false
}
}, [noteStats, pubkey])
const showZapAmount = !hideCount && (statsLoaded || (zapAmount ?? 0) > 0)
const authorPubkey = event.pubkey.toLowerCase()
const isSelf = !!pubkey && pubkey.toLowerCase() === authorPubkey
const feedProfiles = useNoteFeedProfileContext()
const feedProfile = feedProfiles?.profiles.get(authorPubkey)
const feedProfileRef = useRef(feedProfile)
feedProfileRef.current = feedProfile
const feedProfileSyncKey = feedProfileRowSyncKey(
feedProfile,
Boolean(feedProfiles?.pendingPubkeys.has(authorPubkey))
)
const [disable, setDisable] = useState(true)
const [canLightningZap, setCanLightningZap] = useState(false)
const [tipPaymentData, setTipPaymentData] = useState<RecipientZapPaymentData | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isLongPressRef = useRef(false)
const applyTipAvailability = useCallback(
(
profile: TProfile | null,
profileEvent: Event | null | undefined,
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null,
forDialogPrefetch: boolean
) => {
const event = profileEvent ?? null
const canTip = recipientHasAnyPaymentOptions(paymentInfo, profile, event)
setDisable(!canTip)
setCanLightningZap(
buildOrderedZapLightningAddresses({
profileEvent: event,
profile,
paymentInfo
}).length > 0
)
if (forDialogPrefetch) {
setTipPaymentData((prev) =>
mergeRecipientZapPaymentData(
buildRecipientZapPaymentData(paymentInfo, profile, event),
prev
)
)
}
},
[]
)
/** Enable zap from feed profile; seed dialog prefetch from kind 0 JSON when available. */
useEffect(() => {
if (isSelf) return
if (!feedProfile || feedProfile.batchPlaceholder) return
applyTipAvailability(feedProfile, null, null, true)
}, [isSelf, feedProfile, feedProfileSyncKey, applyTipAvailability])
useEffect(() => {
if (isSelf) {
setDisable(true)
setCanLightningZap(false)
setTipPaymentData(null)
return
}
setDisable(true)
setCanLightningZap(false)
setTipPaymentData(null)
let cancelled = false
void resolveZapRecipientData(authorPubkey, feedProfileRef.current).then(
({ profile, profileEvent, paymentInfo }) => {
if (cancelled) return
applyTipAvailability(profile, profileEvent ?? null, paymentInfo, true)
}
)
return () => {
cancelled = true
}
}, [authorPubkey, isSelf, feedProfileSyncKey, applyTipAvailability])
const handleZap = async () => {
try {
if (!pubkey) {
throw new Error('You need to be logged in to zap')
}
if (zapping) return
setZapping(true)
const paymentDetails = { amountMsat: defaultZapSats * 1000 }
const zapResult = await lightning.zap(
pubkey,
event,
defaultZapSats,
defaultZapComment,
undefined,
() => {
handlePostPaymentRequest(
mergePostPaymentContext(
{ recipientPubkey: event.pubkey, referencedEvent: event },
{ amountMsat: paymentDetails.amountMsat }
)
)
}
)
if (!zapResult) {
return
}
noteStatsService.addZap(
pubkey,
event.id,
zapResult.invoice,
defaultZapSats,
defaultZapComment
)
} catch (error) {
toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
} finally {
setZapping(false)
}
}
const handleClickStart = (e: MouseEvent | TouchEvent) => {
e.stopPropagation()
e.preventDefault()
if (disable) return
isLongPressRef.current = false
if ('touches' in e) {
const touch = e.touches[0]
setTouchStart({ x: touch.clientX, y: touch.clientY })
}
if (quickZap) {
timerRef.current = setTimeout(() => {
isLongPressRef.current = true
checkLogin(() => {
setOpenZapDialog(true)
setZapping(true)
})
}, 500)
}
}
const handleClickEnd = (e: MouseEvent | TouchEvent) => {
e.stopPropagation()
e.preventDefault()
if (timerRef.current) {
clearTimeout(timerRef.current)
}
if (disable) return
if ('touches' in e) {
setTouchStart(null)
if (!touchStart) return
const touch = e.changedTouches[0]
const diffX = Math.abs(touch.clientX - touchStart.x)
const diffY = Math.abs(touch.clientY - touchStart.y)
if (diffX > 10 || diffY > 10) return
}
if (!quickZap) {
checkLogin(() => {
setOpenZapDialog(true)
setZapping(true)
})
} else if (!isLongPressRef.current) {
if (canLightningZap) {
checkLogin(() => handleZap())
} else {
checkLogin(() => {
setOpenZapDialog(true)
setZapping(true)
})
}
}
isLongPressRef.current = false
}
const handleMouseLeave = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
return (
<>
<div className="flex h-full min-w-0 select-none items-center">
<button
type="button"
className={cn(
'group flex h-full items-center pl-3 pr-1',
disable ? 'cursor-not-allowed' : 'cursor-pointer'
)}
title={t('Zap')}
disabled={disable || zapping}
onMouseDown={handleClickStart}
onMouseUp={handleClickEnd}
onMouseLeave={handleMouseLeave}
onTouchStart={handleClickStart}
onTouchEnd={handleClickEnd}
>
{zapping ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : (
<Zap
className={cn(
hasZapped && 'fill-yellow-400',
disable
? 'text-muted-foreground/40'
: cn(
'text-muted-foreground group-hover:text-yellow-400',
hasZapped && 'text-yellow-400'
)
)}
/>
)}
</button>
{showZapAmount ? (
<ZapCountHover noteStats={noteStats}>
<div
className={cn(
'pr-3 text-sm tabular-nums',
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
)}
>
{formatAmount(zapAmount ?? 0)}
</div>
</ZapCountHover>
) : (
<span className="pr-3" aria-hidden />
)}
</div>
<ZapDialog
open={openZapDialog}
setOpen={(open) => {
setOpenZapDialog(open)
setZapping(open)
}}
pubkey={event.pubkey}
event={event}
prefetchedPayment={tipPaymentData}
onPostPaymentRequest={handlePostPaymentRequest}
/>
<PostPaymentMessagePrompt
open={postPaymentOpen}
onOpenChange={setPostPaymentOpen}
recipientPubkey={event.pubkey}
paymentContext={postPaymentContext}
/>
</>
)
}
export default function ZapButton({ event, hideCount = false }: ZapButtonProps) { export default function ZapButton({ event, hideCount = false }: ZapButtonProps) {
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
return <ZapButtonWithStats event={event} hideCount={hideCount} noteStats={noteStats} /> return <ZapButtonWithStats event={event} hideCount={hideCount} noteStats={noteStats} />

32
src/components/PaymentMethodsSection/index.tsx

@ -1,7 +1,6 @@
import PaytoLink from '@/components/PaytoLink' import PaytoLink from '@/components/PaytoLink'
import type { PaymentMethodGroup } from '@/lib/merge-payment-methods' import type { PaymentMethodGroup } from '@/lib/merge-payment-methods'
import { PRIMARY_LINK_HOVER_CLASS } from '@/lib/link-styles' import { PRIMARY_LINK_HOVER_CLASS } from '@/lib/link-styles'
import { isZappableLightningPaytoType } from '@/lib/payto'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Copy } from 'lucide-react' import { Copy } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -13,18 +12,14 @@ import type { PostPaymentContext } from '@/lib/post-payment-context'
export default function PaymentMethodsSection({ export default function PaymentMethodsSection({
groups, groups,
recipientPubkey, recipientPubkey,
onOpenZap,
referencedEvent, referencedEvent,
offerTipNoticeOnClose = true, offerTipNoticeOnClose = true,
onPostPaymentRequest, onPostPaymentRequest,
title, title,
className, className
headerHelpText
}: { }: {
groups: PaymentMethodGroup[] groups: PaymentMethodGroup[]
recipientPubkey?: string recipientPubkey?: string
/** When set, lightning rows open the zap flow with that address as the default. */
onOpenZap?: (lightningAuthority: string) => void
/** Thread context passed to PaytoDialog for superchat requests. */ /** Thread context passed to PaytoDialog for superchat requests. */
referencedEvent?: NostrEvent referencedEvent?: NostrEvent
/** When false, PaytoDialog defer post-payment prompt to parent. */ /** When false, PaytoDialog defer post-payment prompt to parent. */
@ -32,8 +27,6 @@ export default function PaymentMethodsSection({
onPostPaymentRequest?: (context: PostPaymentContext) => void 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). */
headerHelpText?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -44,24 +37,10 @@ export default function PaymentMethodsSection({
<div className="text-xs font-semibold text-muted-foreground mb-2"> <div className="text-xs font-semibold text-muted-foreground mb-2">
{title ?? t('Payment Methods')} {title ?? t('Payment Methods')}
</div> </div>
{headerHelpText ? (
<p className="mb-3 text-xs leading-snug text-muted-foreground" role="note">
{headerHelpText}
</p>
) : null}
<div className="space-y-3 min-w-0"> <div className="space-y-3 min-w-0">
{groups.map((group, groupIdx) => ( {groups.map((group, groupIdx) => (
<div <div key={groupIdx} className="text-sm min-w-0">
key={groupIdx} <div className="font-medium">{group.displayType}</div>
className={cn(
'text-sm min-w-0',
group.highlighted &&
'rounded-md border border-amber-500/50 bg-amber-500/10 px-2.5 py-2'
)}
>
<div className={cn('font-medium', group.highlighted && 'text-foreground')}>
{group.displayType}
</div>
<div className="space-y-1.5 mt-1"> <div className="space-y-1.5 mt-1">
{group.methods.map((method, idx) => ( {group.methods.map((method, idx) => (
<div key={idx} className="min-w-0"> <div key={idx} className="min-w-0">
@ -73,11 +52,6 @@ export default function PaymentMethodsSection({
paytoUri={method.payto} paytoUri={method.payto}
displayFormat="full" displayFormat="full"
pubkey={recipientPubkey} pubkey={recipientPubkey}
onOpenZap={
isZappableLightningPaytoType(method.type) && onOpenZap
? (_pk, authority) => onOpenZap(authority)
: undefined
}
offerTipNoticeOnClose={offerTipNoticeOnClose} offerTipNoticeOnClose={offerTipNoticeOnClose}
onPostPaymentRequest={onPostPaymentRequest} onPostPaymentRequest={onPostPaymentRequest}
referencedEvent={referencedEvent} referencedEvent={referencedEvent}

4
src/components/PaytoDialog/LightningInvoiceSection.tsx

@ -1,4 +1,5 @@
import QrCode from '@/components/QrCode' import QrCode from '@/components/QrCode'
import SatsAmountEquivalents from '@/components/SatsAmountEquivalents'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@ -185,6 +186,7 @@ export default function LightningInvoiceSection({
<Label htmlFor="ln-invoice-sats" className="text-sm font-medium text-muted-foreground sm:text-base"> <Label htmlFor="ln-invoice-sats" className="text-sm font-medium text-muted-foreground sm:text-base">
{t('Amount (sats)')} {t('Amount (sats)')}
</Label> </Label>
<SatsAmountEquivalents sats={sats} id="ln-invoice-sats-equiv" />
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<Input <Input
id="ln-invoice-sats" id="ln-invoice-sats"
@ -192,7 +194,7 @@ export default function LightningInvoiceSection({
value={sats === 0 ? '' : formatSatsGrouped(sats)} value={sats === 0 ? '' : formatSatsGrouped(sats)}
onChange={(e) => setSats(parseGroupedIntegerInput(e.target.value))} onChange={(e) => setSats(parseGroupedIntegerInput(e.target.value))}
className="h-12 min-w-0 flex-1 text-xl font-semibold tabular-nums sm:h-14 sm:text-2xl" className="h-12 min-w-0 flex-1 text-xl font-semibold tabular-nums sm:h-14 sm:text-2xl"
aria-describedby="ln-invoice-preset-hint" aria-describedby="ln-invoice-sats-equiv ln-invoice-preset-hint"
/> />
<span className="shrink-0 text-base font-medium text-muted-foreground sm:text-lg">{t('sats')}</span> <span className="shrink-0 text-base font-medium text-muted-foreground sm:text-lg">{t('sats')}</span>
</div> </div>

13
src/components/PaytoLink/index.tsx

@ -1,4 +1,3 @@
import { ZAP_SENDING_ENABLED } from '@/constants'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -9,7 +8,6 @@ import {
getCanonicalPaytoType, getCanonicalPaytoType,
getPaytoTypeInfo, getPaytoTypeInfo,
isKnownPaytoType, isKnownPaytoType,
isZappableLightningPaytoType,
flattenPaytoLinkChildText, flattenPaytoLinkChildText,
formatPaytoLinkDisplayText, formatPaytoLinkDisplayText,
paytoLinkChildTextLooksLikeAuthority paytoLinkChildTextLooksLikeAuthority
@ -25,7 +23,6 @@ export default function PaytoLink({
type: typeProp, type: typeProp,
authority: authorityProp, authority: authorityProp,
pubkey, pubkey,
onOpenZap,
offerTipNoticeOnClose = true, offerTipNoticeOnClose = true,
onPostPaymentRequest, onPostPaymentRequest,
referencedEvent, referencedEvent,
@ -39,9 +36,7 @@ export default function PaytoLink({
paytoUri?: string paytoUri?: string
type?: string type?: string
authority?: string authority?: string
/** When set with lightning type, clicking can open Zap dialog via onOpenZap */
pubkey?: string pubkey?: string
onOpenZap?: (pubkey: string, lightningAuthority: string) => void
/** Passed to PaytoDialog; set false when a parent already offers the post-payment prompt. */ /** 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). */ /** Parent-owned post-payment prompt (e.g. ZapDialog). */
@ -73,16 +68,10 @@ export default function PaytoLink({
const { type, authority, raw } = parsed const { type, authority, raw } = parsed
const info = getPaytoTypeInfo(type) const info = getPaytoTypeInfo(type)
const known = isKnownPaytoType(type) const known = isKnownPaytoType(type)
const canZap =
ZAP_SENDING_ENABLED && isZappableLightningPaytoType(type) && !!pubkey && !!onOpenZap
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (canZap) {
onOpenZap(pubkey!, authority)
return
}
if (!known) { if (!known) {
navigator.clipboard.writeText(raw) navigator.clipboard.writeText(raw)
toast.success(t('Copied payto address')) toast.success(t('Copied payto address'))
@ -134,7 +123,7 @@ export default function PaytoLink({
{iconEl} {iconEl}
{content} {content}
</button> </button>
{known && !canZap && ( {known && (
<PaytoDialog <PaytoDialog
open={dialogOpen} open={dialogOpen}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}

13
src/components/Profile/index.tsx

@ -80,15 +80,16 @@ import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service' import { nip66Service } from '@/services/nip66.service'
import PaymentMethodsSection from '@/components/PaymentMethodsSection' import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import { buildRecipientZapPaymentData } from '@/hooks/useRecipientAlternativePayments' import { buildRecipientPaymentData } from '@/hooks/useRecipientAlternativePayments'
import { loadAuthorReplaceablesFromLocalCache } from '@/lib/profile-author-replaceables-cache' import { loadAuthorReplaceablesFromLocalCache } from '@/lib/profile-author-replaceables-cache'
import ZapDialog from '@/components/ZapDialog' import ZapDialog from '@/components/ZapDialog'
import { import {
groupPaymentMethodsByDisplayType, groupPaymentMethodsForDisplay,
mergePaymentMethods, mergePaymentMethods,
recipientHasAnyPaymentOptions, recipientHasAnyPaymentOptions,
sortMergedPaymentMethods sortMergedPaymentMethods
} from '@/lib/merge-payment-methods' } from '@/lib/merge-payment-methods'
import { useSenderPaytoTypes } from '@/hooks/useSenderPaytoTypes'
import { PRIMARY_LINK_HOVER_CLASS } from '@/lib/link-styles' import { PRIMARY_LINK_HOVER_CLASS } from '@/lib/link-styles'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -135,6 +136,8 @@ export default function Profile({
return accountProfileEvent.created_at >= profileEvent.created_at ? accountProfileEvent : profileEvent return accountProfileEvent.created_at >= profileEvent.created_at ? accountProfileEvent : profileEvent
}, [isSelf, profileEvent, accountProfileEvent]) }, [isSelf, profileEvent, accountProfileEvent])
const senderPaytoTypes = useSenderPaytoTypes(!!accountPubkey && !isSelf)
const mergedPaymentMethods = useMemo( const mergedPaymentMethods = useMemo(
() => () =>
sortMergedPaymentMethods( sortMergedPaymentMethods(
@ -144,8 +147,8 @@ export default function Profile({
) )
const paymentMethodsByType = useMemo( const paymentMethodsByType = useMemo(
() => groupPaymentMethodsByDisplayType(mergedPaymentMethods), () => groupPaymentMethodsForDisplay(mergedPaymentMethods, senderPaytoTypes),
[mergedPaymentMethods] [mergedPaymentMethods, senderPaytoTypes]
) )
const hasPaymentMethods = useMemo( const hasPaymentMethods = useMemo(
@ -156,7 +159,7 @@ export default function Profile({
const prefetchedPaymentData = useMemo( const prefetchedPaymentData = useMemo(
() => () =>
profile?.pubkey profile?.pubkey
? buildRecipientZapPaymentData(paymentInfo, profile ?? null, effectiveProfileEvent ?? null) ? buildRecipientPaymentData(paymentInfo, profile ?? null, effectiveProfileEvent ?? null)
: null, : null,
[paymentInfo, profile, effectiveProfileEvent] [paymentInfo, profile, effectiveProfileEvent]
) )

38
src/components/SatsAmountEquivalents/index.tsx

@ -0,0 +1,38 @@
import { formatSatsEquivalentsParts } from '@/lib/sats-fiat'
import { cn } from '@/lib/utils'
import { useSatsFiatRates } from '@/hooks/useSatsFiatRates'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
/** Subtle USD / BTC / XMR equivalents for a sats amount (live spot rates). */
export default function SatsAmountEquivalents({
sats,
className,
id
}: {
sats: number
className?: string
/** Optional id for `aria-describedby` on the sats input. */
id?: string
}) {
const { t } = useTranslation()
const { btcUsd, xmrUsd } = useSatsFiatRates()
const parts = useMemo(
() => formatSatsEquivalentsParts(sats, btcUsd, xmrUsd),
[sats, btcUsd, xmrUsd]
)
const line = [parts.usd ?? '—', parts.btc, parts.xmr ?? '—'].join(' · ')
return (
<p
id={id}
className={cn('text-xs leading-snug tabular-nums text-muted-foreground/90', className)}
aria-live="polite"
>
<span className="sr-only">{t('Approximate equivalent:')} </span>
<span aria-hidden> </span>
{line}
</p>
)
}

4
src/components/ZapDialog/SuperchatRequestForm.tsx

@ -17,6 +17,7 @@ import { useNostr } from '@/providers/NostrProvider'
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 SatsAmountEquivalents from '@/components/SatsAmountEquivalents'
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle' import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle'
import SuperchatPaymentMethodLabel from '../Note/SuperchatPaymentMethodLabel' import SuperchatPaymentMethodLabel from '../Note/SuperchatPaymentMethodLabel'
@ -105,6 +106,7 @@ export default function SuperchatRequestForm({
) : null} ) : null}
<div className="mt-3 grid gap-2"> <div className="mt-3 grid gap-2">
<Label htmlFor="superchat-amount">{t('Superchat estimated amount (sats)')}</Label> <Label htmlFor="superchat-amount">{t('Superchat estimated amount (sats)')}</Label>
<SatsAmountEquivalents sats={amountSats} id="superchat-amount-equiv" />
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Input <Input
id="superchat-amount" id="superchat-amount"
@ -114,7 +116,7 @@ export default function SuperchatRequestForm({
placeholder="0" placeholder="0"
disabled={sending} disabled={sending}
className="min-w-0 flex-1 tabular-nums" className="min-w-0 flex-1 tabular-nums"
aria-describedby="superchat-amount-hint" aria-describedby="superchat-amount-equiv superchat-amount-hint"
/> />
<span className="shrink-0 text-sm text-muted-foreground">{t('sats')}</span> <span className="shrink-0 text-sm text-muted-foreground">{t('sats')}</span>
</div> </div>

65
src/components/ZapDialog/ZapSatsAmountInput.tsx

@ -1,65 +0,0 @@
import {
clampZapSats,
formatSatsGrouped,
parseGroupedIntegerInput,
shouldHighlightLeadingSatsGroups,
splitSatsGroupedParts
} from '@/lib/lightning'
import { superchatSatsLeadingHighlightClass } from '@/lib/superchat-ui'
import { cn } from '@/lib/utils'
const inputTypography =
'text-center w-full p-0 text-6xl font-bold tabular-nums tracking-tight'
export default function ZapSatsAmountInput({
sats,
onSatsChange,
id = 'sats'
}: {
sats: number
onSatsChange: (next: number) => void
id?: string
}) {
const clamped = clampZapSats(sats)
const highlightLeading = shouldHighlightLeadingSatsGroups(clamped)
const parts = splitSatsGroupedParts(clamped)
return (
<div className="relative flex justify-center w-full max-w-xs min-h-[4.5rem] items-center">
<div
className={cn(
'pointer-events-none absolute inset-0 flex items-center justify-center gap-[0.2em]',
inputTypography
)}
aria-hidden
>
{parts.map((part, index) => (
<span
key={`${index}-${part}`}
className={cn(index === 0 && highlightLeading && superchatSatsLeadingHighlightClass)}
>
{part}
</span>
))}
</div>
<input
id={id}
inputMode="numeric"
value={formatSatsGrouped(clamped)}
onChange={(e) => {
onSatsChange(parseGroupedIntegerInput(e.target.value))
}}
onFocus={(e) => {
requestAnimationFrame(() => {
const val = e.target.value
e.target.setSelectionRange(val.length, val.length)
})
}}
className={cn(
inputTypography,
'relative z-10 bg-transparent text-transparent caret-foreground focus-visible:outline-none'
)}
/>
</div>
)
}

569
src/components/ZapDialog/index.tsx

@ -1,5 +1,3 @@
import { ZAP_SENDING_ENABLED } from '@/constants'
import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -14,59 +12,33 @@ import {
DrawerOverlay, DrawerOverlay,
DrawerTitle DrawerTitle
} from '@/components/ui/drawer' } from '@/components/ui/drawer'
import { Input } from '@/components/ui/input' import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import { Skeleton } from '@/components/ui/skeleton' import UserAvatar from '@/components/UserAvatar'
import { Label } from '@/components/ui/label' import Username from '@/components/Username'
import { useSenderPaytoTypes } from '@/hooks/useSenderPaytoTypes'
import {
mergeRecipientPaymentData,
useRecipientPaymentData,
type RecipientPaymentData
} from '@/hooks/useRecipientAlternativePayments'
import {
groupPaymentMethodsForDisplay,
mergePaymentMethods,
sortMergedPaymentMethods
} from '@/lib/merge-payment-methods'
import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
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 { useBtcUsdRate } from '@/hooks/useBtcUsdRate'
import { clampZapSats, formatSatsGrouped, shouldHighlightLeadingSatsGroups } from '@/lib/lightning'
import { formatBtcFromSats, formatUsdFromSats } from '@/lib/sats-fiat'
import { superchatAmountHighlightClass, superchatLightningAccentClass } from '@/lib/superchat-ui'
import { cn } from '@/lib/utils'
import lightning from '@/services/lightning.service'
import noteStatsService from '@/services/note-stats.service'
import { NostrEvent } from 'nostr-tools' 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 { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
import { buildPaytoUri } from '@/lib/payto'
import {
buildOrderedZapLightningAddresses,
groupPaymentMethodsByDisplayType,
mergePaymentMethods,
prepareZapDialogAlternativePayments,
sortMergedPaymentMethods,
ZAP_HIDE_BITCOIN_ALTS_MAX_SATS
} from '@/lib/merge-payment-methods'
import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import {
mergeRecipientZapPaymentData,
useRecipientZapPaymentData,
type RecipientZapPaymentData
} from '@/hooks/useRecipientAlternativePayments'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import PostPaymentMessagePrompt from './PostPaymentMessagePrompt' import PostPaymentMessagePrompt from './PostPaymentMessagePrompt'
import ZapSatsAmountInput from './ZapSatsAmountInput'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
export default function ZapDialog({ export default function ZapDialog({
open, open,
setOpen, setOpen,
pubkey, pubkey,
event, event,
defaultAmount,
defaultComment,
defaultLightningAddress,
prefetchedPayment = null, prefetchedPayment = null,
onPostPaymentRequest onPostPaymentRequest
}: { }: {
@ -75,12 +47,8 @@ export default function ZapDialog({
pubkey: string pubkey: string
/** When set, kind 9740 superchats reference this note (e/a + k + author). Omit for profile tips. */ /** When set, kind 9740 superchats reference this note (e/a + k + author). Omit for profile tips. */
event?: NostrEvent event?: NostrEvent
defaultAmount?: number
defaultComment?: string
/** Lightning address to pre-select (e.g. from a profile payto link click). */
defaultLightningAddress?: string | null
/** Profile/feed snapshot shown immediately; relay fetch while open may enrich this. */ /** Profile/feed snapshot shown immediately; relay fetch while open may enrich this. */
prefetchedPayment?: RecipientZapPaymentData | null prefetchedPayment?: RecipientPaymentData | null
/** Parent-owned post-payment prompt (e.g. note ZapButton). Skips internal prompt when set. */ /** Parent-owned post-payment prompt (e.g. note ZapButton). Skips internal prompt when set. */
onPostPaymentRequest?: (context: PostPaymentContext) => void onPostPaymentRequest?: (context: PostPaymentContext) => void
}) { }) {
@ -107,57 +75,72 @@ export default function ZapDialog({
setOpen(false) setOpen(false)
} }
const fetchedPayment = useRecipientZapPaymentData(pubkey, open) const fetchedPayment = useRecipientPaymentData(pubkey, open)
const recipientPayment = useMemo( const recipientPayment = useMemo(
() => mergeRecipientZapPaymentData(prefetchedPayment, fetchedPayment), () => mergeRecipientPaymentData(prefetchedPayment, fetchedPayment),
[prefetchedPayment, fetchedPayment] [prefetchedPayment, fetchedPayment]
) )
const lightningAddressOptions = useMemo( const senderPaytoTypes = useSenderPaytoTypes(open)
() =>
buildOrderedZapLightningAddresses({ const paymentGroups = useMemo(() => {
profileEvent: recipientPayment.profileEvent, const merged = sortMergedPaymentMethods(
profile: recipientPayment.profile, mergePaymentMethods(
paymentInfo: recipientPayment.paymentInfo,
preferredAddress: defaultLightningAddress
}),
[
recipientPayment.profileEvent,
recipientPayment.profile,
recipientPayment.paymentInfo, recipientPayment.paymentInfo,
defaultLightningAddress recipientPayment.profile,
] recipientPayment.profileEvent
)
) )
const canLightningZap = lightningAddressOptions.length > 0 return groupPaymentMethodsForDisplay(merged, senderPaytoTypes)
const paymentsOnly = !ZAP_SENDING_ENABLED }, [recipientPayment, senderPaytoTypes])
const dialogTitlePrefix = paymentsOnly
? t('Payment methods')
: canLightningZap
? t('Zap to')
: t('Pay to')
const dialogDescription = paymentsOnly
? t('Payment methods')
: canLightningZap
? t('Send a Lightning payment to this user')
: t('Send a payment to this user')
const handleZapDialogOpenChange: Dispatch<SetStateAction<boolean>> = setOpen const dialogTitle = t('Payment methods')
const body =
paymentGroups.length > 0 ? (
<PaymentMethodsSection
groups={paymentGroups}
recipientPubkey={pubkey}
referencedEvent={event}
offerTipNoticeOnClose={false}
onPostPaymentRequest={openPostPaymentPrompt}
title={t('Payment methods')}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('No payment methods available for this profile')}
</p>
)
const content = (
<div
className="px-4 pb-4"
style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}
>
{body}
</div>
)
const postPaymentPrompt = !onPostPaymentRequest ? (
<PostPaymentMessagePrompt
open={postPaymentOpen}
onOpenChange={setPostPaymentOpen}
recipientPubkey={pubkey}
paymentContext={postPaymentContext}
/>
) : null
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (drawerContentRef.current) { if (drawerContentRef.current) {
// Use visual viewport height to ensure proper positioning when keyboard/emoji picker opens
const viewportHeight = window.visualViewport?.height || window.innerHeight const viewportHeight = window.visualViewport?.height || window.innerHeight
const maxHeight = viewportHeight - 100
// Ensure drawer doesn't go above the viewport, but don't override bottom positioning
const maxHeight = viewportHeight - 100 // Leave some space at top
drawerContentRef.current.style.setProperty('max-height', `${maxHeight}px`) drawerContentRef.current.style.setProperty('max-height', `${maxHeight}px`)
// Don't set bottom position here - let the drawer handle it naturally
} }
} }
if (window.visualViewport) { if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handleResize) window.visualViewport.addEventListener('resize', handleResize)
handleResize() // Initial call in case the keyboard is already open handleResize()
} }
return () => { return () => {
@ -169,8 +152,9 @@ export default function ZapDialog({
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Drawer open={open} onOpenChange={handleZapDialogOpenChange}> <>
<DrawerOverlay onClick={() => handleZapDialogOpenChange(false)} /> <Drawer open={open} onOpenChange={setOpen}>
<DrawerOverlay onClick={() => setOpen(false)} />
<DrawerContent <DrawerContent
hideOverlay hideOverlay
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
@ -178,428 +162,41 @@ export default function ZapDialog({
className="flex max-h-[80vh] flex-col overflow-y-auto overscroll-contain" className="flex max-h-[80vh] flex-col overflow-y-auto overscroll-contain"
style={{ style={{
maxHeight: 'calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 2rem)', maxHeight: 'calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 2rem)',
paddingBottom: '0' // Remove default padding since we handle it in the button container paddingBottom: '0'
}} }}
> >
<DrawerHeader className="shrink-0 px-4"> <DrawerHeader className="shrink-0 px-4">
<DrawerTitle className="flex gap-2 items-center"> <DrawerTitle className="flex items-center gap-2">
<div className="shrink-0">{dialogTitlePrefix}</div> <div className="shrink-0">{dialogTitle}</div>
<UserAvatar size="small" userId={pubkey} /> <UserAvatar size="small" userId={pubkey} />
<Username userId={pubkey} className="truncate flex-1 w-0 text-start h-5" /> <Username userId={pubkey} className="h-5 w-0 flex-1 truncate text-start" />
</DrawerTitle> </DrawerTitle>
<DialogDescription className="sr-only">{dialogDescription}</DialogDescription> <DialogDescription className="sr-only">{dialogTitle}</DialogDescription>
</DrawerHeader> </DrawerHeader>
<ZapDialogContent {content}
open={open}
setOpen={handleZapDialogOpenChange}
recipient={pubkey}
event={event}
defaultAmount={defaultAmount}
defaultComment={defaultComment}
recipientPayment={recipientPayment}
lightningAddressOptions={lightningAddressOptions}
canLightningZap={canLightningZap}
onPaymentFlowComplete={(_result, paymentDetails) => {
openPostPaymentPrompt(
mergePostPaymentContext(
{ recipientPubkey: pubkey, referencedEvent: event },
{
amountMsat: paymentDetails?.amountMsat,
paytoUri: paymentDetails?.paytoUri
}
)
)
}}
onPostPaymentRequest={openPostPaymentPrompt}
/>
</DrawerContent> </DrawerContent>
{!onPostPaymentRequest ? (
<PostPaymentMessagePrompt
open={postPaymentOpen}
onOpenChange={setPostPaymentOpen}
recipientPubkey={pubkey}
paymentContext={postPaymentContext}
/>
) : null}
</Drawer> </Drawer>
{postPaymentPrompt}
</>
) )
} }
return ( return (
<> <>
<Dialog open={open} onOpenChange={handleZapDialogOpenChange}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex gap-2 items-center"> <DialogTitle className="flex items-center gap-2">
<div className="shrink-0">{dialogTitlePrefix}</div> <div className="shrink-0">{dialogTitle}</div>
<UserAvatar size="small" userId={pubkey} /> <UserAvatar size="small" userId={pubkey} />
<Username userId={pubkey} className="truncate flex-1 max-w-fit text-start h-5" /> <Username userId={pubkey} className="h-5 max-w-fit flex-1 truncate text-start" />
</DialogTitle> </DialogTitle>
<DialogDescription className="sr-only">{dialogDescription}</DialogDescription> <DialogDescription className="sr-only">{dialogTitle}</DialogDescription>
</DialogHeader> </DialogHeader>
<ZapDialogContent {content}
open={open}
setOpen={handleZapDialogOpenChange}
recipient={pubkey}
event={event}
defaultAmount={defaultAmount}
defaultComment={defaultComment}
recipientPayment={recipientPayment}
lightningAddressOptions={lightningAddressOptions}
canLightningZap={canLightningZap}
onPaymentFlowComplete={(_result, paymentDetails) => {
openPostPaymentPrompt(
mergePostPaymentContext(
{ recipientPubkey: pubkey, referencedEvent: event },
{
amountMsat: paymentDetails?.amountMsat,
paytoUri: paymentDetails?.paytoUri
}
)
)
}}
onPostPaymentRequest={openPostPaymentPrompt}
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{!onPostPaymentRequest ? ( {postPaymentPrompt}
<PostPaymentMessagePrompt
open={postPaymentOpen}
onOpenChange={setPostPaymentOpen}
recipientPubkey={pubkey}
paymentContext={postPaymentContext}
/>
) : null}
</>
)
}
function ZapDialogContent({
open,
setOpen,
recipient,
event,
defaultAmount,
defaultComment,
recipientPayment,
lightningAddressOptions,
canLightningZap,
onPaymentFlowComplete,
onPostPaymentRequest
}: {
open: boolean
setOpen: Dispatch<SetStateAction<boolean>>
recipient: string
event?: NostrEvent
defaultAmount?: number
defaultComment?: string
recipientPayment: RecipientZapPaymentData
lightningAddressOptions: string[]
canLightningZap: boolean
onPaymentFlowComplete?: (
result: import('@/services/lightning.service').PaymentFlowResult,
paymentDetails?: { amountMsat?: number; paytoUri?: string }
) => void
onPostPaymentRequest?: (context: PostPaymentContext) => void
}) {
const { t, i18n } = useTranslation()
const { pubkey } = useNostr()
const paymentsOnly = !ZAP_SENDING_ENABLED
const { defaultZapSats, defaultZapComment } = useZap()
const allPaymentGroups = useMemo(() => {
if (!paymentsOnly) return []
const merged = sortMergedPaymentMethods(
mergePaymentMethods(
recipientPayment.paymentInfo,
recipientPayment.profile,
recipientPayment.profileEvent
)
)
return groupPaymentMethodsByDisplayType(merged)
}, [paymentsOnly, recipientPayment])
if (paymentsOnly) {
return (
<div
className="px-4 pb-4"
style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}
>
{allPaymentGroups.length > 0 ? (
<PaymentMethodsSection
groups={allPaymentGroups}
recipientPubkey={recipient}
referencedEvent={event}
offerTipNoticeOnClose={false}
onPostPaymentRequest={onPostPaymentRequest}
title={t('Payment methods')}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('No payment methods available for this profile')}
</p>
)}
</div>
)
}
const [sats, setSats] = useState(() => clampZapSats(defaultAmount ?? defaultZapSats))
const [comment, setComment] = useState(defaultComment ?? defaultZapComment)
const [zapping, setZapping] = useState(false)
const [selectedLightning, setSelectedLightning] = useState('')
const btcUsd = useBtcUsdRate()
const clampedSats = clampZapSats(sats)
const highlightLargeAmount = shouldHighlightLeadingSatsGroups(clampedSats)
const btcEquivalent = useMemo(() => formatBtcFromSats(clampedSats), [clampedSats])
const usdEquivalent = useMemo(() => formatUsdFromSats(clampedSats, btcUsd), [clampedSats, btcUsd])
const { alternativeGroups } = recipientPayment
useEffect(() => {
if (!open) return
setSelectedLightning(lightningAddressOptions[0] ?? '')
}, [open, lightningAddressOptions])
const zapAlternativePayments = useMemo(
() =>
prepareZapDialogAlternativePayments(
alternativeGroups,
canLightningZap ? clampedSats : ZAP_HIDE_BITCOIN_ALTS_MAX_SATS
),
[alternativeGroups, clampedSats, canLightningZap]
)
const hasAlternativePayments = zapAlternativePayments.groups.length > 0
const presetAmounts = useMemo(() => {
if (i18n.language.startsWith('zh')) {
return [
{ display: '21', val: 21 },
{ display: '66', val: 66 },
{ display: '210', val: 210 },
{ display: '666', val: 666 },
{ display: '1k', val: 1000 },
{ display: '2.1k', val: 2100 },
{ display: '6.6k', val: 6666 },
{ display: '10k', val: 10000 },
{ display: '21k', val: 21000 },
{ display: '66k', val: 66666 },
{ display: '100k', val: 100000 },
{ display: '210k', val: 210000 }
]
}
return [
{ display: '21', val: 21 },
{ display: '42', val: 42 },
{ display: '210', val: 210 },
{ display: '420', val: 420 },
{ display: '1k', val: 1000 },
{ display: '2.1k', val: 2100 },
{ display: '4.2k', val: 4200 },
{ display: '10k', val: 10000 },
{ display: '21k', val: 21000 },
{ display: '42k', val: 42000 },
{ display: '100k', val: 100000 },
{ display: '210k', val: 210000 }
]
}, [i18n.language])
const handleZap = async () => {
try {
if (!pubkey) {
throw new Error('You need to be logged in to zap')
}
setZapping(true)
const paytoUri = selectedLightning ? buildPaytoUri('lightning', selectedLightning) : undefined
const paymentDetails = {
amountMsat: clampedSats * 1000,
paytoUri
}
const closeZapDialog = () => setOpen(false)
const zapResult = await lightning.zap(
pubkey,
event ?? recipient,
clampedSats,
comment,
closeZapDialog,
(result) => onPaymentFlowComplete?.(result, paymentDetails),
{
address: selectedLightning || undefined,
candidates: lightningAddressOptions.length > 0 ? lightningAddressOptions : undefined
}
)
if (!zapResult) {
return
}
if (event) {
noteStatsService.addZap(pubkey, event.id, zapResult.invoice, clampedSats, comment)
}
} catch (error) {
toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
} finally {
setZapping(false)
}
}
if (!canLightningZap) {
return (
<div
className="px-4 pb-4"
style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}
>
{hasAlternativePayments ? (
<PaymentMethodsSection
groups={zapAlternativePayments.groups}
recipientPubkey={recipient}
referencedEvent={event}
offerTipNoticeOnClose={false}
onPostPaymentRequest={onPostPaymentRequest}
title={t('Payment methods')}
headerHelpText={
zapAlternativePayments.showBitcoinOnChainHint
? t('Tips above 10k sats can use Bitcoin on-chain.')
: undefined
}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('No payment methods available for this profile')}
</p>
)}
</div>
)
}
return (
<div>
<div className="space-y-4">
{/* Sats slider or input */}
<div className="flex flex-col items-center px-4">
<div
className="mb-1 flex min-h-[1.125rem] flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground tabular-nums"
aria-live="polite"
>
<span
className={cn(
highlightLargeAmount && superchatAmountHighlightClass
)}
>
{btcEquivalent}
</span>
{usdEquivalent != null ? (
<>
<span className="text-muted-foreground/40" aria-hidden>
·
</span>
<span>{usdEquivalent}</span>
</> </>
) : null}
</div>
<ZapSatsAmountInput sats={sats} onSatsChange={setSats} />
<Label htmlFor="sats">{t('Sats')}</Label>
</div>
{/* Preset sats buttons */}
<div className="grid grid-cols-6 gap-2 px-4">
{presetAmounts.map(({ display, val }) => (
<Button variant="secondary" key={val} onClick={() => setSats(clampZapSats(val))}>
{display}
</Button>
))}
</div>
{/* Comment input */}
<div className="px-4">
<Label htmlFor="comment">{t('Zap lnurl comment label')}</Label>
<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
className="space-y-3 border-t border-border bg-background px-4 pt-3"
style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}
>
<p className="text-xs leading-relaxed text-muted-foreground">{t('Zap superchat flow hint')}</p>
<div className="min-w-0 space-y-1.5">
<Label
htmlFor={
lightningAddressOptions.length > 1 ? 'zap-lightning-address' : undefined
}
>
{t('Lightning address for zap')}
</Label>
{lightningAddressOptions.length === 1 ? (
<p
id="zap-lightning-address"
className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground"
>
<span className={cn('shrink-0 text-lg leading-none', superchatLightningAccentClass)} aria-hidden>
</span>
<span className="min-w-0 break-all">{lightningAddressOptions[0]}</span>
</p>
) : (
<Select value={selectedLightning} onValueChange={setSelectedLightning}>
<SelectTrigger id="zap-lightning-address" className="min-w-0 gap-2">
<SelectValue placeholder={t('Select lightning address')}>
{selectedLightning ? (
<span className="flex min-w-0 items-center gap-2">
<span
className={cn('shrink-0 text-lg leading-none', superchatLightningAccentClass)}
aria-hidden
>
</span>
<span className="min-w-0 truncate">{selectedLightning}</span>
</span>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{lightningAddressOptions.map((addr) => (
<SelectItem key={addr} value={addr} className="break-all">
<span className="flex items-start gap-2">
<span
className={cn('shrink-0 text-lg leading-none', superchatLightningAccentClass)}
aria-hidden
>
</span>
<span className="min-w-0 break-all">{addr}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Button onClick={handleZap} className="w-full">
{zapping && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}{' '}
{t('Zap n sats', { n: formatSatsGrouped(clampedSats) })}
</Button>
{hasAlternativePayments ? (
<PaymentMethodsSection
groups={zapAlternativePayments.groups}
recipientPubkey={recipient}
referencedEvent={event}
offerTipNoticeOnClose={false}
onPostPaymentRequest={onPostPaymentRequest}
title={t('Other payment methods')}
headerHelpText={
zapAlternativePayments.showBitcoinOnChainHint
? t('Tips above 10k sats can use Bitcoin on-chain.')
: undefined
}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/>
) : null}
</div>
</div>
) )
} }

3
src/constants.ts

@ -638,9 +638,6 @@ export const ExtendedKind = {
EVENTS_I_MUTED_NOTIFICATIONS_LIST: 19132 EVENTS_I_MUTED_NOTIFICATIONS_LIST: 19132
} }
/** NIP-57 send flow (zap dialog, note zap button, public zap receipts). Payment targets stay visible when false. */
export const ZAP_SENDING_ENABLED = false
/** WebLN wallet connect + pay BOLT11 from LNURL-pay invoices (no NIP-57 zaps). */ /** WebLN wallet connect + pay BOLT11 from LNURL-pay invoices (no NIP-57 zaps). */
export const LIGHTNING_WALLET_PAY_ENABLED = true export const LIGHTNING_WALLET_PAY_ENABLED = true

14
src/hooks/useRecipientAlternativePayments.test.ts

@ -1,19 +1,17 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { buildRecipientZapPaymentData, mergeRecipientZapPaymentData } from './useRecipientAlternativePayments' import { buildRecipientPaymentData, mergeRecipientPaymentData } from './useRecipientAlternativePayments'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
describe('mergeRecipientZapPaymentData', () => { describe('mergeRecipientPaymentData', () => {
it('keeps lightning from feed profile when relay fetch is still empty', () => { it('keeps lightning from feed profile when relay fetch is still empty', () => {
const feedProfile = { const feedProfile = {
pubkey: 'aa'.repeat(32), pubkey: 'aa'.repeat(32),
lightningAddress: 'user@example.com' lightningAddress: 'user@example.com'
} as TProfile } as TProfile
const partial = buildRecipientZapPaymentData(null, feedProfile, null) const partial = buildRecipientPaymentData(null, feedProfile, null)
const empty = buildRecipientZapPaymentData(null, null, null) const empty = buildRecipientPaymentData(null, null, null)
const merged = mergeRecipientZapPaymentData(partial, empty) const merged = mergeRecipientPaymentData(partial, empty)
expect(merged.canReceiveTip).toBe(true) expect(merged.canReceiveTip).toBe(true)
expect( expect(merged.profile?.lightningAddress).toBe('user@example.com')
merged.alternativeGroups.length + (partial.canReceiveTip ? 1 : 0)
).toBeGreaterThan(0)
}) })
}) })

67
src/hooks/useRecipientAlternativePayments.ts

@ -1,11 +1,4 @@
import { import { recipientHasAnyPaymentOptions } from '@/lib/merge-payment-methods'
getAlternativePaymentMethods,
groupPaymentMethodsByDisplayType,
mergePaymentMethods,
recipientHasAnyPaymentOptions,
sortMergedPaymentMethods,
type PaymentMethodGroup
} from '@/lib/merge-payment-methods'
import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata'
import client, { replaceableEventService } from '@/services/client.service' import client, { replaceableEventService } from '@/services/client.service'
import { kinds, type Event } from 'nostr-tools' import { kinds, type Event } from 'nostr-tools'
@ -13,34 +6,39 @@ import { useEffect, useMemo, useState } from 'react'
import type { TPaymentInfo } from '@/types' import type { TPaymentInfo } from '@/types'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
export type RecipientZapPaymentData = { export type RecipientPaymentData = {
paymentInfo: TPaymentInfo | null paymentInfo: TPaymentInfo | null
profile: TProfile | null profile: TProfile | null
profileEvent: Event | null profileEvent: Event | null
alternativeGroups: PaymentMethodGroup[] /** Any payto / Lightning target on kind 0 or 10133. */
/** Any payto / Lightning target on kind 0 or 10133 — used to enable zap UI. */
canReceiveTip: boolean canReceiveTip: boolean
} }
export function buildRecipientZapPaymentData( /** @deprecated Use {@link RecipientPaymentData} */
export type RecipientZapPaymentData = RecipientPaymentData
export function buildRecipientPaymentData(
paymentInfo: TPaymentInfo | null, paymentInfo: TPaymentInfo | null,
profile: TProfile | null, profile: TProfile | null,
profileEvent: Event | null profileEvent: Event | null
): RecipientZapPaymentData { ): RecipientPaymentData {
const canReceiveTip = recipientHasAnyPaymentOptions(paymentInfo, profile, profileEvent) return {
const merged = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile, profileEvent)) paymentInfo,
const alts = getAlternativePaymentMethods(merged) profile,
const alternativeGroups = groupPaymentMethodsByDisplayType(alts) profileEvent,
return { paymentInfo, profile, profileEvent, alternativeGroups, canReceiveTip } canReceiveTip: recipientHasAnyPaymentOptions(paymentInfo, profile, profileEvent)
}
} }
/** Combine feed/profile snapshot with fresher relay data (dialog opens fast, then enriches). */ /** @deprecated Use {@link buildRecipientPaymentData} */
export function mergeRecipientZapPaymentData( export const buildRecipientZapPaymentData = buildRecipientPaymentData
partial: RecipientZapPaymentData | null | undefined,
fresh: RecipientZapPaymentData | null | undefined export function mergeRecipientPaymentData(
): RecipientZapPaymentData { partial: RecipientPaymentData | null | undefined,
fresh: RecipientPaymentData | null | undefined
): RecipientPaymentData {
if (!partial) { if (!partial) {
return fresh ?? buildRecipientZapPaymentData(null, null, null) return fresh ?? buildRecipientPaymentData(null, null, null)
} }
if (!fresh) return partial if (!fresh) return partial
@ -50,9 +48,12 @@ export function mergeRecipientZapPaymentData(
: (partial.profile ?? fresh.profile) : (partial.profile ?? fresh.profile)
const paymentInfo = pickRicherPaymentInfo(partial.paymentInfo, fresh.paymentInfo) const paymentInfo = pickRicherPaymentInfo(partial.paymentInfo, fresh.paymentInfo)
return buildRecipientZapPaymentData(paymentInfo, profile ?? null, profileEvent) return buildRecipientPaymentData(paymentInfo, profile ?? null, profileEvent)
} }
/** @deprecated Use {@link mergeRecipientPaymentData} */
export const mergeRecipientZapPaymentData = mergeRecipientPaymentData
function pickRicherPaymentInfo( function pickRicherPaymentInfo(
a: TPaymentInfo | null | undefined, a: TPaymentInfo | null | undefined,
b: TPaymentInfo | null | undefined b: TPaymentInfo | null | undefined
@ -64,11 +65,10 @@ function pickRicherPaymentInfo(
return b ?? a ?? null return b ?? a ?? null
} }
/** Kind 10133 + profile payto targets except the Lightning address used for zapping. */ export function useRecipientPaymentData(
export function useRecipientZapPaymentData(
recipientPubkey: string | undefined, recipientPubkey: string | undefined,
enabled: boolean enabled: boolean
): RecipientZapPaymentData { ): RecipientPaymentData {
const [paymentInfo, setPaymentInfo] = useState<TPaymentInfo | null>(null) const [paymentInfo, setPaymentInfo] = useState<TPaymentInfo | null>(null)
const [profile, setProfile] = useState<TProfile | null>(null) const [profile, setProfile] = useState<TProfile | null>(null)
const [profileEvent, setProfileEvent] = useState<Event | null>(null) const [profileEvent, setProfileEvent] = useState<Event | null>(null)
@ -105,15 +105,10 @@ export function useRecipientZapPaymentData(
}, [recipientPubkey, enabled]) }, [recipientPubkey, enabled])
return useMemo( return useMemo(
() => buildRecipientZapPaymentData(paymentInfo, profile, profileEvent), () => buildRecipientPaymentData(paymentInfo, profile, profileEvent),
[paymentInfo, profile, profileEvent] [paymentInfo, profile, profileEvent]
) )
} }
/** @deprecated Use {@link useRecipientZapPaymentData} */ /** @deprecated Use {@link useRecipientPaymentData} */
export function useRecipientAlternativePayments( export const useRecipientZapPaymentData = useRecipientPaymentData
recipientPubkey: string | undefined,
enabled: boolean
): PaymentMethodGroup[] {
return useRecipientZapPaymentData(recipientPubkey, enabled).alternativeGroups
}

25
src/hooks/useSatsFiatRates.ts

@ -0,0 +1,25 @@
import { fetchBtcUsdRate } from '@/lib/btc-usd-rate'
import { fetchXmrUsdRate } from '@/lib/xmr-usd-rate'
import { useEffect, useState } from 'react'
export type SatsFiatRates = {
btcUsd: number | null
xmrUsd: number | null
}
/** BTC/USD and XMR/USD spot rates for sats amount hints (null while loading or on failure). */
export function useSatsFiatRates(): SatsFiatRates {
const [rates, setRates] = useState<SatsFiatRates>({ btcUsd: null, xmrUsd: null })
useEffect(() => {
let cancelled = false
void Promise.all([fetchBtcUsdRate(), fetchXmrUsdRate()]).then(([btcUsd, xmrUsd]) => {
if (!cancelled) setRates({ btcUsd, xmrUsd })
})
return () => {
cancelled = true
}
}, [])
return rates
}

32
src/hooks/useSenderPaytoTypes.ts

@ -0,0 +1,32 @@
import { getProfileFromEvent } from '@/lib/event-metadata'
import { collectPaytoTypeFamiliesFromProfile } from '@/lib/merge-payment-methods'
import { loadAuthorReplaceablesFromLocalCache } from '@/lib/profile-author-replaceables-cache'
import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useState } from 'react'
/** Payto families configured on the logged-in viewer (for ordering recipient payment lists). */
export function useSenderPaytoTypes(enabled = true): Set<string> {
const { pubkey, profileEvent: accountProfileEvent } = useNostr()
const [families, setFamilies] = useState<Set<string>>(() => new Set())
useEffect(() => {
if (!enabled || !pubkey) {
setFamilies(new Set())
return
}
let cancelled = false
void loadAuthorReplaceablesFromLocalCache(pubkey).then(({ paymentInfo, profileEvent }) => {
if (cancelled) return
const event = profileEvent ?? accountProfileEvent ?? null
const profile = event ? getProfileFromEvent(event) : null
setFamilies(collectPaytoTypeFamiliesFromProfile(paymentInfo, profile, event))
})
return () => {
cancelled = true
}
}, [enabled, pubkey, accountProfileEvent])
return families
}

1
src/i18n/locales/cs.ts

@ -153,6 +153,7 @@ export default {
'Copy invoice': 'Copy invoice', 'Copy invoice': 'Copy invoice',
'Failed to create invoice': 'Failed to create invoice', 'Failed to create invoice': 'Failed to create invoice',
'Amount (sats)': 'Amount (sats)', 'Amount (sats)': 'Amount (sats)',
'Approximate equivalent:': 'Approximate equivalent:',
'Pay with connected wallet': 'Pay with connected wallet', 'Pay with connected wallet': 'Pay with connected wallet',
'Payment sent': 'Payment sent', 'Payment sent': 'Payment sent',
'Pay via a BOLT11 invoice or copy the Lightning address': 'Pay via a BOLT11 invoice or copy the Lightning address':

1
src/i18n/locales/de.ts

@ -155,6 +155,7 @@ export default {
'Copy invoice': 'Copy invoice', 'Copy invoice': 'Copy invoice',
'Failed to create invoice': 'Failed to create invoice', 'Failed to create invoice': 'Failed to create invoice',
'Amount (sats)': 'Amount (sats)', 'Amount (sats)': 'Amount (sats)',
'Approximate equivalent:': 'Approximate equivalent:',
'Pay with connected wallet': 'Pay with connected wallet', 'Pay with connected wallet': 'Pay with connected wallet',
'Payment sent': 'Payment sent', 'Payment sent': 'Payment sent',
'Pay via a BOLT11 invoice or copy the Lightning address': 'Pay via a BOLT11 invoice or copy the Lightning address':

1
src/i18n/locales/en.ts

@ -151,6 +151,7 @@ export default {
'Copy invoice': 'Copy invoice', 'Copy invoice': 'Copy invoice',
'Failed to create invoice': 'Failed to create invoice', 'Failed to create invoice': 'Failed to create invoice',
'Amount (sats)': 'Amount (sats)', 'Amount (sats)': 'Amount (sats)',
'Approximate equivalent:': 'Approximate equivalent:',
'Pay with connected wallet': 'Pay with connected wallet', 'Pay with connected wallet': 'Pay with connected wallet',
'Payment sent': 'Payment sent', 'Payment sent': 'Payment sent',
'Pay via a BOLT11 invoice or copy the Lightning address': 'Pay via a BOLT11 invoice or copy the Lightning address':

1
src/i18n/locales/es.ts

@ -153,6 +153,7 @@ export default {
'Copy invoice': 'Copy invoice', 'Copy invoice': 'Copy invoice',
'Failed to create invoice': 'Failed to create invoice', 'Failed to create invoice': 'Failed to create invoice',
'Amount (sats)': 'Amount (sats)', 'Amount (sats)': 'Amount (sats)',
'Approximate equivalent:': 'Approximate equivalent:',
'Pay with connected wallet': 'Pay with connected wallet', 'Pay with connected wallet': 'Pay with connected wallet',
'Payment sent': 'Payment sent', 'Payment sent': 'Payment sent',
'Pay via a BOLT11 invoice or copy the Lightning address': 'Pay via a BOLT11 invoice or copy the Lightning address':

1
src/i18n/locales/fr.ts

@ -153,6 +153,7 @@ export default {
'Copy invoice': 'Copy invoice', 'Copy invoice': 'Copy invoice',
'Failed to create invoice': 'Failed to create invoice', 'Failed to create invoice': 'Failed to create invoice',
'Amount (sats)': 'Amount (sats)', 'Amount (sats)': 'Amount (sats)',
'Approximate equivalent:': 'Approximate equivalent:',
'Pay with connected wallet': 'Pay with connected wallet', 'Pay with connected wallet': 'Pay with connected wallet',
'Payment sent': 'Payment sent', 'Payment sent': 'Payment sent',
'Pay via a BOLT11 invoice or copy the Lightning address': 'Pay via a BOLT11 invoice or copy the Lightning address':

1
src/i18n/locales/nl.ts

@ -153,6 +153,7 @@ export default {
'Copy invoice': 'Copy invoice', 'Copy invoice': 'Copy invoice',
'Failed to create invoice': 'Failed to create invoice', 'Failed to create invoice': 'Failed to create invoice',
'Amount (sats)': 'Amount (sats)', 'Amount (sats)': 'Amount (sats)',
'Approximate equivalent:': 'Approximate equivalent:',
'Pay with connected wallet': 'Pay with connected wallet', 'Pay with connected wallet': 'Pay with connected wallet',
'Payment sent': 'Payment sent', 'Payment sent': 'Payment sent',
'Pay via a BOLT11 invoice or copy the Lightning address': 'Pay via a BOLT11 invoice or copy the Lightning address':

1
src/i18n/locales/pl.ts

@ -153,6 +153,7 @@ export default {
'Copy invoice': 'Copy invoice', 'Copy invoice': 'Copy invoice',
'Failed to create invoice': 'Failed to create invoice', 'Failed to create invoice': 'Failed to create invoice',
'Amount (sats)': 'Amount (sats)', 'Amount (sats)': 'Amount (sats)',
'Approximate equivalent:': 'Approximate equivalent:',
'Pay with connected wallet': 'Pay with connected wallet', 'Pay with connected wallet': 'Pay with connected wallet',
'Payment sent': 'Payment sent', 'Payment sent': 'Payment sent',
'Pay via a BOLT11 invoice or copy the Lightning address': 'Pay via a BOLT11 invoice or copy the Lightning address':

1
src/i18n/locales/ru.ts

@ -153,6 +153,7 @@ export default {
'Copy invoice': 'Copy invoice', 'Copy invoice': 'Copy invoice',
'Failed to create invoice': 'Failed to create invoice', 'Failed to create invoice': 'Failed to create invoice',
'Amount (sats)': 'Amount (sats)', 'Amount (sats)': 'Amount (sats)',
'Approximate equivalent:': 'Approximate equivalent:',
'Pay with connected wallet': 'Pay with connected wallet', 'Pay with connected wallet': 'Pay with connected wallet',
'Payment sent': 'Payment sent', 'Payment sent': 'Payment sent',
'Pay via a BOLT11 invoice or copy the Lightning address': 'Pay via a BOLT11 invoice or copy the Lightning address':

1
src/i18n/locales/tr.ts

@ -153,6 +153,7 @@ export default {
'Copy invoice': 'Copy invoice', 'Copy invoice': 'Copy invoice',
'Failed to create invoice': 'Failed to create invoice', 'Failed to create invoice': 'Failed to create invoice',
'Amount (sats)': 'Amount (sats)', 'Amount (sats)': 'Amount (sats)',
'Approximate equivalent:': 'Approximate equivalent:',
'Pay with connected wallet': 'Pay with connected wallet', 'Pay with connected wallet': 'Pay with connected wallet',
'Payment sent': 'Payment sent', 'Payment sent': 'Payment sent',
'Pay via a BOLT11 invoice or copy the Lightning address': 'Pay via a BOLT11 invoice or copy the Lightning address':

1
src/i18n/locales/zh.ts

@ -153,6 +153,7 @@ export default {
'Copy invoice': 'Copy invoice', 'Copy invoice': 'Copy invoice',
'Failed to create invoice': 'Failed to create invoice', 'Failed to create invoice': 'Failed to create invoice',
'Amount (sats)': 'Amount (sats)', 'Amount (sats)': 'Amount (sats)',
'Approximate equivalent:': 'Approximate equivalent:',
'Pay with connected wallet': 'Pay with connected wallet', 'Pay with connected wallet': 'Pay with connected wallet',
'Payment sent': 'Payment sent', 'Payment sent': 'Payment sent',
'Pay via a BOLT11 invoice or copy the Lightning address': 'Pay via a BOLT11 invoice or copy the Lightning address':

161
src/lib/merge-payment-methods.test.ts

@ -3,13 +3,11 @@ import { isLightningPaytoType, isZappableLightningPaytoType } from '@/lib/payto-
import { getProfileFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent } from '@/lib/event-metadata'
import { kinds, type Event } from 'nostr-tools' import { kinds, type Event } from 'nostr-tools'
import { import {
buildOrderedZapLightningAddresses,
getAlternativePaymentMethods,
groupPaymentMethodsByDisplayType, groupPaymentMethodsByDisplayType,
prepareZapDialogAlternativePayments, groupPaymentMethodsForDisplay,
sortPaymentMethodGroupsForSender,
mergePaymentMethods, mergePaymentMethods,
normalizeLightningAuthority, normalizeLightningAuthority
recipientHasAnyPaymentOptions
} from './merge-payment-methods' } from './merge-payment-methods'
describe('normalizeLightningAuthority', () => { describe('normalizeLightningAuthority', () => {
@ -62,79 +60,6 @@ describe('isZappableLightningPaytoType', () => {
}) })
}) })
describe('getAlternativePaymentMethods', () => {
it('includes BIP-353 in zap dialog other payments', () => {
const merged = mergePaymentMethods(
{
methods: [
{
type: 'lightning',
authority: 'zap@example.com',
payto: 'payto://lightning/zap@example.com',
displayType: 'Lightning Network'
},
{
type: 'bip353',
authority: 'dns@example.com',
payto: 'payto://bip353/dns@example.com',
displayType: 'DNS Payment Instructions (BIP-353)'
}
]
},
null
)
const alts = getAlternativePaymentMethods(merged)
expect(alts.some((m) => m.type === 'bip353')).toBe(true)
expect(alts.some((m) => m.type === 'lightning')).toBe(false)
})
})
describe('buildOrderedZapLightningAddresses', () => {
it('excludes BIP-353 from zap selector (payment options only)', () => {
const addrs = buildOrderedZapLightningAddresses({
profileEvent: null,
paymentInfo: {
methods: [
{
type: 'bip353',
authority: 'user@example.com',
payto: 'payto://bip353/user@example.com',
displayType: 'DNS Payment Instructions (BIP-353)'
},
{
type: 'lightning',
authority: 'zap@example.com',
payto: 'payto://lightning/zap@example.com',
displayType: 'Lightning Network'
}
]
}
})
expect(addrs).toEqual(['zap@example.com'])
})
it('includes lud16 from kind 0 JSON when not in tags', () => {
const profileEvent = {
kind: kinds.Metadata,
pubkey: 'aa'.repeat(32),
created_at: 1,
tags: [] as string[][],
content: JSON.stringify({ lud16: 'user@example.com' }),
id: 'bb'.repeat(64),
sig: 'cc'.repeat(128)
} as Event
const addrs = buildOrderedZapLightningAddresses({
profileEvent,
paymentInfo: null
})
expect(addrs).toEqual(['user@example.com'])
expect(recipientHasAnyPaymentOptions(null, getProfileFromEvent(profileEvent), profileEvent)).toBe(
true
)
})
})
describe('mergePaymentMethods kind 0 about coin lines', () => { describe('mergePaymentMethods kind 0 about coin lines', () => {
it('imports XMR from about text', () => { it('imports XMR from about text', () => {
const addr = const addr =
@ -261,46 +186,70 @@ describe('mergePaymentMethods kind 0 cryptocurrency_addresses', () => {
}) })
}) })
describe('prepareZapDialogAlternativePayments', () => { describe('sortPaymentMethodGroupsForSender', () => {
const groups = [ const groups = [
{ {
displayType: 'Tether (USDT)', displayType: 'Geyser Fund',
methods: [{ type: 'usdt', authority: '0xusdt', displayType: 'Tether (USDT)' }] methods: [{ type: 'geyser', authority: 'proj', displayType: 'Geyser Fund' }]
}, },
{ {
displayType: 'Bitcoin', displayType: 'Monero',
methods: [{ type: 'bitcoin', authority: 'bc1qtest', displayType: 'Bitcoin' }] methods: [{ type: 'monero', authority: '4xmr', displayType: 'Monero' }]
},
{
displayType: 'Liquid Bitcoin (LBTC)',
methods: [{ type: 'lbtc', authority: 'lq1…', displayType: 'Liquid Bitcoin (LBTC)' }]
}, },
{ {
displayType: 'Monero', displayType: 'Bitcoin',
methods: [{ type: 'monero', authority: '4…', displayType: 'Monero' }] methods: [{ type: 'bitcoin', authority: 'bc1q', displayType: 'Bitcoin' }]
}, },
{ {
displayType: 'USD Coin', displayType: 'Ko-fi',
methods: [{ type: 'usdc', authority: '0xusdc', displayType: 'USD Coin' }] methods: [{ type: 'ko-fi', authority: 'user', displayType: 'Ko-fi' }]
} }
] ]
it('hides Bitcoin-category methods below 10k sats', () => { it('puts shared families first in lightning → monero → bitcoin → geyser order', () => {
const { groups: out, showBitcoinOnChainHint } = prepareZapDialogAlternativePayments(groups, 9999) const sorted = sortPaymentMethodGroupsForSender(groups, new Set(['monero', 'bitcoin']))
expect(showBitcoinOnChainHint).toBe(false) expect(sorted.map((g) => g.displayType)).toEqual([
expect(out.some((g) => g.methods.some((m) => m.type === 'bitcoin'))).toBe(false) 'Monero',
expect(out[0].displayType).toBe('Liquid Bitcoin (LBTC)') 'Bitcoin',
expect(out[1].displayType).toBe('Monero') 'Geyser Fund',
'Ko-fi'
])
}) })
it('puts Bitcoin first with hint at 10k sats and above', () => { it('orders geyser before other alphabetic types when both are shared', () => {
const { groups: out, showBitcoinOnChainHint } = prepareZapDialogAlternativePayments(groups, 10_000) const sorted = sortPaymentMethodGroupsForSender(groups, new Set(['geyser', 'ko-fi']))
expect(showBitcoinOnChainHint).toBe(true) expect(sorted.map((g) => g.displayType)).toEqual([
expect(out[0].displayType).toBe('Bitcoin') 'Geyser Fund',
expect(out[0].highlighted).toBe(true) 'Ko-fi',
expect(out[1].displayType).toBe('Liquid Bitcoin (LBTC)') 'Monero',
expect(out[2].displayType).toBe('Monero') 'Bitcoin'
expect(out[3].displayType).toBe('Tether (USDT)') ])
expect(out[4].displayType).toBe('USD Coin') })
})
describe('groupPaymentMethodsForDisplay', () => {
it('applies sender-aware ordering to merged methods', () => {
const methods = mergePaymentMethods(
{
methods: [
{
type: 'geyser',
authority: 'a',
payto: 'payto://geyser/a',
displayType: 'Geyser Fund'
},
{
type: 'monero',
authority: '4xmr',
payto: 'payto://monero/4xmr',
displayType: 'Monero'
}
]
},
null,
null
)
const groups = groupPaymentMethodsForDisplay(methods, new Set(['geyser']))
expect(groups.map((g) => g.displayType)).toEqual(['Geyser Fund', 'Monero'])
}) })
}) })

181
src/lib/merge-payment-methods.ts

@ -1,12 +1,11 @@
import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' import { getPaymentInfoFromEvent } from '@/lib/event-metadata'
import { import {
buildPaytoUri, buildPaytoUri,
getCanonicalPaytoType, getCanonicalPaytoType,
getPaytoEditorTypeLabel, getPaytoEditorTypeLabel,
getPaytoTypeInfo, getPaytoTypeInfo,
isKnownPaytoType, isKnownPaytoType,
isLightningPaytoType, isLightningPaytoType
isZappableLightningPaytoType
} from '@/lib/payto' } from '@/lib/payto'
import { extractKind0PaymentMethodsFromProfileJson } from '@/lib/payto-kind0-import' import { extractKind0PaymentMethodsFromProfileJson } from '@/lib/payto-kind0-import'
import { normalizePaypalAuthority } from '@/lib/payto-paypal-url' import { normalizePaypalAuthority } from '@/lib/payto-paypal-url'
@ -40,14 +39,6 @@ type PaymentMethodInput = {
export type PaymentMethodGroup = { export type PaymentMethodGroup = {
displayType: string displayType: string
methods: MergedPaymentMethod[] methods: MergedPaymentMethod[]
/** Zap dialog: emphasize on-chain Bitcoin when tip is ≥ 10k sats. */
highlighted?: boolean
}
export type ZapDialogAlternativePayments = {
groups: PaymentMethodGroup[]
/** Show banner when on-chain Bitcoin targets are listed (≥ 10k sats). */
showBitcoinOnChainHint: boolean
} }
/** Normalize lightning/LUD-16 authority to a canonical form for deduplication. */ /** Normalize lightning/LUD-16 authority to a canonical form for deduplication. */
@ -79,63 +70,94 @@ function resolveLightningAuthority(a: string, b?: string): string {
return normalizeLightningAuthority(preferred) || preferred.trim() return normalizeLightningAuthority(preferred) || preferred.trim()
} }
/** Below this zap size, on-chain Bitcoin payto targets are hidden in the zap dialog. */ /** Preferred group order when sorting payment targets (by payto “family”). */
export const ZAP_HIDE_BITCOIN_ALTS_MAX_SATS = 10_000 const PREFERRED_PAYMENT_GROUP_FAMILIES = ['lightning', 'monero', 'bitcoin', 'geyser'] as const
/** On-chain Bitcoin family (not Lightning / Liquid layer types). */ /** Map payto types to a family for sender/recipient overlap (e.g. BIP-353 → lightning). */
export function isBitcoinCategoryPaytoType(type: string): boolean { export function paytoTypeFamily(type: string): string {
return getPaytoTypeInfo(getCanonicalPaytoType(type))?.category === 'bitcoin' const canonical = getCanonicalPaytoType(type)
if (isLightningPaytoType(canonical)) return 'lightning'
return canonical
} }
/** Sort key for zap dialog “other payment” groups (lower = higher in list). */ export function collectPaytoTypeFamilies(methods: MergedPaymentMethod[]): Set<string> {
function zapAlternativeGroupSortRank(group: PaymentMethodGroup): number { return new Set(methods.map((m) => paytoTypeFamily(m.type)))
const types = group.methods.map((m) => getCanonicalPaytoType(m.type))
if (types.some((t) => isBitcoinCategoryPaytoType(t))) return -1000
if (types.some((t) => getPaytoTypeInfo(t)?.category === 'bitcoin-layer' || t === 'liquid' || t === 'lbtc')) {
return 0
}
if (types.some((t) => t === 'monero')) return 1
if (types.some((t) => t === 'usdt')) return 2
if (types.some((t) => t === 'usdc')) return 3
return 10
} }
/** Filter, order, and annotate payto groups for the zap dialog “other payment methods” block. */ export function collectPaytoTypeFamiliesFromProfile(
export function prepareZapDialogAlternativePayments( paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null,
groups: PaymentMethodGroup[], profile: TProfile | null,
zapSats: number profileEvent?: Event | null
): ZapDialogAlternativePayments { ): Set<string> {
const showBitcoin = zapSats >= ZAP_HIDE_BITCOIN_ALTS_MAX_SATS return collectPaytoTypeFamilies(mergePaymentMethods(paymentInfo, profile, profileEvent))
}
const filtered = showBitcoin
? groups
: groups
.map((group) => ({
...group,
methods: group.methods.filter((m) => !isBitcoinCategoryPaytoType(m.type))
}))
.filter((group) => group.methods.length > 0)
const prepared = filtered
.map((group) => ({
...group,
highlighted:
showBitcoin && group.methods.some((m) => isBitcoinCategoryPaytoType(m.type))
}))
.sort((a, b) => zapAlternativeGroupSortRank(a) - zapAlternativeGroupSortRank(b))
return { function paymentMethodGroupFamilies(group: PaymentMethodGroup): string[] {
groups: prepared, return [...new Set(group.methods.map((m) => paytoTypeFamily(m.type)))]
showBitcoinOnChainHint: showBitcoin && prepared.some((g) => g.highlighted) }
function groupSharesSenderPaytoFamily(group: PaymentMethodGroup, senderFamilies: Set<string>): boolean {
if (senderFamilies.size === 0) return false
return paymentMethodGroupFamilies(group).some((family) => senderFamilies.has(family))
}
function groupPreferenceRank(group: PaymentMethodGroup): number {
let best: number = PREFERRED_PAYMENT_GROUP_FAMILIES.length
for (const family of paymentMethodGroupFamilies(group)) {
const idx = PREFERRED_PAYMENT_GROUP_FAMILIES.indexOf(
family as (typeof PREFERRED_PAYMENT_GROUP_FAMILIES)[number]
)
if (idx >= 0 && idx < best) best = idx
} }
return best
}
function comparePaymentMethodGroups(a: PaymentMethodGroup, b: PaymentMethodGroup): number {
const rankA = groupPreferenceRank(a)
const rankB = groupPreferenceRank(b)
if (rankA !== rankB) return rankA - rankB
const primaryA = a.methods[0]?.type ?? ''
const primaryB = b.methods[0]?.type ?? ''
const subA = paytoPaymentSortRank(primaryA)
const subB = paytoPaymentSortRank(primaryB)
if (subA !== subB) return subA - subB
return a.displayType.localeCompare(b.displayType, undefined, { sensitivity: 'base' })
} }
/** @deprecated Use {@link prepareZapDialogAlternativePayments} */ /** Sort groups: lightning → monero → bitcoin → geyser → alphabetical (within bitcoin-layer etc.). */
export function filterPaymentMethodGroupsForZapAmount( export function sortPaymentMethodGroupsByPreference(groups: PaymentMethodGroup[]): PaymentMethodGroup[] {
return [...groups].sort(comparePaymentMethodGroups)
}
/**
* When the viewer shares payto families with the recipient, list those groups first;
* then apply {@link sortPaymentMethodGroupsByPreference} within each section.
*/
export function sortPaymentMethodGroupsForSender(
groups: PaymentMethodGroup[], groups: PaymentMethodGroup[],
zapSats: number senderPaytoFamilies?: Iterable<string> | null
): PaymentMethodGroup[] { ): PaymentMethodGroup[] {
return prepareZapDialogAlternativePayments(groups, zapSats).groups const senderSet = senderPaytoFamilies
? new Set([...senderPaytoFamilies].map((t) => paytoTypeFamily(t)))
: null
if (!senderSet || senderSet.size === 0) {
return sortPaymentMethodGroupsByPreference(groups)
}
const shared: PaymentMethodGroup[] = []
const other: PaymentMethodGroup[] = []
for (const group of groups) {
if (groupSharesSenderPaytoFamily(group, senderSet)) shared.push(group)
else other.push(group)
}
return [
...sortPaymentMethodGroupsByPreference(shared),
...sortPaymentMethodGroupsByPreference(other)
]
} }
/** Bitcoin-layer first, then on-chain Bitcoin family, then everything else. */ /** Bitcoin-layer first, then on-chain Bitcoin family, then everything else. */
@ -534,41 +556,14 @@ export function groupPaymentMethodsByDisplayType(methods: MergedPaymentMethod[])
return order.map((key) => ({ displayType: key, methods: groups.get(key) ?? [] })) return order.map((key) => ({ displayType: key, methods: groups.get(key) ?? [] }))
} }
/** /** Group by display type, then sort for display (shared types with sender first when provided). */
* Ordered Lightning targets for zaps: profile (kind 0) event order, then payment (kind 10133). export function groupPaymentMethodsForDisplay(
* Optional `preferredAddress` is moved to the front. methods: MergedPaymentMethod[],
*/ senderPaytoFamilies?: Iterable<string> | null
export function buildOrderedZapLightningAddresses(opts: { ): PaymentMethodGroup[] {
profileEvent?: Event | null return sortPaymentMethodGroupsForSender(
/** Parsed kind 0 when the event is not loaded yet (e.g. feed profile row). */ groupPaymentMethodsByDisplayType(methods),
profile?: TProfile | null senderPaytoFamilies
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null )
preferredAddress?: string | null
}): string[] {
const ev = opts.profileEvent
const profile =
ev?.kind === kinds.Metadata ? getProfileFromEvent(ev) : (opts.profile ?? null)
const addrs = mergePaymentMethods(opts.paymentInfo, profile, ev)
.filter((m) => isZappableLightningPaytoType(m.type))
.map((m) => m.authority)
return prioritizeZapLightningAddress(addrs, opts.preferredAddress ?? undefined)
}
/** Move `preferred` to the front when present; append if not already listed. */
export function prioritizeZapLightningAddress(candidates: string[], preferred?: string): string[] {
if (!preferred?.trim()) return candidates
const norm = normalizePaymentAuthority('lightning', preferred)
const idx = candidates.findIndex((c) => normalizePaymentAuthority('lightning', c) === norm)
if (idx === -1) {
return [resolveLightningAuthority(preferred.trim()), ...candidates]
}
const rest = candidates.filter((_, i) => i !== idx)
return [candidates[idx], ...rest]
} }
/** Non-zap payto targets for zap dialog “other payment methods” (LUD-16 uses the Lightning selector). */
export function getAlternativePaymentMethods(methods: MergedPaymentMethod[]): MergedPaymentMethod[] {
return methods.filter((m) => !isZappableLightningPaytoType(m.type))
}

23
src/lib/sats-fiat.test.ts

@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { formatBtcFromSats, formatUsdFromSats, satsToBtc, satsToUsd } from './sats-fiat' import {
formatBtcFromSats,
formatUsdFromSats,
formatXmrFromSats,
formatSatsEquivalentsParts,
satsToBtc,
satsToUsd,
satsToXmr
} from './sats-fiat'
describe('sats-fiat', () => { describe('sats-fiat', () => {
it('converts sats to btc', () => { it('converts sats to btc', () => {
@ -19,4 +27,17 @@ describe('sats-fiat', () => {
expect(usd).toMatch(/\$|USD/) expect(usd).toMatch(/\$|USD/)
expect(satsToUsd(100_000_000, 100_000)).toBe(100_000) expect(satsToUsd(100_000_000, 100_000)).toBe(100_000)
}) })
it('formats xmr from sats using btc and xmr usd rates', () => {
expect(formatXmrFromSats(100_000_000, 100_000, null)).toBeNull()
expect(formatXmrFromSats(100_000_000, 100_000, 200)).toContain('XMR')
expect(satsToXmr(100_000_000, 100_000, 200)).toBe(500)
})
it('builds equivalent parts in usd btc xmr order', () => {
const parts = formatSatsEquivalentsParts(21_000, 100_000, 200)
expect(parts.btc).toContain('BTC')
expect(parts.usd).toMatch(/\$|USD/)
expect(parts.xmr).toContain('XMR')
})
}) })

41
src/lib/sats-fiat.ts

@ -8,6 +8,11 @@ export function satsToUsd(sats: number, btcUsd: number): number {
return satsToBtc(sats) * btcUsd return satsToBtc(sats) * btcUsd
} }
export function satsToXmr(sats: number, btcUsd: number, xmrUsd: number): number {
if (xmrUsd <= 0) return 0
return satsToUsd(sats, btcUsd) / xmrUsd
}
/** Human-readable BTC equivalent (e.g. 0.0021 BTC). */ /** Human-readable BTC equivalent (e.g. 0.0021 BTC). */
export function formatBtcFromSats(sats: number): string { export function formatBtcFromSats(sats: number): string {
const btc = satsToBtc(sats) const btc = satsToBtc(sats)
@ -32,3 +37,39 @@ export function formatUsdFromSats(sats: number, btcUsd: number | null): string |
maximumFractionDigits: maxFrac maximumFractionDigits: maxFrac
}).format(usd) }).format(usd)
} }
/** Human-readable XMR equivalent via BTC/USD and XMR/USD spot rates. */
export function formatXmrFromSats(
sats: number,
btcUsd: number | null,
xmrUsd: number | null
): string | null {
if (btcUsd == null || !Number.isFinite(btcUsd) || btcUsd <= 0) return null
if (xmrUsd == null || !Number.isFinite(xmrUsd) || xmrUsd <= 0) return null
const xmr = satsToXmr(sats, btcUsd, xmrUsd)
if (xmr === 0) return '0 XMR'
const maxFrac = xmr >= 1 ? 4 : xmr >= 0.01 ? 6 : 8
const num = xmr.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: maxFrac
})
return `${num} XMR`
}
export type SatsEquivalentsParts = {
usd: string | null
btc: string
xmr: string | null
}
export function formatSatsEquivalentsParts(
sats: number,
btcUsd: number | null,
xmrUsd: number | null
): SatsEquivalentsParts {
return {
usd: formatUsdFromSats(sats, btcUsd),
btc: formatBtcFromSats(sats),
xmr: formatXmrFromSats(sats, btcUsd, xmrUsd)
}
}

23
src/lib/xmr-usd-rate.ts

@ -0,0 +1,23 @@
const CACHE_MS = 5 * 60 * 1000
let cache: { usd: number; at: number } | null = null
/** Latest XMR/USD spot price (cached ~5 min). */
export async function fetchXmrUsdRate(): Promise<number | null> {
if (cache && Date.now() - cache.at < CACHE_MS) {
return cache.usd
}
try {
const res = await fetch(
'https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd'
)
if (!res.ok) return cache?.usd ?? null
const data = (await res.json()) as { monero?: { usd?: number } }
const usd = Number(data.monero?.usd)
if (!Number.isFinite(usd) || usd <= 0) return cache?.usd ?? null
cache = { usd, at: Date.now() }
return usd
} catch {
return cache?.usd ?? null
}
}

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

@ -10,13 +10,10 @@ import {
AlertDialogTrigger AlertDialogTrigger
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ZAP_SENDING_ENABLED } from '@/constants'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react' import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import DefaultZapAmountInput from './DefaultZapAmountInput' import DefaultZapAmountInput from './DefaultZapAmountInput'
import DefaultZapCommentInput from './DefaultZapCommentInput'
import QuickZapSwitch from './QuickZapSwitch'
import WalletConnectionDetails from './WalletConnectionDetails' import WalletConnectionDetails from './WalletConnectionDetails'
export default function WalletZapSendingSettings() { export default function WalletZapSendingSettings() {
@ -54,15 +51,6 @@ export default function WalletZapSendingSettings() {
</AlertDialog> </AlertDialog>
</div> </div>
<DefaultZapAmountInput /> <DefaultZapAmountInput />
{ZAP_SENDING_ENABLED ? (
<>
<DefaultZapCommentInput />
<QuickZapSwitch />
<p className="text-sm text-muted-foreground leading-relaxed">
{t('Zap superchat wallet hint')}
</p>
</>
) : null}
</> </>
) )
} }

194
src/services/lightning.service.ts

@ -1,31 +1,22 @@
import { import {
CODY_PUBKEY, CODY_PUBKEY,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS, IMWALD_MAINTAINER_PUBKEY
IMWALD_MAINTAINER_PUBKEY,
ZAP_SENDING_ENABLED
} from '@/constants' } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { TProfile } from '@/types'
import { closeModal, init, launchPaymentModal } from '@getalby/bitcoin-connect-react' import { closeModal, init, launchPaymentModal } from '@getalby/bitcoin-connect-react'
import { import {
isNwcWalletServiceInfoError, isNwcWalletServiceInfoError,
sendWebLNPaymentWithRetry sendWebLNPaymentWithRetry
} from '@/lib/webln-payment' } from '@/lib/webln-payment'
import { Invoice } from '@getalby/lightning-tools'
import { bech32 } from '@scure/base' import { bech32 } from '@scure/base'
import { WebLNProvider } from '@webbtc/webln-types' import { WebLNProvider } from '@webbtc/webln-types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Filter, kinds, NostrEvent } from 'nostr-tools' import { kinds, NostrEvent } from 'nostr-tools'
import { SubCloser } from 'nostr-tools/abstract-pool'
import { makeZapRequest } from 'nostr-tools/nip57'
import { utf8Decoder } from 'nostr-tools/utils' import { utf8Decoder } from 'nostr-tools/utils'
import client from './client.service' import { queryService } from './client.service'
import { queryService, replaceableEventService } from './client.service'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { clampZapSats } from '@/lib/lightning' import { clampZapSats } from '@/lib/lightning'
import { prioritizeZapLightningAddress } from '@/lib/merge-payment-methods'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import { buildLnurlPayCallbackUrl, parseLnurlCommentAllowed } from '@/lib/lnurl-pay' import { buildLnurlPayCallbackUrl, parseLnurlCommentAllowed } from '@/lib/lnurl-pay'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -74,130 +65,14 @@ class LightningService {
onPaymentFlowComplete?: (result: PaymentFlowResult) => void, onPaymentFlowComplete?: (result: PaymentFlowResult) => void,
zapLightning?: { address?: string; candidates?: string[] } zapLightning?: { address?: string; candidates?: string[] }
): Promise<PaymentFlowResult> { ): Promise<PaymentFlowResult> {
if (!ZAP_SENDING_ENABLED) { void sender
throw new Error('NIP-57 zaps are disabled; use LNURL-pay invoices instead') void recipientOrEvent
} void sats
if (!client.signer) { void comment
throw new Error('You need to be logged in to zap') void closeOuterModel
} void onPaymentFlowComplete
const { recipient, event } = void zapLightning
typeof recipientOrEvent === 'string' throw new Error('NIP-57 zaps are not supported; use payment targets or LNURL-pay invoices')
? { recipient: recipientOrEvent }
: { recipient: recipientOrEvent.pubkey, event: recipientOrEvent }
// Privacy: Only use current user's relays + defaults
const [profile, senderRelayList] = await Promise.all([
(async () => {
const profileEvent = await replaceableEventService.fetchReplaceableEvent(recipient, kinds.Metadata)
return profileEvent ? getProfileFromEvent(profileEvent) : undefined
})(),
sender
? client.fetchRelayList(sender) // Keep using client for relay list merging
: Promise.resolve({ read: FAST_READ_RELAY_URLS, write: FAST_WRITE_RELAY_URLS })
])
if (!profile) {
throw new Error('Recipient not found')
}
const zapEndpoint = await this.getZapEndpoint(profile, zapLightning)
if (!zapEndpoint) {
throw new Error("Recipient's lightning address is invalid")
}
const { callback, lnurl } = zapEndpoint
const amount = sats * 1000
const zapRequestDraft = makeZapRequest({
...(event ? { event } : { pubkey: recipient }),
amount,
relays: [],
comment
})
const zapRequest = await client.signer.signEvent(zapRequestDraft)
const zapRequestUrl = buildLnurlPayCallbackUrl(callback, {
amount: String(amount),
nostr: JSON.stringify(zapRequest),
lnurl
})
const zapRequestRes = await fetchWithTimeout(zapRequestUrl, { timeoutMs: 25_000 })
const zapRequestResBody = await zapRequestRes.json()
if (zapRequestResBody.error) {
throw new Error(zapRequestResBody.message)
}
const { pr, verify, reason } = zapRequestResBody
if (!pr) {
throw new Error(reason ?? 'Failed to create invoice')
}
if (this.provider) {
try {
const { preimage } = await sendWebLNPaymentWithRetry(this.provider, pr)
closeOuterModel?.()
const result = { preimage, invoice: pr }
onPaymentFlowComplete?.(result)
return result
} catch (error) {
if (!isNwcWalletServiceInfoError(error)) {
throw error
}
}
}
return new Promise((resolve) => {
runAfterReleasingRadixScrollLock(closeOuterModel, () => {
closeModal()
let checkPaymentInterval: ReturnType<typeof setInterval> | undefined
let subCloser: SubCloser | undefined
const finish = (result: PaymentFlowResult) => {
clearInterval(checkPaymentInterval)
subCloser?.close()
onPaymentFlowComplete?.(result)
resolve(result)
}
const { setPaid } = launchPaymentModal({
invoice: pr,
onPaid: (response) => {
finish({ preimage: response.preimage, invoice: pr })
},
onCancelled: () => {
finish(null)
}
})
if (verify) {
checkPaymentInterval = setInterval(async () => {
const invoice = new Invoice({ pr, verify })
const paid = await invoice.verifyPayment()
if (paid && invoice.preimage) {
setPaid({
preimage: invoice.preimage
})
}
}, 1000)
} else {
const filter: Filter = {
kinds: [kinds.Zap],
'#p': [recipient],
since: dayjs().subtract(1, 'minute').unix()
}
if (event) {
filter['#e'] = [event.id]
}
subCloser = client.subscribe(
senderRelayList.write.concat(FAST_READ_RELAY_URLS).slice(0, 4),
filter,
{
onevent: (evt) => {
const info = getZapInfoFromEvent(evt)
if (!info) return
if (info.invoice === pr) {
setPaid({ preimage: info.preimage ?? '' })
}
}
}
)
}
})
})
} }
async payInvoice( async payInvoice(
@ -270,44 +145,6 @@ class LightningService {
return this.recentSupportersCache return this.recentSupportersCache
} }
private async getZapEndpoint(
profile: TProfile,
zapLightning?: { address?: string; candidates?: string[] }
): Promise<null | {
callback: string
lnurl: string
}> {
const candidates = zapLightning?.candidates?.length
? prioritizeZapLightningAddress(zapLightning.candidates, zapLightning.address)
: this.lightningAddressCandidates(profile, zapLightning?.address)
for (const addr of candidates) {
const resolved = await this.fetchLnurlPayZapEndpoint(addr)
if (resolved) return resolved
}
return null
}
/** Ordered lightning identifiers from kind 0 (lud16/lud06 + `w` lightning rows); de-duplicated. */
private lightningAddressCandidates(profile: TProfile, preferredFirst?: string): string[] {
const raw =
profile.lightningAddressList?.length && profile.lightningAddressList.length > 0
? profile.lightningAddressList
: profile.lightningAddress
? [profile.lightningAddress]
: []
const out: string[] = []
const seen = new Set<string>()
for (const a of raw) {
const t = a?.trim()
if (!t) continue
const k = t.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(t)
}
return prioritizeZapLightningAddress(out, preferredFirst)
}
/** /**
* LNURL-pay metadata for a lightning address (LUD-16 or lnurl bech32). * LNURL-pay metadata for a lightning address (LUD-16 or lnurl bech32).
* Does not require Nostr zap support use {@link createLnurlInvoice} for plain invoices. * Does not require Nostr zap support use {@link createLnurlInvoice} for plain invoices.
@ -372,15 +209,6 @@ class LightningService {
return body.pr return body.pr
} }
private async fetchLnurlPayZapEndpoint(lightningAddress: string): Promise<null | {
callback: string
lnurl: string
}> {
const meta = await this.resolveLnurlPayMetadata(lightningAddress)
if (!meta?.allowsNostr || !meta.nostrPubkey) return null
return { callback: meta.callback, lnurl: meta.lnurl }
}
private async resolveLnurlPayMetadata(lightningAddress: string): Promise<null | { private async resolveLnurlPayMetadata(lightningAddress: string): Promise<null | {
callback: string callback: string
lnurl: string lnurl: string

Loading…
Cancel
Save