- Add keyboard mode toggle button (⇧K) in sidebar - Triple-Escape to quickly exit keyboard mode - Extract QrScannerModal to shared component - Add QR scanner for NWC wallet connection in Settings - Update Help page with keyboard toggle documentation - Fix keyboard navigation getting stuck on inbox - Improve feed loading after login (loads immediately) - DM conversation page layout improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
180 lines
5.4 KiB
TypeScript
180 lines
5.4 KiB
TypeScript
import { LONG_PRESS_THRESHOLD } from '@/constants'
|
|
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
|
import { useStuff } from '@/hooks/useStuff'
|
|
import { getLightningAddressFromProfile } from '@/lib/lightning'
|
|
import { cn } from '@/lib/utils'
|
|
import { useNostr } from '@/providers/NostrProvider'
|
|
import { useZap } from '@/providers/ZapProvider'
|
|
import client from '@/services/client.service'
|
|
import lightning from '@/services/lightning.service'
|
|
import stuffStatsService from '@/services/stuff-stats.service'
|
|
import { Loader, Zap } from 'lucide-react'
|
|
import { Event } from 'nostr-tools'
|
|
import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import ZapDialog from '../ZapDialog'
|
|
|
|
export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
|
const { t } = useTranslation()
|
|
const { checkLogin, pubkey } = useNostr()
|
|
const { event, stuffKey } = useStuff(stuff)
|
|
const noteStats = useStuffStatsById(stuffKey)
|
|
const { defaultZapSats, defaultZapComment, quickZap } = useZap()
|
|
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
|
|
const [openZapDialog, setOpenZapDialog] = useState(false)
|
|
const [zapping, setZapping] = useState(false)
|
|
const { zapAmount, hasZapped } = useMemo(() => {
|
|
return {
|
|
zapAmount: noteStats?.zaps?.reduce((acc, zap) => acc + zap.amount, 0),
|
|
hasZapped: pubkey ? noteStats?.zaps?.some((zap) => zap.pubkey === pubkey) : false
|
|
}
|
|
}, [noteStats, pubkey])
|
|
const [disable, setDisable] = useState(true)
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const isLongPressRef = useRef(false)
|
|
|
|
useEffect(() => {
|
|
if (!event) {
|
|
setDisable(true)
|
|
return
|
|
}
|
|
|
|
client.fetchProfile(event.pubkey).then((profile) => {
|
|
if (!profile) return
|
|
const lightningAddress = getLightningAddressFromProfile(profile)
|
|
if (lightningAddress) setDisable(false)
|
|
})
|
|
}, [event])
|
|
|
|
const handleZap = async () => {
|
|
try {
|
|
if (!pubkey) {
|
|
throw new Error('You need to be logged in to zap')
|
|
}
|
|
if (zapping || !event) return
|
|
|
|
setZapping(true)
|
|
const zapResult = await lightning.zap(pubkey, event, defaultZapSats, defaultZapComment)
|
|
// user canceled
|
|
if (!zapResult) {
|
|
return
|
|
}
|
|
stuffStatsService.addZap(
|
|
pubkey,
|
|
event.id,
|
|
zapResult.invoice,
|
|
defaultZapSats,
|
|
defaultZapComment
|
|
)
|
|
} catch (error) {
|
|
toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
|
|
} finally {
|
|
setZapping(false)
|
|
}
|
|
}
|
|
|
|
const handleClickStart = (e: MouseEvent | TouchEvent) => {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
if (disable) return
|
|
|
|
isLongPressRef.current = false
|
|
|
|
if ('touches' in e) {
|
|
const touch = e.touches[0]
|
|
setTouchStart({ x: touch.clientX, y: touch.clientY })
|
|
}
|
|
|
|
if (quickZap) {
|
|
timerRef.current = setTimeout(() => {
|
|
isLongPressRef.current = true
|
|
checkLogin(() => {
|
|
setOpenZapDialog(true)
|
|
setZapping(true)
|
|
})
|
|
}, LONG_PRESS_THRESHOLD)
|
|
}
|
|
}
|
|
|
|
const handleClickEnd = (e: MouseEvent | TouchEvent) => {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
if (timerRef.current) {
|
|
clearTimeout(timerRef.current)
|
|
}
|
|
if (disable) return
|
|
|
|
if ('touches' in e) {
|
|
setTouchStart(null)
|
|
if (!touchStart) return
|
|
const touch = e.changedTouches[0]
|
|
const diffX = Math.abs(touch.clientX - touchStart.x)
|
|
const diffY = Math.abs(touch.clientY - touchStart.y)
|
|
if (diffX > 10 || diffY > 10) return
|
|
}
|
|
|
|
if (!quickZap) {
|
|
checkLogin(() => {
|
|
setOpenZapDialog(true)
|
|
setZapping(true)
|
|
})
|
|
} else if (!isLongPressRef.current) {
|
|
checkLogin(() => handleZap())
|
|
}
|
|
isLongPressRef.current = false
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
if (timerRef.current) {
|
|
clearTimeout(timerRef.current)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
className={cn(
|
|
'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',
|
|
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
|
|
)}
|
|
title={t('Zap (z)')}
|
|
disabled={disable || zapping}
|
|
data-action="zap"
|
|
onMouseDown={handleClickStart}
|
|
onMouseUp={handleClickEnd}
|
|
onMouseLeave={handleMouseLeave}
|
|
onTouchStart={handleClickStart}
|
|
onTouchEnd={handleClickEnd}
|
|
>
|
|
<span className="relative">
|
|
{zapping ? (
|
|
<Loader className="animate-spin" />
|
|
) : (
|
|
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
|
|
)}
|
|
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">z</kbd>
|
|
</span>
|
|
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
|
</button>
|
|
{event && (
|
|
<ZapDialog
|
|
open={openZapDialog}
|
|
setOpen={(open) => {
|
|
setOpenZapDialog(open)
|
|
setZapping(open)
|
|
}}
|
|
pubkey={event.pubkey}
|
|
event={event}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function formatAmount(amount: number) {
|
|
if (amount < 1000) return amount
|
|
if (amount < 1000000) return `${Math.round(amount / 100) / 10}k`
|
|
return `${Math.round(amount / 100000) / 10}M`
|
|
}
|