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