feat: add support for commenting and reacting on external content

This commit is contained in:
codytseng 2025-11-15 16:26:19 +08:00
parent 5ba5c26fcd
commit 0bb62dd3fb
76 changed files with 1635 additions and 639 deletions

View file

@ -0,0 +1,159 @@
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { BIG_RELAY_URLS } from '@/constants'
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { useStuff } from '@/hooks/useStuff'
import {
createExternalContentReactionDraftEvent,
createReactionDraftEvent
} from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import stuffStatsService from '@/services/stuff-stats.service'
import { TEmoji } from '@/types'
import { Loader, SmilePlus } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Emoji from '../Emoji'
import EmojiPicker from '../EmojiPicker'
import SuggestedEmojis from '../SuggestedEmojis'
import { formatCount } from './utils'
export default function LikeButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { pubkey, publish, checkLogin } = useNostr()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { event, externalContent, stuffKey } = useStuff(stuff)
const [liking, setLiking] = useState(false)
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
const [isPickerOpen, setIsPickerOpen] = useState(false)
const noteStats = useStuffStatsById(stuffKey)
const { myLastEmoji, likeCount } = useMemo(() => {
const stats = noteStats || {}
const myLike = stats.likes?.find((like) => like.pubkey === pubkey)
const likes = hideUntrustedInteractions
? stats.likes?.filter((like) => isUserTrusted(like.pubkey))
: stats.likes
return { myLastEmoji: myLike?.emoji, likeCount: likes?.length }
}, [noteStats, pubkey, hideUntrustedInteractions])
const like = async (emoji: string | TEmoji) => {
checkLogin(async () => {
if (liking || !pubkey) return
setLiking(true)
const timer = setTimeout(() => setLiking(false), 10_000)
try {
if (!noteStats?.updatedAt) {
await stuffStatsService.fetchStuffStats(stuffKey, pubkey)
}
const reaction = event
? createReactionDraftEvent(event, emoji)
: createExternalContentReactionDraftEvent(externalContent, emoji)
const seenOn = event ? client.getSeenEventRelayUrls(event.id) : BIG_RELAY_URLS
const evt = await publish(reaction, { additionalRelayUrls: seenOn })
stuffStatsService.updateStuffStatsByEvents([evt])
} catch (error) {
console.error('like failed', error)
} finally {
setLiking(false)
clearTimeout(timer)
}
})
}
const trigger = (
<button
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground"
title={t('Like')}
disabled={liking}
onClick={() => {
if (isSmallScreen) {
setIsEmojiReactionsOpen(true)
}
}}
>
{liking ? (
<Loader className="animate-spin" />
) : myLastEmoji ? (
<>
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</>
) : (
<>
<SmilePlus />
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</>
)}
</button>
)
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}>
<DrawerOverlay onClick={() => setIsEmojiReactionsOpen(false)} />
<DrawerContent hideOverlay>
<EmojiPicker
onEmojiClick={(emoji) => {
setIsEmojiReactionsOpen(false)
if (!emoji) return
like(emoji)
}}
/>
</DrawerContent>
</Drawer>
</>
)
}
return (
<DropdownMenu
open={isEmojiReactionsOpen}
onOpenChange={(open) => {
setIsEmojiReactionsOpen(open)
if (open) {
setIsPickerOpen(false)
}
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0 w-fit">
{isPickerOpen ? (
<EmojiPicker
onEmojiClick={(emoji, e) => {
e.stopPropagation()
setIsEmojiReactionsOpen(false)
if (!emoji) return
like(emoji)
}}
/>
) : (
<SuggestedEmojis
onEmojiClick={(emoji) => {
setIsEmojiReactionsOpen(false)
like(emoji)
}}
onMoreButtonClick={() => {
setIsPickerOpen(true)
}}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,175 @@
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { BIG_RELAY_URLS } from '@/constants'
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { useStuff } from '@/hooks/useStuff'
import {
createExternalContentReactionDraftEvent,
createReactionDraftEvent
} from '@/lib/draft-event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import stuffStatsService from '@/services/stuff-stats.service'
import { TEmoji } from '@/types'
import { Loader } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useRef, useState } from 'react'
import Emoji from '../Emoji'
export default function Likes({ stuff }: { stuff: Event | string }) {
const { pubkey, checkLogin, publish } = useNostr()
const { event, externalContent, stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey)
const [liking, setLiking] = useState<string | null>(null)
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
const [isLongPressing, setIsLongPressing] = useState<string | null>(null)
const [isCompleted, setIsCompleted] = useState<string | null>(null)
const likes = useMemo(() => {
const _likes = noteStats?.likes
if (!_likes) return []
const stats = new Map<string, { key: string; emoji: TEmoji | string; pubkeys: Set<string> }>()
_likes.forEach((item) => {
const key = typeof item.emoji === 'string' ? item.emoji : item.emoji.url
if (!stats.has(key)) {
stats.set(key, { key, pubkeys: new Set(), emoji: item.emoji })
}
stats.get(key)?.pubkeys.add(item.pubkey)
})
return Array.from(stats.values()).sort((a, b) => b.pubkeys.size - a.pubkeys.size)
}, [noteStats, event])
if (!likes.length) return null
const like = async (key: string, emoji: TEmoji | string) => {
checkLogin(async () => {
if (liking || !pubkey) return
setLiking(key)
const timer = setTimeout(() => setLiking((prev) => (prev === key ? null : prev)), 5000)
try {
const reaction = event
? createReactionDraftEvent(event, emoji)
: createExternalContentReactionDraftEvent(externalContent, emoji)
const seenOn = event ? client.getSeenEventRelayUrls(event.id) : BIG_RELAY_URLS
const evt = await publish(reaction, { additionalRelayUrls: seenOn })
stuffStatsService.updateStuffStatsByEvents([evt])
} catch (error) {
console.error('like failed', error)
} finally {
setLiking(null)
clearTimeout(timer)
}
})
}
const handleMouseDown = (key: string) => {
if (pubkey && likes.find((l) => l.key === key)?.pubkeys.has(pubkey)) {
return
}
setIsLongPressing(key)
longPressTimerRef.current = setTimeout(() => {
setIsCompleted(key)
setIsLongPressing(null)
}, 800)
}
const handleMouseUp = () => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
if (isCompleted) {
const completedKey = isCompleted
const completedEmoji = likes.find((l) => l.key === completedKey)?.emoji
if (completedEmoji) {
like(completedKey, completedEmoji)
}
}
setIsLongPressing(null)
setIsCompleted(null)
}
const handleMouseLeave = () => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
setIsLongPressing(null)
setIsCompleted(null)
}
const handleTouchMove = (e: React.TouchEvent) => {
const touch = e.touches[0]
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const isInside =
touch.clientX >= rect.left &&
touch.clientX <= rect.right &&
touch.clientY >= rect.top &&
touch.clientY <= rect.bottom
if (!isInside) {
handleMouseLeave()
}
}
return (
<ScrollArea className="pb-2 mb-1">
<div className="flex gap-1">
{likes.map(({ key, emoji, pubkeys }) => (
<div
key={key}
className={cn(
'flex h-7 w-fit gap-2 px-2 rounded-full items-center border shrink-0 select-none relative overflow-hidden transition-all duration-200',
pubkey && pubkeys.has(pubkey)
? 'border-primary bg-primary/20 text-foreground cursor-not-allowed'
: 'bg-muted/80 text-muted-foreground cursor-pointer hover:bg-primary/40 hover:border-primary hover:text-foreground',
(isLongPressing === key || isCompleted === key) && 'border-primary bg-primary/20'
)}
onClick={(e) => e.stopPropagation()}
onMouseDown={() => handleMouseDown(key)}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onTouchStart={() => handleMouseDown(key)}
onTouchMove={handleTouchMove}
onTouchEnd={handleMouseUp}
onTouchCancel={handleMouseLeave}
>
{(isLongPressing === key || isCompleted === key) && (
<div className="absolute inset-0 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary/40 via-primary/60 to-primary/80"
style={{
width: isCompleted === key ? '100%' : '0%',
animation:
isLongPressing === key ? 'progressFill 1000ms ease-out forwards' : 'none'
}}
/>
</div>
)}
<div className="relative z-10 flex items-center gap-2">
{liking === key ? (
<Loader className="animate-spin size-4" />
) : (
<div
style={{
animation: isCompleted === key ? 'shake 0.5s ease-in-out infinite' : undefined
}}
>
<Emoji emoji={emoji} classNames={{ img: 'size-4' }} />
</div>
)}
<div className="text-sm">{pubkeys.size}</div>
</div>
</div>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}

View file

@ -0,0 +1,76 @@
import { useStuff } from '@/hooks/useStuff'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor'
import { formatCount } from './utils'
export default function ReplyButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const { event, stuffKey } = useStuff(stuff)
const { repliesMap } = useReply()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { replyCount, hasReplied } = useMemo(() => {
const hasReplied = pubkey
? repliesMap.get(stuffKey)?.events.some((evt) => evt.pubkey === pubkey)
: false
let replyCount = 0
const replies = [...(repliesMap.get(stuffKey)?.events || [])]
while (replies.length > 0) {
const reply = replies.pop()
if (!reply) break
const replyKey = getEventKey(reply)
const nestedReplies = repliesMap.get(replyKey)?.events ?? []
replies.push(...nestedReplies)
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
continue
}
if (mutePubkeySet.has(reply.pubkey)) {
continue
}
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
continue
}
replyCount++
}
return { replyCount, hasReplied }
}, [repliesMap, event, stuffKey, hideUntrustedInteractions])
const [open, setOpen] = useState(false)
return (
<>
<button
className={cn(
'flex gap-1 items-center enabled:hover:text-blue-400 pr-3 h-full',
hasReplied ? 'text-blue-400' : 'text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation()
checkLogin(() => {
setOpen(true)
})
}}
title={t('Reply')}
>
<MessageCircle />
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
</button>
<PostEditor parentStuff={stuff} open={open} setOpen={setOpen} />
</>
)
}

View file

@ -0,0 +1,180 @@
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { useStuff } from '@/hooks/useStuff'
import { createRepostDraftEvent } from '@/lib/draft-event'
import { getNoteBech32Id } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import stuffStatsService from '@/services/stuff-stats.service'
import { Loader, PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor'
import { formatCount } from './utils'
export default function RepostButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { publish, checkLogin, pubkey } = useNostr()
const { event, stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey)
const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const { repostCount, hasReposted } = useMemo(() => {
// external content
if (!event) return { repostCount: 0, hasReposted: false }
return {
repostCount: hideUntrustedInteractions
? noteStats?.reposts?.filter((repost) => isUserTrusted(repost.pubkey)).length
: noteStats?.reposts?.length,
hasReposted: pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false
}
}, [noteStats, event, hideUntrustedInteractions])
const canRepost = !hasReposted && !reposting && !!event
const repost = async () => {
checkLogin(async () => {
if (!canRepost || !pubkey) return
setReposting(true)
const timer = setTimeout(() => setReposting(false), 5000)
try {
const hasReposted = noteStats?.repostPubkeySet?.has(pubkey)
if (hasReposted) return
if (!noteStats?.updatedAt) {
const noteStats = await stuffStatsService.fetchStuffStats(stuff, pubkey)
if (noteStats.repostPubkeySet?.has(pubkey)) {
return
}
}
const repost = createRepostDraftEvent(event)
const evt = await publish(repost)
stuffStatsService.updateStuffStatsByEvents([evt])
} catch (error) {
console.error('repost failed', error)
} finally {
setReposting(false)
clearTimeout(timer)
}
})
}
const trigger = (
<button
className={cn(
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40',
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)}
disabled={!event}
title={t('Repost')}
onClick={() => {
if (!event) return
if (isSmallScreen) {
setIsDrawerOpen(true)
}
}}
>
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
</button>
)
if (!event) {
return trigger
}
const postEditor = (
<PostEditor
open={isPostDialogOpen}
setOpen={setIsPostDialogOpen}
defaultContent={'\nnostr:' + getNoteBech32Id(event)}
/>
)
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay>
<div className="py-2">
<Button
onClick={(e) => {
e.stopPropagation()
setIsDrawerOpen(false)
repost()
}}
disabled={!canRepost}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<Repeat /> {t('Repost')}
</Button>
<Button
onClick={(e) => {
e.stopPropagation()
setIsDrawerOpen(false)
checkLogin(() => {
setIsPostDialogOpen(true)
})
}}
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost"
>
<PencilLine /> {t('Quote')}
</Button>
</div>
</DrawerContent>
</Drawer>
{postEditor}
</>
)
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
repost()
}}
disabled={!canRepost}
>
<Repeat /> {t('Repost')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
checkLogin(() => {
setIsPostDialogOpen(true)
})
}}
>
<PencilLine /> {t('Quote')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{postEditor}
</>
)
}

