You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
572 lines
20 KiB
572 lines
20 KiB
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<SetStateAction<boolean>> |
|
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<HTMLDivElement | null>(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<SetStateAction<boolean>> = (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 ( |
|
<Drawer open={open} onOpenChange={handleZapDialogOpenChange}> |
|
<DrawerOverlay onClick={() => handleZapDialogOpenChange(false)} /> |
|
<DrawerContent |
|
hideOverlay |
|
onOpenAutoFocus={(e) => 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 |
|
}} |
|
> |
|
<DrawerHeader className="shrink-0 px-4"> |
|
<DrawerTitle className="flex gap-2 items-center"> |
|
<div className="shrink-0">{dialogTitlePrefix}</div> |
|
<UserAvatar size="small" userId={pubkey} /> |
|
<Username userId={pubkey} className="truncate flex-1 w-0 text-start h-5" /> |
|
</DrawerTitle> |
|
<DialogDescription className="sr-only">{dialogDescription}</DialogDescription> |
|
</DrawerHeader> |
|
<ZapDialogContent |
|
open={open} |
|
setOpen={handleZapDialogOpenChange} |
|
recipient={pubkey} |
|
event={event} |
|
defaultAmount={defaultAmount} |
|
defaultComment={defaultComment} |
|
recipientPayment={recipientPayment} |
|
lightningAddressOptions={lightningAddressOptions} |
|
canLightningZap={canLightningZap} |
|
onBeforeZapDialogClose={(withPublicReceipt) => { |
|
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true |
|
}} |
|
/> |
|
</DrawerContent> |
|
{!paymentsOnly && ( |
|
<TipPublicMessagePrompt |
|
open={tipNoticeOpen} |
|
onOpenChange={setTipNoticeOpen} |
|
recipientPubkey={pubkey} |
|
/> |
|
)} |
|
</Drawer> |
|
) |
|
} |
|
|
|
return ( |
|
<> |
|
<Dialog open={open} onOpenChange={handleZapDialogOpenChange}> |
|
<DialogContent> |
|
<DialogHeader> |
|
<DialogTitle className="flex gap-2 items-center"> |
|
<div className="shrink-0">{dialogTitlePrefix}</div> |
|
<UserAvatar size="small" userId={pubkey} /> |
|
<Username userId={pubkey} className="truncate flex-1 max-w-fit text-start h-5" /> |
|
</DialogTitle> |
|
<DialogDescription className="sr-only">{dialogDescription}</DialogDescription> |
|
</DialogHeader> |
|
<ZapDialogContent |
|
open={open} |
|
setOpen={handleZapDialogOpenChange} |
|
recipient={pubkey} |
|
event={event} |
|
defaultAmount={defaultAmount} |
|
defaultComment={defaultComment} |
|
recipientPayment={recipientPayment} |
|
lightningAddressOptions={lightningAddressOptions} |
|
canLightningZap={canLightningZap} |
|
onBeforeZapDialogClose={(withPublicReceipt) => { |
|
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true |
|
}} |
|
/> |
|
</DialogContent> |
|
</Dialog> |
|
{!paymentsOnly && ( |
|
<TipPublicMessagePrompt |
|
open={tipNoticeOpen} |
|
onOpenChange={setTipNoticeOpen} |
|
recipientPubkey={pubkey} |
|
/> |
|
)} |
|
</> |
|
) |
|
} |
|
|
|
function ZapDialogContent({ |
|
open, |
|
setOpen, |
|
recipient, |
|
event, |
|
defaultAmount, |
|
defaultComment, |
|
recipientPayment, |
|
lightningAddressOptions, |
|
canLightningZap, |
|
onBeforeZapDialogClose |
|
}: { |
|
open: boolean |
|
setOpen: Dispatch<SetStateAction<boolean>> |
|
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 ( |
|
<div |
|
className="px-4 pb-4" |
|
style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }} |
|
> |
|
{allPaymentGroups.length > 0 ? ( |
|
<PaymentMethodsSection |
|
groups={allPaymentGroups} |
|
recipientPubkey={recipient} |
|
title={t('Payment methods')} |
|
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0" |
|
/> |
|
) : ( |
|
<p className="py-8 text-center text-sm text-muted-foreground"> |
|
{t('No payment methods available for this profile')} |
|
</p> |
|
)} |
|
</div> |
|
) |
|
} |
|
const [sats, setSats] = useState(() => clampZapSats(defaultAmount ?? defaultZapSats)) |
|
const [comment, setComment] = useState(defaultComment ?? defaultZapComment) |
|
const [zapping, setZapping] = useState(false) |
|
const [selectedLightning, setSelectedLightning] = useState('') |
|
const btcUsd = useBtcUsdRate() |
|
const clampedSats = clampZapSats(sats) |
|
const highlightLargeAmount = shouldHighlightLeadingSatsGroups(clampedSats) |
|
const btcEquivalent = useMemo(() => formatBtcFromSats(clampedSats), [clampedSats]) |
|
const usdEquivalent = useMemo(() => formatUsdFromSats(clampedSats, btcUsd), [clampedSats, btcUsd]) |
|
|
|
const { alternativeGroups } = recipientPayment |
|
|
|
useEffect(() => { |
|
if (!open) return |
|
setSelectedLightning(lightningAddressOptions[0] ?? '') |
|
}, [open, lightningAddressOptions]) |
|
|
|
const zapAlternativePayments = useMemo( |
|
() => |
|
prepareZapDialogAlternativePayments( |
|
alternativeGroups, |
|
canLightningZap ? clampedSats : ZAP_HIDE_BITCOIN_ALTS_MAX_SATS |
|
), |
|
[alternativeGroups, clampedSats, canLightningZap] |
|
) |
|
|
|
const hasAlternativePayments = zapAlternativePayments.groups.length > 0 |
|
|
|
const presetAmounts = useMemo(() => { |
|
if (i18n.language.startsWith('zh')) { |
|
return [ |
|
{ display: '21', val: 21 }, |
|
{ display: '66', val: 66 }, |
|
{ display: '210', val: 210 }, |
|
{ display: '666', val: 666 }, |
|
{ display: '1k', val: 1000 }, |
|
{ display: '2.1k', val: 2100 }, |
|
{ display: '6.6k', val: 6666 }, |
|
{ display: '10k', val: 10000 }, |
|
{ display: '21k', val: 21000 }, |
|
{ display: '66k', val: 66666 }, |
|
{ display: '100k', val: 100000 }, |
|
{ display: '210k', val: 210000 } |
|
] |
|
} |
|
|
|
return [ |
|
{ display: '21', val: 21 }, |
|
{ display: '42', val: 42 }, |
|
{ display: '210', val: 210 }, |
|
{ display: '420', val: 420 }, |
|
{ display: '1k', val: 1000 }, |
|
{ display: '2.1k', val: 2100 }, |
|
{ display: '4.2k', val: 4200 }, |
|
{ display: '10k', val: 10000 }, |
|
{ display: '21k', val: 21000 }, |
|
{ display: '42k', val: 42000 }, |
|
{ display: '100k', val: 100000 }, |
|
{ display: '210k', val: 210000 } |
|
] |
|
}, [i18n.language]) |
|
|
|
const handleZap = async () => { |
|
try { |
|
if (!pubkey) { |
|
throw new Error('You need to be logged in to zap') |
|
} |
|
setZapping(true) |
|
const 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 ( |
|
<div |
|
className="px-4 pb-4" |
|
style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }} |
|
> |
|
{hasAlternativePayments ? ( |
|
<PaymentMethodsSection |
|
groups={zapAlternativePayments.groups} |
|
recipientPubkey={recipient} |
|
title={t('Payment methods')} |
|
headerHelpText={ |
|
zapAlternativePayments.showBitcoinOnChainHint |
|
? t('Tips above 10k sats can use Bitcoin on-chain.') |
|
: undefined |
|
} |
|
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0" |
|
/> |
|
) : ( |
|
<p className="py-8 text-center text-sm text-muted-foreground"> |
|
{t('No payment methods available for this profile')} |
|
</p> |
|
)} |
|
</div> |
|
) |
|
} |
|
|
|
return ( |
|
<div> |
|
<div className="space-y-4"> |
|
{/* Sats slider or input */} |
|
<div className="flex flex-col items-center px-4"> |
|
<div |
|
className="mb-1 flex min-h-[1.125rem] flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground tabular-nums" |
|
aria-live="polite" |
|
> |
|
<span |
|
className={cn( |
|
highlightLargeAmount && |
|
'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' |
|
)} |
|
> |
|
{btcEquivalent} |
|
</span> |
|
{usdEquivalent != null ? ( |
|
<> |
|
<span className="text-muted-foreground/40" aria-hidden> |
|
· |
|
</span> |
|
<span>{usdEquivalent}</span> |
|
</> |
|
) : null} |
|
</div> |
|
<ZapSatsAmountInput sats={sats} onSatsChange={setSats} /> |
|
<Label htmlFor="sats">{t('Sats')}</Label> |
|
</div> |
|
|
|
{/* Preset sats buttons */} |
|
<div className="grid grid-cols-6 gap-2 px-4"> |
|
{presetAmounts.map(({ display, val }) => ( |
|
<Button variant="secondary" key={val} onClick={() => setSats(clampZapSats(val))}> |
|
{display} |
|
</Button> |
|
))} |
|
</div> |
|
|
|
{/* Comment input */} |
|
<div className="px-4"> |
|
<Label htmlFor="comment">{t('zapComment')}</Label> |
|
<Input id="comment" value={comment} onChange={(e) => setComment(e.target.value)} /> |
|
</div> |
|
</div> |
|
|
|
<div |
|
className="space-y-3 border-t border-border bg-background px-4 pt-3" |
|
style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }} |
|
> |
|
<div className="flex items-center justify-between gap-3"> |
|
<Label htmlFor="zap-include-receipt" className="flex-1 cursor-pointer"> |
|
<div className="text-sm font-medium">{t('Include public zap receipt')}</div> |
|
<div className="text-xs text-muted-foreground font-normal"> |
|
{t('When off, your zap may still succeed but a public receipt may not be published to relays')} |
|
</div> |
|
</Label> |
|
<Switch |
|
id="zap-include-receipt" |
|
checked={includePublicZapReceipt} |
|
onCheckedChange={updateIncludePublicZapReceipt} |
|
/> |
|
</div> |
|
|
|
<div className="min-w-0 space-y-1.5"> |
|
<Label |
|
htmlFor={ |
|
lightningAddressOptions.length > 1 ? 'zap-lightning-address' : undefined |
|
} |
|
> |
|
{t('Lightning address for zap')} |
|
</Label> |
|
{lightningAddressOptions.length === 1 ? ( |
|
<p |
|
id="zap-lightning-address" |
|
className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground" |
|
> |
|
<span className="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}> |
|
<SelectTrigger id="zap-lightning-address" className="min-w-0 gap-2"> |
|
<SelectValue placeholder={t('Select lightning address')}> |
|
{selectedLightning ? ( |
|
<span className="flex min-w-0 items-center gap-2"> |
|
<span className="shrink-0 text-lg leading-none text-yellow-400" aria-hidden> |
|
⚡ |
|
</span> |
|
<span className="min-w-0 truncate">{selectedLightning}</span> |
|
</span> |
|
) : null} |
|
</SelectValue> |
|
</SelectTrigger> |
|
<SelectContent> |
|
{lightningAddressOptions.map((addr) => ( |
|
<SelectItem key={addr} value={addr} className="break-all"> |
|
<span className="flex items-start gap-2"> |
|
<span className="shrink-0 text-lg leading-none text-yellow-400" aria-hidden> |
|
⚡ |
|
</span> |
|
<span className="min-w-0 break-all">{addr}</span> |
|
</span> |
|
</SelectItem> |
|
))} |
|
</SelectContent> |
|
</Select> |
|
)} |
|
</div> |
|
|
|
<Button onClick={handleZap} className="w-full"> |
|
{zapping && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}{' '} |
|
{t('Zap n sats', { n: formatSatsGrouped(clampedSats) })} |
|
</Button> |
|
|
|
{hasAlternativePayments ? ( |
|
<PaymentMethodsSection |
|
groups={zapAlternativePayments.groups} |
|
recipientPubkey={recipient} |
|
title={t('Other payment methods')} |
|
headerHelpText={ |
|
zapAlternativePayments.showBitcoinOnChainHint |
|
? t('Tips above 10k sats can use Bitcoin on-chain.') |
|
: undefined |
|
} |
|
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0" |
|
/> |
|
) : null} |
|
</div> |
|
</div> |
|
) |
|
}
|
|
|