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