feat: emoji reactions
This commit is contained in:
parent
40b487994d
commit
2c9a5b219b
15 changed files with 382 additions and 50 deletions
|
|
@ -1,42 +1,50 @@
|
|||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { createReactionDraftEvent } from '@/lib/draft-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { Heart, Loader } from 'lucide-react'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Loader, SmilePlus } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatCount } from './utils'
|
||||
import Emoji from '../Emoji'
|
||||
import EmojiPicker from '../EmojiPicker'
|
||||
import SuggestedEmojis from '../SuggestedEmojis'
|
||||
|
||||
export default function LikeButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey, publish, checkLogin } = useNostr()
|
||||
const { noteStatsMap, updateNoteStatsByEvents, fetchNoteStats } = useNoteStats()
|
||||
const [liking, setLiking] = useState(false)
|
||||
const { likeCount, hasLiked } = useMemo(() => {
|
||||
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false)
|
||||
const myLastEmoji = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
return { likeCount: stats.likes?.size, hasLiked: pubkey ? stats.likes?.has(pubkey) : false }
|
||||
const like = stats.likes?.find((like) => like.pubkey === pubkey)
|
||||
return like?.emoji
|
||||
}, [noteStatsMap, event, pubkey])
|
||||
const canLike = !hasLiked && !liking
|
||||
|
||||
const like = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const like = async (emoji: string) => {
|
||||
checkLogin(async () => {
|
||||
if (!canLike || !pubkey) return
|
||||
if (liking || !pubkey) return
|
||||
|
||||
setLiking(true)
|
||||
const timer = setTimeout(() => setLiking(false), 5000)
|
||||
|
||||
try {
|
||||
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
|
||||
await fetchNoteStats(event)
|
||||
}
|
||||
|
||||
const reaction = createReactionDraftEvent(event)
|
||||
const reaction = createReactionDraftEvent(event, emoji)
|
||||
const evt = await publish(reaction)
|
||||
updateNoteStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
|
|
@ -48,22 +56,82 @@ export default function LikeButton({ event }: { event: Event }) {
|
|||
})
|
||||
}
|
||||
|
||||
return (
|
||||
const trigger = (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center enabled:hover:text-red-400 gap-1 px-3 h-full',
|
||||
hasLiked ? 'text-red-400' : 'text-muted-foreground'
|
||||
'flex items-center enabled:hover:text-primary gap-1 px-3 h-full',
|
||||
!myLastEmoji ? 'text-muted-foreground' : ''
|
||||
)}
|
||||
onClick={like}
|
||||
disabled={!canLike}
|
||||
title={t('Like')}
|
||||
onClick={() => {
|
||||
if (isSmallScreen) {
|
||||
setIsEmojiReactionsOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{liking ? (
|
||||
<Loader className="animate-spin" />
|
||||
) : myLastEmoji ? (
|
||||
<div className="h-5 w-5 flex items-center justify-center">
|
||||
<Emoji emoji={myLastEmoji} />
|
||||
</div>
|
||||
) : (
|
||||
<Heart className={hasLiked ? 'fill-red-400' : ''} />
|
||||
<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={(data) => {
|
||||
setIsEmojiReactionsOpen(false)
|
||||
like(data.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={(data, e) => {
|
||||
e.stopPropagation()
|
||||
setIsEmojiReactionsOpen(false)
|
||||
like(data.emoji)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SuggestedEmojis
|
||||
onEmojiClick={(emoji) => {
|
||||
setIsEmojiReactionsOpen(false)
|
||||
like(emoji)
|
||||
}}
|
||||
onMoreButtonClick={() => {
|
||||
setIsPickerOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
78
src/components/NoteStats/Likes.tsx
Normal file
78
src/components/NoteStats/Likes.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { createReactionDraftEvent } from '@/lib/draft-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { TEmoji } from '@/types'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Emoji from '../Emoji'
|
||||
|
||||
export default function Likes({ event }: { event: Event }) {
|
||||
const { pubkey, checkLogin, publish } = useNostr()
|
||||
const { noteStatsMap, updateNoteStatsByEvents } = useNoteStats()
|
||||
const [liking, setLiking] = useState<string | null>(null)
|
||||
const likes = useMemo(() => {
|
||||
const _likes = noteStatsMap.get(event.id)?.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)
|
||||
}, [noteStatsMap, 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 = createReactionDraftEvent(event, emoji)
|
||||
const evt = await publish(reaction)
|
||||
updateNoteStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('like failed', error)
|
||||
} finally {
|
||||
setLiking(null)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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',
|
||||
pubkey && pubkeys.has(pubkey)
|
||||
? 'border-primary bg-primary/20 text-foreground cursor-not-allowed'
|
||||
: 'transition-colors bg-muted/80 text-muted-foreground cursor-pointer hover:bg-primary/40 hover:border-primary hover:text-foreground'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
like(key, emoji)
|
||||
}}
|
||||
>
|
||||
{liking === key ? <Loader className="animate-spin size-5" /> : <Emoji emoji={emoji} />}
|
||||
<div className="text-sm">{pubkeys.size}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
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 { useMemo, useState } from 'react'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
|
||||
export default function TopZaps({ event }: { event: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { noteStatsMap } = useNoteStats()
|
||||
const [zapIndex, setZapIndex] = useState(-1)
|
||||
const topZaps = useMemo(() => {
|
||||
const stats = noteStatsMap.get(event.id) || {}
|
||||
return stats.zaps?.slice(0, 10) || []
|
||||
|
|
@ -21,19 +20,35 @@ export default function TopZaps({ event }: { event: Event }) {
|
|||
return (
|
||||
<ScrollArea className="pb-2 mb-1">
|
||||
<div className="flex gap-1">
|
||||
{topZaps.map((zap) => (
|
||||
{topZaps.map((zap, index) => (
|
||||
<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"
|
||||
className="flex gap-1 py-1 pl-1 pr-2 text-sm 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()
|
||||
push(toProfile(zap.pubkey))
|
||||
setZapIndex(index)
|
||||
}}
|
||||
>
|
||||
<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 onClick={(e) => e.stopPropagation()}>
|
||||
<ZapDialog
|
||||
open={zapIndex === index}
|
||||
setOpen={(open) => {
|
||||
if (open) {
|
||||
setZapIndex(index)
|
||||
} else {
|
||||
setZapIndex(-1)
|
||||
}
|
||||
}}
|
||||
pubkey={event.pubkey}
|
||||
eventId={event.id}
|
||||
defaultAmount={zap.amount}
|
||||
defaultComment={zap.comment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { cn } from '@/lib/utils'
|
|||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect } from 'react'
|
||||
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'
|
||||
|
|
@ -28,19 +29,23 @@ export default function NoteStats({
|
|||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { fetchNoteStats } = useNoteStats()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchIfNotExisting) return
|
||||
fetchNoteStats(event)
|
||||
setLoading(true)
|
||||
fetchNoteStats(event).finally(() => setLoading(false))
|
||||
}, [event, fetchIfNotExisting])
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<div className={cn('select-none', className)}>
|
||||
<TopZaps event={event} />
|
||||
<Likes event={event} />
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-between items-center h-5 [&_svg]:size-5',
|
||||
loading ? 'animate-pulse' : '',
|
||||
classNames?.buttonBar
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
|
@ -59,8 +64,12 @@ export default function NoteStats({
|
|||
return (
|
||||
<div className={cn('select-none', className)}>
|
||||
<TopZaps event={event} />
|
||||
<Likes event={event} />
|
||||
<div className="flex justify-between h-5 [&_svg]:size-4">
|
||||
<div className="flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ReplyButton event={event} variant={variant} />
|
||||
<RepostButton event={event} />
|
||||
<LikeButton event={event} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue