diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index d0875076..47a76b41 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/NoteStats/ZapButton.tsx @@ -4,6 +4,11 @@ import { buildOrderedZapLightningAddresses, recipientHasAnyPaymentOptions } from '@/lib/merge-payment-methods' +import { + buildRecipientZapPaymentData, + mergeRecipientZapPaymentData, + type RecipientZapPaymentData +} from '@/hooks/useRecipientAlternativePayments' import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' import { cn } from '@/lib/utils' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' @@ -53,6 +58,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB const [disable, setDisable] = useState(true) const [canLightningZap, setCanLightningZap] = useState(false) + const [tipPaymentData, setTipPaymentData] = useState(null) const timerRef = useRef | null>(null) const isLongPressRef = useRef(false) @@ -60,33 +66,49 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB ( profile: TProfile | null, profileEvent: Event | null | undefined, - paymentInfo: ReturnType | null + paymentInfo: ReturnType | null, + forDialogPrefetch: boolean ) => { - const canTip = recipientHasAnyPaymentOptions(paymentInfo, profile, profileEvent ?? null) + const event = profileEvent ?? null + const canTip = recipientHasAnyPaymentOptions(paymentInfo, profile, event) setDisable(!canTip) 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(() => { if (isSelf) return if (!feedProfile || feedProfile.batchPlaceholder) return - applyTipAvailability(feedProfile, null, null) + applyTipAvailability(feedProfile, null, null, true) }, [isSelf, feedProfile, feedProfiles?.version, applyTipAvailability]) useEffect(() => { if (isSelf) { setDisable(true) setCanLightningZap(false) + setTipPaymentData(null) return } setDisable(true) setCanLightningZap(false) + setTipPaymentData(null) let cancelled = false void Promise.allSettled([ @@ -110,7 +132,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB null const paymentInfo = paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null - applyTipAvailability(profile, profileEvent ?? null, paymentInfo) + applyTipAvailability(profile, profileEvent ?? null, paymentInfo, true) }) return () => { @@ -268,6 +290,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB }} pubkey={event.pubkey} event={event} + prefetchedPayment={tipPaymentData} /> + profile?.pubkey + ? buildRecipientZapPaymentData(paymentInfo, profile ?? null, profileEvent ?? null) + : null, + [paymentInfo, profile, profileEvent] + ) + const syncAuthorReplaceablesFromCache = useCallback(async (pubkey: string) => { try { const [paymentEvent, metaEvent] = await Promise.all([ @@ -624,6 +633,7 @@ export default function Profile({ }} pubkey={pubkey} defaultLightningAddress={zapLightningDefault} + prefetchedPayment={prefetchedZapPayment} />
diff --git a/src/components/ZapDialog/ZapSatsAmountInput.tsx b/src/components/ZapDialog/ZapSatsAmountInput.tsx new file mode 100644 index 00000000..4ad9f279 --- /dev/null +++ b/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 ( +
+
+ {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 607f234a..a25299fe 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -20,6 +20,10 @@ import { Switch } from '@/components/ui/switch' 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 { cn } from '@/lib/utils' import lightning from '@/services/lightning.service' import noteStatsService from '@/services/note-stats.service' import { NostrEvent } from 'nostr-tools' @@ -33,6 +37,7 @@ import { } from '@/lib/merge-payment-methods' import PaymentMethodsSection from '@/components/PaymentMethodsSection' import { + mergeRecipientZapPaymentData, useRecipientZapPaymentData, type RecipientZapPaymentData } from '@/hooks/useRecipientAlternativePayments' @@ -44,6 +49,7 @@ import { SelectValue } from '@/components/ui/select' import TipPublicMessagePrompt from './TipPublicMessagePrompt' +import ZapSatsAmountInput from './ZapSatsAmountInput' import UserAvatar from '../UserAvatar' import Username from '../Username' @@ -54,7 +60,8 @@ export default function ZapDialog({ event, defaultAmount, defaultComment, - defaultLightningAddress + defaultLightningAddress, + prefetchedPayment = null }: { open: boolean setOpen: Dispatch> @@ -64,6 +71,8 @@ export default function ZapDialog({ 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 }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() @@ -72,16 +81,22 @@ export default function ZapDialog({ const [tipNoticeOpen, setTipNoticeOpen] = useState(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( () => buildOrderedZapLightningAddresses({ profileEvent: recipientPayment.profileEvent, + profile: recipientPayment.profile, paymentInfo: recipientPayment.paymentInfo, preferredAddress: defaultLightningAddress }), [ recipientPayment.profileEvent, + recipientPayment.profile, recipientPayment.paymentInfo, defaultLightningAddress ] @@ -245,10 +260,15 @@ function ZapDialogContent({ const { pubkey } = useNostr() const { defaultZapSats, defaultZapComment, includePublicZapReceipt, updateIncludePublicZapReceipt } = useZap() - const [sats, setSats] = useState(defaultAmount ?? defaultZapSats) + 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 @@ -261,9 +281,9 @@ function ZapDialogContent({ () => prepareZapDialogAlternativePayments( 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 @@ -315,7 +335,7 @@ function ZapDialogContent({ const zapResult = await lightning.zap( pubkey, event ?? recipient, - sats, + clampedSats, comment, closeZapDialog, includePublicZapReceipt, @@ -329,7 +349,7 @@ function ZapDialogContent({ return } if (event) { - noteStatsService.addZap(pubkey, event.id, zapResult.invoice, sats, comment) + noteStatsService.addZap(pubkey, event.id, zapResult.invoice, clampedSats, comment) } } catch (error) { toast.error(`${t('Zap failed')}: ${(error as Error).message}`) @@ -370,38 +390,35 @@ function ZapDialogContent({
{/* Sats slider or input */}
-
- { - setSats((pre) => { - if (e.target.value === '') { - return 0 - } - let num = parseInt(e.target.value, 10) - if (isNaN(num) || num < 0) { - num = pre - } - return num - }) - }} - onFocus={(e) => { - requestAnimationFrame(() => { - const val = e.target.value - e.target.setSelectionRange(val.length, val.length) - }) - }} - className="bg-transparent text-center w-full p-0 focus-visible:outline-none text-6xl font-bold" - /> +
+ + {btcEquivalent} + + {usdEquivalent != null ? ( + <> + + · + + {usdEquivalent} + + ) : null}
+
{/* Preset sats buttons */}
{presetAmounts.map(({ display, val }) => ( - ))} @@ -433,38 +450,56 @@ function ZapDialogContent({
- - + + + {selectedLightning ? ( + + + ⚡ + + {selectedLightning} - {selectedLightning} - - ) : null} - - - - {lightningAddressOptions.map((addr) => ( - - - - ⚡ + ) : null} + + + + {lightningAddressOptions.map((addr) => ( + + + + ⚡ + + {addr} - {addr} - - - ))} - - + + ))} + + + )}
{hasAlternativePayments ? ( diff --git a/src/hooks/useBtcUsdRate.ts b/src/hooks/useBtcUsdRate.ts new file mode 100644 index 00000000..8ab4a81a --- /dev/null +++ b/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(null) + + useEffect(() => { + let cancelled = false + fetchBtcUsdRate().then((rate) => { + if (!cancelled) setBtcUsd(rate) + }) + return () => { + cancelled = true + } + }, []) + + return btcUsd +} diff --git a/src/hooks/useRecipientAlternativePayments.test.ts b/src/hooks/useRecipientAlternativePayments.test.ts new file mode 100644 index 00000000..fd445eb9 --- /dev/null +++ b/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) + }) +}) diff --git a/src/hooks/useRecipientAlternativePayments.ts b/src/hooks/useRecipientAlternativePayments.ts index b6e82404..c52b3413 100644 --- a/src/hooks/useRecipientAlternativePayments.ts +++ b/src/hooks/useRecipientAlternativePayments.ts @@ -22,6 +22,48 @@ export type RecipientZapPaymentData = { 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. */ export function useRecipientZapPaymentData( recipientPubkey: string | undefined, @@ -62,19 +104,10 @@ export function useRecipientZapPaymentData( } }, [recipientPubkey, enabled]) - const canReceiveTip = useMemo( - () => recipientHasAnyPaymentOptions(paymentInfo, profile, profileEvent), + return useMemo( + () => buildRecipientZapPaymentData(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} */ diff --git a/src/lib/btc-usd-rate.ts b/src/lib/btc-usd-rate.ts new file mode 100644 index 00000000..82ee5e28 --- /dev/null +++ b/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 { + 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 + } +} diff --git a/src/lib/lightning-zap-amount.test.ts b/src/lib/lightning-zap-amount.test.ts new file mode 100644 index 00000000..54287c3a --- /dev/null +++ b/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 999 999 sats', () => { + expect(shouldHighlightLeadingSatsGroups(999_999)).toBe(false) + expect(shouldHighlightLeadingSatsGroups(1_000_000)).toBe(true) + expect(splitSatsGroupedParts(1_000_000)).toEqual(['1', '000', '000']) + }) +}) diff --git a/src/lib/lightning.ts b/src/lib/lightning.ts index 1f4160f2..68a649e0 100644 --- a/src/lib/lightning.ts +++ b/src/lib/lightning.ts @@ -13,6 +13,45 @@ export function formatAmount(amount: number) { return `${Math.round(amount / 100000) / 10}M` } +const SAT_GROUP_SEPARATOR = '\u2009' + +/** Max sats digits in the zap amount field (9 999 999). */ +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. 210 000). */ +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 999 999 sats (≥ 1 000 000). */ +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) { if (profile.lightningAddress?.trim()) return profile.lightningAddress.trim() if (profile.lightningAddressList?.length) { diff --git a/src/lib/merge-payment-methods.ts b/src/lib/merge-payment-methods.ts index 3a4a2a57..696e2e12 100644 --- a/src/lib/merge-payment-methods.ts +++ b/src/lib/merge-payment-methods.ts @@ -281,6 +281,8 @@ export function groupPaymentMethodsByDisplayType(methods: MergedPaymentMethod[]) */ 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[] { @@ -297,7 +299,8 @@ export function buildOrderedZapLightningAddresses(opts: { } 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) { for (const tag of ev.tags) { if (tag[0] === 'lud16' && tag[1]) add(tag[1]) diff --git a/src/lib/sats-fiat.test.ts b/src/lib/sats-fiat.test.ts new file mode 100644 index 00000000..9103ee07 --- /dev/null +++ b/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) + }) +}) diff --git a/src/lib/sats-fiat.ts b/src/lib/sats-fiat.ts new file mode 100644 index 00000000..19af4590 --- /dev/null +++ b/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) +}