diff --git a/src/components/StuffStats/LikeButton.tsx b/src/components/StuffStats/LikeButton.tsx index 9f5c88d..8848f84 100644 --- a/src/components/StuffStats/LikeButton.tsx +++ b/src/components/StuffStats/LikeButton.tsx @@ -1,25 +1,22 @@ import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { BIG_RELAY_URLS } from '@/constants' -import { useStuffStatsById } from '@/hooks/useStuffStatsById' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { BIG_RELAY_URLS, LONG_PRESS_THRESHOLD } from '@/constants' import { useStuff } from '@/hooks/useStuff' +import { useStuffStatsById } from '@/hooks/useStuffStatsById' import { createExternalContentReactionDraftEvent, createReactionDraftEvent } from '@/lib/draft-event' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' import stuffStatsService from '@/services/stuff-stats.service' import { TEmoji } from '@/types' import { Loader, SmilePlus } from 'lucide-react' import { Event } from 'nostr-tools' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Emoji from '../Emoji' import EmojiPicker from '../EmojiPicker' @@ -31,10 +28,13 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) { const { isSmallScreen } = useScreenSize() const { pubkey, publish, checkLogin } = useNostr() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() + const { quickReaction, quickReactionEmoji } = useUserPreferences() const { event, externalContent, stuffKey } = useStuff(stuff) const [liking, setLiking] = useState(false) const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false) const [isPickerOpen, setIsPickerOpen] = useState(false) + const longPressTimerRef = useRef(null) + const isLongPressRef = useRef(false) const noteStats = useStuffStatsById(stuffKey) const { myLastEmoji, likeCount } = useMemo(() => { const stats = noteStats || {} @@ -45,6 +45,10 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) { return { myLastEmoji: myLike?.emoji, likeCount: likes?.length } }, [noteStats, pubkey, hideUntrustedInteractions]) + useEffect(() => { + setTimeout(() => setIsPickerOpen(false), 100) + }, [isEmojiReactionsOpen]) + const like = async (emoji: string | TEmoji) => { checkLogin(async () => { if (liking || !pubkey) return @@ -72,16 +76,50 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) { }) } + const handleLongPressStart = () => { + if (!quickReaction) return + isLongPressRef.current = false + longPressTimerRef.current = setTimeout(() => { + isLongPressRef.current = true + setIsEmojiReactionsOpen(true) + }, LONG_PRESS_THRESHOLD) + } + + const handleLongPressEnd = () => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + } + + const handleClick = (e: React.MouseEvent | React.TouchEvent) => { + if (quickReaction) { + // If it was a long press, don't trigger the click action + if (isLongPressRef.current) { + isLongPressRef.current = false + return + } + // Quick reaction mode: click to react with default emoji + // Prevent dropdown from opening + e.preventDefault() + e.stopPropagation() + like(quickReactionEmoji) + } else { + setIsEmojiReactionsOpen(true) + } + } + const trigger = ( + { + if (!emoji) return + updateQuickReactionEmoji(emoji) + }} + > + + + + + )} ) diff --git a/src/providers/UserPreferencesProvider.tsx b/src/providers/UserPreferencesProvider.tsx index 35ad530..9302aef 100644 --- a/src/providers/UserPreferencesProvider.tsx +++ b/src/providers/UserPreferencesProvider.tsx @@ -1,5 +1,5 @@ import storage from '@/services/local-storage.service' -import { TNotificationStyle } from '@/types' +import { TEmoji, TNotificationStyle } from '@/types' import { createContext, useContext, useEffect, useState } from 'react' import { useScreenSize } from './ScreenSizeProvider' @@ -15,6 +15,12 @@ type TUserPreferencesContext = { enableSingleColumnLayout: boolean updateEnableSingleColumnLayout: (enable: boolean) => void + + quickReaction: boolean + updateQuickReaction: (enable: boolean) => void + + quickReactionEmoji: string | TEmoji + updateQuickReactionEmoji: (emoji: string | TEmoji) => void } const UserPreferencesContext = createContext(undefined) @@ -37,6 +43,8 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod const [enableSingleColumnLayout, setEnableSingleColumnLayout] = useState( storage.getEnableSingleColumnLayout() ) + const [quickReaction, setQuickReaction] = useState(storage.getQuickReaction()) + const [quickReactionEmoji, setQuickReactionEmoji] = useState(storage.getQuickReactionEmoji()) useEffect(() => { if (!isSmallScreen && enableSingleColumnLayout) { @@ -61,6 +69,16 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod storage.setEnableSingleColumnLayout(enable) } + const updateQuickReaction = (enable: boolean) => { + setQuickReaction(enable) + storage.setQuickReaction(enable) + } + + const updateQuickReactionEmoji = (emoji: string | TEmoji) => { + setQuickReactionEmoji(emoji) + storage.setQuickReactionEmoji(emoji) + } + return ( {children} diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index a17bcee..2489afd 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -14,6 +14,7 @@ import { isTorBrowser } from '@/lib/utils' import { TAccount, TAccountPointer, + TEmoji, TFeedInfo, TMediaAutoLoadPolicy, TMediaUploadServiceConfig, @@ -57,6 +58,8 @@ class LocalStorageService { private enableSingleColumnLayout: boolean = true private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE private filterOutOnionRelays: boolean = !isTorBrowser() + private quickReaction: boolean = false + private quickReactionEmoji: string | TEmoji = '+' constructor() { if (!LocalStorageService.instance) { @@ -230,6 +233,15 @@ class LocalStorageService { this.filterOutOnionRelays = filterOutOnionRelaysStr !== 'false' } + this.quickReaction = window.localStorage.getItem(StorageKey.QUICK_REACTION) === 'true' + const quickReactionEmojiStr = + window.localStorage.getItem(StorageKey.QUICK_REACTION_EMOJI) ?? '+' + if (quickReactionEmojiStr.startsWith('{')) { + this.quickReactionEmoji = JSON.parse(quickReactionEmojiStr) as TEmoji + } else { + this.quickReactionEmoji = quickReactionEmojiStr + } + // Clean up deprecated data window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS) window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) @@ -559,6 +571,27 @@ class LocalStorageService { this.filterOutOnionRelays = filterOut window.localStorage.setItem(StorageKey.FILTER_OUT_ONION_RELAYS, filterOut.toString()) } + + getQuickReaction() { + return this.quickReaction + } + + setQuickReaction(quickReaction: boolean) { + this.quickReaction = quickReaction + window.localStorage.setItem(StorageKey.QUICK_REACTION, quickReaction.toString()) + } + + getQuickReactionEmoji() { + return this.quickReactionEmoji + } + + setQuickReactionEmoji(emoji: string | TEmoji) { + this.quickReactionEmoji = emoji + window.localStorage.setItem( + StorageKey.QUICK_REACTION_EMOJI, + typeof emoji === 'string' ? emoji : JSON.stringify(emoji) + ) + } } const instance = new LocalStorageService()