View file

@ -0,0 +1,105 @@
import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useStuff } from '@/hooks/useStuff'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import { Server } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
export default function SeenOnButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const { event } = useStuff(stuff)
const [relays, setRelays] = useState<string[]>([])
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
useEffect(() => {
if (!event) return
const seenOn = client.getSeenEventRelayUrls(event.id)
setRelays(seenOn)
}, [])
const trigger = (
<button
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full disabled:text-muted-foreground/40"
title={t('Seen on')}
disabled={relays.length === 0}
onClick={() => {
if (!event) return
if (isSmallScreen) {
setIsDrawerOpen(true)
}
}}
>
<Server />
{relays.length > 0 && <div className="text-sm">{relays.length}</div>}
</button>
)
if (relays.length === 0) {
return trigger
}
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay>
<div className="py-2">
{relays.map((relay) => (
<Button
className="w-full p-6 justify-start text-lg gap-4"
variant="ghost"
key={relay}
onClick={() => {
setIsDrawerOpen(false)
setTimeout(() => {
push(toRelay(relay))
}, 50) // Timeout to allow the drawer to close before navigating
}}
>
<RelayIcon url={relay} /> {simplifyUrl(relay)}
</Button>
))}
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{t('Seen on')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{relays.map((relay) => (
<DropdownMenuItem key={relay} onClick={() => push(toRelay(relay))} className="min-w-52">
<RelayIcon url={relay} />
{simplifyUrl(relay)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,59 @@
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { useStuff } from '@/hooks/useStuff'
import { formatAmount } from '@/lib/lightning'
import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { SimpleUserAvatar } from '../UserAvatar'
import ZapDialog from '../ZapDialog'
export default function TopZaps({ stuff }: { stuff: Event | string }) {
const { event, stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey)
const [zapIndex, setZapIndex] = useState(-1)
const topZaps = useMemo(() => {
return noteStats?.zaps?.sort((a, b) => b.amount - a.amount).slice(0, 10) || []
}, [noteStats])
if (!topZaps.length || !event) return null
return (
<ScrollArea className="pb-2 mb-1">
<div className="flex gap-1">
{topZaps.map((zap, index) => (
<div
key={zap.pr}
className="flex gap-1 py-1 pl-1 pr-2 text-sm max-w-72 rounded-full bg-muted/80 items-center text-yellow-400 border border-yellow-400 hover:bg-yellow-400/20 cursor-pointer"
onClick={(e) => {
e.stopPropagation()
setZapIndex(index)
}}
>
<SimpleUserAvatar userId={zap.pubkey} size="xSmall" />
<Zap className="size-3 fill-yellow-400 shrink-0" />
<div className="font-semibold">{formatAmount(zap.amount)}</div>
<div className="truncate">{zap.comment}</div>
<div onClick={(e) => e.stopPropagation()}>
<ZapDialog
open={zapIndex === index}
setOpen={(open) => {
if (open) {
setZapIndex(index)
} else {
setZapIndex(-1)
}
}}
pubkey={event.pubkey}
event={event}
defaultAmount={zap.amount}
defaultComment={zap.comment}
/>
</div>
</div>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}

View file

@ -0,0 +1,175 @@
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',
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
)}
title={t('Zap')}
disabled={disable || zapping}
onMouseDown={handleClickStart}
onMouseUp={handleClickEnd}
onMouseLeave={handleMouseLeave}
onTouchStart={handleClickStart}
onTouchEnd={handleClickEnd}
>
{zapping ? (
<Loader className="animate-spin" />
) : (
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
)}
{!!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`
}

View file

@ -0,0 +1,96 @@
import { useStuff } from '@/hooks/useStuff'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import stuffStatsService from '@/services/stuff-stats.service'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import BookmarkButton from '../BookmarkButton'
import LikeButton from './LikeButton'
import Likes from './Likes'
import ReplyButton from './ReplyButton'
import RepostButton from './RepostButton'
import SeenOnButton from './SeenOnButton'
import TopZaps from './TopZaps'
import ZapButton from './ZapButton'
export default function StuffStats({
stuff,
className,
classNames,
fetchIfNotExisting = false,
displayTopZapsAndLikes = false
}: {
stuff: Event | string
className?: string
classNames?: {
buttonBar?: string
}
fetchIfNotExisting?: boolean
displayTopZapsAndLikes?: boolean
}) {
const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr()
const [loading, setLoading] = useState(false)
const { event } = useStuff(stuff)
useEffect(() => {
if (!fetchIfNotExisting) return
setLoading(true)
stuffStatsService.fetchStuffStats(stuff, pubkey).finally(() => setLoading(false))
}, [event, fetchIfNotExisting])
if (isSmallScreen) {
return (
<div className={cn('select-none', className)}>
{displayTopZapsAndLikes && (
<>
<TopZaps stuff={stuff} />
<Likes stuff={stuff} />
</>
)}
<div
className={cn(
'flex justify-between items-center h-5 [&_svg]:size-5',
loading ? 'animate-pulse' : '',
classNames?.buttonBar
)}
onClick={(e) => e.stopPropagation()}
>
<ReplyButton stuff={stuff} />
<RepostButton stuff={stuff} />
<LikeButton stuff={stuff} />
<ZapButton stuff={stuff} />
<BookmarkButton stuff={stuff} />
<SeenOnButton stuff={stuff} />
</div>
</div>
)
}
return (
<div className={cn('select-none', className)}>
{displayTopZapsAndLikes && (
<>
<TopZaps stuff={stuff} />
<Likes stuff={stuff} />
</>
)}
<div className="flex justify-between h-5 [&_svg]:size-4">
<div
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
onClick={(e) => e.stopPropagation()}
>
<ReplyButton stuff={stuff} />
<RepostButton stuff={stuff} />
<LikeButton stuff={stuff} />
<ZapButton stuff={stuff} />
</div>
<div className="flex items-center" onClick={(e) => e.stopPropagation()}>
<BookmarkButton stuff={stuff} />
<SeenOnButton stuff={stuff} />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,4 @@
export function formatCount(count?: number) {
if (count === undefined || count <= 0) return ''
return count >= 100 ? '99+' : count
}