import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer' import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind } from '@/constants' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot' import { shouldHideInteractions } from '@/lib/event-filtering' import { createDeletionRequestDraftEvent, createReactionDraftEvent } from '@/lib/draft-event' import { DISCUSSION_DOWNVOTE_DISPLAY, DISCUSSION_UPVOTE_DISPLAY, DISCUSSION_VOTE_EMOJIS, discussionVoteMatches, isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji, isDiscussionVoteEmoji } from '@/lib/discussion-votes' import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/contexts/user-trust-context' import { eventService } from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import { TEmoji } from '@/types' import { 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' import { WEB_EXTERNAL_REACTION_PUBLISHED_EVENT } from '@/lib/rss-web-feed' export default function LikeButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() const { pubkey, publish, checkLogin } = useNostr() const { relays: statsRelays } = useNoteStatsRelayHints() 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) const isReplyToDiscussion = useReplyUnderDiscussionRoot(event) const showDiscussionVotes = isDiscussion || isReplyToDiscussion const { myLastEmoji, likeCount, upVoteCount, downVoteCount } = useMemo(() => { const stats = noteStats || {} const likes = hideUntrustedInteractions ? stats.likes?.filter((like) => isUserTrusted(like.pubkey)) : stats.likes const myLike = likes?.find((like) => { if (like.pubkey !== pubkey) return false if (showDiscussionVotes) return isDiscussionVoteEmoji(like.emoji) return true }) let upVoteCount = 0 let downVoteCount = 0 if (showDiscussionVotes) { upVoteCount = likes?.filter((like) => isDiscussionUpvoteEmoji(like.emoji)).length || 0 downVoteCount = likes?.filter((like) => isDiscussionDownvoteEmoji(like.emoji)).length || 0 } return { myLastEmoji: myLike?.emoji, likeCount: likes?.length, upVoteCount, downVoteCount } }, [noteStats, pubkey, hideUntrustedInteractions, showDiscussionVotes]) 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, statsRelays) } const emojiString = typeof emoji === 'string' ? emoji : emoji.shortcode const myLastEmojiString = typeof myLastEmoji === 'string' ? myLastEmoji : typeof myLastEmoji === 'object' ? myLastEmoji.shortcode : undefined const isTogglingOff = showDiscussionVotes ? discussionVoteMatches(myLastEmoji, emoji) : 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 if (showDiscussionVotes) return discussionVoteMatches(like.emoji, emoji) 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 eventService.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')) } if ( event.kind === ExtendedKind.RSS_THREAD_ROOT && reactionEvent?.kind === ExtendedKind.EXTERNAL_REACTION ) { window.dispatchEvent(new CustomEvent(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT)) } } } } 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], undefined, { interactionTargetNoteId: event.id }) if (event.kind === ExtendedKind.RSS_THREAD_ROOT && evt.kind === ExtendedKind.EXTERNAL_REACTION) { window.dispatchEvent(new CustomEvent(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT)) } } } catch (error) { logger.error('Like failed', { error, eventId: event.id }) } finally { setLiking(false) clearTimeout(timer) } }) } const trigger = ( ) // Discussions (kind 11) and kind 1111 under a discussion: only +/- vote reactions if (showDiscussionVotes) { return (
{DISCUSSION_VOTE_EMOJIS.map((emoji, index) => { const isSelected = index === 0 ? isDiscussionUpvoteEmoji(myLastEmoji) : isDiscussionDownvoteEmoji(myLastEmoji) const count = index === 0 ? upVoteCount : downVoteCount const arrow = index === 0 ? DISCUSSION_UPVOTE_DISPLAY : DISCUSSION_DOWNVOTE_DISPLAY return ( ) })}
) } if (isSmallScreen) { return ( <> {trigger} setIsEmojiReactionsOpen(false)} /> React { setIsEmojiReactionsOpen(false) if (!emoji) return like(emoji) }} /> ) } return ( { setIsEmojiReactionsOpen(open) if (open) { setIsPickerOpen(false) } }} > {trigger} {isPickerOpen ? ( { e.stopPropagation() setIsEmojiReactionsOpen(false) if (!emoji) return like(emoji) }} /> ) : ( { setIsEmojiReactionsOpen(false) like(emoji) }} onMoreButtonClick={() => { setIsPickerOpen(true) }} /> )} ) }