Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #130 from barazo-forum/feat/like-button

feat(reactions): wire up interactive like button

authored by

Guido X Jansen and committed by
GitHub
a25cbfb6 f17b202d

+627 -18
+358
src/components/like-button.test.tsx
··· 1 + /** 2 + * Tests for LikeButton component. 3 + * Interactive heart button for liking/unliking topics and replies. 4 + */ 5 + 6 + import { describe, it, expect, vi, beforeEach } from 'vitest' 7 + import { render, screen, waitFor } from '@testing-library/react' 8 + import userEvent from '@testing-library/user-event' 9 + import { axe } from 'vitest-axe' 10 + import { LikeButton } from './like-button' 11 + 12 + // --- Mocks --- 13 + 14 + const mockGetAccessToken = vi.fn(() => 'mock-access-token') 15 + const mockAuthFetch = vi.fn() 16 + 17 + vi.mock('@/hooks/use-auth', () => ({ 18 + useAuth: () => ({ 19 + user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' }, 20 + isAuthenticated: mockGetAccessToken() !== null, 21 + isLoading: false, 22 + getAccessToken: mockGetAccessToken, 23 + authFetch: mockAuthFetch, 24 + login: vi.fn(), 25 + logout: vi.fn(), 26 + setSessionFromCallback: vi.fn(), 27 + crossPostScopesGranted: false, 28 + requestCrossPostAuth: vi.fn(), 29 + }), 30 + })) 31 + 32 + vi.mock('@/lib/api/client', () => ({ 33 + getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 34 + createReaction: vi.fn().mockResolvedValue({ 35 + uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/abc123', 36 + cid: 'bafyrei-abc123', 37 + rkey: 'abc123', 38 + type: 'like', 39 + subjectUri: 'at://did:plc:author/forum.barazo.topic.post/topic1', 40 + createdAt: '2026-02-14T12:00:00.000Z', 41 + }), 42 + deleteReaction: vi.fn().mockResolvedValue(undefined), 43 + })) 44 + 45 + // Import after mocks so we can spy on them 46 + const { getReactions, createReaction, deleteReaction } = await import('@/lib/api/client') 47 + 48 + const defaultProps = { 49 + subjectUri: 'at://did:plc:author/forum.barazo.topic.post/topic1', 50 + subjectCid: 'bafyrei-topic1', 51 + initialCount: 5, 52 + } 53 + 54 + beforeEach(() => { 55 + vi.clearAllMocks() 56 + mockGetAccessToken.mockReturnValue('mock-access-token') 57 + mockAuthFetch.mockReset() 58 + vi.mocked(getReactions).mockResolvedValue({ reactions: [], cursor: null }) 59 + vi.mocked(createReaction).mockResolvedValue({ 60 + uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/abc123', 61 + cid: 'bafyrei-abc123', 62 + rkey: 'abc123', 63 + type: 'like', 64 + subjectUri: defaultProps.subjectUri, 65 + createdAt: '2026-02-14T12:00:00.000Z', 66 + }) 67 + vi.mocked(deleteReaction).mockResolvedValue(undefined) 68 + }) 69 + 70 + describe('LikeButton', () => { 71 + describe('rendering', () => { 72 + it('renders a button with heart icon and count', () => { 73 + render(<LikeButton {...defaultProps} />) 74 + const button = screen.getByRole('button', { name: /5 reactions/i }) 75 + expect(button).toBeInTheDocument() 76 + }) 77 + 78 + it('displays the initial count', () => { 79 + render(<LikeButton {...defaultProps} initialCount={12} />) 80 + expect(screen.getByText('12')).toBeInTheDocument() 81 + }) 82 + 83 + it('displays zero count', () => { 84 + render(<LikeButton {...defaultProps} initialCount={0} />) 85 + expect(screen.getByText('0')).toBeInTheDocument() 86 + }) 87 + }) 88 + 89 + describe('fetching user reaction status', () => { 90 + it('checks if the current user has already liked on mount', async () => { 91 + render(<LikeButton {...defaultProps} />) 92 + await waitFor(() => { 93 + expect(getReactions).toHaveBeenCalledWith( 94 + defaultProps.subjectUri, 95 + { type: 'like' }, 96 + expect.objectContaining({ 97 + headers: expect.objectContaining({ 98 + Authorization: 'Bearer mock-access-token', 99 + }), 100 + }) 101 + ) 102 + }) 103 + }) 104 + 105 + it('shows filled state when user has already liked', async () => { 106 + vi.mocked(getReactions).mockResolvedValueOnce({ 107 + reactions: [ 108 + { 109 + uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing', 110 + rkey: 'existing', 111 + authorDid: 'did:plc:user-test-001', 112 + subjectUri: defaultProps.subjectUri, 113 + subjectCid: defaultProps.subjectCid, 114 + type: 'like', 115 + communityDid: 'did:plc:community', 116 + cid: 'bafyrei-existing', 117 + createdAt: '2026-02-14T12:00:00.000Z', 118 + }, 119 + ], 120 + cursor: null, 121 + }) 122 + 123 + render(<LikeButton {...defaultProps} />) 124 + await waitFor(() => { 125 + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') 126 + }) 127 + }) 128 + 129 + it('shows unfilled state when user has not liked', async () => { 130 + render(<LikeButton {...defaultProps} />) 131 + await waitFor(() => { 132 + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false') 133 + }) 134 + }) 135 + }) 136 + 137 + describe('liking', () => { 138 + it('calls createReaction when clicking an unliked button', async () => { 139 + const user = userEvent.setup() 140 + render(<LikeButton {...defaultProps} />) 141 + 142 + await waitFor(() => { 143 + expect(getReactions).toHaveBeenCalled() 144 + }) 145 + 146 + await user.click(screen.getByRole('button')) 147 + 148 + expect(createReaction).toHaveBeenCalledWith( 149 + { 150 + subjectUri: defaultProps.subjectUri, 151 + subjectCid: defaultProps.subjectCid, 152 + type: 'like', 153 + }, 154 + 'mock-access-token' 155 + ) 156 + }) 157 + 158 + it('optimistically increments count on like', async () => { 159 + const user = userEvent.setup() 160 + render(<LikeButton {...defaultProps} initialCount={5} />) 161 + 162 + await waitFor(() => { 163 + expect(getReactions).toHaveBeenCalled() 164 + }) 165 + 166 + await user.click(screen.getByRole('button')) 167 + expect(screen.getByText('6')).toBeInTheDocument() 168 + }) 169 + 170 + it('optimistically sets pressed state on like', async () => { 171 + const user = userEvent.setup() 172 + render(<LikeButton {...defaultProps} />) 173 + 174 + await waitFor(() => { 175 + expect(getReactions).toHaveBeenCalled() 176 + }) 177 + 178 + await user.click(screen.getByRole('button')) 179 + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') 180 + }) 181 + }) 182 + 183 + describe('unliking', () => { 184 + it('calls deleteReaction when clicking a liked button', async () => { 185 + vi.mocked(getReactions).mockResolvedValueOnce({ 186 + reactions: [ 187 + { 188 + uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing', 189 + rkey: 'existing', 190 + authorDid: 'did:plc:user-test-001', 191 + subjectUri: defaultProps.subjectUri, 192 + subjectCid: defaultProps.subjectCid, 193 + type: 'like', 194 + communityDid: 'did:plc:community', 195 + cid: 'bafyrei-existing', 196 + createdAt: '2026-02-14T12:00:00.000Z', 197 + }, 198 + ], 199 + cursor: null, 200 + }) 201 + 202 + const user = userEvent.setup() 203 + render(<LikeButton {...defaultProps} initialCount={5} />) 204 + 205 + await waitFor(() => { 206 + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') 207 + }) 208 + 209 + await user.click(screen.getByRole('button')) 210 + 211 + expect(deleteReaction).toHaveBeenCalledWith( 212 + 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing', 213 + 'mock-access-token' 214 + ) 215 + }) 216 + 217 + it('optimistically decrements count on unlike', async () => { 218 + vi.mocked(getReactions).mockResolvedValueOnce({ 219 + reactions: [ 220 + { 221 + uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing', 222 + rkey: 'existing', 223 + authorDid: 'did:plc:user-test-001', 224 + subjectUri: defaultProps.subjectUri, 225 + subjectCid: defaultProps.subjectCid, 226 + type: 'like', 227 + communityDid: 'did:plc:community', 228 + cid: 'bafyrei-existing', 229 + createdAt: '2026-02-14T12:00:00.000Z', 230 + }, 231 + ], 232 + cursor: null, 233 + }) 234 + 235 + const user = userEvent.setup() 236 + render(<LikeButton {...defaultProps} initialCount={5} />) 237 + 238 + await waitFor(() => { 239 + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') 240 + }) 241 + 242 + await user.click(screen.getByRole('button')) 243 + expect(screen.getByText('4')).toBeInTheDocument() 244 + }) 245 + 246 + it('does not go below zero on unlike', async () => { 247 + vi.mocked(getReactions).mockResolvedValueOnce({ 248 + reactions: [ 249 + { 250 + uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/existing', 251 + rkey: 'existing', 252 + authorDid: 'did:plc:user-test-001', 253 + subjectUri: defaultProps.subjectUri, 254 + subjectCid: defaultProps.subjectCid, 255 + type: 'like', 256 + communityDid: 'did:plc:community', 257 + cid: 'bafyrei-existing', 258 + createdAt: '2026-02-14T12:00:00.000Z', 259 + }, 260 + ], 261 + cursor: null, 262 + }) 263 + 264 + const user = userEvent.setup() 265 + render(<LikeButton {...defaultProps} initialCount={0} />) 266 + 267 + await waitFor(() => { 268 + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') 269 + }) 270 + 271 + await user.click(screen.getByRole('button')) 272 + expect(screen.getByText('0')).toBeInTheDocument() 273 + }) 274 + }) 275 + 276 + describe('error handling', () => { 277 + it('reverts count on like failure', async () => { 278 + vi.mocked(createReaction).mockRejectedValueOnce(new Error('Network error')) 279 + 280 + const user = userEvent.setup() 281 + render(<LikeButton {...defaultProps} initialCount={5} />) 282 + 283 + await waitFor(() => { 284 + expect(getReactions).toHaveBeenCalled() 285 + }) 286 + 287 + await user.click(screen.getByRole('button')) 288 + 289 + // After error: reverts to 5 290 + await waitFor(() => { 291 + expect(screen.getByText('5')).toBeInTheDocument() 292 + }) 293 + }) 294 + 295 + it('reverts pressed state on like failure', async () => { 296 + vi.mocked(createReaction).mockRejectedValueOnce(new Error('Network error')) 297 + 298 + const user = userEvent.setup() 299 + render(<LikeButton {...defaultProps} />) 300 + 301 + await waitFor(() => { 302 + expect(getReactions).toHaveBeenCalled() 303 + }) 304 + 305 + await user.click(screen.getByRole('button')) 306 + 307 + await waitFor(() => { 308 + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false') 309 + }) 310 + }) 311 + }) 312 + 313 + describe('unauthenticated state', () => { 314 + it('does not fetch reactions when not authenticated', () => { 315 + mockGetAccessToken.mockReturnValue(null as unknown as string) 316 + render(<LikeButton {...defaultProps} />) 317 + expect(getReactions).not.toHaveBeenCalled() 318 + }) 319 + 320 + it('disables the button when not authenticated', () => { 321 + mockGetAccessToken.mockReturnValue(null as unknown as string) 322 + render(<LikeButton {...defaultProps} />) 323 + expect(screen.getByRole('button')).toBeDisabled() 324 + }) 325 + }) 326 + 327 + describe('size variants', () => { 328 + it('renders with default size', () => { 329 + render(<LikeButton {...defaultProps} />) 330 + expect(screen.getByRole('button')).toBeInTheDocument() 331 + }) 332 + 333 + it('accepts sm size prop', () => { 334 + render(<LikeButton {...defaultProps} size="sm" />) 335 + expect(screen.getByRole('button')).toBeInTheDocument() 336 + }) 337 + }) 338 + 339 + describe('accessibility', () => { 340 + it('has aria-pressed reflecting liked state', async () => { 341 + render(<LikeButton {...defaultProps} />) 342 + await waitFor(() => { 343 + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false') 344 + }) 345 + }) 346 + 347 + it('has accessible label with count', () => { 348 + render(<LikeButton {...defaultProps} initialCount={5} />) 349 + expect(screen.getByRole('button', { name: /5 reactions/i })).toBeInTheDocument() 350 + }) 351 + 352 + it('passes axe accessibility check', async () => { 353 + const { container } = render(<LikeButton {...defaultProps} />) 354 + const results = await axe(container) 355 + expect(results).toHaveNoViolations() 356 + }) 357 + }) 358 + })
+131
src/components/like-button.tsx
··· 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 + 9 + import { useState, useEffect, useCallback, useRef } from 'react' 10 + import { Heart } from '@phosphor-icons/react' 11 + import { useAuth } from '@/hooks/use-auth' 12 + import { getReactions, createReaction, deleteReaction } from '@/lib/api/client' 13 + import { cn } from '@/lib/utils' 14 + import { formatCompactNumber } from '@/lib/format' 15 + 16 + interface LikeButtonProps { 17 + subjectUri: string 18 + subjectCid: string 19 + initialCount: number 20 + size?: 'sm' | 'md' 21 + className?: string 22 + } 23 + 24 + export function LikeButton({ 25 + subjectUri, 26 + subjectCid, 27 + initialCount, 28 + size = 'md', 29 + className, 30 + }: LikeButtonProps) { 31 + const { user, isAuthenticated, getAccessToken } = useAuth() 32 + const [liked, setLiked] = useState(false) 33 + const [count, setCount] = useState(initialCount) 34 + const [pending, setPending] = useState(false) 35 + const reactionUriRef = useRef<string | null>(null) 36 + 37 + // Fetch the current user's like status on mount 38 + useEffect(() => { 39 + if (!isAuthenticated || !user) return 40 + 41 + const token = getAccessToken() 42 + if (!token) return 43 + 44 + let cancelled = false 45 + 46 + async function fetchLikeStatus() { 47 + try { 48 + const result = await getReactions( 49 + subjectUri, 50 + { type: 'like' }, 51 + { 52 + headers: { Authorization: `Bearer ${token}` }, 53 + } 54 + ) 55 + if (cancelled) return 56 + 57 + const userReaction = result.reactions.find((r) => r.authorDid === user!.did) 58 + if (userReaction) { 59 + setLiked(true) 60 + reactionUriRef.current = userReaction.uri 61 + } 62 + } catch { 63 + // Non-critical: button still works, just won't show pre-existing like state 64 + } 65 + } 66 + 67 + void fetchLikeStatus() 68 + return () => { 69 + cancelled = true 70 + } 71 + }, [subjectUri, isAuthenticated, user, getAccessToken]) 72 + 73 + const handleToggle = useCallback(async () => { 74 + const token = getAccessToken() 75 + if (!token || pending) return 76 + 77 + const wasLiked = liked 78 + const previousCount = count 79 + const previousUri = reactionUriRef.current 80 + 81 + // Optimistic update 82 + if (wasLiked) { 83 + setLiked(false) 84 + setCount(Math.max(0, previousCount - 1)) 85 + } else { 86 + setLiked(true) 87 + setCount(previousCount + 1) 88 + } 89 + 90 + setPending(true) 91 + 92 + try { 93 + if (wasLiked && previousUri) { 94 + await deleteReaction(previousUri, token) 95 + reactionUriRef.current = null 96 + } else { 97 + const result = await createReaction({ subjectUri, subjectCid, type: 'like' }, token) 98 + reactionUriRef.current = result.uri 99 + } 100 + } catch { 101 + // Revert optimistic update 102 + setLiked(wasLiked) 103 + setCount(previousCount) 104 + reactionUriRef.current = previousUri 105 + } finally { 106 + setPending(false) 107 + } 108 + }, [liked, count, pending, subjectUri, subjectCid, getAccessToken]) 109 + 110 + const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4' 111 + 112 + return ( 113 + <button 114 + type="button" 115 + aria-pressed={liked} 116 + aria-label={`${formatCompactNumber(count)} reactions`} 117 + disabled={!isAuthenticated} 118 + onClick={handleToggle} 119 + className={cn( 120 + 'inline-flex items-center gap-1.5 transition-colors', 121 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded', 122 + 'disabled:cursor-not-allowed disabled:opacity-50', 123 + liked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground', 124 + className 125 + )} 126 + > 127 + <Heart className={iconSize} weight={liked ? 'fill' : 'regular'} aria-hidden="true" /> 128 + <span>{formatCompactNumber(count)}</span> 129 + </button> 130 + ) 131 + }
+21
src/components/reply-card.test.tsx
··· 9 9 import { ReplyCard } from './reply-card' 10 10 import { mockReplies, mockAuthorDeletedReply, mockModDeletedReply } from '@/mocks/data' 11 11 12 + vi.mock('@/hooks/use-auth', () => ({ 13 + useAuth: () => ({ 14 + user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' }, 15 + isAuthenticated: true, 16 + isLoading: false, 17 + getAccessToken: () => 'mock-access-token', 18 + authFetch: vi.fn(), 19 + login: vi.fn(), 20 + logout: vi.fn(), 21 + setSessionFromCallback: vi.fn(), 22 + crossPostScopesGranted: false, 23 + requestCrossPostAuth: vi.fn(), 24 + }), 25 + })) 26 + 27 + vi.mock('@/lib/api/client', () => ({ 28 + getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 29 + createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), 30 + deleteReaction: vi.fn().mockResolvedValue(undefined), 31 + })) 32 + 12 33 const reply = mockReplies[0]! 13 34 const nestedReply = mockReplies[1]! // depth 1 14 35
+9 -9
src/components/reply-card.tsx
··· 8 8 9 9 import Link from 'next/link' 10 10 import Image from 'next/image' 11 - import { Heart, Clock, Link as LinkIcon, ChatCircle } from '@phosphor-icons/react/dist/ssr' 11 + import { Clock, Link as LinkIcon, ChatCircle } from '@phosphor-icons/react/dist/ssr' 12 12 import type { Reply } from '@/lib/api/types' 13 13 import { cn } from '@/lib/utils' 14 - import { formatRelativeTime, formatCompactNumber } from '@/lib/format' 14 + import { formatRelativeTime } from '@/lib/format' 15 15 import { MarkdownContent } from './markdown-content' 16 16 import { ReactionBar } from './reaction-bar' 17 17 import { ReportDialog, type ReportSubmission } from './report-dialog' 18 18 import { SelfLabelIndicator } from './self-label-indicator' 19 + import { LikeButton } from './like-button' 19 20 20 21 interface ReactionData { 21 22 type: string ··· 163 164 {reactions && onReactionToggle && ( 164 165 <ReactionBar reactions={reactions} onToggle={onReactionToggle} /> 165 166 )} 166 - <span 167 - className="flex items-center gap-1" 168 - aria-label={`${formatCompactNumber(reply.reactionCount)} reactions`} 169 - > 170 - <Heart className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 171 - {formatCompactNumber(reply.reactionCount)} 172 - </span> 167 + <LikeButton 168 + subjectUri={reply.uri} 169 + subjectCid={reply.cid} 170 + initialCount={reply.reactionCount} 171 + size="sm" 172 + /> 173 173 <span className="flex items-center gap-1"> 174 174 <Clock className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 175 175 {formatRelativeTime(reply.createdAt)}
+22 -1
src/components/reply-thread.test.tsx
··· 2 2 * Tests for ReplyThread component. 3 3 */ 4 4 5 - import { describe, it, expect } from 'vitest' 5 + import { describe, it, expect, vi } from 'vitest' 6 6 import { render, screen } from '@testing-library/react' 7 7 import { axe } from 'vitest-axe' 8 8 import { ReplyThread } from './reply-thread' 9 9 import { mockReplies } from '@/mocks/data' 10 + 11 + vi.mock('@/hooks/use-auth', () => ({ 12 + useAuth: () => ({ 13 + user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' }, 14 + isAuthenticated: true, 15 + isLoading: false, 16 + getAccessToken: () => 'mock-access-token', 17 + authFetch: vi.fn(), 18 + login: vi.fn(), 19 + logout: vi.fn(), 20 + setSessionFromCallback: vi.fn(), 21 + crossPostScopesGranted: false, 22 + requestCrossPostAuth: vi.fn(), 23 + }), 24 + })) 25 + 26 + vi.mock('@/lib/api/client', () => ({ 27 + getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 28 + createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), 29 + deleteReaction: vi.fn().mockResolvedValue(undefined), 30 + })) 10 31 11 32 describe('ReplyThread', () => { 12 33 it('renders all replies', () => {
+21
src/components/topic-view.test.tsx
··· 9 9 import { TopicView } from './topic-view' 10 10 import { mockTopics, mockUsers, mockAuthorDeletedTopic, mockModDeletedTopic } from '@/mocks/data' 11 11 12 + vi.mock('@/hooks/use-auth', () => ({ 13 + useAuth: () => ({ 14 + user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' }, 15 + isAuthenticated: true, 16 + isLoading: false, 17 + getAccessToken: () => 'mock-access-token', 18 + authFetch: vi.fn(), 19 + login: vi.fn(), 20 + logout: vi.fn(), 21 + setSessionFromCallback: vi.fn(), 22 + crossPostScopesGranted: false, 23 + requestCrossPostAuth: vi.fn(), 24 + }), 25 + })) 26 + 27 + vi.mock('@/lib/api/client', () => ({ 28 + getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 29 + createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), 30 + deleteReaction: vi.fn().mockResolvedValue(undefined), 31 + })) 32 + 12 33 const topic = mockTopics[0]! 13 34 14 35 const mockReactions = [
+7 -8
src/components/topic-view.tsx
··· 6 6 */ 7 7 8 8 import Link from 'next/link' 9 - import { ChatCircle, Heart, Clock, Tag } from '@phosphor-icons/react/dist/ssr' 9 + import { ChatCircle, Clock, Tag } from '@phosphor-icons/react/dist/ssr' 10 10 import type { Topic } from '@/lib/api/types' 11 11 import { cn } from '@/lib/utils' 12 12 import { formatRelativeTime, formatCompactNumber } from '@/lib/format' ··· 15 15 import { ModerationControls, type ModerationAction } from './moderation-controls' 16 16 import { ReportDialog, type ReportSubmission } from './report-dialog' 17 17 import { SelfLabelIndicator } from './self-label-indicator' 18 + import { LikeButton } from './like-button' 18 19 19 20 interface ReactionData { 20 21 type: string ··· 155 156 <ChatCircle className="h-4 w-4" weight="regular" aria-hidden="true" /> 156 157 {formatCompactNumber(topic.replyCount)} 157 158 </span> 158 - <span 159 - className="flex items-center gap-1.5" 160 - aria-label={`${formatCompactNumber(topic.reactionCount)} reactions`} 161 - > 162 - <Heart className="h-4 w-4" weight="regular" aria-hidden="true" /> 163 - {formatCompactNumber(topic.reactionCount)} 164 - </span> 159 + <LikeButton 160 + subjectUri={topic.uri} 161 + subjectCid={topic.cid} 162 + initialCount={topic.reactionCount} 163 + /> 165 164 <span className="flex items-center gap-1.5"> 166 165 <Clock className="h-4 w-4" weight="regular" aria-hidden="true" /> 167 166 Last activity {formatRelativeTime(topic.lastActivityAt)}
+58
src/lib/api/client.ts
··· 63 63 TrustGraphStatus, 64 64 BehavioralFlagsResponse, 65 65 BehavioralFlag, 66 + ReactionsResponse, 66 67 } from './types' 67 68 68 69 /** Client: relative URLs (empty string). Server: internal Docker network URL. */ ··· 1124 1125 headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1125 1126 body: input, 1126 1127 }) 1128 + } 1129 + 1130 + // --- Reaction endpoints --- 1131 + 1132 + export interface CreateReactionInput { 1133 + subjectUri: string 1134 + subjectCid: string 1135 + type: string 1136 + } 1137 + 1138 + export interface CreateReactionResponse { 1139 + uri: string 1140 + cid: string 1141 + rkey: string 1142 + type: string 1143 + subjectUri: string 1144 + createdAt: string 1145 + } 1146 + 1147 + export function createReaction( 1148 + input: CreateReactionInput, 1149 + accessToken: string, 1150 + options?: FetchOptions 1151 + ): Promise<CreateReactionResponse> { 1152 + return apiFetch<CreateReactionResponse>('/api/reactions', { 1153 + ...options, 1154 + method: 'POST', 1155 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1156 + body: input, 1157 + }) 1158 + } 1159 + 1160 + export function deleteReaction( 1161 + uri: string, 1162 + accessToken: string, 1163 + options?: FetchOptions 1164 + ): Promise<void> { 1165 + const url = `/api/reactions/${encodeURIComponent(uri)}` 1166 + return apiFetch<void>(url, { 1167 + ...options, 1168 + method: 'DELETE', 1169 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1170 + }) 1171 + } 1172 + 1173 + export function getReactions( 1174 + subjectUri: string, 1175 + params: { type?: string; cursor?: string; limit?: number } = {}, 1176 + options?: FetchOptions 1177 + ): Promise<ReactionsResponse> { 1178 + const query = buildQuery({ 1179 + subjectUri, 1180 + type: params.type, 1181 + cursor: params.cursor, 1182 + limit: params.limit, 1183 + }) 1184 + return apiFetch<ReactionsResponse>(`/api/reactions${query}`, options) 1127 1185 } 1128 1186 1129 1187 export { ApiError }