feat: zap (#107)

This commit is contained in:
Cody Tseng 2025-03-01 23:52:05 +08:00 committed by GitHub
parent 407a6fb802
commit 249593d547
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 2582 additions and 818 deletions

View file

@ -5,57 +5,44 @@ import { useNoteStats } from '@/providers/NoteStatsProvider'
import client from '@/services/client.service'
import { Heart, Loader } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { formatCount } from './utils'
export default function LikeButton({
event,
canFetch = false
}: {
event: Event
canFetch?: boolean
}) {
export default function LikeButton({ event }: { event: Event }) {
const { t } = useTranslation()
const { publish, checkLogin } = useNostr()
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
const { pubkey, publish, checkLogin } = useNostr()
const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats()
const [liking, setLiking] = useState(false)
const { likeCount, hasLiked } = useMemo(
() => noteStatsMap.get(event.id) ?? {},
[noteStatsMap, event.id]
)
const { likeCount, hasLiked } = useMemo(() => {
const stats = noteStatsMap.get(event.id) || {}
return { likeCount: stats.likes?.size, hasLiked: pubkey ? stats.likes?.has(pubkey) : false }
}, [noteStatsMap, event, pubkey])
const canLike = !hasLiked && !liking
useEffect(() => {
if (!canFetch) return
if (likeCount === undefined) {
fetchNoteLikeCount(event)
}
if (hasLiked === undefined) {
fetchNoteLikedStatus(event)
}
}, [canFetch, event])
const like = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!canLike) return
if (!canLike || !pubkey) return
setLiking(true)
const timer = setTimeout(() => setLiking(false), 5000)
try {
const [liked] = await Promise.all([
hasLiked === undefined ? fetchNoteLikedStatus(event) : hasLiked,
likeCount === undefined ? fetchNoteLikeCount(event) : likeCount
])
if (liked) return
const noteStats = noteStatsMap.get(event.id)
const hasLiked = noteStats?.likes?.has(pubkey)
if (hasLiked) return
if (!noteStats?.updatedAt) {
const stats = await fetchNoteStats(event)
if (stats?.likes?.has(pubkey)) return
}
const targetRelayList = await client.fetchRelayList(event.pubkey)
const reaction = createReactionDraftEvent(event)
await publish(reaction, { additionalRelayUrls: targetRelayList.read.slice(0, 4) })
markNoteAsLiked(event.id)
const evt = await publish(reaction, {
additionalRelayUrls: targetRelayList.read.slice(0, 4)
})
updateNoteStatsByEvents([evt])
} catch (error) {
console.error('like failed', error)
} finally {

View file

@ -12,60 +12,47 @@ import { useNoteStats } from '@/providers/NoteStatsProvider'
import client from '@/services/client.service'
import { Loader, PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor'
import { formatCount } from './utils'
export default function RepostButton({
event,
canFetch = false
}: {
event: Event
canFetch?: boolean
}) {
export default function RepostButton({ event }: { event: Event }) {
const { t } = useTranslation()
const { publish, checkLogin } = useNostr()
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
useNoteStats()
const { publish, checkLogin, pubkey } = useNostr()
const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats()
const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
const { repostCount, hasReposted } = useMemo(
() => noteStatsMap.get(event.id) ?? {},
[noteStatsMap, event.id]
)
const { repostCount, hasReposted } = useMemo(() => {
const stats = noteStatsMap.get(event.id) || {}
return {
repostCount: stats.reposts?.size,
hasReposted: pubkey ? stats.reposts?.has(pubkey) : false
}
}, [noteStatsMap, event.id])
const canRepost = !hasReposted && !reposting
useEffect(() => {
if (!canFetch) return
if (repostCount === undefined) {
fetchNoteRepostCount(event)
}
if (hasReposted === undefined) {
fetchNoteRepostedStatus(event)
}
}, [canFetch, event])
const repost = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!canRepost) return
if (!canRepost || !pubkey) return
setReposting(true)
const timer = setTimeout(() => setReposting(false), 5000)
try {
const [reposted] = await Promise.all([
hasReposted === undefined ? fetchNoteRepostedStatus(event) : hasReposted,
repostCount === undefined ? fetchNoteRepostCount(event) : repostCount
])
if (reposted) return
const noteStats = noteStatsMap.get(event.id)
const hasReposted = noteStats?.reposts?.has(pubkey)
if (hasReposted) return
if (!noteStats?.updatedAt) {
const stats = await fetchNoteStats(event)
if (stats?.reposts?.has(pubkey)) return
}
const targetRelayList = await client.fetchRelayList(event.pubkey)
const repost = createRepostDraftEvent(event)
await publish(repost, { additionalRelayUrls: targetRelayList.read.slice(0, 5) })
markNoteAsReposted(event.id)
const evt = await publish(repost, { additionalRelayUrls: targetRelayList.read.slice(0, 5) })
updateNoteStatsByEvents([evt])
} catch (error) {
console.error('repost failed', error)
} finally {

View file

@ -0,0 +1,43 @@
import { useSecondaryPage } from '@/PageManager'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { formatAmount } from '@/lib/lightning'
import { toProfile } from '@/lib/link'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { SimpleUserAvatar } from '../UserAvatar'
export default function TopZaps({ event }: { event: Event }) {
const { push } = useSecondaryPage()
const { noteStatsMap } = useNoteStats()
const topZaps = useMemo(() => {
const stats = noteStatsMap.get(event.id) || {}
return stats.zaps?.slice(0, 10) || []
}, [noteStatsMap, event])
if (!topZaps.length) return null
return (
<ScrollArea className="pb-2 mb-1">
<div className="flex gap-1">
{topZaps.map((zap) => (
<div
key={zap.pr}
className="flex gap-1 py-1 pl-1 pr-2 text-sm rounded-full bg-muted items-center text-yellow-400 clickable"
onClick={(e) => {
e.stopPropagation()
push(toProfile(zap.pubkey))
}}
>
<SimpleUserAvatar userId={zap.pubkey} size="xSmall" />
<Zap className="size-3 fill-yellow-400" />
<div className="font-semibold">{formatAmount(zap.amount)}</div>
<div className="truncate">{zap.comment}</div>
</div>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}

View file

@ -0,0 +1,147 @@
import { useToast } from '@/hooks'
import { getLightningAddressFromProfile } from '@/lib/lightning'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service'
import lightning from '@/services/lightning.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 ZapDialog from '../ZapDialog'
export default function ZapButton({ event }: { event: Event }) {
const { t } = useTranslation()
const { toast } = useToast()
const { checkLogin, pubkey } = useNostr()
const { noteStatsMap, addZap } = useNoteStats()
const { defaultZapSats, defaultZapComment, quickZap } = useZap()
const [openZapDialog, setOpenZapDialog] = useState(false)
const [zapping, setZapping] = useState(false)
const { zapAmount, hasZapped } = useMemo(() => {
const stats = noteStatsMap.get(event.id) || {}
return {
zapAmount: stats.zaps?.reduce((acc, zap) => acc + zap.amount, 0),
hasZapped: pubkey ? stats.zaps?.some((zap) => zap.pubkey === pubkey) : false
}
}, [noteStatsMap, event, pubkey])
const [showButton, setShowButton] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isLongPressRef = useRef(false)
useEffect(() => {
client.fetchProfile(event.pubkey).then((profile) => {
if (!profile) return
const lightningAddress = getLightningAddressFromProfile(profile)
if (lightningAddress) setShowButton(true)
})
}, [event])
if (!showButton) return null
const handleZap = async () => {
try {
if (!pubkey) {
throw new Error('You need to be logged in to zap')
}
setZapping(true)
const { invoice } = await lightning.zap(
pubkey,
event.pubkey,
defaultZapSats,
defaultZapComment,
event.id
)
addZap(event.id, invoice, defaultZapSats, defaultZapComment)
} catch (error) {
toast({
title: t('Zap failed'),
description: (error as Error).message,
variant: 'destructive'
})
} finally {
setZapping(false)
}
}
const handleClickStart = (e: MouseEvent | TouchEvent) => {
e.stopPropagation()
e.preventDefault()
isLongPressRef.current = false
if (quickZap) {
timerRef.current = setTimeout(() => {
isLongPressRef.current = true
checkLogin(() => {
setOpenZapDialog(true)
setZapping(true)
})
}, 500)
}
}
const handleClickEnd = (e: MouseEvent | TouchEvent) => {
e.stopPropagation()
e.preventDefault()
if (timerRef.current) {
clearTimeout(timerRef.current)
}
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 enabled:hover:text-yellow-400 gap-1 select-none',
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
)}
title={t('Zap')}
onMouseDown={handleClickStart}
onMouseUp={handleClickEnd}
onMouseLeave={handleMouseLeave}
onTouchStart={handleClickStart}
onTouchEnd={handleClickEnd}
>
{zapping ? (
<Loader className="animate-spin" size={16} />
) : (
<Zap size={16} className={hasZapped ? 'fill-yellow-400' : ''} />
)}
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
</button>
<ZapDialog
open={openZapDialog}
setOpen={(open) => {
setOpenZapDialog(open)
setZapping(open)
}}
pubkey={event.pubkey}
eventId={event.id}
/>
</>
)
}
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`
}

View file

@ -1,10 +1,14 @@
import { cn } from '@/lib/utils'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import { Event } from 'nostr-tools'
import { useEffect } from 'react'
import LikeButton from './LikeButton'
import NoteOptions from './NoteOptions'
import ReplyButton from './ReplyButton'
import RepostButton from './RepostButton'
import SeenOnButton from './SeenOnButton'
import TopZaps from './TopZaps'
import ZapButton from './ZapButton'
export default function NoteStats({
event,
@ -17,16 +21,27 @@ export default function NoteStats({
fetchIfNotExisting?: boolean
variant?: 'note' | 'reply'
}) {
const { fetchNoteStats } = useNoteStats()
useEffect(() => {
if (!fetchIfNotExisting) return
fetchNoteStats(event)
}, [event, fetchIfNotExisting])
return (
<div className={cn('flex justify-between', className)}>
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
<ReplyButton event={event} variant={variant} />
<RepostButton event={event} canFetch={fetchIfNotExisting} />
<LikeButton event={event} canFetch={fetchIfNotExisting} />
</div>
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
<SeenOnButton event={event} />
<NoteOptions event={event} />
<div className={cn('select-none', className)}>
<TopZaps event={event} />
<div className="flex justify-between">
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
<ReplyButton event={event} variant={variant} />
<RepostButton event={event} />
<LikeButton event={event} />
<ZapButton event={event} />
</div>
<div className="flex gap-5 h-4 items-center" onClick={(e) => e.stopPropagation()}>
<SeenOnButton event={event} />
<NoteOptions event={event} />
</div>
</div>
</div>
)