Likes.tsx raw
1 import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
2 import { useStuffStatsById } from '@/hooks/useStuffStatsById'
3 import { useStuff } from '@/hooks/useStuff'
4 import {
5 createExternalContentReactionDraftEvent,
6 createReactionDraftEvent
7 } from '@/lib/draft-event'
8 import { cn } from '@/lib/utils'
9 import { useNostr } from '@/providers/NostrProvider'
10 import client from '@/services/client.service'
11 import stuffStatsService from '@/services/stuff-stats.service'
12 import { TEmoji } from '@/types'
13 import { Loader } from 'lucide-react'
14 import { Event } from 'nostr-tools'
15 import { useMemo, useRef, useState } from 'react'
16 import Emoji from '../Emoji'
17
18 export default function Likes({ stuff }: { stuff: Event | string }) {
19 const { pubkey, checkLogin, publish } = useNostr()
20 const { event, externalContent, stuffKey } = useStuff(stuff)
21 const noteStats = useStuffStatsById(stuffKey)
22 const [liking, setLiking] = useState<string | null>(null)
23 const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
24 const [isLongPressing, setIsLongPressing] = useState<string | null>(null)
25 const [isCompleted, setIsCompleted] = useState<string | null>(null)
26
27 const likes = useMemo(() => {
28 const _likes = noteStats?.likes
29 if (!_likes) return []
30
31 const stats = new Map<string, { key: string; emoji: TEmoji | string; pubkeys: Set<string> }>()
32 _likes.forEach((item) => {
33 const key = typeof item.emoji === 'string' ? item.emoji : item.emoji.url
34 if (!stats.has(key)) {
35 stats.set(key, { key, pubkeys: new Set(), emoji: item.emoji })
36 }
37 stats.get(key)?.pubkeys.add(item.pubkey)
38 })
39 return Array.from(stats.values()).sort((a, b) => b.pubkeys.size - a.pubkeys.size)
40 }, [noteStats, event])
41
42 if (!likes.length) return null
43
44 const like = async (key: string, emoji: TEmoji | string) => {
45 checkLogin(async () => {
46 if (liking || !pubkey) return
47
48 setLiking(key)
49 const timer = setTimeout(() => setLiking((prev) => (prev === key ? null : prev)), 5000)
50
51 try {
52 const reaction = event
53 ? createReactionDraftEvent(event, emoji)
54 : createExternalContentReactionDraftEvent(externalContent, emoji)
55 const seenOn = event ? client.getSeenEventRelayUrls(event.id) : client.currentRelays
56 const evt = await publish(reaction, { additionalRelayUrls: seenOn })
57 stuffStatsService.updateStuffStatsByEvents([evt])
58 } catch (error) {
59 console.error('like failed', error)
60 } finally {
61 setLiking(null)
62 clearTimeout(timer)
63 }
64 })
65 }
66
67 const handleMouseDown = (key: string) => {
68 if (pubkey && likes.find((l) => l.key === key)?.pubkeys.has(pubkey)) {
69 return
70 }
71
72 setIsLongPressing(key)
73 longPressTimerRef.current = setTimeout(() => {
74 setIsCompleted(key)
75 setIsLongPressing(null)
76 }, 800)
77 }
78
79 const handleMouseUp = () => {
80 if (longPressTimerRef.current) {
81 clearTimeout(longPressTimerRef.current)
82 longPressTimerRef.current = null
83 }
84
85 if (isCompleted) {
86 const completedKey = isCompleted
87 const completedEmoji = likes.find((l) => l.key === completedKey)?.emoji
88 if (completedEmoji) {
89 like(completedKey, completedEmoji)
90 }
91 }
92
93 setIsLongPressing(null)
94 setIsCompleted(null)
95 }
96
97 const handleMouseLeave = () => {
98 if (longPressTimerRef.current) {
99 clearTimeout(longPressTimerRef.current)
100 longPressTimerRef.current = null
101 }
102 setIsLongPressing(null)
103 setIsCompleted(null)
104 }
105
106 const handleTouchMove = (e: React.TouchEvent) => {
107 const touch = e.touches[0]
108 const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
109 const isInside =
110 touch.clientX >= rect.left &&
111 touch.clientX <= rect.right &&
112 touch.clientY >= rect.top &&
113 touch.clientY <= rect.bottom
114
115 if (!isInside) {
116 handleMouseLeave()
117 }
118 }
119
120 return (
121 <ScrollArea className="pb-2 mb-1">
122 <div className="flex gap-1">
123 {likes.map(({ key, emoji, pubkeys }) => (
124 <div
125 key={key}
126 className={cn(
127 'flex h-7 w-fit gap-2 px-2 rounded-full items-center border shrink-0 select-none relative overflow-hidden transition-all duration-200',
128 pubkey && pubkeys.has(pubkey)
129 ? 'border-primary bg-primary/20 text-foreground cursor-not-allowed'
130 : 'bg-muted/80 text-muted-foreground cursor-pointer hover:bg-primary/40 hover:border-primary hover:text-foreground',
131 (isLongPressing === key || isCompleted === key) && 'border-primary bg-primary/20'
132 )}
133 onClick={(e) => e.stopPropagation()}
134 onMouseDown={() => handleMouseDown(key)}
135 onMouseUp={handleMouseUp}
136 onMouseLeave={handleMouseLeave}
137 onTouchStart={() => handleMouseDown(key)}
138 onTouchMove={handleTouchMove}
139 onTouchEnd={handleMouseUp}
140 onTouchCancel={handleMouseLeave}
141 >
142 {(isLongPressing === key || isCompleted === key) && (
143 <div className="absolute inset-0 rounded-full overflow-hidden">
144 <div
145 className="h-full bg-gradient-to-r from-primary/40 via-primary/60 to-primary/80"
146 style={{
147 width: isCompleted === key ? '100%' : '0%',
148 animation:
149 isLongPressing === key ? 'progressFill 1000ms ease-out forwards' : 'none'
150 }}
151 />
152 </div>
153 )}
154 <div className="relative z-10 flex items-center gap-2">
155 {liking === key ? (
156 <Loader className="animate-spin size-4" />
157 ) : (
158 <div
159 style={{
160 animation: isCompleted === key ? 'shake 0.5s ease-in-out infinite' : undefined
161 }}
162 >
163 <Emoji emoji={emoji} classNames={{ img: 'size-4' }} />
164 </div>
165 )}
166 <div className="text-sm">{pubkeys.size}</div>
167 </div>
168 </div>
169 ))}
170 </div>
171 <ScrollBar orientation="horizontal" />
172 </ScrollArea>
173 )
174 }
175