Barazo default frontend
barazo.forum
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}