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