import { ZAP_SENDING_ENABLED } from '@/constants' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Drawer, DrawerContent, DrawerHeader, 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 { 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' import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' 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 TipPublicMessagePrompt from './TipPublicMessagePrompt' 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 }: { open: boolean setOpen: Dispatch> pubkey: string 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 }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() const drawerContentRef = useRef(null) const { pubkey: selfPubkey } = useNostr() const [tipNoticeOpen, setTipNoticeOpen] = useState(false) const skipTipNoticeOnCloseRef = useRef(false) 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 ] ) 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 maybeOfferTipNoticeOnClose = () => { if (paymentsOnly) return if (skipTipNoticeOnCloseRef.current) return if (selfPubkey && pubkey === selfPubkey) return setTipNoticeOpen(true) } const handleZapDialogOpenChange: Dispatch> = (next) => { const willOpen = typeof next === 'function' ? next(open) : next if (!willOpen) { maybeOfferTipNoticeOnClose() skipTipNoticeOnCloseRef.current = false } else { skipTipNoticeOnCloseRef.current = false } setOpen(next) } 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 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 } return () => { if (window.visualViewport) { window.visualViewport.removeEventListener('resize', handleResize) } } }, []) 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}
{ if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true }} />
{!paymentsOnly && ( )}
) } return ( <>
{dialogTitlePrefix}
{dialogDescription}
{ if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true }} />
{!paymentsOnly && ( )} ) } function ZapDialogContent({ open, setOpen, recipient, event, defaultAmount, defaultComment, recipientPayment, lightningAddressOptions, canLightningZap, onBeforeZapDialogClose }: { open: boolean setOpen: Dispatch> recipient: string event?: NostrEvent defaultAmount?: number defaultComment?: string recipientPayment: RecipientZapPaymentData lightningAddressOptions: string[] canLightningZap: boolean /** Runs before the zap dialog closes (e.g. after payment); skip tip notice if a public receipt was sent. */ onBeforeZapDialogClose?: (withPublicReceipt: boolean) => void }) { const { t, i18n } = useTranslation() const { pubkey } = useNostr() const paymentsOnly = !ZAP_SENDING_ENABLED const { defaultZapSats, defaultZapComment, includePublicZapReceipt, updateIncludePublicZapReceipt } = 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 closeZapDialog = () => { onBeforeZapDialogClose?.(includePublicZapReceipt) setOpen(false) } const zapResult = await lightning.zap( pubkey, event ?? recipient, clampedSats, comment, closeZapDialog, includePublicZapReceipt, { address: selectedLightning || undefined, candidates: lightningAddressOptions.length > 0 ? lightningAddressOptions : undefined } ) // user canceled 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)} />
{lightningAddressOptions.length === 1 ? (

{lightningAddressOptions[0]}

) : ( )}
{hasAlternativePayments ? ( ) : null}
) }