index.tsx raw

   1  import { Button } from '@/components/ui/button'
   2  import {
   3    Dialog,
   4    DialogContent,
   5    DialogDescription,
   6    DialogHeader,
   7    DialogTitle
   8  } from '@/components/ui/dialog'
   9  import {
  10    Drawer,
  11    DrawerContent,
  12    DrawerHeader,
  13    DrawerOverlay,
  14    DrawerTitle
  15  } from '@/components/ui/drawer'
  16  import { Input } from '@/components/ui/input'
  17  import { Label } from '@/components/ui/label'
  18  import { useNostr } from '@/providers/NostrProvider'
  19  import { useScreenSize } from '@/providers/ScreenSizeProvider'
  20  import { useZap } from '@/providers/ZapProvider'
  21  import lightning from '@/services/lightning.service'
  22  import stuffStatsService from '@/services/stuff-stats.service'
  23  import { Loader } from 'lucide-react'
  24  import { NostrEvent } from 'nostr-tools'
  25  import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
  26  import { useTranslation } from 'react-i18next'
  27  import { toast } from 'sonner'
  28  import UserAvatar from '../UserAvatar'
  29  import Username from '../Username'
  30  
  31  export default function ZapDialog({
  32    open,
  33    setOpen,
  34    pubkey,
  35    event,
  36    defaultAmount,
  37    defaultComment
  38  }: {
  39    open: boolean
  40    setOpen: Dispatch<SetStateAction<boolean>>
  41    pubkey: string
  42    event?: NostrEvent
  43    defaultAmount?: number
  44    defaultComment?: string
  45  }) {
  46    const { t } = useTranslation()
  47    const { isSmallScreen } = useScreenSize()
  48    const drawerContentRef = useRef<HTMLDivElement | null>(null)
  49  
  50    useEffect(() => {
  51      const handleResize = () => {
  52        if (drawerContentRef.current) {
  53          drawerContentRef.current.style.setProperty('bottom', `env(safe-area-inset-bottom)`)
  54        }
  55      }
  56  
  57      if (window.visualViewport) {
  58        window.visualViewport.addEventListener('resize', handleResize)
  59        handleResize() // Initial call in case the keyboard is already open
  60      }
  61  
  62      return () => {
  63        if (window.visualViewport) {
  64          window.visualViewport.removeEventListener('resize', handleResize)
  65        }
  66      }
  67    }, [])
  68  
  69    if (isSmallScreen) {
  70      return (
  71        <Drawer open={open} onOpenChange={setOpen}>
  72          <DrawerOverlay onClick={() => setOpen(false)} />
  73          <DrawerContent
  74            hideOverlay
  75            onOpenAutoFocus={(e) => e.preventDefault()}
  76            ref={drawerContentRef}
  77            className="flex flex-col gap-4 px-4 mb-4"
  78          >
  79            <DrawerHeader>
  80              <DrawerTitle className="flex gap-2 items-center">
  81                <div className="shrink-0">{t('Zap to')}</div>
  82                <UserAvatar size="small" userId={pubkey} />
  83                <Username userId={pubkey} className="truncate flex-1 w-0 text-start h-5" />
  84              </DrawerTitle>
  85              <DialogDescription></DialogDescription>
  86            </DrawerHeader>
  87            <ZapDialogContent
  88              open={open}
  89              setOpen={setOpen}
  90              recipient={pubkey}
  91              event={event}
  92              defaultAmount={defaultAmount}
  93              defaultComment={defaultComment}
  94            />
  95          </DrawerContent>
  96        </Drawer>
  97      )
  98    }
  99  
 100    return (
 101      <Dialog open={open} onOpenChange={setOpen}>
 102        <DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
 103          <DialogHeader>
 104            <DialogTitle className="flex gap-2 items-center">
 105              <div className="shrink-0">{t('Zap to')}</div>
 106              <UserAvatar size="small" userId={pubkey} />
 107              <Username userId={pubkey} className="truncate flex-1 max-w-fit text-start h-5" />
 108            </DialogTitle>
 109          </DialogHeader>
 110          <ZapDialogContent
 111            open={open}
 112            setOpen={setOpen}
 113            recipient={pubkey}
 114            event={event}
 115            defaultAmount={defaultAmount}
 116            defaultComment={defaultComment}
 117          />
 118        </DialogContent>
 119      </Dialog>
 120    )
 121  }
 122  
 123  function ZapDialogContent({
 124    setOpen,
 125    recipient,
 126    event,
 127    defaultAmount,
 128    defaultComment
 129  }: {
 130    open: boolean
 131    setOpen: Dispatch<SetStateAction<boolean>>
 132    recipient: string
 133    event?: NostrEvent
 134    defaultAmount?: number
 135    defaultComment?: string
 136  }) {
 137    const { t, i18n } = useTranslation()
 138    const { pubkey } = useNostr()
 139    const { defaultZapSats, defaultZapComment } = useZap()
 140    const [sats, setSats] = useState(defaultAmount ?? defaultZapSats)
 141    const [comment, setComment] = useState(defaultComment ?? defaultZapComment)
 142    const isSelfZap = useMemo(() => pubkey === recipient, [pubkey, recipient])
 143    const [zapping, setZapping] = useState(false)
 144    const presetAmounts = useMemo(() => {
 145      if (i18n.language.startsWith('zh')) {
 146        return [
 147          { display: '21', val: 21 },
 148          { display: '66', val: 66 },
 149          { display: '210', val: 210 },
 150          { display: '666', val: 666 },
 151          { display: '1k', val: 1000 },
 152          { display: '2.1k', val: 2100 },
 153          { display: '6.6k', val: 6666 },
 154          { display: '10k', val: 10000 },
 155          { display: '21k', val: 21000 },
 156          { display: '66k', val: 66666 },
 157          { display: '100k', val: 100000 },
 158          { display: '210k', val: 210000 }
 159        ]
 160      }
 161  
 162      return [
 163        { display: '21', val: 21 },
 164        { display: '42', val: 42 },
 165        { display: '210', val: 210 },
 166        { display: '420', val: 420 },
 167        { display: '1k', val: 1000 },
 168        { display: '2.1k', val: 2100 },
 169        { display: '4.2k', val: 4200 },
 170        { display: '10k', val: 10000 },
 171        { display: '21k', val: 21000 },
 172        { display: '42k', val: 42000 },
 173        { display: '100k', val: 100000 },
 174        { display: '210k', val: 210000 }
 175      ]
 176    }, [i18n.language])
 177  
 178    const handleZap = async () => {
 179      try {
 180        if (!pubkey) {
 181          throw new Error('You need to be logged in to zap')
 182        }
 183        setZapping(true)
 184        const zapResult = await lightning.zap(pubkey, event ?? recipient, sats, comment, () =>
 185          setOpen(false)
 186        )
 187        // user canceled
 188        if (!zapResult) {
 189          return
 190        }
 191        if (event) {
 192          stuffStatsService.addZap(pubkey, event.id, zapResult.invoice, sats, comment)
 193        }
 194      } catch (error) {
 195        toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
 196      } finally {
 197        setZapping(false)
 198      }
 199    }
 200  
 201    return (
 202      <>
 203        {/* Sats slider or input */}
 204        <div className="flex flex-col items-center">
 205          <div className="flex justify-center w-full">
 206            <input
 207              id="sats"
 208              value={sats}
 209              onChange={(e) => {
 210                setSats((pre) => {
 211                  if (e.target.value === '') {
 212                    return 0
 213                  }
 214                  let num = parseInt(e.target.value, 10)
 215                  if (isNaN(num) || num < 0) {
 216                    num = pre
 217                  }
 218                  return num
 219                })
 220              }}
 221              onFocus={(e) => {
 222                requestAnimationFrame(() => {
 223                  const val = e.target.value
 224                  e.target.setSelectionRange(val.length, val.length)
 225                })
 226              }}
 227              className="bg-transparent text-center w-full p-0 focus-visible:outline-none text-6xl font-bold"
 228            />
 229          </div>
 230          <Label htmlFor="sats">{t('Sats')}</Label>
 231        </div>
 232  
 233        {/* Self-zap easter egg warning */}
 234        {isSelfZap && (
 235          <div className="text-sm text-yellow-600 dark:text-yellow-400 text-center px-4 py-2 bg-yellow-50 dark:bg-yellow-950/30 rounded-md border border-yellow-200 dark:border-yellow-900">
 236            {t('selfZapWarning')}
 237          </div>
 238        )}
 239  
 240        {/* Preset sats buttons */}
 241        <div className="grid grid-cols-6 gap-2">
 242          {presetAmounts.map(({ display, val }) => (
 243            <Button variant="secondary" key={val} onClick={() => setSats(val)}>
 244              {display}
 245            </Button>
 246          ))}
 247        </div>
 248  
 249        {/* Comment input */}
 250        <div>
 251          <Label htmlFor="comment">{t('zapComment')}</Label>
 252          <Input id="comment" value={comment} onChange={(e) => setComment(e.target.value)} />
 253        </div>
 254  
 255        <Button onClick={handleZap}>
 256          {zapping && <Loader className="animate-spin" />} {t('Zap n sats', { n: sats })}
 257        </Button>
 258      </>
 259    )
 260  }
 261