feat: add support for commenting and reacting on external content
This commit is contained in:
parent
5ba5c26fcd
commit
0bb62dd3fb
76 changed files with 1635 additions and 639 deletions
159
src/components/StuffStats/LikeButton.tsx
Normal file
159
src/components/StuffStats/LikeButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
src/components/StuffStats/Likes.tsx
Normal file
175
src/components/StuffStats/Likes.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
src/components/StuffStats/ReplyButton.tsx
Normal file
76
src/components/StuffStats/ReplyButton.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
180
src/components/StuffStats/RepostButton.tsx
Normal file
180
src/components/StuffStats/RepostButton.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
105
src/components/StuffStats/SeenOnButton.tsx
Normal file
105
src/components/StuffStats/SeenOnButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
src/components/StuffStats/TopZaps.tsx
Normal file
59
src/components/StuffStats/TopZaps.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
src/components/StuffStats/ZapButton.tsx
Normal file
175
src/components/StuffStats/ZapButton.tsx
Normal 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`
|
||||
}
|
||||
96
src/components/StuffStats/index.tsx
Normal file
96
src/components/StuffStats/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
src/components/StuffStats/utils.ts
Normal file
4
src/components/StuffStats/utils.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function formatCount(count?: number) {
|
||||
if (count === undefined || count <= 0) return ''
|
||||
return count >= 100 ? '99+' : count
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue