Barazo default frontend barazo.forum
at main 147 lines 4.5 kB view raw
1/** 2 * LikeButton - Interactive heart button for liking/unliking topics and replies. 3 * Fetches current user's like status on mount and handles optimistic updates. 4 * @see specs/prd-web.md Section M7 (Reactions + Moderation UI) 5 */ 6 7'use client' 8 9import { useState, useEffect, useCallback, useRef } from 'react' 10import { Heart } from '@phosphor-icons/react' 11import { useAuth } from '@/hooks/use-auth' 12import { useOnboardingContext } from '@/context/onboarding-context' 13import { useToast } from '@/hooks/use-toast' 14import { getReactions, createReaction, deleteReaction } from '@/lib/api/client' 15import { cn } from '@/lib/utils' 16import { formatCompactNumber } from '@/lib/format' 17 18interface LikeButtonProps { 19 subjectUri: string 20 subjectCid: string 21 initialCount: number 22 size?: 'sm' | 'md' 23 disabled?: boolean 24 className?: string 25} 26 27export function LikeButton({ 28 subjectUri, 29 subjectCid, 30 initialCount, 31 size = 'md', 32 disabled = false, 33 className, 34}: LikeButtonProps) { 35 const { user, isAuthenticated, getAccessToken } = useAuth() 36 const { ensureOnboarded } = useOnboardingContext() 37 const { toast } = useToast() 38 const [liked, setLiked] = useState(false) 39 const [count, setCount] = useState(initialCount) 40 const [pending, setPending] = useState(false) 41 const reactionUriRef = useRef<string | null>(null) 42 43 // Fetch the current user's like status on mount 44 useEffect(() => { 45 if (!isAuthenticated || !user) return 46 47 const token = getAccessToken() 48 if (!token) return 49 50 let cancelled = false 51 52 async function fetchLikeStatus() { 53 try { 54 const result = await getReactions( 55 subjectUri, 56 { type: 'like' }, 57 { 58 headers: { Authorization: `Bearer ${token}` }, 59 } 60 ) 61 if (cancelled) return 62 63 const userReaction = result.reactions.find((r) => r.authorDid === user!.did) 64 if (userReaction) { 65 setLiked(true) 66 reactionUriRef.current = userReaction.uri 67 } 68 } catch { 69 // Non-critical: button still works, just won't show pre-existing like state 70 } 71 } 72 73 void fetchLikeStatus() 74 return () => { 75 cancelled = true 76 } 77 }, [subjectUri, isAuthenticated, user, getAccessToken]) 78 79 const handleToggle = useCallback(async () => { 80 if (!ensureOnboarded()) return 81 const token = getAccessToken() 82 if (!token || pending) return 83 84 const wasLiked = liked 85 const previousCount = count 86 const previousUri = reactionUriRef.current 87 88 // Optimistic update 89 if (wasLiked) { 90 setLiked(false) 91 setCount(Math.max(0, previousCount - 1)) 92 } else { 93 setLiked(true) 94 setCount(previousCount + 1) 95 } 96 97 setPending(true) 98 99 try { 100 if (wasLiked && previousUri) { 101 await deleteReaction(previousUri, token) 102 reactionUriRef.current = null 103 } else { 104 const result = await createReaction({ subjectUri, subjectCid, type: 'like' }, token) 105 reactionUriRef.current = result.uri 106 } 107 } catch (err) { 108 const message = err instanceof Error ? err.message : 'Failed to update reaction' 109 const isNotFound = message === 'Not Found' || message.includes('not found') 110 111 if (wasLiked && isNotFound) { 112 // Reaction was already deleted server-side -- accept the unliked state 113 reactionUriRef.current = null 114 } else { 115 // Revert optimistic update 116 setLiked(wasLiked) 117 setCount(previousCount) 118 reactionUriRef.current = previousUri 119 toast({ title: 'Error', description: message, variant: 'destructive' }) 120 } 121 } finally { 122 setPending(false) 123 } 124 }, [liked, count, pending, subjectUri, subjectCid, getAccessToken, ensureOnboarded, toast]) 125 126 const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4' 127 128 return ( 129 <button 130 type="button" 131 aria-pressed={liked} 132 aria-label={`${formatCompactNumber(count)} reactions`} 133 disabled={disabled || !isAuthenticated} 134 onClick={handleToggle} 135 className={cn( 136 'inline-flex items-center gap-1.5 transition-colors', 137 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded', 138 'disabled:cursor-not-allowed disabled:opacity-50', 139 liked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground', 140 className 141 )} 142 > 143 <Heart className={iconSize} weight={liked ? 'fill' : 'regular'} aria-hidden="true" /> 144 <span>{formatCompactNumber(count)}</span> 145 </button> 146 ) 147}