LikeButton.tsx raw
1 import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
2 import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
3 import { LONG_PRESS_THRESHOLD } from '@/constants'
4 import { useStuff } from '@/hooks/useStuff'
5 import { useStuffStatsById } from '@/hooks/useStuffStatsById'
6 import {
7 createExternalContentReactionDraftEvent,
8 createReactionDraftEvent
9 } from '@/lib/draft-event'
10 import { useNostr } from '@/providers/NostrProvider'
11 import { useScreenSize } from '@/providers/ScreenSizeProvider'
12 import { useUserPreferences } from '@/providers/UserPreferencesProvider'
13 import { useUserTrust } from '@/providers/UserTrustProvider'
14 import client from '@/services/client.service'
15 import stuffStatsService from '@/services/stuff-stats.service'
16 import { TEmoji } from '@/types'
17 import { Loader, SmilePlus } from 'lucide-react'
18 import { Event } from 'nostr-tools'
19 import { useEffect, useMemo, useRef, useState } from 'react'
20 import { useTranslation } from 'react-i18next'
21 import Emoji from '../Emoji'
22 import EmojiPicker from '../EmojiPicker'
23 import SuggestedEmojis from '../SuggestedEmojis'
24 import KeyboardShortcut from './KeyboardShortcut'
25 import { formatCount } from './utils'
26
27 export default function LikeButton({ stuff }: { stuff: Event | string }) {
28 const { t } = useTranslation()
29 const { isSmallScreen } = useScreenSize()
30 const { pubkey, publish, checkLogin } = useNostr()
31 const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
32 const { quickReaction, quickReactionEmoji } = useUserPreferences()
33 const { event, externalContent, stuffKey } = useStuff(stuff)
34 const [liking, setLiking] = useState(false)
35 const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
36 const [isPickerOpen, setIsPickerOpen] = useState(false)
37 const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
38 const isLongPressRef = useRef(false)
39 const noteStats = useStuffStatsById(stuffKey)
40 const { myLastEmoji, likeCount } = useMemo(() => {
41 const stats = noteStats || {}
42 const myLike = stats.likes?.find((like) => like.pubkey === pubkey)
43 const likes = hideUntrustedInteractions
44 ? stats.likes?.filter((like) => isUserTrusted(like.pubkey))
45 : stats.likes
46 return { myLastEmoji: myLike?.emoji, likeCount: likes?.length }
47 }, [noteStats, pubkey, hideUntrustedInteractions])
48
49 useEffect(() => {
50 setTimeout(() => setIsPickerOpen(false), 100)
51 }, [isEmojiReactionsOpen])
52
53 const like = async (emoji: string | TEmoji) => {
54 checkLogin(async () => {
55 if (liking || !pubkey) return
56
57 setLiking(true)
58 const timer = setTimeout(() => setLiking(false), 10_000)
59
60 try {
61 if (!noteStats?.updatedAt) {
62 await stuffStatsService.fetchStuffStats(stuffKey, pubkey)
63 }
64
65 const reaction = event
66 ? createReactionDraftEvent(event, emoji)
67 : createExternalContentReactionDraftEvent(externalContent, emoji)
68 const seenOn = event ? client.getSeenEventRelayUrls(event.id) : client.currentRelays
69 const evt = await publish(reaction, { additionalRelayUrls: seenOn })
70 stuffStatsService.updateStuffStatsByEvents([evt])
71 } catch (error) {
72 console.error('like failed', error)
73 } finally {
74 setLiking(false)
75 clearTimeout(timer)
76 }
77 })
78 }
79
80 const handleLongPressStart = () => {
81 if (!quickReaction) return
82 isLongPressRef.current = false
83 longPressTimerRef.current = setTimeout(() => {
84 isLongPressRef.current = true
85 setIsEmojiReactionsOpen(true)
86 }, LONG_PRESS_THRESHOLD)
87 }
88
89 const handleLongPressEnd = () => {
90 if (longPressTimerRef.current) {
91 clearTimeout(longPressTimerRef.current)
92 longPressTimerRef.current = null
93 }
94 }
95
96 const handleClick = (e: React.MouseEvent | React.TouchEvent) => {
97 if (quickReaction) {
98 // If it was a long press, don't trigger the click action
99 if (isLongPressRef.current) {
100 isLongPressRef.current = false
101 return
102 }
103 // Quick reaction mode: click to react with default emoji
104 // Prevent dropdown from opening
105 e.preventDefault()
106 e.stopPropagation()
107 like(quickReactionEmoji)
108 } else {
109 setIsEmojiReactionsOpen(true)
110 }
111 }
112
113 const trigger = (
114 <button
115 className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground group"
116 title={t('React (Shift+R)')}
117 disabled={liking}
118 data-action="react"
119 onClick={handleClick}
120 onMouseDown={handleLongPressStart}
121 onMouseUp={handleLongPressEnd}
122 onMouseLeave={handleLongPressEnd}
123 onTouchStart={handleLongPressStart}
124 onTouchEnd={handleLongPressEnd}
125 >
126 {liking ? (
127 <Loader className="animate-spin" />
128 ) : myLastEmoji ? (
129 <>
130 <span className="relative">
131 <Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
132 <KeyboardShortcut shortcut="R" />
133 </span>
134 {!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
135 </>
136 ) : (
137 <>
138 <span className="relative">
139 <SmilePlus />
140 <KeyboardShortcut shortcut="R" />
141 </span>
142 {!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
143 </>
144 )}
145 </button>
146 )
147
148 if (isSmallScreen) {
149 return (
150 <>
151 {trigger}
152 <Drawer open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}>
153 <DrawerOverlay onClick={() => setIsEmojiReactionsOpen(false)} />
154 <DrawerContent hideOverlay>
155 <EmojiPicker
156 onEmojiClick={(emoji) => {
157 setIsEmojiReactionsOpen(false)
158 if (!emoji) return
159
160 like(emoji)
161 }}
162 />
163 </DrawerContent>
164 </Drawer>
165 </>
166 )
167 }
168
169 return (
170 <Popover open={isEmojiReactionsOpen} onOpenChange={(open) => setIsEmojiReactionsOpen(open)}>
171 <PopoverAnchor asChild>{trigger}</PopoverAnchor>
172 <PopoverContent side="top" className="p-0 w-fit border-0 shadow-lg">
173 {isPickerOpen ? (
174 <EmojiPicker
175 onEmojiClick={(emoji, e) => {
176 e.stopPropagation()
177 setIsEmojiReactionsOpen(false)
178 if (!emoji) return
179
180 like(emoji)
181 }}
182 />
183 ) : (
184 <SuggestedEmojis
185 onEmojiClick={(emoji) => {
186 setIsEmojiReactionsOpen(false)
187 like(emoji)
188 }}
189 onMoreButtonClick={() => {
190 setIsPickerOpen(true)
191 }}
192 onClose={() => setIsEmojiReactionsOpen(false)}
193 />
194 )}
195 </PopoverContent>
196 </Popover>
197 )
198 }
199