You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

301 lines
11 KiB

import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { ExtendedKind } from '@/constants'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { createDeletionRequestDraftEvent, createReactionDraftEvent } from '@/lib/draft-event'
import { getRootEventHexId } from '@/lib/event'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
import { TEmoji } from '@/types'
import { Loader, SmilePlus } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import logger from '@/lib/logger'
import { useTranslation } from 'react-i18next'
import Emoji from '../Emoji'
import EmojiPicker from '../EmojiPicker'
import SuggestedEmojis from '../SuggestedEmojis'
import { formatCount } from './utils'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
const DISCUSSION_EMOJIS = ['⬆', '⬇']
export default function LikeButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { pubkey, publish, checkLogin } = useNostr()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const [liking, setLiking] = useState(false)
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
const [isPickerOpen, setIsPickerOpen] = useState(false)
const noteStats = useNoteStatsById(event.id)
const isDiscussion = event.kind === ExtendedKind.DISCUSSION
const inQuietMode = shouldHideInteractions(event)
// Check if this is a reply to a discussion event
const [isReplyToDiscussion, setIsReplyToDiscussion] = useState(false)
useMemo(() => {
if (isDiscussion) return // Already a discussion event
const rootEventId = getRootEventHexId(event)
if (rootEventId) {
// Fetch the root event to check if it's a discussion
client.fetchEvent(rootEventId).then(rootEvent => {
if (rootEvent && rootEvent.kind === ExtendedKind.DISCUSSION) {
setIsReplyToDiscussion(true)
}
}).catch(() => {
// If we can't fetch the root event, assume it's not a discussion reply
setIsReplyToDiscussion(false)
})
}
}, [event.id, isDiscussion])
const { myLastEmoji, likeCount, upVoteCount, downVoteCount } = 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
// Calculate separate up/down vote counts for discussions
let upVoteCount = 0
let downVoteCount = 0
if (isDiscussion || isReplyToDiscussion) {
upVoteCount = likes?.filter(like => like.emoji === '⬆').length || 0
downVoteCount = likes?.filter(like => like.emoji === '⬇').length || 0
}
return { myLastEmoji: myLike?.emoji, likeCount: likes?.length, upVoteCount, downVoteCount }
}, [noteStats, pubkey, hideUntrustedInteractions, isDiscussion, isReplyToDiscussion])
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 noteStatsService.fetchNoteStats(event, pubkey)
}
// Check if user is clicking the same emoji they already reacted with
const emojiString = typeof emoji === 'string' ? emoji : emoji.shortcode
// Normalize myLastEmoji for comparison
const myLastEmojiString = typeof myLastEmoji === 'string' ? myLastEmoji : typeof myLastEmoji === 'object' ? myLastEmoji.shortcode : undefined
const isTogglingOff = myLastEmojiString === emojiString
logger.debug('Like toggle check', {
myLastEmoji,
myLastEmojiString,
emojiString,
isTogglingOff,
myLikes: noteStats?.likes?.filter(like => like.pubkey === pubkey)
})
if (isTogglingOff) {
// User wants to toggle off - find their previous reaction and delete it
const myReaction = noteStats?.likes?.find((like) => {
if (like.pubkey !== pubkey) return false
const likeEmojiString = typeof like.emoji === 'string' ? like.emoji : like.emoji.shortcode
return likeEmojiString === emojiString
})
if (myReaction) {
// Optimistically update the UI immediately
noteStatsService.removeLike(event.id, myReaction.id)
// Fetch the actual reaction event
const reactionEvent = await client.fetchEvent(myReaction.id)
if (reactionEvent) {
// Create and publish a deletion request (kind 5)
const deletionRequest = createDeletionRequestDraftEvent(reactionEvent)
const deletedEvent = await publish(deletionRequest)
// Show publishing feedback
if ((deletedEvent as any)?.relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (deletedEvent as any).relayStatuses,
successCount: (deletedEvent as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (deletedEvent as any).relayStatuses.length
}, {
message: t('Reaction removed'),
duration: 4000
})
} else {
showSimplePublishSuccess(t('Reaction removed'))
}
}
}
} else {
// User is adding a new reaction
const reaction = createReactionDraftEvent(event, emoji)
const evt = await publish(reaction)
// Show publishing feedback
if ((evt as any)?.relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (evt as any).relayStatuses,
successCount: (evt as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (evt as any).relayStatuses.length
}, {
message: t('Reaction published'),
duration: 4000
})
} else {
showSimplePublishSuccess(t('Reaction published'))
}
noteStatsService.updateNoteStatsByEvents([evt])
}
} catch (error) {
logger.error('Like failed', { error, eventId: event.id })
} 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 user has already reacted, clicking the button again should toggle it off
if (myLastEmoji && !isEmojiReactionsOpen) {
like(myLastEmoji)
return
}
// Otherwise, open the emoji picker
setIsEmojiReactionsOpen(true)
}}
>
{liking ? (
<Loader className="animate-spin" />
) : myLastEmoji ? (
<>
<Emoji emoji={inQuietMode ? '+' : myLastEmoji} classNames={{ img: 'size-4' }} />
{!hideCount && !!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</>
) : (
<>
<SmilePlus />
{!hideCount && !!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</>
)}
</button>
)
// For discussions, show the two arrow emojis directly as buttons
if (isDiscussion || isReplyToDiscussion) {
return (
<div className="flex items-center gap-1">
{DISCUSSION_EMOJIS.map((emoji, index) => {
const isSelected = myLastEmoji === emoji
const count = index === 0 ? upVoteCount : downVoteCount
return (
<button
key={index}
className={`flex items-center enabled:hover:text-primary gap-1 px-2 h-full text-muted-foreground rounded ${
isSelected ? 'text-primary bg-muted' : ''
}`}
title={emoji === '⬆' ? t('Upvote') : t('Downvote')}
disabled={liking}
onClick={() => {
like(emoji)
}}
>
{liking && index === 0 ? (
<Loader className="animate-spin" />
) : (
<>
<span className="text-base">{emoji}</span>
{!hideCount && !!count && (
<div className="text-sm">{formatCount(count)}</div>
)}
</>
)}
</button>
)
})}
</div>
)
}
if (isSmallScreen) {
return (
<>
{trigger}
<Drawer open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}>
<DrawerOverlay onClick={() => setIsEmojiReactionsOpen(false)} />
<DrawerContent hideOverlay>
<DrawerHeader className="sr-only">
<DrawerTitle>React</DrawerTitle>
</DrawerHeader>
<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>
)
}