ZapButton.tsx raw

   1  import { LONG_PRESS_THRESHOLD } from '@/constants'
   2  import { useStuffStatsById } from '@/hooks/useStuffStatsById'
   3  import { useStuff } from '@/hooks/useStuff'
   4  import { getLightningAddressFromProfile } from '@/lib/lightning'
   5  import { cn } from '@/lib/utils'
   6  import { useNostr } from '@/providers/NostrProvider'
   7  import { useZap } from '@/providers/ZapProvider'
   8  import client from '@/services/client.service'
   9  import lightning from '@/services/lightning.service'
  10  import stuffStatsService from '@/services/stuff-stats.service'
  11  import { Loader, Zap } from 'lucide-react'
  12  import { Event } from 'nostr-tools'
  13  import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react'
  14  import { useTranslation } from 'react-i18next'
  15  import { toast } from 'sonner'
  16  import ZapDialog from '../ZapDialog'
  17  import KeyboardShortcut from './KeyboardShortcut'
  18  
  19  export default function ZapButton({ stuff }: { stuff: Event | string }) {
  20    const { t } = useTranslation()
  21    const { checkLogin, pubkey } = useNostr()
  22    const { event, stuffKey } = useStuff(stuff)
  23    const noteStats = useStuffStatsById(stuffKey)
  24    const { defaultZapSats, defaultZapComment, quickZap } = useZap()
  25    const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
  26    const [openZapDialog, setOpenZapDialog] = useState(false)
  27    const [zapping, setZapping] = useState(false)
  28    const { zapAmount, hasZapped } = useMemo(() => {
  29      return {
  30        zapAmount: noteStats?.zaps?.reduce((acc, zap) => acc + zap.amount, 0),
  31        hasZapped: pubkey ? noteStats?.zaps?.some((zap) => zap.pubkey === pubkey) : false
  32      }
  33    }, [noteStats, pubkey])
  34    const [disable, setDisable] = useState(true)
  35    const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  36    const isLongPressRef = useRef(false)
  37  
  38    useEffect(() => {
  39      if (!event) {
  40        setDisable(true)
  41        return
  42      }
  43  
  44      client.fetchProfile(event.pubkey).then((profile) => {
  45        if (!profile) return
  46        const lightningAddress = getLightningAddressFromProfile(profile)
  47        if (lightningAddress) setDisable(false)
  48      })
  49    }, [event])
  50  
  51    const handleZap = async () => {
  52      try {
  53        if (!pubkey) {
  54          throw new Error('You need to be logged in to zap')
  55        }
  56        if (zapping || !event) return
  57  
  58        setZapping(true)
  59        const zapResult = await lightning.zap(pubkey, event, defaultZapSats, defaultZapComment)
  60        // user canceled
  61        if (!zapResult) {
  62          return
  63        }
  64        stuffStatsService.addZap(
  65          pubkey,
  66          event.id,
  67          zapResult.invoice,
  68          defaultZapSats,
  69          defaultZapComment
  70        )
  71      } catch (error) {
  72        toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
  73      } finally {
  74        setZapping(false)
  75      }
  76    }
  77  
  78    const handleClickStart = (e: MouseEvent | TouchEvent) => {
  79      e.stopPropagation()
  80      e.preventDefault()
  81      if (disable) return
  82  
  83      isLongPressRef.current = false
  84  
  85      if ('touches' in e) {
  86        const touch = e.touches[0]
  87        setTouchStart({ x: touch.clientX, y: touch.clientY })
  88      }
  89  
  90      if (quickZap) {
  91        timerRef.current = setTimeout(() => {
  92          isLongPressRef.current = true
  93          checkLogin(() => {
  94            setOpenZapDialog(true)
  95            setZapping(true)
  96          })
  97        }, LONG_PRESS_THRESHOLD)
  98      }
  99    }
 100  
 101    const handleClickEnd = (e: MouseEvent | TouchEvent) => {
 102      e.stopPropagation()
 103      e.preventDefault()
 104      if (timerRef.current) {
 105        clearTimeout(timerRef.current)
 106      }
 107      if (disable) return
 108  
 109      if ('touches' in e) {
 110        setTouchStart(null)
 111        if (!touchStart) return
 112        const touch = e.changedTouches[0]
 113        const diffX = Math.abs(touch.clientX - touchStart.x)
 114        const diffY = Math.abs(touch.clientY - touchStart.y)
 115        if (diffX > 10 || diffY > 10) return
 116      }
 117  
 118      if (!quickZap) {
 119        checkLogin(() => {
 120          setOpenZapDialog(true)
 121          setZapping(true)
 122        })
 123      } else if (!isLongPressRef.current) {
 124        checkLogin(() => handleZap())
 125      }
 126      isLongPressRef.current = false
 127    }
 128  
 129    const handleMouseLeave = () => {
 130      if (timerRef.current) {
 131        clearTimeout(timerRef.current)
 132      }
 133    }
 134  
 135    return (
 136      <>
 137        <button
 138          className={cn(
 139            'flex items-center gap-1 select-none px-3 h-full cursor-pointer enabled:hover:text-yellow-400 disabled:text-muted-foreground/40 disabled:cursor-default group',
 140            hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
 141          )}
 142          title={t('Zap (z)')}
 143          disabled={disable || zapping}
 144          data-action="zap"
 145          onMouseDown={handleClickStart}
 146          onMouseUp={handleClickEnd}
 147          onMouseLeave={handleMouseLeave}
 148          onTouchStart={handleClickStart}
 149          onTouchEnd={handleClickEnd}
 150        >
 151          <span className="relative">
 152            {zapping ? (
 153              <Loader className="animate-spin" />
 154            ) : (
 155              <Zap className={hasZapped ? 'fill-yellow-400' : ''} />
 156            )}
 157            <KeyboardShortcut shortcut="z" />
 158          </span>
 159          {!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
 160        </button>
 161        {event && (
 162          <ZapDialog
 163            open={openZapDialog}
 164            setOpen={(open) => {
 165              setOpenZapDialog(open)
 166              setZapping(open)
 167            }}
 168            pubkey={event.pubkey}
 169            event={event}
 170          />
 171        )}
 172      </>
 173    )
 174  }
 175  
 176  function formatAmount(amount: number) {
 177    if (amount < 1000) return amount
 178    if (amount < 1000000) return `${Math.round(amount / 100) / 10}k`
 179    return `${Math.round(amount / 100000) / 10}M`
 180  }
 181