feat: change like trigger from click to long-press
This commit is contained in:
@@ -7,13 +7,17 @@ import noteStatsService from '@/services/note-stats.service'
|
|||||||
import { TEmoji } from '@/types'
|
import { TEmoji } from '@/types'
|
||||||
import { Loader } from 'lucide-react'
|
import { Loader } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import Emoji from '../Emoji'
|
import Emoji from '../Emoji'
|
||||||
|
|
||||||
export default function Likes({ event }: { event: Event }) {
|
export default function Likes({ event }: { event: Event }) {
|
||||||
const { pubkey, checkLogin, publish } = useNostr()
|
const { pubkey, checkLogin, publish } = useNostr()
|
||||||
const noteStats = useNoteStatsById(event.id)
|
const noteStats = useNoteStatsById(event.id)
|
||||||
const [liking, setLiking] = useState<string | null>(null)
|
const [liking, setLiking] = useState<string | null>(null)
|
||||||
|
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const [isLongPressing, setIsLongPressing] = useState<string | null>(null)
|
||||||
|
const [isCompleted, setIsCompleted] = useState<string | null>(null)
|
||||||
|
|
||||||
const likes = useMemo(() => {
|
const likes = useMemo(() => {
|
||||||
const _likes = noteStats?.likes
|
const _likes = noteStats?.likes
|
||||||
if (!_likes) return []
|
if (!_likes) return []
|
||||||
@@ -51,6 +55,59 @@ export default function Likes({ event }: { event: Event }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMouseDown = (key: string) => {
|
||||||
|
if (pubkey && likes.find((l) => l.key === key)?.pubkeys.has(pubkey)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLongPressing(key)
|
||||||
|
longPressTimerRef.current = setTimeout(() => {
|
||||||
|
setIsCompleted(key)
|
||||||
|
setIsLongPressing(null)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (longPressTimerRef.current) {
|
||||||
|
clearTimeout(longPressTimerRef.current)
|
||||||
|
longPressTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
const completedKey = isCompleted
|
||||||
|
const completedEmoji = likes.find((l) => l.key === completedKey)?.emoji
|
||||||
|
if (completedEmoji) {
|
||||||
|
like(completedKey, completedEmoji)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLongPressing(null)
|
||||||
|
setIsCompleted(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (longPressTimerRef.current) {
|
||||||
|
clearTimeout(longPressTimerRef.current)
|
||||||
|
longPressTimerRef.current = null
|
||||||
|
}
|
||||||
|
setIsLongPressing(null)
|
||||||
|
setIsCompleted(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0]
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
const isInside =
|
||||||
|
touch.clientX >= rect.left &&
|
||||||
|
touch.clientX <= rect.right &&
|
||||||
|
touch.clientY >= rect.top &&
|
||||||
|
touch.clientY <= rect.bottom
|
||||||
|
|
||||||
|
if (!isInside) {
|
||||||
|
handleMouseLeave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="pb-2 mb-1">
|
<ScrollArea className="pb-2 mb-1">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@@ -58,25 +115,46 @@ export default function Likes({ event }: { event: Event }) {
|
|||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-7 w-fit gap-2 px-2 rounded-full items-center border shrink-0',
|
'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',
|
||||||
pubkey && pubkeys.has(pubkey)
|
pubkey && pubkeys.has(pubkey)
|
||||||
? 'border-primary bg-primary/20 text-foreground cursor-not-allowed'
|
? 'border-primary bg-primary/20 text-foreground cursor-not-allowed'
|
||||||
: 'transition-colors bg-muted/80 text-muted-foreground cursor-pointer hover:bg-primary/40 hover:border-primary hover:text-foreground'
|
: 'bg-muted/80 text-muted-foreground cursor-pointer hover:bg-primary/40 hover:border-primary hover:text-foreground',
|
||||||
|
(isLongPressing === key || isCompleted === key) && 'border-primary bg-primary/20'
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onMouseDown={() => handleMouseDown(key)}
|
||||||
e.stopPropagation()
|
onMouseUp={handleMouseUp}
|
||||||
if (pubkey && pubkeys.has(pubkey)) {
|
onMouseLeave={handleMouseLeave}
|
||||||
return
|
onTouchStart={() => handleMouseDown(key)}
|
||||||
}
|
onTouchMove={handleTouchMove}
|
||||||
like(key, emoji)
|
onTouchEnd={handleMouseUp}
|
||||||
}}
|
onTouchCancel={handleMouseLeave}
|
||||||
>
|
>
|
||||||
{liking === key ? (
|
{(isLongPressing === key || isCompleted === key) && (
|
||||||
<Loader className="animate-spin size-4" />
|
<div className="absolute inset-0 rounded-full overflow-hidden">
|
||||||
) : (
|
<div
|
||||||
<Emoji emoji={emoji} classNames={{ img: 'size-4' }} />
|
className="h-full bg-gradient-to-r from-primary/40 via-primary/60 to-primary/80"
|
||||||
|
style={{
|
||||||
|
width: isCompleted === key ? '100%' : '0%',
|
||||||
|
animation:
|
||||||
|
isLongPressing === key ? 'progressFill 1000ms ease-out forwards' : 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-sm">{pubkeys.size}</div>
|
<div className="relative z-10 flex items-center gap-2">
|
||||||
|
{liking === key ? (
|
||||||
|
<Loader className="animate-spin size-4" />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
animation: isCompleted === key ? 'shake 0.5s ease-in-out infinite' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Emoji emoji={emoji} classNames={{ img: 'size-4' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-sm">{pubkeys.size}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -129,3 +129,46 @@
|
|||||||
filter: invert(1) brightness(1.5);
|
filter: invert(1) brightness(1.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes progressFill {
|
||||||
|
0% {
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0) rotate(0deg);
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
transform: translate(-1px, -1px) rotate(-1deg);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: translate(1px, -1px) rotate(1deg);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translate(-1px, 1px) rotate(-1deg);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translate(1px, 1px) rotate(1deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(-1px, -1px) rotate(-1deg);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translate(1px, -1px) rotate(1deg);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: translate(-1px, 1px) rotate(-1deg);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: translate(1px, 1px) rotate(1deg);
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
transform: translate(-1px, -1px) rotate(-1deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user