diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index e00b066b..5f1f5fe7 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/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 { recipientHasAnyPaymentOptions } from '@/lib/merge-payment-methods' import { - buildOrderedZapLightningAddresses, - recipientHasAnyPaymentOptions -} from '@/lib/merge-payment-methods' -import { - buildRecipientZapPaymentData, - mergeRecipientZapPaymentData, - type RecipientZapPaymentData + buildRecipientPaymentData, + mergeRecipientPaymentData, + type RecipientPaymentData } from '@/hooks/useRecipientAlternativePayments' import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' import { shouldDeferPerPubkeyProfileNetwork } from '@/lib/profile-batch-coordinator' import { cn } from '@/lib/utils' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' import { useNostr } from '@/providers/NostrProvider' -import { useZap } from '@/providers/ZapProvider' import client, { replaceableEventService } from '@/services/client.service' import type { TProfile } from '@/types' 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 { MouseEvent, TouchEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' import ZapDialog from '../ZapDialog' import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt' import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context' @@ -34,7 +24,7 @@ import { ZapCountHover } from './NoteStatsCountHover' type ZapButtonProps = { event: Event hideCount?: boolean - noteStats?: Partial + noteStats?: Partial } function formatAmount(amount: number) { @@ -43,13 +33,13 @@ function formatAmount(amount: number) { return `${Math.round(amount / 100000) / 10}M` } -type ZapRecipientResolveResult = { +type RecipientResolveResult = { profile: TProfile | null profileEvent: Event | undefined paymentInfo: ReturnType | null } -const zapRecipientResolveByPubkey = new Map>() +const recipientResolveByPubkey = new Map>() function feedProfileRowSyncKey( profile: TProfile | undefined | null, @@ -64,28 +54,27 @@ function feedProfileRowSyncKey( ].join('\x1e') } -/** Avoid one metadata + payment REQ per visible note while feed profile batch runs. */ -async function resolveZapRecipientData( +async function resolveRecipientPaymentData( authorPubkey: string, feedProfile: TProfile | undefined | null -): Promise { +): Promise { const pk = authorPubkey.toLowerCase() - const inFlight = zapRecipientResolveByPubkey.get(pk) + const inFlight = recipientResolveByPubkey.get(pk) if (inFlight) return inFlight - const run = resolveZapRecipientDataBody(pk, feedProfile).finally(() => { - if (zapRecipientResolveByPubkey.get(pk) === run) { - zapRecipientResolveByPubkey.delete(pk) + const run = resolveRecipientPaymentDataBody(pk, feedProfile).finally(() => { + if (recipientResolveByPubkey.get(pk) === run) { + recipientResolveByPubkey.delete(pk) } }) - zapRecipientResolveByPubkey.set(pk, run) + recipientResolveByPubkey.set(pk, run) return run } -async function resolveZapRecipientDataBody( +async function resolveRecipientPaymentDataBody( authorPubkey: string, feedProfile: TProfile | undefined | null -): Promise { +): Promise { const cachedFeed = feedProfile && !feedProfile.batchPlaceholder ? feedProfile : null const deferNetwork = shouldDeferPerPubkeyProfileNetwork(authorPubkey) @@ -131,8 +120,7 @@ async function resolveZapRecipientDataBody( } } -/** Zap tally + payment-methods dialog when {@link ZAP_SENDING_ENABLED} is false. */ -function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapButtonProps) { +export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapButtonProps) { const { t } = useTranslation() const { pubkey } = useNostr() const [openPaymentDialog, setOpenPaymentDialog] = useState(false) @@ -156,7 +144,7 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut ) const [disable, setDisable] = useState(true) - const [tipPaymentData, setTipPaymentData] = useState(null) + const [tipPaymentData, setTipPaymentData] = useState(null) const [postPaymentOpen, setPostPaymentOpen] = useState(false) const [postPaymentContext, setPostPaymentContext] = useState(null) @@ -184,8 +172,8 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut ) setDisable(!canTip) setTipPaymentData((prev) => - mergeRecipientZapPaymentData( - buildRecipientZapPaymentData(paymentInfo, profile, profileEvent ?? null), + mergeRecipientPaymentData( + buildRecipientPaymentData(paymentInfo, profile, profileEvent ?? null), prev ) ) @@ -210,7 +198,7 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut setTipPaymentData(null) let cancelled = false - void resolveZapRecipientData(authorPubkey, feedProfileRef.current).then( + void resolveRecipientPaymentData(authorPubkey, feedProfileRef.current).then( ({ profile, profileEvent, paymentInfo }) => { if (cancelled) return 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 - } - - 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(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(null) - const timerRef = useRef | null>(null) - const isLongPressRef = useRef(false) - - const applyTipAvailability = useCallback( - ( - profile: TProfile | null, - profileEvent: Event | null | undefined, - paymentInfo: ReturnType | 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 ( - <> -
- - {showZapAmount ? ( - -
- {formatAmount(zapAmount ?? 0)} -
-
- ) : ( - - )} -
- { - setOpenZapDialog(open) - setZapping(open) - }} - pubkey={event.pubkey} - event={event} - prefetchedPayment={tipPaymentData} - onPostPaymentRequest={handlePostPaymentRequest} - /> - - - ) -} - export default function ZapButton({ event, hideCount = false }: ZapButtonProps) { const noteStats = useNoteStatsById(event.id) return diff --git a/src/components/PaymentMethodsSection/index.tsx b/src/components/PaymentMethodsSection/index.tsx index bfb5f599..2244cf66 100644 --- a/src/components/PaymentMethodsSection/index.tsx +++ b/src/components/PaymentMethodsSection/index.tsx @@ -1,7 +1,6 @@ import PaytoLink from '@/components/PaytoLink' import type { PaymentMethodGroup } from '@/lib/merge-payment-methods' import { PRIMARY_LINK_HOVER_CLASS } from '@/lib/link-styles' -import { isZappableLightningPaytoType } from '@/lib/payto' import { cn } from '@/lib/utils' import { Copy } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -13,18 +12,14 @@ import type { PostPaymentContext } from '@/lib/post-payment-context' export default function PaymentMethodsSection({ groups, recipientPubkey, - onOpenZap, referencedEvent, offerTipNoticeOnClose = true, onPostPaymentRequest, title, - className, - headerHelpText + className }: { groups: PaymentMethodGroup[] 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. */ referencedEvent?: NostrEvent /** When false, PaytoDialog defer post-payment prompt to parent. */ @@ -32,8 +27,6 @@ export default function PaymentMethodsSection({ onPostPaymentRequest?: (context: PostPaymentContext) => void title?: string className?: string - /** Prominent note above the list (e.g. on-chain Bitcoin eligibility in zap dialog). */ - headerHelpText?: string }) { const { t } = useTranslation() @@ -44,24 +37,10 @@ export default function PaymentMethodsSection({
{title ?? t('Payment Methods')}
- {headerHelpText ? ( -

- {headerHelpText} -

- ) : null}
{groups.map((group, groupIdx) => ( -
-
- {group.displayType} -
+
+
{group.displayType}
{group.methods.map((method, idx) => (
@@ -73,11 +52,6 @@ export default function PaymentMethodsSection({ paytoUri={method.payto} displayFormat="full" pubkey={recipientPubkey} - onOpenZap={ - isZappableLightningPaytoType(method.type) && onOpenZap - ? (_pk, authority) => onOpenZap(authority) - : undefined - } offerTipNoticeOnClose={offerTipNoticeOnClose} onPostPaymentRequest={onPostPaymentRequest} referencedEvent={referencedEvent} diff --git a/src/components/PaytoDialog/LightningInvoiceSection.tsx b/src/components/PaytoDialog/LightningInvoiceSection.tsx index 46dcb0df..1aed0d2d 100644 --- a/src/components/PaytoDialog/LightningInvoiceSection.tsx +++ b/src/components/PaytoDialog/LightningInvoiceSection.tsx @@ -1,4 +1,5 @@ import QrCode from '@/components/QrCode' +import SatsAmountEquivalents from '@/components/SatsAmountEquivalents' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -185,6 +186,7 @@ export default function LightningInvoiceSection({ +
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" - aria-describedby="ln-invoice-preset-hint" + aria-describedby="ln-invoice-sats-equiv ln-invoice-preset-hint" /> {t('sats')}
diff --git a/src/components/PaytoLink/index.tsx b/src/components/PaytoLink/index.tsx index 80d4a059..1f5837b4 100644 --- a/src/components/PaytoLink/index.tsx +++ b/src/components/PaytoLink/index.tsx @@ -1,4 +1,3 @@ -import { ZAP_SENDING_ENABLED } from '@/constants' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -9,7 +8,6 @@ import { getCanonicalPaytoType, getPaytoTypeInfo, isKnownPaytoType, - isZappableLightningPaytoType, flattenPaytoLinkChildText, formatPaytoLinkDisplayText, paytoLinkChildTextLooksLikeAuthority @@ -25,7 +23,6 @@ export default function PaytoLink({ type: typeProp, authority: authorityProp, pubkey, - onOpenZap, offerTipNoticeOnClose = true, onPostPaymentRequest, referencedEvent, @@ -39,9 +36,7 @@ export default function PaytoLink({ paytoUri?: string type?: string authority?: string - /** When set with lightning type, clicking can open Zap dialog via onOpenZap */ pubkey?: string - onOpenZap?: (pubkey: string, lightningAuthority: string) => void /** Passed to PaytoDialog; set false when a parent already offers the post-payment prompt. */ offerTipNoticeOnClose?: boolean /** Parent-owned post-payment prompt (e.g. ZapDialog). */ @@ -73,16 +68,10 @@ export default function PaytoLink({ const { type, authority, raw } = parsed const info = getPaytoTypeInfo(type) const known = isKnownPaytoType(type) - const canZap = - ZAP_SENDING_ENABLED && isZappableLightningPaytoType(type) && !!pubkey && !!onOpenZap const handleClick = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - if (canZap) { - onOpenZap(pubkey!, authority) - return - } if (!known) { navigator.clipboard.writeText(raw) toast.success(t('Copied payto address')) @@ -134,7 +123,7 @@ export default function PaytoLink({ {iconEl} {content} - {known && !canZap && ( + {known && ( = profileEvent.created_at ? accountProfileEvent : profileEvent }, [isSelf, profileEvent, accountProfileEvent]) + const senderPaytoTypes = useSenderPaytoTypes(!!accountPubkey && !isSelf) + const mergedPaymentMethods = useMemo( () => sortMergedPaymentMethods( @@ -144,8 +147,8 @@ export default function Profile({ ) const paymentMethodsByType = useMemo( - () => groupPaymentMethodsByDisplayType(mergedPaymentMethods), - [mergedPaymentMethods] + () => groupPaymentMethodsForDisplay(mergedPaymentMethods, senderPaytoTypes), + [mergedPaymentMethods, senderPaytoTypes] ) const hasPaymentMethods = useMemo( @@ -156,7 +159,7 @@ export default function Profile({ const prefetchedPaymentData = useMemo( () => profile?.pubkey - ? buildRecipientZapPaymentData(paymentInfo, profile ?? null, effectiveProfileEvent ?? null) + ? buildRecipientPaymentData(paymentInfo, profile ?? null, effectiveProfileEvent ?? null) : null, [paymentInfo, profile, effectiveProfileEvent] ) diff --git a/src/components/SatsAmountEquivalents/index.tsx b/src/components/SatsAmountEquivalents/index.tsx new file mode 100644 index 00000000..c3e1b4e6 --- /dev/null +++ b/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 ( +

+ {t('Approximate equivalent:')} + + {line} +

+ ) +} diff --git a/src/components/ZapDialog/SuperchatRequestForm.tsx b/src/components/ZapDialog/SuperchatRequestForm.tsx index 4be6538d..335522af 100644 --- a/src/components/ZapDialog/SuperchatRequestForm.tsx +++ b/src/components/ZapDialog/SuperchatRequestForm.tsx @@ -17,6 +17,7 @@ import { useNostr } from '@/providers/NostrProvider' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import SatsAmountEquivalents from '@/components/SatsAmountEquivalents' import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle' import SuperchatPaymentMethodLabel from '../Note/SuperchatPaymentMethodLabel' @@ -105,6 +106,7 @@ export default function SuperchatRequestForm({ ) : null}
+
{t('sats')}
diff --git a/src/components/ZapDialog/ZapSatsAmountInput.tsx b/src/components/ZapDialog/ZapSatsAmountInput.tsx deleted file mode 100644 index d6c81761..00000000 --- a/src/components/ZapDialog/ZapSatsAmountInput.tsx +++ /dev/null @@ -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 ( -
-
- {parts.map((part, index) => ( - - {part} - - ))} -
- { - 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' - )} - /> -
- ) -} diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx index 4f18945f..fa0c9893 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -1,5 +1,3 @@ -import { ZAP_SENDING_ENABLED } from '@/constants' -import { Button } from '@/components/ui/button' import { Dialog, DialogContent, @@ -14,59 +12,33 @@ import { DrawerOverlay, DrawerTitle } from '@/components/ui/drawer' -import { Input } from '@/components/ui/input' -import { Skeleton } from '@/components/ui/skeleton' -import { Label } from '@/components/ui/label' +import PaymentMethodsSection from '@/components/PaymentMethodsSection' +import UserAvatar from '@/components/UserAvatar' +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 { 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 { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react' 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 ZapSatsAmountInput from './ZapSatsAmountInput' -import UserAvatar from '../UserAvatar' -import Username from '../Username' export default function ZapDialog({ open, setOpen, pubkey, event, - defaultAmount, - defaultComment, - defaultLightningAddress, prefetchedPayment = null, onPostPaymentRequest }: { @@ -75,12 +47,8 @@ export default function ZapDialog({ pubkey: string /** When set, kind 9740 superchats reference this note (e/a + k + author). Omit for profile tips. */ 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. */ - prefetchedPayment?: RecipientZapPaymentData | null + prefetchedPayment?: RecipientPaymentData | null /** Parent-owned post-payment prompt (e.g. note ZapButton). Skips internal prompt when set. */ onPostPaymentRequest?: (context: PostPaymentContext) => void }) { @@ -107,57 +75,72 @@ export default function ZapDialog({ setOpen(false) } - const fetchedPayment = useRecipientZapPaymentData(pubkey, open) + const fetchedPayment = useRecipientPaymentData(pubkey, open) const recipientPayment = useMemo( - () => mergeRecipientZapPaymentData(prefetchedPayment, fetchedPayment), + () => mergeRecipientPaymentData(prefetchedPayment, fetchedPayment), [prefetchedPayment, fetchedPayment] ) - const lightningAddressOptions = useMemo( - () => - buildOrderedZapLightningAddresses({ - profileEvent: recipientPayment.profileEvent, - profile: recipientPayment.profile, - paymentInfo: recipientPayment.paymentInfo, - preferredAddress: defaultLightningAddress - }), - [ - recipientPayment.profileEvent, - recipientPayment.profile, - recipientPayment.paymentInfo, - defaultLightningAddress - ] + const senderPaytoTypes = useSenderPaytoTypes(open) + + const paymentGroups = useMemo(() => { + const merged = sortMergedPaymentMethods( + mergePaymentMethods( + recipientPayment.paymentInfo, + recipientPayment.profile, + recipientPayment.profileEvent + ) + ) + return groupPaymentMethodsForDisplay(merged, senderPaytoTypes) + }, [recipientPayment, senderPaytoTypes]) + + const dialogTitle = t('Payment methods') + const body = + paymentGroups.length > 0 ? ( + + ) : ( +

+ {t('No payment methods available for this profile')} +

+ ) + + const content = ( +
+ {body} +
) - const canLightningZap = lightningAddressOptions.length > 0 - const paymentsOnly = !ZAP_SENDING_ENABLED - 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> = setOpen + const postPaymentPrompt = !onPostPaymentRequest ? ( + + ) : null useEffect(() => { const handleResize = () => { if (drawerContentRef.current) { - // Use visual viewport height to ensure proper positioning when keyboard/emoji picker opens const viewportHeight = window.visualViewport?.height || window.innerHeight - - // Ensure drawer doesn't go above the viewport, but don't override bottom positioning - const maxHeight = viewportHeight - 100 // Leave some space at top + const maxHeight = viewportHeight - 100 drawerContentRef.current.style.setProperty('max-height', `${maxHeight}px`) - // Don't set bottom position here - let the drawer handle it naturally } } if (window.visualViewport) { window.visualViewport.addEventListener('resize', handleResize) - handleResize() // Initial call in case the keyboard is already open + handleResize() } return () => { @@ -169,437 +152,51 @@ export default function ZapDialog({ if (isSmallScreen) { return ( - - handleZapDialogOpenChange(false)} /> - e.preventDefault()} - ref={drawerContentRef} - className="flex max-h-[80vh] flex-col overflow-y-auto overscroll-contain" - style={{ - 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 - }} - > - - -
{dialogTitlePrefix}
- - -
- {dialogDescription} -
- { - openPostPaymentPrompt( - mergePostPaymentContext( - { recipientPubkey: pubkey, referencedEvent: event }, - { - amountMsat: paymentDetails?.amountMsat, - paytoUri: paymentDetails?.paytoUri - } - ) - ) + <> + + setOpen(false)} /> + e.preventDefault()} + ref={drawerContentRef} + className="flex max-h-[80vh] flex-col overflow-y-auto overscroll-contain" + style={{ + maxHeight: 'calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 2rem)', + paddingBottom: '0' }} - onPostPaymentRequest={openPostPaymentPrompt} - /> - - {!onPostPaymentRequest ? ( - - ) : null} - + > + + +
{dialogTitle}
+ + +
+ {dialogTitle} +
+ {content} +
+
+ {postPaymentPrompt} + ) } return ( <> - - - - -
{dialogTitlePrefix}
- - -
- {dialogDescription} -
- { - openPostPaymentPrompt( - mergePostPaymentContext( - { recipientPubkey: pubkey, referencedEvent: event }, - { - amountMsat: paymentDetails?.amountMsat, - paytoUri: paymentDetails?.paytoUri - } - ) - ) - }} - onPostPaymentRequest={openPostPaymentPrompt} - /> -
-
- {!onPostPaymentRequest ? ( - - ) : null} + + + + +
{dialogTitle}
+ + +
+ {dialogTitle} +
+ {content} +
+
+ {postPaymentPrompt} ) } - -function ZapDialogContent({ - open, - setOpen, - recipient, - event, - defaultAmount, - defaultComment, - recipientPayment, - lightningAddressOptions, - canLightningZap, - onPaymentFlowComplete, - onPostPaymentRequest -}: { - open: boolean - setOpen: Dispatch> - 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 ( -
- {allPaymentGroups.length > 0 ? ( - - ) : ( -

- {t('No payment methods available for this profile')} -

- )} -
- ) - } - 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 ( -
- {hasAlternativePayments ? ( - - ) : ( -

- {t('No payment methods available for this profile')} -

- )} -
- ) - } - - return ( -
-
- {/* Sats slider or input */} -
-
- - {btcEquivalent} - - {usdEquivalent != null ? ( - <> - - · - - {usdEquivalent} - - ) : null} -
- - -
- - {/* Preset sats buttons */} -
- {presetAmounts.map(({ display, val }) => ( - - ))} -
- - {/* Comment input */} -
- - setComment(e.target.value)} /> -

{t('Zap lnurl comment hint')}

-
-
- -
-

{t('Zap superchat flow hint')}

- -
- - {lightningAddressOptions.length === 1 ? ( -

- - ⚡ - - {lightningAddressOptions[0]} -

- ) : ( - - )} -
- - - - {hasAlternativePayments ? ( - - ) : null} -
-
- ) -} diff --git a/src/constants.ts b/src/constants.ts index c8283f98..62b8d60f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -638,9 +638,6 @@ export const ExtendedKind = { 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). */ export const LIGHTNING_WALLET_PAY_ENABLED = true diff --git a/src/hooks/useRecipientAlternativePayments.test.ts b/src/hooks/useRecipientAlternativePayments.test.ts index fd445eb9..6b41d2bc 100644 --- a/src/hooks/useRecipientAlternativePayments.test.ts +++ b/src/hooks/useRecipientAlternativePayments.test.ts @@ -1,19 +1,17 @@ import { describe, expect, it } from 'vitest' -import { buildRecipientZapPaymentData, mergeRecipientZapPaymentData } from './useRecipientAlternativePayments' +import { buildRecipientPaymentData, mergeRecipientPaymentData } from './useRecipientAlternativePayments' import type { TProfile } from '@/types' -describe('mergeRecipientZapPaymentData', () => { +describe('mergeRecipientPaymentData', () => { it('keeps lightning from feed profile when relay fetch is still empty', () => { const feedProfile = { pubkey: 'aa'.repeat(32), lightningAddress: 'user@example.com' } as TProfile - const partial = buildRecipientZapPaymentData(null, feedProfile, null) - const empty = buildRecipientZapPaymentData(null, null, null) - const merged = mergeRecipientZapPaymentData(partial, empty) + const partial = buildRecipientPaymentData(null, feedProfile, null) + const empty = buildRecipientPaymentData(null, null, null) + const merged = mergeRecipientPaymentData(partial, empty) expect(merged.canReceiveTip).toBe(true) - expect( - merged.alternativeGroups.length + (partial.canReceiveTip ? 1 : 0) - ).toBeGreaterThan(0) + expect(merged.profile?.lightningAddress).toBe('user@example.com') }) }) diff --git a/src/hooks/useRecipientAlternativePayments.ts b/src/hooks/useRecipientAlternativePayments.ts index c52b3413..21da76b5 100644 --- a/src/hooks/useRecipientAlternativePayments.ts +++ b/src/hooks/useRecipientAlternativePayments.ts @@ -1,11 +1,4 @@ -import { - getAlternativePaymentMethods, - groupPaymentMethodsByDisplayType, - mergePaymentMethods, - recipientHasAnyPaymentOptions, - sortMergedPaymentMethods, - type PaymentMethodGroup -} from '@/lib/merge-payment-methods' +import { recipientHasAnyPaymentOptions } from '@/lib/merge-payment-methods' import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' import client, { replaceableEventService } from '@/services/client.service' import { kinds, type Event } from 'nostr-tools' @@ -13,34 +6,39 @@ import { useEffect, useMemo, useState } from 'react' import type { TPaymentInfo } from '@/types' import type { TProfile } from '@/types' -export type RecipientZapPaymentData = { +export type RecipientPaymentData = { paymentInfo: TPaymentInfo | null profile: TProfile | null profileEvent: Event | null - alternativeGroups: PaymentMethodGroup[] - /** Any payto / Lightning target on kind 0 or 10133 — used to enable zap UI. */ + /** Any payto / Lightning target on kind 0 or 10133. */ canReceiveTip: boolean } -export function buildRecipientZapPaymentData( +/** @deprecated Use {@link RecipientPaymentData} */ +export type RecipientZapPaymentData = RecipientPaymentData + +export function buildRecipientPaymentData( paymentInfo: TPaymentInfo | null, profile: TProfile | null, profileEvent: Event | null -): RecipientZapPaymentData { - const canReceiveTip = recipientHasAnyPaymentOptions(paymentInfo, profile, profileEvent) - const merged = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile, profileEvent)) - const alts = getAlternativePaymentMethods(merged) - const alternativeGroups = groupPaymentMethodsByDisplayType(alts) - return { paymentInfo, profile, profileEvent, alternativeGroups, canReceiveTip } +): RecipientPaymentData { + return { + paymentInfo, + profile, + profileEvent, + canReceiveTip: recipientHasAnyPaymentOptions(paymentInfo, profile, profileEvent) + } } -/** Combine feed/profile snapshot with fresher relay data (dialog opens fast, then enriches). */ -export function mergeRecipientZapPaymentData( - partial: RecipientZapPaymentData | null | undefined, - fresh: RecipientZapPaymentData | null | undefined -): RecipientZapPaymentData { +/** @deprecated Use {@link buildRecipientPaymentData} */ +export const buildRecipientZapPaymentData = buildRecipientPaymentData + +export function mergeRecipientPaymentData( + partial: RecipientPaymentData | null | undefined, + fresh: RecipientPaymentData | null | undefined +): RecipientPaymentData { if (!partial) { - return fresh ?? buildRecipientZapPaymentData(null, null, null) + return fresh ?? buildRecipientPaymentData(null, null, null) } if (!fresh) return partial @@ -50,9 +48,12 @@ export function mergeRecipientZapPaymentData( : (partial.profile ?? fresh.profile) 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( a: TPaymentInfo | null | undefined, b: TPaymentInfo | null | undefined @@ -64,11 +65,10 @@ function pickRicherPaymentInfo( return b ?? a ?? null } -/** Kind 10133 + profile payto targets except the Lightning address used for zapping. */ -export function useRecipientZapPaymentData( +export function useRecipientPaymentData( recipientPubkey: string | undefined, enabled: boolean -): RecipientZapPaymentData { +): RecipientPaymentData { const [paymentInfo, setPaymentInfo] = useState(null) const [profile, setProfile] = useState(null) const [profileEvent, setProfileEvent] = useState(null) @@ -105,15 +105,10 @@ export function useRecipientZapPaymentData( }, [recipientPubkey, enabled]) return useMemo( - () => buildRecipientZapPaymentData(paymentInfo, profile, profileEvent), + () => buildRecipientPaymentData(paymentInfo, profile, profileEvent), [paymentInfo, profile, profileEvent] ) } -/** @deprecated Use {@link useRecipientZapPaymentData} */ -export function useRecipientAlternativePayments( - recipientPubkey: string | undefined, - enabled: boolean -): PaymentMethodGroup[] { - return useRecipientZapPaymentData(recipientPubkey, enabled).alternativeGroups -} +/** @deprecated Use {@link useRecipientPaymentData} */ +export const useRecipientZapPaymentData = useRecipientPaymentData diff --git a/src/hooks/useSatsFiatRates.ts b/src/hooks/useSatsFiatRates.ts new file mode 100644 index 00000000..862aa430 --- /dev/null +++ b/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({ 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 +} diff --git a/src/hooks/useSenderPaytoTypes.ts b/src/hooks/useSenderPaytoTypes.ts new file mode 100644 index 00000000..93a3fbd1 --- /dev/null +++ b/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 { + const { pubkey, profileEvent: accountProfileEvent } = useNostr() + const [families, setFamilies] = useState>(() => 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 +} diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index 4f150497..c5edb561 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -153,6 +153,7 @@ export default { 'Copy invoice': 'Copy invoice', 'Failed to create invoice': 'Failed to create invoice', 'Amount (sats)': 'Amount (sats)', + 'Approximate equivalent:': 'Approximate equivalent:', 'Pay with connected wallet': 'Pay with connected wallet', 'Payment sent': 'Payment sent', 'Pay via a BOLT11 invoice or copy the Lightning address': diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index e38aa759..d99fde8c 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -155,6 +155,7 @@ export default { 'Copy invoice': 'Copy invoice', 'Failed to create invoice': 'Failed to create invoice', 'Amount (sats)': 'Amount (sats)', + 'Approximate equivalent:': 'Approximate equivalent:', 'Pay with connected wallet': 'Pay with connected wallet', 'Payment sent': 'Payment sent', 'Pay via a BOLT11 invoice or copy the Lightning address': diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index efa1b1a1..c577892c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -151,6 +151,7 @@ export default { 'Copy invoice': 'Copy invoice', 'Failed to create invoice': 'Failed to create invoice', 'Amount (sats)': 'Amount (sats)', + 'Approximate equivalent:': 'Approximate equivalent:', 'Pay with connected wallet': 'Pay with connected wallet', 'Payment sent': 'Payment sent', 'Pay via a BOLT11 invoice or copy the Lightning address': diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 978c39e8..8229a38f 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -153,6 +153,7 @@ export default { 'Copy invoice': 'Copy invoice', 'Failed to create invoice': 'Failed to create invoice', 'Amount (sats)': 'Amount (sats)', + 'Approximate equivalent:': 'Approximate equivalent:', 'Pay with connected wallet': 'Pay with connected wallet', 'Payment sent': 'Payment sent', 'Pay via a BOLT11 invoice or copy the Lightning address': diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 0effb93f..317f7676 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -153,6 +153,7 @@ export default { 'Copy invoice': 'Copy invoice', 'Failed to create invoice': 'Failed to create invoice', 'Amount (sats)': 'Amount (sats)', + 'Approximate equivalent:': 'Approximate equivalent:', 'Pay with connected wallet': 'Pay with connected wallet', 'Payment sent': 'Payment sent', 'Pay via a BOLT11 invoice or copy the Lightning address': diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index e05b53a0..9f9ff889 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -153,6 +153,7 @@ export default { 'Copy invoice': 'Copy invoice', 'Failed to create invoice': 'Failed to create invoice', 'Amount (sats)': 'Amount (sats)', + 'Approximate equivalent:': 'Approximate equivalent:', 'Pay with connected wallet': 'Pay with connected wallet', 'Payment sent': 'Payment sent', 'Pay via a BOLT11 invoice or copy the Lightning address': diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 324b342b..0d63712a 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -153,6 +153,7 @@ export default { 'Copy invoice': 'Copy invoice', 'Failed to create invoice': 'Failed to create invoice', 'Amount (sats)': 'Amount (sats)', + 'Approximate equivalent:': 'Approximate equivalent:', 'Pay with connected wallet': 'Pay with connected wallet', 'Payment sent': 'Payment sent', 'Pay via a BOLT11 invoice or copy the Lightning address': diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index cb30738e..efdc0769 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -153,6 +153,7 @@ export default { 'Copy invoice': 'Copy invoice', 'Failed to create invoice': 'Failed to create invoice', 'Amount (sats)': 'Amount (sats)', + 'Approximate equivalent:': 'Approximate equivalent:', 'Pay with connected wallet': 'Pay with connected wallet', 'Payment sent': 'Payment sent', 'Pay via a BOLT11 invoice or copy the Lightning address': diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index b007609b..5e9766cb 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -153,6 +153,7 @@ export default { 'Copy invoice': 'Copy invoice', 'Failed to create invoice': 'Failed to create invoice', 'Amount (sats)': 'Amount (sats)', + 'Approximate equivalent:': 'Approximate equivalent:', 'Pay with connected wallet': 'Pay with connected wallet', 'Payment sent': 'Payment sent', 'Pay via a BOLT11 invoice or copy the Lightning address': diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 4c94a161..3ecd79ad 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -153,6 +153,7 @@ export default { 'Copy invoice': 'Copy invoice', 'Failed to create invoice': 'Failed to create invoice', 'Amount (sats)': 'Amount (sats)', + 'Approximate equivalent:': 'Approximate equivalent:', 'Pay with connected wallet': 'Pay with connected wallet', 'Payment sent': 'Payment sent', 'Pay via a BOLT11 invoice or copy the Lightning address': diff --git a/src/lib/merge-payment-methods.test.ts b/src/lib/merge-payment-methods.test.ts index f9d10d73..cfe164a0 100644 --- a/src/lib/merge-payment-methods.test.ts +++ b/src/lib/merge-payment-methods.test.ts @@ -3,13 +3,11 @@ import { isLightningPaytoType, isZappableLightningPaytoType } from '@/lib/payto- import { getProfileFromEvent } from '@/lib/event-metadata' import { kinds, type Event } from 'nostr-tools' import { - buildOrderedZapLightningAddresses, - getAlternativePaymentMethods, groupPaymentMethodsByDisplayType, - prepareZapDialogAlternativePayments, + groupPaymentMethodsForDisplay, + sortPaymentMethodGroupsForSender, mergePaymentMethods, - normalizeLightningAuthority, - recipientHasAnyPaymentOptions + normalizeLightningAuthority } from './merge-payment-methods' 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', () => { it('imports XMR from about text', () => { const addr = @@ -261,46 +186,70 @@ describe('mergePaymentMethods kind 0 cryptocurrency_addresses', () => { }) }) -describe('prepareZapDialogAlternativePayments', () => { +describe('sortPaymentMethodGroupsForSender', () => { const groups = [ { - displayType: 'Tether (USDT)', - methods: [{ type: 'usdt', authority: '0xusdt', displayType: 'Tether (USDT)' }] + displayType: 'Geyser Fund', + methods: [{ type: 'geyser', authority: 'proj', displayType: 'Geyser Fund' }] }, { - displayType: 'Bitcoin', - methods: [{ type: 'bitcoin', authority: 'bc1qtest', displayType: 'Bitcoin' }] - }, - { - displayType: 'Liquid Bitcoin (LBTC)', - methods: [{ type: 'lbtc', authority: 'lq1…', displayType: 'Liquid Bitcoin (LBTC)' }] + displayType: 'Monero', + methods: [{ type: 'monero', authority: '4xmr', displayType: 'Monero' }] }, { - displayType: 'Monero', - methods: [{ type: 'monero', authority: '4…', displayType: 'Monero' }] + displayType: 'Bitcoin', + methods: [{ type: 'bitcoin', authority: 'bc1q', displayType: 'Bitcoin' }] }, { - displayType: 'USD Coin', - methods: [{ type: 'usdc', authority: '0xusdc', displayType: 'USD Coin' }] + displayType: 'Ko-fi', + methods: [{ type: 'ko-fi', authority: 'user', displayType: 'Ko-fi' }] } ] - it('hides Bitcoin-category methods below 10k sats', () => { - const { groups: out, showBitcoinOnChainHint } = prepareZapDialogAlternativePayments(groups, 9999) - expect(showBitcoinOnChainHint).toBe(false) - expect(out.some((g) => g.methods.some((m) => m.type === 'bitcoin'))).toBe(false) - expect(out[0].displayType).toBe('Liquid Bitcoin (LBTC)') - expect(out[1].displayType).toBe('Monero') + it('puts shared families first in lightning → monero → bitcoin → geyser order', () => { + const sorted = sortPaymentMethodGroupsForSender(groups, new Set(['monero', 'bitcoin'])) + expect(sorted.map((g) => g.displayType)).toEqual([ + 'Monero', + 'Bitcoin', + 'Geyser Fund', + 'Ko-fi' + ]) }) - it('puts Bitcoin first with hint at 10k sats and above', () => { - const { groups: out, showBitcoinOnChainHint } = prepareZapDialogAlternativePayments(groups, 10_000) - expect(showBitcoinOnChainHint).toBe(true) - expect(out[0].displayType).toBe('Bitcoin') - expect(out[0].highlighted).toBe(true) - expect(out[1].displayType).toBe('Liquid Bitcoin (LBTC)') - expect(out[2].displayType).toBe('Monero') - expect(out[3].displayType).toBe('Tether (USDT)') - expect(out[4].displayType).toBe('USD Coin') + it('orders geyser before other alphabetic types when both are shared', () => { + const sorted = sortPaymentMethodGroupsForSender(groups, new Set(['geyser', 'ko-fi'])) + expect(sorted.map((g) => g.displayType)).toEqual([ + 'Geyser Fund', + 'Ko-fi', + 'Monero', + 'Bitcoin' + ]) + }) +}) + +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']) }) }) diff --git a/src/lib/merge-payment-methods.ts b/src/lib/merge-payment-methods.ts index f38e4438..b38e1af7 100644 --- a/src/lib/merge-payment-methods.ts +++ b/src/lib/merge-payment-methods.ts @@ -1,12 +1,11 @@ -import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' +import { getPaymentInfoFromEvent } from '@/lib/event-metadata' import { buildPaytoUri, getCanonicalPaytoType, getPaytoEditorTypeLabel, getPaytoTypeInfo, isKnownPaytoType, - isLightningPaytoType, - isZappableLightningPaytoType + isLightningPaytoType } from '@/lib/payto' import { extractKind0PaymentMethodsFromProfileJson } from '@/lib/payto-kind0-import' import { normalizePaypalAuthority } from '@/lib/payto-paypal-url' @@ -40,14 +39,6 @@ type PaymentMethodInput = { export type PaymentMethodGroup = { displayType: string 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. */ @@ -79,63 +70,94 @@ function resolveLightningAuthority(a: string, b?: string): string { return normalizeLightningAuthority(preferred) || preferred.trim() } -/** Below this zap size, on-chain Bitcoin payto targets are hidden in the zap dialog. */ -export const ZAP_HIDE_BITCOIN_ALTS_MAX_SATS = 10_000 +/** Preferred group order when sorting payment targets (by payto “family”). */ +const PREFERRED_PAYMENT_GROUP_FAMILIES = ['lightning', 'monero', 'bitcoin', 'geyser'] as const -/** On-chain Bitcoin family (not Lightning / Liquid layer types). */ -export function isBitcoinCategoryPaytoType(type: string): boolean { - return getPaytoTypeInfo(getCanonicalPaytoType(type))?.category === 'bitcoin' +/** Map payto types to a family for sender/recipient overlap (e.g. BIP-353 → lightning). */ +export function paytoTypeFamily(type: string): string { + const canonical = getCanonicalPaytoType(type) + if (isLightningPaytoType(canonical)) return 'lightning' + return canonical } -/** Sort key for zap dialog “other payment” groups (lower = higher in list). */ -function zapAlternativeGroupSortRank(group: PaymentMethodGroup): number { - 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 +export function collectPaytoTypeFamilies(methods: MergedPaymentMethod[]): Set { + return new Set(methods.map((m) => paytoTypeFamily(m.type))) } -/** Filter, order, and annotate payto groups for the zap dialog “other payment methods” block. */ -export function prepareZapDialogAlternativePayments( - groups: PaymentMethodGroup[], - zapSats: number -): ZapDialogAlternativePayments { - const showBitcoin = zapSats >= ZAP_HIDE_BITCOIN_ALTS_MAX_SATS - - 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)) +export function collectPaytoTypeFamiliesFromProfile( + paymentInfo: ReturnType | null, + profile: TProfile | null, + profileEvent?: Event | null +): Set { + return collectPaytoTypeFamilies(mergePaymentMethods(paymentInfo, profile, profileEvent)) +} - return { - groups: prepared, - showBitcoinOnChainHint: showBitcoin && prepared.some((g) => g.highlighted) +function paymentMethodGroupFamilies(group: PaymentMethodGroup): string[] { + return [...new Set(group.methods.map((m) => paytoTypeFamily(m.type)))] +} + +function groupSharesSenderPaytoFamily(group: PaymentMethodGroup, senderFamilies: Set): 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' }) +} + +/** Sort groups: lightning → monero → bitcoin → geyser → alphabetical (within bitcoin-layer etc.). */ +export function sortPaymentMethodGroupsByPreference(groups: PaymentMethodGroup[]): PaymentMethodGroup[] { + return [...groups].sort(comparePaymentMethodGroups) } -/** @deprecated Use {@link prepareZapDialogAlternativePayments} */ -export function filterPaymentMethodGroupsForZapAmount( +/** + * 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[], - zapSats: number + senderPaytoFamilies?: Iterable | null ): 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. */ @@ -534,41 +556,14 @@ export function groupPaymentMethodsByDisplayType(methods: MergedPaymentMethod[]) return order.map((key) => ({ displayType: key, methods: groups.get(key) ?? [] })) } -/** - * Ordered Lightning targets for zaps: profile (kind 0) event order, then payment (kind 10133). - * Optional `preferredAddress` is moved to the front. - */ -export function buildOrderedZapLightningAddresses(opts: { - profileEvent?: Event | null - /** Parsed kind 0 when the event is not loaded yet (e.g. feed profile row). */ - profile?: TProfile | null - paymentInfo: ReturnType | 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] +/** Group by display type, then sort for display (shared types with sender first when provided). */ +export function groupPaymentMethodsForDisplay( + methods: MergedPaymentMethod[], + senderPaytoFamilies?: Iterable | null +): PaymentMethodGroup[] { + return sortPaymentMethodGroupsForSender( + groupPaymentMethodsByDisplayType(methods), + senderPaytoFamilies + ) } -/** 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)) -} diff --git a/src/lib/sats-fiat.test.ts b/src/lib/sats-fiat.test.ts index 9103ee07..d7e48b6c 100644 --- a/src/lib/sats-fiat.test.ts +++ b/src/lib/sats-fiat.test.ts @@ -1,5 +1,13 @@ 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', () => { it('converts sats to btc', () => { @@ -19,4 +27,17 @@ describe('sats-fiat', () => { expect(usd).toMatch(/\$|USD/) 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') + }) }) diff --git a/src/lib/sats-fiat.ts b/src/lib/sats-fiat.ts index 19af4590..720c06fc 100644 --- a/src/lib/sats-fiat.ts +++ b/src/lib/sats-fiat.ts @@ -8,6 +8,11 @@ export function satsToUsd(sats: number, btcUsd: number): number { 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). */ export function formatBtcFromSats(sats: number): string { const btc = satsToBtc(sats) @@ -32,3 +37,39 @@ export function formatUsdFromSats(sats: number, btcUsd: number | null): string | maximumFractionDigits: maxFrac }).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) + } +} diff --git a/src/lib/xmr-usd-rate.ts b/src/lib/xmr-usd-rate.ts new file mode 100644 index 00000000..a4c3e185 --- /dev/null +++ b/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 { + 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 + } +} diff --git a/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx b/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx index 2e7ac33c..b7bd05bd 100644 --- a/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx +++ b/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx @@ -10,13 +10,10 @@ import { AlertDialogTrigger } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' -import { ZAP_SENDING_ENABLED } from '@/constants' import { useZap } from '@/providers/ZapProvider' import { disconnect, launchModal } from '@getalby/bitcoin-connect-react' import { useTranslation } from 'react-i18next' import DefaultZapAmountInput from './DefaultZapAmountInput' -import DefaultZapCommentInput from './DefaultZapCommentInput' -import QuickZapSwitch from './QuickZapSwitch' import WalletConnectionDetails from './WalletConnectionDetails' export default function WalletZapSendingSettings() { @@ -54,15 +51,6 @@ export default function WalletZapSendingSettings() {
- {ZAP_SENDING_ENABLED ? ( - <> - - -

- {t('Zap superchat wallet hint')} -

- - ) : null} ) } diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts index 73b5d21a..0eb97f85 100644 --- a/src/services/lightning.service.ts +++ b/src/services/lightning.service.ts @@ -1,31 +1,22 @@ import { CODY_PUBKEY, FAST_READ_RELAY_URLS, - FAST_WRITE_RELAY_URLS, - IMWALD_MAINTAINER_PUBKEY, - ZAP_SENDING_ENABLED + IMWALD_MAINTAINER_PUBKEY } from '@/constants' import { getZapInfoFromEvent } from '@/lib/event-metadata' -import { TProfile } from '@/types' import { closeModal, init, launchPaymentModal } from '@getalby/bitcoin-connect-react' import { isNwcWalletServiceInfoError, sendWebLNPaymentWithRetry } from '@/lib/webln-payment' -import { Invoice } from '@getalby/lightning-tools' import { bech32 } from '@scure/base' import { WebLNProvider } from '@webbtc/webln-types' import dayjs from 'dayjs' -import { Filter, kinds, NostrEvent } from 'nostr-tools' -import { SubCloser } from 'nostr-tools/abstract-pool' -import { makeZapRequest } from 'nostr-tools/nip57' +import { kinds, NostrEvent } from 'nostr-tools' import { utf8Decoder } from 'nostr-tools/utils' -import client from './client.service' -import { queryService, replaceableEventService } from './client.service' -import { getProfileFromEvent } from '@/lib/event-metadata' +import { queryService } from './client.service' import { clampZapSats } from '@/lib/lightning' -import { prioritizeZapLightningAddress } from '@/lib/merge-payment-methods' import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import { buildLnurlPayCallbackUrl, parseLnurlCommentAllowed } from '@/lib/lnurl-pay' import logger from '@/lib/logger' @@ -74,130 +65,14 @@ class LightningService { onPaymentFlowComplete?: (result: PaymentFlowResult) => void, zapLightning?: { address?: string; candidates?: string[] } ): Promise { - if (!ZAP_SENDING_ENABLED) { - throw new Error('NIP-57 zaps are disabled; use LNURL-pay invoices instead') - } - if (!client.signer) { - throw new Error('You need to be logged in to zap') - } - const { recipient, event } = - typeof recipientOrEvent === 'string' - ? { 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 | 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 ?? '' }) - } - } - } - ) - } - }) - }) + void sender + void recipientOrEvent + void sats + void comment + void closeOuterModel + void onPaymentFlowComplete + void zapLightning + throw new Error('NIP-57 zaps are not supported; use payment targets or LNURL-pay invoices') } async payInvoice( @@ -270,44 +145,6 @@ class LightningService { return this.recentSupportersCache } - private async getZapEndpoint( - profile: TProfile, - zapLightning?: { address?: string; candidates?: string[] } - ): Promise { - 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() - 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). * Does not require Nostr zap support — use {@link createLnurlInvoice} for plain invoices. @@ -372,15 +209,6 @@ class LightningService { return body.pr } - private async fetchLnurlPayZapEndpoint(lightningAddress: string): Promise { - 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