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.
 
 
 
 

321 lines
12 KiB

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 = (
<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 ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : 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>
)
// Discussions (kind 11) and kind 1111 under a discussion: only +/- vote reactions
if (showDiscussionVotes) {
return (
<div className="flex items-center gap-1">
{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 (
<button
key={emoji}
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 ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : (
<>
<span className="text-base leading-none" aria-hidden>
{arrow}
</span>
{!hideCount && noteStats?.updatedAt != null && (
<div className="text-sm tabular-nums">
{count >= 100 ? '99+' : 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>
)
}