Browse Source

add bitcoin and usd pricing

highlight amounts over 999.999 and clamp at 9.999.999
imwald
Silberengel 4 weeks ago
parent
commit
6e03de58ba
  1. 35
      src/components/NoteStats/ZapButton.tsx
  2. 10
      src/components/Profile/index.tsx
  3. 64
      src/components/ZapDialog/ZapSatsAmountInput.tsx
  4. 103
      src/components/ZapDialog/index.tsx
  5. 19
      src/hooks/useBtcUsdRate.ts
  6. 19
      src/hooks/useRecipientAlternativePayments.test.ts
  7. 55
      src/hooks/useRecipientAlternativePayments.ts
  8. 21
      src/lib/btc-usd-rate.ts
  9. 21
      src/lib/lightning-zap-amount.test.ts
  10. 39
      src/lib/lightning.ts
  11. 5
      src/lib/merge-payment-methods.ts
  12. 22
      src/lib/sats-fiat.test.ts
  13. 34
      src/lib/sats-fiat.ts

35
src/components/NoteStats/ZapButton.tsx

@ -4,6 +4,11 @@ import {
buildOrderedZapLightningAddresses, buildOrderedZapLightningAddresses,
recipientHasAnyPaymentOptions recipientHasAnyPaymentOptions
} from '@/lib/merge-payment-methods' } from '@/lib/merge-payment-methods'
import {
buildRecipientZapPaymentData,
mergeRecipientZapPaymentData,
type RecipientZapPaymentData
} from '@/hooks/useRecipientAlternativePayments'
import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
@ -53,6 +58,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
const [disable, setDisable] = useState(true) const [disable, setDisable] = useState(true)
const [canLightningZap, setCanLightningZap] = useState(false) const [canLightningZap, setCanLightningZap] = useState(false)
const [tipPaymentData, setTipPaymentData] = useState<RecipientZapPaymentData | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isLongPressRef = useRef(false) const isLongPressRef = useRef(false)
@ -60,33 +66,49 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
( (
profile: TProfile | null, profile: TProfile | null,
profileEvent: Event | null | undefined, profileEvent: Event | null | undefined,
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null,
forDialogPrefetch: boolean
) => { ) => {
const canTip = recipientHasAnyPaymentOptions(paymentInfo, profile, profileEvent ?? null) const event = profileEvent ?? null
const canTip = recipientHasAnyPaymentOptions(paymentInfo, profile, event)
setDisable(!canTip) setDisable(!canTip)
setCanLightningZap( setCanLightningZap(
buildOrderedZapLightningAddresses({ profileEvent, paymentInfo }).length > 0 buildOrderedZapLightningAddresses({
profileEvent: event,
profile,
paymentInfo
}).length > 0
)
if (forDialogPrefetch) {
setTipPaymentData((prev) =>
mergeRecipientZapPaymentData(
buildRecipientZapPaymentData(paymentInfo, profile, event),
prev
) )
)
}
}, },
[] []
) )
/** Re-enable when the feed batch loads a real profile (not a placeholder row). */ /** Enable zap from feed profile; seed dialog prefetch from kind 0 JSON when available. */
useEffect(() => { useEffect(() => {
if (isSelf) return if (isSelf) return
if (!feedProfile || feedProfile.batchPlaceholder) return if (!feedProfile || feedProfile.batchPlaceholder) return
applyTipAvailability(feedProfile, null, null) applyTipAvailability(feedProfile, null, null, true)
}, [isSelf, feedProfile, feedProfiles?.version, applyTipAvailability]) }, [isSelf, feedProfile, feedProfiles?.version, applyTipAvailability])
useEffect(() => { useEffect(() => {
if (isSelf) { if (isSelf) {
setDisable(true) setDisable(true)
setCanLightningZap(false) setCanLightningZap(false)
setTipPaymentData(null)
return return
} }
setDisable(true) setDisable(true)
setCanLightningZap(false) setCanLightningZap(false)
setTipPaymentData(null)
let cancelled = false let cancelled = false
void Promise.allSettled([ void Promise.allSettled([
@ -110,7 +132,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
null null
const paymentInfo = paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null const paymentInfo = paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null
applyTipAvailability(profile, profileEvent ?? null, paymentInfo) applyTipAvailability(profile, profileEvent ?? null, paymentInfo, true)
}) })
return () => { return () => {
@ -268,6 +290,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
}} }}
pubkey={event.pubkey} pubkey={event.pubkey}
event={event} event={event}
prefetchedPayment={tipPaymentData}
/> />
<TipPublicMessagePrompt <TipPublicMessagePrompt
open={tipNoticeOpen} open={tipNoticeOpen}

10
src/components/Profile/index.tsx

@ -85,6 +85,7 @@ 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 { import {
groupPaymentMethodsByDisplayType, groupPaymentMethodsByDisplayType,
mergePaymentMethods, mergePaymentMethods,
@ -154,6 +155,14 @@ export default function Profile({
[paymentInfo, profile, profileEvent] [paymentInfo, profile, profileEvent]
) )
const prefetchedZapPayment = useMemo(
() =>
profile?.pubkey
? buildRecipientZapPaymentData(paymentInfo, profile ?? null, profileEvent ?? null)
: null,
[paymentInfo, profile, profileEvent]
)
const syncAuthorReplaceablesFromCache = useCallback(async (pubkey: string) => { const syncAuthorReplaceablesFromCache = useCallback(async (pubkey: string) => {
try { try {
const [paymentEvent, metaEvent] = await Promise.all([ const [paymentEvent, metaEvent] = await Promise.all([
@ -624,6 +633,7 @@ export default function Profile({
}} }}
pubkey={pubkey} pubkey={pubkey}
defaultLightningAddress={zapLightningDefault} defaultLightningAddress={zapLightningDefault}
prefetchedPayment={prefetchedZapPayment}
/> />
<div className="flex flex-wrap justify-between items-center gap-x-4 gap-y-2 mt-2 text-sm min-w-0"> <div className="flex flex-wrap justify-between items-center gap-x-4 gap-y-2 mt-2 text-sm min-w-0">
<div className="flex flex-wrap gap-4 items-center min-w-0"> <div className="flex flex-wrap gap-4 items-center min-w-0">

64
src/components/ZapDialog/ZapSatsAmountInput.tsx

@ -0,0 +1,64 @@
import {
clampZapSats,
formatSatsGrouped,
parseGroupedIntegerInput,
shouldHighlightLeadingSatsGroups,
splitSatsGroupedParts
} from '@/lib/lightning'
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 && 'text-yellow-400')}
>
{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>
)
}

103
src/components/ZapDialog/index.tsx

@ -20,6 +20,10 @@ import { Switch } from '@/components/ui/switch'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import { useBtcUsdRate } from '@/hooks/useBtcUsdRate'
import { clampZapSats, formatSatsGrouped, shouldHighlightLeadingSatsGroups } from '@/lib/lightning'
import { formatBtcFromSats, formatUsdFromSats } from '@/lib/sats-fiat'
import { cn } from '@/lib/utils'
import lightning from '@/services/lightning.service' import lightning from '@/services/lightning.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
@ -33,6 +37,7 @@ import {
} from '@/lib/merge-payment-methods' } from '@/lib/merge-payment-methods'
import PaymentMethodsSection from '@/components/PaymentMethodsSection' import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import { import {
mergeRecipientZapPaymentData,
useRecipientZapPaymentData, useRecipientZapPaymentData,
type RecipientZapPaymentData type RecipientZapPaymentData
} from '@/hooks/useRecipientAlternativePayments' } from '@/hooks/useRecipientAlternativePayments'
@ -44,6 +49,7 @@ import {
SelectValue SelectValue
} from '@/components/ui/select' } from '@/components/ui/select'
import TipPublicMessagePrompt from './TipPublicMessagePrompt' import TipPublicMessagePrompt from './TipPublicMessagePrompt'
import ZapSatsAmountInput from './ZapSatsAmountInput'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
@ -54,7 +60,8 @@ export default function ZapDialog({
event, event,
defaultAmount, defaultAmount,
defaultComment, defaultComment,
defaultLightningAddress defaultLightningAddress,
prefetchedPayment = null
}: { }: {
open: boolean open: boolean
setOpen: Dispatch<SetStateAction<boolean>> setOpen: Dispatch<SetStateAction<boolean>>
@ -64,6 +71,8 @@ export default function ZapDialog({
defaultComment?: string defaultComment?: string
/** Lightning address to pre-select (e.g. from a profile payto link click). */ /** Lightning address to pre-select (e.g. from a profile payto link click). */
defaultLightningAddress?: string | null defaultLightningAddress?: string | null
/** Profile/feed snapshot shown immediately; relay fetch while open may enrich this. */
prefetchedPayment?: RecipientZapPaymentData | null
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
@ -72,16 +81,22 @@ export default function ZapDialog({
const [tipNoticeOpen, setTipNoticeOpen] = useState(false) const [tipNoticeOpen, setTipNoticeOpen] = useState(false)
const skipTipNoticeOnCloseRef = useRef(false) const skipTipNoticeOnCloseRef = useRef(false)
const recipientPayment = useRecipientZapPaymentData(pubkey, open) const fetchedPayment = useRecipientZapPaymentData(pubkey, open)
const recipientPayment = useMemo(
() => mergeRecipientZapPaymentData(prefetchedPayment, fetchedPayment),
[prefetchedPayment, fetchedPayment]
)
const lightningAddressOptions = useMemo( const lightningAddressOptions = useMemo(
() => () =>
buildOrderedZapLightningAddresses({ buildOrderedZapLightningAddresses({
profileEvent: recipientPayment.profileEvent, profileEvent: recipientPayment.profileEvent,
profile: recipientPayment.profile,
paymentInfo: recipientPayment.paymentInfo, paymentInfo: recipientPayment.paymentInfo,
preferredAddress: defaultLightningAddress preferredAddress: defaultLightningAddress
}), }),
[ [
recipientPayment.profileEvent, recipientPayment.profileEvent,
recipientPayment.profile,
recipientPayment.paymentInfo, recipientPayment.paymentInfo,
defaultLightningAddress defaultLightningAddress
] ]
@ -245,10 +260,15 @@ function ZapDialogContent({
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { defaultZapSats, defaultZapComment, includePublicZapReceipt, updateIncludePublicZapReceipt } = const { defaultZapSats, defaultZapComment, includePublicZapReceipt, updateIncludePublicZapReceipt } =
useZap() useZap()
const [sats, setSats] = useState(defaultAmount ?? defaultZapSats) const [sats, setSats] = useState(() => clampZapSats(defaultAmount ?? defaultZapSats))
const [comment, setComment] = useState(defaultComment ?? defaultZapComment) const [comment, setComment] = useState(defaultComment ?? defaultZapComment)
const [zapping, setZapping] = useState(false) const [zapping, setZapping] = useState(false)
const [selectedLightning, setSelectedLightning] = useState('') 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 const { alternativeGroups } = recipientPayment
@ -261,9 +281,9 @@ function ZapDialogContent({
() => () =>
prepareZapDialogAlternativePayments( prepareZapDialogAlternativePayments(
alternativeGroups, alternativeGroups,
canLightningZap ? sats : ZAP_HIDE_BITCOIN_ALTS_MAX_SATS canLightningZap ? clampedSats : ZAP_HIDE_BITCOIN_ALTS_MAX_SATS
), ),
[alternativeGroups, sats, canLightningZap] [alternativeGroups, clampedSats, canLightningZap]
) )
const hasAlternativePayments = zapAlternativePayments.groups.length > 0 const hasAlternativePayments = zapAlternativePayments.groups.length > 0
@ -315,7 +335,7 @@ function ZapDialogContent({
const zapResult = await lightning.zap( const zapResult = await lightning.zap(
pubkey, pubkey,
event ?? recipient, event ?? recipient,
sats, clampedSats,
comment, comment,
closeZapDialog, closeZapDialog,
includePublicZapReceipt, includePublicZapReceipt,
@ -329,7 +349,7 @@ function ZapDialogContent({
return return
} }
if (event) { if (event) {
noteStatsService.addZap(pubkey, event.id, zapResult.invoice, sats, comment) noteStatsService.addZap(pubkey, event.id, zapResult.invoice, clampedSats, comment)
} }
} catch (error) { } catch (error) {
toast.error(`${t('Zap failed')}: ${(error as Error).message}`) toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
@ -370,38 +390,35 @@ function ZapDialogContent({
<div className="space-y-4"> <div className="space-y-4">
{/* Sats slider or input */} {/* Sats slider or input */}
<div className="flex flex-col items-center px-4"> <div className="flex flex-col items-center px-4">
<div className="flex justify-center w-full max-w-xs"> <div
<input 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"
id="sats" aria-live="polite"
value={sats} >
onChange={(e) => { <span
setSats((pre) => { className={cn(
if (e.target.value === '') { highlightLargeAmount &&
return 0 'rounded-md bg-yellow-400/25 px-2 py-0.5 font-semibold text-yellow-200 shadow-[0_0_12px_rgba(250,204,21,0.45)] ring-1 ring-yellow-400/70'
} )}
let num = parseInt(e.target.value, 10) >
if (isNaN(num) || num < 0) { {btcEquivalent}
num = pre </span>
} {usdEquivalent != null ? (
return num <>
}) <span className="text-muted-foreground/40" aria-hidden>
}} ·
onFocus={(e) => { </span>
requestAnimationFrame(() => { <span>{usdEquivalent}</span>
const val = e.target.value </>
e.target.setSelectionRange(val.length, val.length) ) : null}
})
}}
className="bg-transparent text-center w-full p-0 focus-visible:outline-none text-6xl font-bold"
/>
</div> </div>
<ZapSatsAmountInput sats={sats} onSatsChange={setSats} />
<Label htmlFor="sats">{t('Sats')}</Label> <Label htmlFor="sats">{t('Sats')}</Label>
</div> </div>
{/* Preset sats buttons */} {/* Preset sats buttons */}
<div className="grid grid-cols-6 gap-2 px-4"> <div className="grid grid-cols-6 gap-2 px-4">
{presetAmounts.map(({ display, val }) => ( {presetAmounts.map(({ display, val }) => (
<Button variant="secondary" key={val} onClick={() => setSats(val)}> <Button variant="secondary" key={val} onClick={() => setSats(clampZapSats(val))}>
{display} {display}
</Button> </Button>
))} ))}
@ -433,7 +450,24 @@ function ZapDialogContent({
</div> </div>
<div className="min-w-0 space-y-1.5"> <div className="min-w-0 space-y-1.5">
<Label htmlFor="zap-lightning-address">{t('Lightning address for zap')}</Label> <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="shrink-0 text-lg leading-none text-yellow-400" aria-hidden>
</span>
<span className="min-w-0 break-all">{lightningAddressOptions[0]}</span>
</p>
) : (
<Select value={selectedLightning} onValueChange={setSelectedLightning}> <Select value={selectedLightning} onValueChange={setSelectedLightning}>
<SelectTrigger id="zap-lightning-address" className="min-w-0 gap-2"> <SelectTrigger id="zap-lightning-address" className="min-w-0 gap-2">
<SelectValue placeholder={t('Select lightning address')}> <SelectValue placeholder={t('Select lightning address')}>
@ -460,11 +494,12 @@ function ZapDialogContent({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)}
</div> </div>
<Button onClick={handleZap} className="w-full"> <Button onClick={handleZap} className="w-full">
{zapping && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}{' '} {zapping && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}{' '}
{t('Zap n sats', { n: sats })} {t('Zap n sats', { n: formatSatsGrouped(clampedSats) })}
</Button> </Button>
{hasAlternativePayments ? ( {hasAlternativePayments ? (

19
src/hooks/useBtcUsdRate.ts

@ -0,0 +1,19 @@
import { fetchBtcUsdRate } from '@/lib/btc-usd-rate'
import { useEffect, useState } from 'react'
/** BTC/USD spot for zap amount hints (null while loading or if fetch failed). */
export function useBtcUsdRate() {
const [btcUsd, setBtcUsd] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
fetchBtcUsdRate().then((rate) => {
if (!cancelled) setBtcUsd(rate)
})
return () => {
cancelled = true
}
}, [])
return btcUsd
}

19
src/hooks/useRecipientAlternativePayments.test.ts

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { buildRecipientZapPaymentData, mergeRecipientZapPaymentData } from './useRecipientAlternativePayments'
import type { TProfile } from '@/types'
describe('mergeRecipientZapPaymentData', () => {
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)
expect(merged.canReceiveTip).toBe(true)
expect(
merged.alternativeGroups.length + (partial.canReceiveTip ? 1 : 0)
).toBeGreaterThan(0)
})
})

55
src/hooks/useRecipientAlternativePayments.ts

@ -22,6 +22,48 @@ export type RecipientZapPaymentData = {
canReceiveTip: boolean canReceiveTip: boolean
} }
export function buildRecipientZapPaymentData(
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 }
}
/** 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 {
if (!partial) {
return fresh ?? buildRecipientZapPaymentData(null, null, null)
}
if (!fresh) return partial
const profileEvent = fresh.profileEvent ?? partial.profileEvent
const profile = profileEvent
? (fresh.profile ?? partial.profile)
: (partial.profile ?? fresh.profile)
const paymentInfo = pickRicherPaymentInfo(partial.paymentInfo, fresh.paymentInfo)
return buildRecipientZapPaymentData(paymentInfo, profile ?? null, profileEvent)
}
function pickRicherPaymentInfo(
a: TPaymentInfo | null | undefined,
b: TPaymentInfo | null | undefined
): TPaymentInfo | null {
const score = (p: TPaymentInfo | null | undefined) =>
p?.methods?.length ?? (p?.payto ? 1 : 0)
if (score(b) > score(a)) return b ?? null
if (score(a) > score(b)) return a ?? null
return b ?? a ?? null
}
/** Kind 10133 + profile payto targets except the Lightning address used for zapping. */ /** Kind 10133 + profile payto targets except the Lightning address used for zapping. */
export function useRecipientZapPaymentData( export function useRecipientZapPaymentData(
recipientPubkey: string | undefined, recipientPubkey: string | undefined,
@ -62,19 +104,10 @@ export function useRecipientZapPaymentData(
} }
}, [recipientPubkey, enabled]) }, [recipientPubkey, enabled])
const canReceiveTip = useMemo( return useMemo(
() => recipientHasAnyPaymentOptions(paymentInfo, profile, profileEvent), () => buildRecipientZapPaymentData(paymentInfo, profile, profileEvent),
[paymentInfo, profile, profileEvent] [paymentInfo, profile, profileEvent]
) )
const alternativeGroups = useMemo(() => {
if (!recipientPubkey) return []
const merged = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile, profileEvent))
const alts = getAlternativePaymentMethods(merged)
return groupPaymentMethodsByDisplayType(alts)
}, [recipientPubkey, paymentInfo, profile, profileEvent])
return { paymentInfo, profile, profileEvent, alternativeGroups, canReceiveTip }
} }
/** @deprecated Use {@link useRecipientZapPaymentData} */ /** @deprecated Use {@link useRecipientZapPaymentData} */

21
src/lib/btc-usd-rate.ts

@ -0,0 +1,21 @@
const CACHE_MS = 5 * 60 * 1000
let cache: { usd: number; at: number } | null = null
/** Latest BTC/USD spot price (cached ~5 min). */
export async function fetchBtcUsdRate(): Promise<number | null> {
if (cache && Date.now() - cache.at < CACHE_MS) {
return cache.usd
}
try {
const res = await fetch('https://mempool.space/api/v1/prices')
if (!res.ok) return cache?.usd ?? null
const data = (await res.json()) as { USD?: number }
const usd = Number(data.USD)
if (!Number.isFinite(usd) || usd <= 0) return cache?.usd ?? null
cache = { usd, at: Date.now() }
return usd
} catch {
return cache?.usd ?? null
}
}

21
src/lib/lightning-zap-amount.test.ts

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import {
MAX_ZAP_SATS,
clampZapSats,
parseGroupedIntegerInput,
shouldHighlightLeadingSatsGroups,
splitSatsGroupedParts
} from './lightning'
describe('zap sats amount limits', () => {
it('clamps to 7 digits', () => {
expect(clampZapSats(99_999_999)).toBe(MAX_ZAP_SATS)
expect(parseGroupedIntegerInput('12345678')).toBe(1_234_567)
})
it('highlights leading group above 999999 sats', () => {
expect(shouldHighlightLeadingSatsGroups(999_999)).toBe(false)
expect(shouldHighlightLeadingSatsGroups(1_000_000)).toBe(true)
expect(splitSatsGroupedParts(1_000_000)).toEqual(['1', '000', '000'])
})
})

39
src/lib/lightning.ts

@ -13,6 +13,45 @@ export function formatAmount(amount: number) {
return `${Math.round(amount / 100000) / 10}M` return `${Math.round(amount / 100000) / 10}M`
} }
const SAT_GROUP_SEPARATOR = '\u2009'
/** Max sats digits in the zap amount field (9999999). */
export const MAX_ZAP_SATS = 9_999_999
/** Leading digit group + BTC hint styling above this amount (exclusive). */
export const ZAP_SATS_HIGHLIGHT_ABOVE = 999_999
/** Group sats in threes with a thin space (e.g. 210000). */
export function formatSatsGrouped(amount: number): string {
const n = clampZapSats(amount)
return n.toLocaleString('en-US').replace(/,/g, SAT_GROUP_SEPARATOR)
}
export function clampZapSats(amount: number): number {
if (!Number.isFinite(amount) || amount < 0) return 0
return Math.min(MAX_ZAP_SATS, Math.floor(amount))
}
/** True when amount is above 999999 sats (≥ 1000000). */
export function shouldHighlightLeadingSatsGroups(amount: number): boolean {
return clampZapSats(amount) > ZAP_SATS_HIGHLIGHT_ABOVE
}
/** Digit groups for display (e.g. ["210", "000"]). */
export function splitSatsGroupedParts(amount: number): string[] {
const formatted = formatSatsGrouped(amount)
if (!formatted) return ['0']
return formatted.split(SAT_GROUP_SEPARATOR)
}
/** Parse user input that may contain digit grouping spaces. */
export function parseGroupedIntegerInput(raw: string): number {
const digits = raw.replace(/\D/g, '').slice(0, 7)
if (digits === '') return 0
const num = parseInt(digits, 10)
return Number.isFinite(num) ? clampZapSats(num) : 0
}
export function getLightningAddressFromProfile(profile: TProfile) { export function getLightningAddressFromProfile(profile: TProfile) {
if (profile.lightningAddress?.trim()) return profile.lightningAddress.trim() if (profile.lightningAddress?.trim()) return profile.lightningAddress.trim()
if (profile.lightningAddressList?.length) { if (profile.lightningAddressList?.length) {

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

@ -281,6 +281,8 @@ export function groupPaymentMethodsByDisplayType(methods: MergedPaymentMethod[])
*/ */
export function buildOrderedZapLightningAddresses(opts: { export function buildOrderedZapLightningAddresses(opts: {
profileEvent?: Event | null profileEvent?: Event | null
/** Parsed kind 0 when the event is not loaded yet (e.g. feed profile row). */
profile?: TProfile | null
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null
preferredAddress?: string | null preferredAddress?: string | null
}): string[] { }): string[] {
@ -297,7 +299,8 @@ export function buildOrderedZapLightningAddresses(opts: {
} }
const ev = opts.profileEvent const ev = opts.profileEvent
const profile = ev?.kind === kinds.Metadata ? getProfileFromEvent(ev) : null const profile =
ev?.kind === kinds.Metadata ? getProfileFromEvent(ev) : (opts.profile ?? null)
if (ev?.kind === kinds.Metadata) { if (ev?.kind === kinds.Metadata) {
for (const tag of ev.tags) { for (const tag of ev.tags) {
if (tag[0] === 'lud16' && tag[1]) add(tag[1]) if (tag[0] === 'lud16' && tag[1]) add(tag[1])

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

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { formatBtcFromSats, formatUsdFromSats, satsToBtc, satsToUsd } from './sats-fiat'
describe('sats-fiat', () => {
it('converts sats to btc', () => {
expect(satsToBtc(100_000_000)).toBe(1)
expect(satsToBtc(210_000)).toBe(0.0021)
})
it('formats btc from sats', () => {
expect(formatBtcFromSats(0)).toBe('0 BTC')
expect(formatBtcFromSats(210_000)).toContain('BTC')
expect(formatBtcFromSats(210_000)).toMatch(/0\.0021|0,0021/)
})
it('formats usd when rate is known', () => {
expect(formatUsdFromSats(210_000, null)).toBeNull()
const usd = formatUsdFromSats(100_000_000, 100_000)
expect(usd).toMatch(/\$|USD/)
expect(satsToUsd(100_000_000, 100_000)).toBe(100_000)
})
})

34
src/lib/sats-fiat.ts

@ -0,0 +1,34 @@
const SATS_PER_BTC = 100_000_000
export function satsToBtc(sats: number): number {
return Math.max(0, sats) / SATS_PER_BTC
}
export function satsToUsd(sats: number, btcUsd: number): number {
return satsToBtc(sats) * btcUsd
}
/** Human-readable BTC equivalent (e.g. 0.0021 BTC). */
export function formatBtcFromSats(sats: number): string {
const btc = satsToBtc(sats)
if (btc === 0) return '0 BTC'
const maxFrac = btc >= 1 ? 4 : btc >= 0.01 ? 6 : 8
const num = btc.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: maxFrac
})
return `${num} BTC`
}
/** USD equivalent; returns null when no rate is available. */
export function formatUsdFromSats(sats: number, btcUsd: number | null): string | null {
if (btcUsd == null || !Number.isFinite(btcUsd) || btcUsd <= 0) return null
const usd = satsToUsd(sats, btcUsd)
const maxFrac = usd >= 1 ? 2 : 4
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: maxFrac
}).format(usd)
}
Loading…
Cancel
Save