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