Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(web): add inline post editing for topics and replies (#134)

* feat(web): add inline post editing for topics and replies

Allow authors to edit their own topics and replies. Topics use a
dedicated /edit page with pre-populated form. Replies use inline
editing with MarkdownEditor directly in the ReplyCard.

- Add updateReply API client function and UpdateReplyInput type
- Add isEdited utility (30s threshold) to format.ts
- Add canEdit/onEdit props to TopicView with edit button
- Convert ReplyCard to client component with inline edit mode
- Wire canEdit through ReplyThread via currentUserDid prop
- Move TopicView rendering into TopicDetailClient for auth access
- Add author guard to edit page (only own posts)
- Add "(edited)" indicator on modified content
- Add comprehensive tests for all edit functionality

* fix(notifications): wait for auth before fetching notifications

The notifications page called getAccessToken() immediately on mount,
before the AuthProvider's silent refresh completed. This sent an empty
Bearer token, causing a 401 that displayed as "Failed to load
notifications." Wrap in ProtectedRoute and gate fetch on auth loading.

authored by

Guido X Jansen and committed by
GitHub
abebe2e0 8474393f

+625 -77
+2 -1
src/app/notifications/page.test.tsx
··· 10 10 11 11 // Mock next/navigation 12 12 vi.mock('next/navigation', () => ({ 13 - useRouter: () => ({ push: vi.fn() }), 13 + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), 14 + usePathname: () => '/notifications', 14 15 })) 15 16 16 17 // Mock next-themes
+14 -3
src/app/notifications/page.tsx
··· 17 17 import { cn } from '@/lib/utils' 18 18 import type { Notification, NotificationType } from '@/lib/api/types' 19 19 import { useAuth } from '@/hooks/use-auth' 20 + import { ProtectedRoute } from '@/components/auth/protected-route' 20 21 21 22 const NOTIFICATION_ICONS: Record<NotificationType, typeof ChatCircle> = { 22 23 reply: ChatCircle, ··· 26 27 } 27 28 28 29 export default function NotificationsPage() { 29 - const { getAccessToken } = useAuth() 30 + return ( 31 + <ProtectedRoute> 32 + <NotificationsContent /> 33 + </ProtectedRoute> 34 + ) 35 + } 36 + 37 + function NotificationsContent() { 38 + const { getAccessToken, isLoading: authLoading } = useAuth() 30 39 const [notifications, setNotifications] = useState<Notification[]>([]) 31 40 const [loading, setLoading] = useState(true) 32 41 const [loadError, setLoadError] = useState<string | null>(null) ··· 52 61 }, [getAccessToken]) 53 62 54 63 useEffect(() => { 55 - void fetchNotifications() 56 - }, [fetchNotifications]) 64 + if (!authLoading) { 65 + void fetchNotifications() 66 + } 67 + }, [authLoading, fetchNotifications]) 57 68 58 69 const handleMarkAllRead = useCallback(async () => { 59 70 const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id)
+109 -12
src/app/t/[slug]/[rkey]/edit/page.test.tsx
··· 2 2 * Tests for edit topic page. 3 3 */ 4 4 5 - import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest' 5 + import { describe, it, expect, vi, beforeAll, afterAll, afterEach, beforeEach } from 'vitest' 6 6 import { render, screen } from '@testing-library/react' 7 7 import { setupServer } from 'msw/node' 8 8 import { handlers } from '@/mocks/handlers' ··· 14 14 afterEach(() => server.resetHandlers()) 15 15 afterAll(() => server.close()) 16 16 17 - // Mock useAuth hook 17 + // Configurable useAuth mock (vi.fn() pattern) 18 + const mockUseAuth = vi.fn(() => ({ 19 + user: { 20 + did: 'did:plc:user-jay-001', 21 + handle: 'jay.bsky.team', 22 + displayName: 'Jay', 23 + avatarUrl: null, 24 + } as Record<string, unknown> | null, 25 + isAuthenticated: true, 26 + isLoading: false, 27 + getAccessToken: (() => 'mock-access-token') as () => string | null, 28 + login: vi.fn(), 29 + logout: vi.fn(), 30 + setSessionFromCallback: vi.fn(), 31 + authFetch: vi.fn(), 32 + })) 33 + 18 34 vi.mock('@/hooks/use-auth', () => ({ 19 - useAuth: () => ({ 20 - user: null, 21 - isAuthenticated: false, 22 - isLoading: false, 23 - getAccessToken: () => null, 24 - login: vi.fn(), 25 - logout: vi.fn(), 26 - setSessionFromCallback: vi.fn(), 27 - authFetch: vi.fn(), 28 - }), 35 + useAuth: () => mockUseAuth(), 29 36 })) 30 37 31 38 // Mock next/navigation ··· 39 46 redirect: vi.fn(), 40 47 })) 41 48 49 + // Mock next/link to render a plain anchor 50 + vi.mock('next/link', () => ({ 51 + default: ({ 52 + children, 53 + href, 54 + ...props 55 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 56 + <a href={href} {...props}> 57 + {children} 58 + </a> 59 + ), 60 + })) 61 + 62 + beforeEach(() => { 63 + vi.clearAllMocks() 64 + mockUseAuth.mockReturnValue({ 65 + user: { 66 + did: 'did:plc:user-jay-001', 67 + handle: 'jay.bsky.team', 68 + displayName: 'Jay', 69 + avatarUrl: null, 70 + } as Record<string, unknown> | null, 71 + isAuthenticated: true, 72 + isLoading: false, 73 + getAccessToken: (() => 'mock-access-token') as () => string | null, 74 + login: vi.fn(), 75 + logout: vi.fn(), 76 + setSessionFromCallback: vi.fn(), 77 + authFetch: vi.fn(), 78 + }) 79 + }) 80 + 42 81 describe('EditTopicPage', () => { 43 82 it('renders edit topic heading', async () => { 44 83 render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) ··· 53 92 it('shows save button', async () => { 54 93 render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 55 94 expect(await screen.findByRole('button', { name: 'Save Changes' })).toBeInTheDocument() 95 + }) 96 + 97 + it('renders edit form when user DID matches topic authorDid', async () => { 98 + mockUseAuth.mockReturnValue({ 99 + user: { 100 + did: 'did:plc:user-jay-001', 101 + handle: 'jay.bsky.team', 102 + displayName: 'Jay', 103 + avatarUrl: null, 104 + } as Record<string, unknown> | null, 105 + isAuthenticated: true, 106 + isLoading: false, 107 + getAccessToken: (() => 'mock-access-token') as () => string | null, 108 + login: vi.fn(), 109 + logout: vi.fn(), 110 + setSessionFromCallback: vi.fn(), 111 + authFetch: vi.fn(), 112 + }) 113 + 114 + render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 115 + expect(await screen.findByRole('heading', { name: 'Edit topic' })).toBeInTheDocument() 116 + }) 117 + 118 + it('renders "You can only edit your own posts" when user DID does not match topic authorDid', async () => { 119 + mockUseAuth.mockReturnValue({ 120 + user: { 121 + did: 'did:plc:user-other-999', 122 + handle: 'other.bsky.social', 123 + displayName: 'Other User', 124 + avatarUrl: null, 125 + } as Record<string, unknown> | null, 126 + isAuthenticated: true, 127 + isLoading: false, 128 + getAccessToken: (() => 'mock-access-token') as () => string | null, 129 + login: vi.fn(), 130 + logout: vi.fn(), 131 + setSessionFromCallback: vi.fn(), 132 + authFetch: vi.fn(), 133 + }) 134 + 135 + render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 136 + expect(await screen.findByText('You can only edit your own posts.')).toBeInTheDocument() 137 + }) 138 + 139 + it('renders "You can only edit your own posts" when user is not authenticated', async () => { 140 + mockUseAuth.mockReturnValue({ 141 + user: null as Record<string, unknown> | null, 142 + isAuthenticated: false, 143 + isLoading: false, 144 + getAccessToken: (() => null) as () => string | null, 145 + login: vi.fn(), 146 + logout: vi.fn(), 147 + setSessionFromCallback: vi.fn(), 148 + authFetch: vi.fn(), 149 + }) 150 + 151 + render(<EditTopicPage params={{ slug: 'welcome-to-barazo-forums', rkey: '3kf1abc' }} />) 152 + expect(await screen.findByText('You can only edit your own posts.')).toBeInTheDocument() 56 153 }) 57 154 })
+21 -3
src/app/t/[slug]/[rkey]/edit/page.tsx
··· 9 9 10 10 import { useState, useEffect } from 'react' 11 11 import { useRouter } from 'next/navigation' 12 + import Link from 'next/link' 12 13 import type { CreateTopicInput, Topic } from '@/lib/api/types' 13 14 import { getTopicByRkey, updateTopic, getPublicSettings } from '@/lib/api/client' 14 15 import { getTopicUrl } from '@/lib/format' 16 + import { useAuth } from '@/hooks/use-auth' 15 17 import { ForumLayout } from '@/components/layout/forum-layout' 16 18 import { Breadcrumbs } from '@/components/breadcrumbs' 17 19 import { TopicForm } from '@/components/topic-form' ··· 22 24 23 25 export default function EditTopicPage({ params }: EditTopicPageProps) { 24 26 const router = useRouter() 27 + const { user, isLoading: authLoading, getAccessToken } = useAuth() 25 28 const [rkey, setRkey] = useState<string | null>(null) 26 29 const [topic, setTopic] = useState<Topic | null>(null) 27 30 const [loading, setLoading] = useState(true) ··· 75 78 setError(null) 76 79 77 80 try { 78 - // TODO: Get access token from auth context when auth is implemented (#39) 79 - const accessToken = '' 81 + const accessToken = getAccessToken() ?? '' 80 82 const updated = await updateTopic( 81 83 rkey, 82 84 { ··· 94 96 } 95 97 } 96 98 97 - if (loading) { 99 + if (loading || authLoading) { 98 100 return ( 99 101 <ForumLayout communityName={communityName}> 100 102 <div className="space-y-6"> ··· 115 117 <p className="text-destructive" role="alert"> 116 118 {error ?? 'Topic not found'} 117 119 </p> 120 + </div> 121 + </ForumLayout> 122 + ) 123 + } 124 + 125 + if (!authLoading && (!user || user.did !== topic.authorDid)) { 126 + return ( 127 + <ForumLayout communityName={communityName}> 128 + <div className="space-y-6"> 129 + <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Edit' }]} /> 130 + <div className="py-8 text-center"> 131 + <p className="text-muted-foreground">You can only edit your own posts.</p> 132 + <Link href={getTopicUrl(topic)} className="text-sm text-primary hover:underline"> 133 + Back to topic 134 + </Link> 135 + </div> 118 136 </div> 119 137 </ForumLayout> 120 138 )
+7
src/app/t/[slug]/[rkey]/page.test.tsx
··· 24 24 user: null, 25 25 isAuthenticated: false, 26 26 isLoading: false, 27 + crossPostScopesGranted: false, 27 28 getAccessToken: () => null, 28 29 login: vi.fn(), 29 30 logout: vi.fn(), 30 31 setSessionFromCallback: vi.fn(), 32 + requestCrossPostAuth: vi.fn(), 31 33 authFetch: vi.fn(), 32 34 }), 35 + })) 36 + 37 + // Mock useToast (required by ReplyCard) 38 + vi.mock('@/hooks/use-toast', () => ({ 39 + useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 33 40 })) 34 41 35 42 // Mock notFound
+1 -6
src/app/t/[slug]/[rkey]/page.tsx
··· 207 207 {/* Breadcrumbs */} 208 208 <Breadcrumbs items={breadcrumbItems} /> 209 209 210 - {/* Topic */} 211 - <div className="mt-4"> 212 - <TopicView topic={topic} /> 213 - </div> 214 - 215 - {/* Replies + Composer */} 210 + {/* Topic + Replies + Composer (client-side for auth context) */} 216 211 <TopicDetailClient topic={topic} replies={repliesResult.replies} /> 217 212 </ForumLayout> 218 213 )
+188 -16
src/components/reply-card.test.tsx
··· 2 2 * Tests for ReplyCard component. 3 3 */ 4 4 5 - import { describe, it, expect, vi } from 'vitest' 6 - import { render, screen } from '@testing-library/react' 5 + import { describe, it, expect, vi, beforeEach } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 7 import userEvent from '@testing-library/user-event' 8 8 import { axe } from 'vitest-axe' 9 9 import { ReplyCard } from './reply-card' 10 10 import { mockReplies, mockAuthorDeletedReply, mockModDeletedReply } from '@/mocks/data' 11 + import { useAuth } from '@/hooks/use-auth' 12 + import { updateReply } from '@/lib/api/client' 13 + import type { Reply } from '@/lib/api/types' 11 14 12 - vi.mock('@/hooks/use-toast', () => ({ 13 - useToast: () => ({ toast: vi.fn() }), 14 - })) 15 - 15 + // Mock useAuth 16 16 vi.mock('@/hooks/use-auth', () => ({ 17 - useAuth: () => ({ 18 - user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' }, 19 - isAuthenticated: true, 17 + useAuth: vi.fn(() => ({ 18 + user: null, 19 + isAuthenticated: false, 20 20 isLoading: false, 21 - getAccessToken: () => 'mock-access-token', 22 - authFetch: vi.fn(), 21 + crossPostScopesGranted: false, 22 + getAccessToken: () => null, 23 23 login: vi.fn(), 24 24 logout: vi.fn(), 25 25 setSessionFromCallback: vi.fn(), 26 - crossPostScopesGranted: false, 27 26 requestCrossPostAuth: vi.fn(), 28 - }), 27 + authFetch: vi.fn(), 28 + })), 29 + })) 30 + 31 + // Mock useToast 32 + const mockToast = vi.fn() 33 + vi.mock('@/hooks/use-toast', () => ({ 34 + useToast: () => ({ toast: mockToast, dismiss: vi.fn() }), 29 35 })) 30 36 37 + // Mock updateReply 31 38 vi.mock('@/lib/api/client', () => ({ 32 - getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 33 - createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), 34 - deleteReaction: vi.fn().mockResolvedValue(undefined), 39 + updateReply: vi.fn(), 35 40 })) 36 41 37 42 const reply = mockReplies[0]! 38 43 const nestedReply = mockReplies[1]! // depth 1 39 44 40 45 const mockReactions = [{ type: 'like', count: 3, reacted: true }] 46 + 47 + beforeEach(() => { 48 + vi.clearAllMocks() 49 + mockToast.mockReset() 50 + }) 41 51 42 52 describe('ReplyCard', () => { 43 53 it('renders reply content', () => { ··· 231 241 232 242 it('passes axe accessibility check for mod-deleted replies', async () => { 233 243 const { container } = render(<ReplyCard reply={mockModDeletedReply} postNumber={5} />) 244 + const results = await axe(container) 245 + expect(results).toHaveNoViolations() 246 + }) 247 + }) 248 + 249 + describe('edit mode', () => { 250 + beforeEach(() => { 251 + vi.mocked(useAuth).mockReturnValue({ 252 + user: { 253 + did: reply.authorDid, 254 + handle: reply.author?.handle ?? '', 255 + displayName: 'Alex', 256 + avatarUrl: null, 257 + role: 'user', 258 + }, 259 + isAuthenticated: true, 260 + isLoading: false, 261 + crossPostScopesGranted: false, 262 + getAccessToken: () => 'mock-token', 263 + login: vi.fn(), 264 + logout: vi.fn(), 265 + setSessionFromCallback: vi.fn(), 266 + requestCrossPostAuth: vi.fn(), 267 + authFetch: vi.fn(), 268 + } as ReturnType<typeof useAuth>) 269 + }) 270 + 271 + it('renders Edit button when canEdit is true', () => { 272 + render(<ReplyCard reply={reply} postNumber={2} canEdit={true} />) 273 + expect( 274 + screen.getByRole('button', { 275 + name: `Edit reply by ${reply.author?.handle ?? reply.authorDid}`, 276 + }) 277 + ).toBeInTheDocument() 278 + }) 279 + 280 + it('does not render Edit button when canEdit is false', () => { 281 + render(<ReplyCard reply={reply} postNumber={2} />) 282 + expect(screen.queryByRole('button', { name: /edit reply by/i })).not.toBeInTheDocument() 283 + }) 284 + 285 + it('does not render Edit button on deleted replies', () => { 286 + render(<ReplyCard reply={mockAuthorDeletedReply} postNumber={4} canEdit={true} />) 287 + expect(screen.queryByRole('button', { name: /edit reply by/i })).not.toBeInTheDocument() 288 + }) 289 + 290 + it('shows MarkdownEditor with reply content when Edit is clicked', async () => { 291 + const user = userEvent.setup() 292 + render(<ReplyCard reply={reply} postNumber={2} canEdit={true} />) 293 + await user.click(screen.getByRole('button', { name: /edit reply by/i })) 294 + expect(screen.getByRole('toolbar')).toBeInTheDocument() 295 + expect(screen.getByRole('textbox')).toHaveValue(reply.content) 296 + }) 297 + 298 + it('returns to read mode when Cancel is clicked', async () => { 299 + const user = userEvent.setup() 300 + render(<ReplyCard reply={reply} postNumber={2} canEdit={true} />) 301 + await user.click(screen.getByRole('button', { name: /edit reply by/i })) 302 + expect(screen.getByRole('toolbar')).toBeInTheDocument() 303 + await user.click(screen.getByRole('button', { name: 'Cancel' })) 304 + expect(screen.queryByRole('toolbar')).not.toBeInTheDocument() 305 + expect(screen.getByText(reply.content)).toBeInTheDocument() 306 + }) 307 + 308 + it('calls updateReply with correct args on save', async () => { 309 + const user = userEvent.setup() 310 + vi.mocked(updateReply).mockResolvedValueOnce({} as Reply) 311 + render(<ReplyCard reply={reply} postNumber={2} canEdit={true} />) 312 + await user.click(screen.getByRole('button', { name: /edit reply by/i })) 313 + const textarea = screen.getByRole('textbox') 314 + await user.clear(textarea) 315 + await user.type(textarea, 'new content') 316 + await user.click(screen.getByRole('button', { name: 'Save' })) 317 + await waitFor(() => { 318 + expect(updateReply).toHaveBeenCalledWith( 319 + reply.uri, 320 + { content: 'new content' }, 321 + 'mock-token' 322 + ) 323 + }) 324 + // Editor should close after save 325 + await waitFor(() => { 326 + expect(screen.queryByRole('toolbar')).not.toBeInTheDocument() 327 + }) 328 + }) 329 + 330 + it('disables Save button when content is empty', async () => { 331 + const user = userEvent.setup() 332 + render(<ReplyCard reply={reply} postNumber={2} canEdit={true} />) 333 + await user.click(screen.getByRole('button', { name: /edit reply by/i })) 334 + const textarea = screen.getByRole('textbox') 335 + await user.clear(textarea) 336 + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() 337 + }) 338 + 339 + it('shows "Saving..." while submitting', async () => { 340 + const user = userEvent.setup() 341 + let resolvePromise: (value: Reply) => void 342 + vi.mocked(updateReply).mockReturnValueOnce( 343 + new Promise<Reply>((resolve) => { 344 + resolvePromise = resolve 345 + }) 346 + ) 347 + render(<ReplyCard reply={reply} postNumber={2} canEdit={true} />) 348 + await user.click(screen.getByRole('button', { name: /edit reply by/i })) 349 + await user.click(screen.getByRole('button', { name: 'Save' })) 350 + expect(screen.getByRole('button', { name: 'Saving...' })).toBeInTheDocument() 351 + // Resolve and wait for state to settle 352 + resolvePromise!({} as Reply) 353 + await waitFor(() => { 354 + expect(screen.queryByRole('toolbar')).not.toBeInTheDocument() 355 + }) 356 + }) 357 + 358 + it('shows error toast on save failure', async () => { 359 + const user = userEvent.setup() 360 + vi.mocked(updateReply).mockRejectedValueOnce(new Error('Network error')) 361 + render(<ReplyCard reply={reply} postNumber={2} canEdit={true} />) 362 + await user.click(screen.getByRole('button', { name: /edit reply by/i })) 363 + await user.click(screen.getByRole('button', { name: 'Save' })) 364 + await waitFor(() => { 365 + expect(mockToast).toHaveBeenCalledWith({ 366 + title: 'Error', 367 + description: 'Network error', 368 + variant: 'destructive', 369 + }) 370 + }) 371 + // Editor should remain open 372 + expect(screen.getByRole('toolbar')).toBeInTheDocument() 373 + }) 374 + 375 + it('shows success toast on save', async () => { 376 + const user = userEvent.setup() 377 + vi.mocked(updateReply).mockResolvedValueOnce({} as Reply) 378 + render(<ReplyCard reply={reply} postNumber={2} canEdit={true} />) 379 + await user.click(screen.getByRole('button', { name: /edit reply by/i })) 380 + await user.click(screen.getByRole('button', { name: 'Save' })) 381 + await waitFor(() => { 382 + expect(mockToast).toHaveBeenCalledWith({ title: 'Reply updated' }) 383 + }) 384 + }) 385 + 386 + it('shows "(edited)" indicator when indexedAt > createdAt + 30s', () => { 387 + const editedReply: Reply = { 388 + ...reply, 389 + createdAt: '2026-02-14T12:00:00.000Z', 390 + indexedAt: '2026-02-14T12:01:00.000Z', 391 + } 392 + render(<ReplyCard reply={editedReply} postNumber={2} />) 393 + expect(screen.getByText('(edited)')).toBeInTheDocument() 394 + }) 395 + 396 + it('does not show "(edited)" indicator when timestamps are close', () => { 397 + // Default mock reply has same createdAt and indexedAt 398 + render(<ReplyCard reply={reply} postNumber={2} />) 399 + expect(screen.queryByText('(edited)')).not.toBeInTheDocument() 400 + }) 401 + 402 + it('passes axe accessibility check in edit mode', async () => { 403 + const user = userEvent.setup() 404 + const { container } = render(<ReplyCard reply={reply} postNumber={2} canEdit={true} />) 405 + await user.click(screen.getByRole('button', { name: /edit reply by/i })) 234 406 const results = await axe(container) 235 407 expect(results).toHaveNoViolations() 236 408 })
+99 -13
src/components/reply-card.tsx
··· 1 1 /** 2 2 * ReplyCard - Displays a single reply with depth indication. 3 - * Includes reactions and report button. 3 + * Includes reactions, report button, and inline editing for authors. 4 4 * Depth is shown via left margin indentation. 5 5 * Deleted replies render as tombstone placeholders. 6 6 * @see specs/prd-web.md Section 4 (Topic Components) 7 7 */ 8 8 9 + 'use client' 10 + 11 + import { useState, useCallback } from 'react' 9 12 import Link from 'next/link' 10 13 import Image from 'next/image' 11 - import { Clock, Link as LinkIcon, ChatCircle } from '@phosphor-icons/react/dist/ssr' 14 + import { Heart, Clock, Link as LinkIcon, ChatCircle, PencilSimple } from '@phosphor-icons/react' 12 15 import type { Reply } from '@/lib/api/types' 13 16 import { cn } from '@/lib/utils' 14 - import { formatRelativeTime } from '@/lib/format' 17 + import { formatRelativeTime, formatCompactNumber, isEdited } from '@/lib/format' 18 + import { updateReply } from '@/lib/api/client' 19 + import { useAuth } from '@/hooks/use-auth' 20 + import { useToast } from '@/hooks/use-toast' 15 21 import { MarkdownContent } from './markdown-content' 22 + import { MarkdownEditor } from './markdown-editor' 16 23 import { ReactionBar } from './reaction-bar' 17 24 import { ReportDialog, type ReportSubmission } from './report-dialog' 18 25 import { SelfLabelIndicator } from './self-label-indicator' 19 - import { LikeButton } from './like-button' 20 26 21 27 interface ReactionData { 22 28 type: string ··· 30 36 reactions?: ReactionData[] 31 37 onReactionToggle?: (type: string) => void 32 38 onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void 39 + canEdit?: boolean 33 40 canReport?: boolean 34 41 onReport?: (report: ReportSubmission) => void 35 42 selfLabels?: string[] ··· 49 56 reactions, 50 57 onReactionToggle, 51 58 onReply, 59 + canEdit, 52 60 canReport, 53 61 onReport, 54 62 selfLabels, 55 63 className, 56 64 }: ReplyCardProps) { 65 + const [isEditingReply, setIsEditingReply] = useState(false) 66 + const [editContent, setEditContent] = useState(reply.content) 67 + const [displayContent, setDisplayContent] = useState(reply.content) 68 + const [saving, setSaving] = useState(false) 69 + const { getAccessToken } = useAuth() 70 + const { toast } = useToast() 71 + 72 + const handleSaveEdit = useCallback(async () => { 73 + const trimmed = editContent.trim() 74 + if (!trimmed) return 75 + 76 + setSaving(true) 77 + try { 78 + const accessToken = getAccessToken() ?? '' 79 + await updateReply(reply.uri, { content: trimmed }, accessToken) 80 + setDisplayContent(trimmed) 81 + setIsEditingReply(false) 82 + toast({ title: 'Reply updated' }) 83 + } catch (err) { 84 + const message = err instanceof Error ? err.message : 'Failed to update reply' 85 + toast({ title: 'Error', description: message, variant: 'destructive' }) 86 + } finally { 87 + setSaving(false) 88 + } 89 + }, [editContent, reply.uri, getAccessToken, toast]) 90 + 57 91 const headingId = `reply-heading-${reply.rkey}` 58 92 const indent = DEPTH_INDENT[Math.min(reply.depth, 3)] ?? DEPTH_INDENT[3] 59 93 const isDeleted = reply.isAuthorDeleted || reply.isModDeleted ··· 138 172 <time className="text-muted-foreground" dateTime={reply.createdAt}> 139 173 {formatRelativeTime(reply.createdAt)} 140 174 </time> 175 + {isEdited(reply.createdAt, reply.indexedAt) && ( 176 + <span 177 + className="text-muted-foreground" 178 + title={`Edited ${new Date(reply.indexedAt).toLocaleString()}`} 179 + > 180 + (edited) 181 + </span> 182 + )} 141 183 </div> 142 184 <a 143 185 href={`#post-${postNumber}`} ··· 150 192 151 193 {/* Content */} 152 194 <div className="p-4" data-reply-content> 153 - {selfLabels && selfLabels.length > 0 ? ( 195 + {isEditingReply ? ( 196 + <div className="space-y-2"> 197 + <MarkdownEditor 198 + value={editContent} 199 + onChange={setEditContent} 200 + id={`edit-reply-${reply.rkey}`} 201 + label="Edit reply" 202 + placeholder="Edit your reply..." 203 + className="[&_label]:sr-only [&_textarea]:min-h-[100px] [&_textarea]:max-h-[40vh]" 204 + /> 205 + <div className="flex items-center justify-end gap-2"> 206 + <button 207 + type="button" 208 + onClick={() => setIsEditingReply(false)} 209 + className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" 210 + > 211 + Cancel 212 + </button> 213 + <button 214 + type="button" 215 + onClick={handleSaveEdit} 216 + disabled={saving || !editContent.trim()} 217 + className="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50" 218 + > 219 + {saving ? 'Saving...' : 'Save'} 220 + </button> 221 + </div> 222 + </div> 223 + ) : selfLabels && selfLabels.length > 0 ? ( 154 224 <SelfLabelIndicator labels={selfLabels}> 155 - <MarkdownContent content={reply.content} /> 225 + <MarkdownContent content={displayContent} /> 156 226 </SelfLabelIndicator> 157 227 ) : ( 158 - <MarkdownContent content={reply.content} /> 228 + <MarkdownContent content={displayContent} /> 159 229 )} 160 230 </div> 161 231 ··· 164 234 {reactions && onReactionToggle && ( 165 235 <ReactionBar reactions={reactions} onToggle={onReactionToggle} /> 166 236 )} 167 - <LikeButton 168 - subjectUri={reply.uri} 169 - subjectCid={reply.cid} 170 - initialCount={reply.reactionCount} 171 - size="sm" 172 - /> 237 + <span 238 + className="flex items-center gap-1" 239 + aria-label={`${formatCompactNumber(reply.reactionCount)} reactions`} 240 + > 241 + <Heart className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 242 + {formatCompactNumber(reply.reactionCount)} 243 + </span> 173 244 <span className="flex items-center gap-1"> 174 245 <Clock className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 175 246 {formatRelativeTime(reply.createdAt)} ··· 181 252 > 182 253 <LinkIcon className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 183 254 </a> 255 + 256 + {canEdit && !isEditingReply && ( 257 + <button 258 + type="button" 259 + onClick={() => { 260 + setIsEditingReply(true) 261 + setEditContent(displayContent) 262 + }} 263 + className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground" 264 + aria-label={`Edit reply by ${reply.author?.handle ?? reply.authorDid}`} 265 + > 266 + <PencilSimple className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 267 + Edit 268 + </button> 269 + )} 184 270 185 271 {onReply && ( 186 272 <button
+12 -9
src/components/reply-thread.test.tsx
··· 8 8 import { ReplyThread } from './reply-thread' 9 9 import { mockReplies } from '@/mocks/data' 10 10 11 - vi.mock('@/hooks/use-toast', () => ({ 12 - useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 13 - })) 14 - 11 + // Mock useAuth (required by ReplyCard) 15 12 vi.mock('@/hooks/use-auth', () => ({ 16 13 useAuth: () => ({ 17 - user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' }, 18 - isAuthenticated: true, 14 + user: null, 15 + isAuthenticated: false, 19 16 isLoading: false, 20 - getAccessToken: () => 'mock-access-token', 21 - authFetch: vi.fn(), 17 + crossPostScopesGranted: false, 18 + getAccessToken: () => null, 22 19 login: vi.fn(), 23 20 logout: vi.fn(), 24 21 setSessionFromCallback: vi.fn(), 25 - crossPostScopesGranted: false, 26 22 requestCrossPostAuth: vi.fn(), 23 + authFetch: vi.fn(), 27 24 }), 28 25 })) 29 26 27 + // Mock useToast (required by ReplyCard) 28 + vi.mock('@/hooks/use-toast', () => ({ 29 + useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 30 + })) 31 + 32 + // Mock updateReply (imported by ReplyCard) 30 33 vi.mock('@/lib/api/client', () => ({ 31 34 getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 32 35 createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }),
+9 -2
src/components/reply-thread.tsx
··· 11 11 interface ReplyThreadProps { 12 12 replies: Reply[] 13 13 onReply?: (target: { uri: string; cid: string; authorHandle: string; snippet: string }) => void 14 + currentUserDid?: string 14 15 className?: string 15 16 } 16 17 17 - export function ReplyThread({ replies, onReply, className }: ReplyThreadProps) { 18 + export function ReplyThread({ replies, onReply, currentUserDid, className }: ReplyThreadProps) { 18 19 const replyCount = replies.length 19 20 const heading = 20 21 replyCount === 0 ? 'Replies' : replyCount === 1 ? '1 Reply' : `${replyCount} Replies` ··· 30 31 ) : ( 31 32 <div className="space-y-3"> 32 33 {replies.map((reply, index) => ( 33 - <ReplyCard key={reply.uri} reply={reply} postNumber={index + 2} onReply={onReply} /> 34 + <ReplyCard 35 + key={reply.uri} 36 + reply={reply} 37 + postNumber={index + 2} 38 + onReply={onReply} 39 + canEdit={currentUserDid ? reply.authorDid === currentUserDid : false} 40 + /> 34 41 ))} 35 42 </div> 36 43 )}
+18 -2
src/components/topic-detail-client.tsx
··· 9 9 import { useRouter } from 'next/navigation' 10 10 import { useAuth } from '@/hooks/use-auth' 11 11 import type { Reply, Topic } from '@/lib/api/types' 12 + import { getTopicUrl } from '@/lib/format' 13 + import { TopicView } from '@/components/topic-view' 12 14 import { ReplyThread } from '@/components/reply-thread' 13 15 import { 14 16 ReplyComposer, ··· 24 26 } 25 27 26 28 export function TopicDetailClient({ topic, replies, isLocked = false }: TopicDetailClientProps) { 27 - const { isAuthenticated, isLoading } = useAuth() 29 + const { user, isAuthenticated, isLoading } = useAuth() 28 30 const router = useRouter() 31 + 32 + const canEdit = isAuthenticated && user?.did === topic.authorDid 33 + const handleEdit = useCallback(() => { 34 + router.push(getTopicUrl(topic) + '/edit') 35 + }, [router, topic]) 29 36 const [replyTarget, setReplyTarget] = useState<ReplyTarget | null>(null) 30 37 const [composerContent, setComposerContent] = useState('') 31 38 const composerRef = useRef<ReplyComposerHandle>(null) ··· 89 96 90 97 return ( 91 98 <> 99 + {/* Topic with edit button for author */} 100 + <div className="mt-4"> 101 + <TopicView topic={topic} canEdit={canEdit} onEdit={handleEdit} /> 102 + </div> 103 + 92 104 {/* Reply thread with reply buttons */} 93 105 <div className="mt-8 pb-16"> 94 - <ReplyThread replies={replies} onReply={isLocked ? undefined : handleReply} /> 106 + <ReplyThread 107 + replies={replies} 108 + onReply={isLocked ? undefined : handleReply} 109 + currentUserDid={user?.did} 110 + /> 95 111 </div> 96 112 97 113 {/* Composer or auth gate */}
+53
src/components/topic-view.test.tsx
··· 35 35 })) 36 36 37 37 const topic = mockTopics[0]! 38 + const baseTopic = topic 39 + const editedTopic = { 40 + ...baseTopic, 41 + indexedAt: new Date(new Date(baseTopic.createdAt).getTime() + 60_000).toISOString(), 42 + } 38 43 39 44 const mockReactions = [ 40 45 { type: 'like', count: 5, reacted: false }, ··· 131 136 render(<TopicView topic={topic} reactions={mockReactions} onReactionToggle={onToggle} />) 132 137 await user.click(screen.getByRole('button', { name: /like/i })) 133 138 expect(onToggle).toHaveBeenCalledWith('like') 139 + }) 140 + 141 + describe('edit button', () => { 142 + it('renders edit button when canEdit is true and onEdit is provided', () => { 143 + render(<TopicView topic={topic} canEdit={true} onEdit={vi.fn()} />) 144 + expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument() 145 + }) 146 + 147 + it('does not render edit button when canEdit is false', () => { 148 + render(<TopicView topic={topic} canEdit={false} onEdit={vi.fn()} />) 149 + expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument() 150 + }) 151 + 152 + it('does not render edit button when canEdit is undefined', () => { 153 + render(<TopicView topic={topic} onEdit={vi.fn()} />) 154 + expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument() 155 + }) 156 + 157 + it('does not render edit button when onEdit is not provided', () => { 158 + render(<TopicView topic={topic} canEdit={true} />) 159 + expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument() 160 + }) 161 + 162 + it('calls onEdit callback when edit button is clicked', async () => { 163 + const user = userEvent.setup() 164 + const onEdit = vi.fn() 165 + render(<TopicView topic={topic} canEdit={true} onEdit={onEdit} />) 166 + await user.click(screen.getByRole('button', { name: /edit/i })) 167 + expect(onEdit).toHaveBeenCalledTimes(1) 168 + }) 169 + 170 + it('passes axe accessibility check with edit button visible', async () => { 171 + const { container } = render(<TopicView topic={topic} canEdit={true} onEdit={vi.fn()} />) 172 + const results = await axe(container) 173 + expect(results).toHaveNoViolations() 174 + }) 175 + }) 176 + 177 + describe('edited indicator', () => { 178 + it('shows "(edited)" when indexedAt is more than 30s after createdAt', () => { 179 + render(<TopicView topic={editedTopic} />) 180 + expect(screen.getByText('(edited)')).toBeInTheDocument() 181 + }) 182 + 183 + it('does not show "(edited)" when timestamps are close', () => { 184 + render(<TopicView topic={topic} />) 185 + expect(screen.queryByText('(edited)')).not.toBeInTheDocument() 186 + }) 134 187 }) 135 188 136 189 describe('tombstone: author-deleted topics', () => {
+33 -9
src/components/topic-view.tsx
··· 1 1 /** 2 2 * TopicView - Displays a full topic post with content and metadata. 3 - * Includes reactions, moderation controls, report button, and self-labels. 3 + * Includes reactions, moderation controls, report button, edit button, and self-labels. 4 4 * Used on the topic detail page. 5 5 * @see specs/prd-web.md Section 4 (Topic Components) 6 6 */ 7 7 8 8 import Link from 'next/link' 9 - import { ChatCircle, Clock, Tag } from '@phosphor-icons/react/dist/ssr' 9 + import { ChatCircle, Heart, Clock, Tag, PencilSimple } from '@phosphor-icons/react/dist/ssr' 10 10 import type { Topic } from '@/lib/api/types' 11 11 import { cn } from '@/lib/utils' 12 - import { formatRelativeTime, formatCompactNumber } from '@/lib/format' 12 + import { formatRelativeTime, formatCompactNumber, isEdited } from '@/lib/format' 13 13 import { MarkdownContent } from './markdown-content' 14 14 import { ReactionBar } from './reaction-bar' 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' 19 18 20 19 interface ReactionData { 21 20 type: string ··· 31 30 isLocked?: boolean 32 31 isPinned?: boolean 33 32 onModerationAction?: (action: ModerationAction) => void 33 + canEdit?: boolean 34 + onEdit?: () => void 34 35 canReport?: boolean 35 36 onReport?: (report: ReportSubmission) => void 36 37 selfLabels?: string[] ··· 45 46 isLocked, 46 47 isPinned, 47 48 onModerationAction, 49 + canEdit, 50 + onEdit, 48 51 canReport, 49 52 onReport, 50 53 selfLabels, ··· 99 102 <span>{topic.authorDid}</span> 100 103 <span aria-hidden="true">·</span> 101 104 <time dateTime={topic.createdAt}>{formatRelativeTime(topic.createdAt)}</time> 105 + {isEdited(topic.createdAt, topic.indexedAt) && ( 106 + <span 107 + className="text-muted-foreground" 108 + title={`Edited ${new Date(topic.indexedAt).toLocaleString()}`} 109 + > 110 + (edited) 111 + </span> 112 + )} 102 113 </div> 103 114 104 115 {/* Category + Tags */} ··· 156 167 <ChatCircle className="h-4 w-4" weight="regular" aria-hidden="true" /> 157 168 {formatCompactNumber(topic.replyCount)} 158 169 </span> 159 - <LikeButton 160 - subjectUri={topic.uri} 161 - subjectCid={topic.cid} 162 - initialCount={topic.reactionCount} 163 - /> 170 + <span 171 + className="flex items-center gap-1.5" 172 + aria-label={`${formatCompactNumber(topic.reactionCount)} reactions`} 173 + > 174 + <Heart className="h-4 w-4" weight="regular" aria-hidden="true" /> 175 + {formatCompactNumber(topic.reactionCount)} 176 + </span> 164 177 <span className="flex items-center gap-1.5"> 165 178 <Clock className="h-4 w-4" weight="regular" aria-hidden="true" /> 166 179 Last activity {formatRelativeTime(topic.lastActivityAt)} 167 180 </span> 181 + 182 + {canEdit && onEdit && ( 183 + <button 184 + type="button" 185 + onClick={onEdit} 186 + className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground" 187 + > 188 + <PencilSimple className="h-3.5 w-3.5" weight="regular" aria-hidden="true" /> 189 + Edit 190 + </button> 191 + )} 168 192 169 193 {canReport && onReport && ( 170 194 <span className="ml-auto">
+18
src/lib/api/client.ts
··· 30 30 Reply, 31 31 RepliesResponse, 32 32 CreateReplyInput, 33 + UpdateReplyInput, 33 34 SearchResponse, 34 35 NotificationsResponse, 35 36 PaginationParams, ··· 273 274 return apiFetch<Reply>(`/api/topics/${encodeURIComponent(topicUri)}/replies`, { 274 275 ...options, 275 276 method: 'POST', 277 + headers: { 278 + ...options?.headers, 279 + Authorization: `Bearer ${accessToken}`, 280 + }, 281 + body: input, 282 + }) 283 + } 284 + 285 + export function updateReply( 286 + uri: string, 287 + input: UpdateReplyInput, 288 + accessToken: string, 289 + options?: FetchOptions 290 + ): Promise<Reply> { 291 + return apiFetch<Reply>(`/api/replies/${encodeURIComponent(uri)}`, { 292 + ...options, 293 + method: 'PUT', 276 294 headers: { 277 295 ...options?.headers, 278 296 Authorization: `Bearer ${accessToken}`,
+5
src/lib/api/types.ts
··· 137 137 labels?: string[] 138 138 } 139 139 140 + export interface UpdateReplyInput { 141 + content: string 142 + labels?: string[] 143 + } 144 + 140 145 // --- Reactions --- 141 146 142 147 export interface Reaction {
+26 -1
src/lib/format.test.ts
··· 3 3 */ 4 4 5 5 import { describe, it, expect } from 'vitest' 6 - import { formatRelativeTime, formatCompactNumber, slugify, getTopicUrl } from './format' 6 + import { formatRelativeTime, formatCompactNumber, slugify, getTopicUrl, isEdited } from './format' 7 7 8 8 describe('formatRelativeTime', () => { 9 9 it('returns "just now" for recent timestamps', () => { ··· 81 81 expect(getTopicUrl(topic)).toBe('/t/feature-request-dark-mode/3kf3ghi') 82 82 }) 83 83 }) 84 + 85 + describe('isEdited', () => { 86 + it('returns false when timestamps are equal', () => { 87 + const ts = '2026-01-01T12:00:00.000Z' 88 + expect(isEdited(ts, ts)).toBe(false) 89 + }) 90 + 91 + it('returns false when difference is under 30 seconds', () => { 92 + const createdAt = '2026-01-01T12:00:00.000Z' 93 + const indexedAt = new Date(new Date(createdAt).getTime() + 15_000).toISOString() 94 + expect(isEdited(createdAt, indexedAt)).toBe(false) 95 + }) 96 + 97 + it('returns true when difference exceeds 30 seconds', () => { 98 + const createdAt = '2026-01-01T12:00:00.000Z' 99 + const indexedAt = new Date(new Date(createdAt).getTime() + 60_000).toISOString() 100 + expect(isEdited(createdAt, indexedAt)).toBe(true) 101 + }) 102 + 103 + it('returns false for invalid date strings', () => { 104 + expect(isEdited('not-a-date', '2026-01-01T12:00:00.000Z')).toBe(false) 105 + expect(isEdited('2026-01-01T12:00:00.000Z', 'not-a-date')).toBe(false) 106 + expect(isEdited('invalid', 'also-invalid')).toBe(false) 107 + }) 108 + })
+10
src/lib/format.ts
··· 100 100 export function getTopicUrl(topic: { title: string; rkey: string }): string { 101 101 return `/t/${slugify(topic.title)}/${topic.rkey}` 102 102 } 103 + 104 + /** 105 + * Returns true if a post was edited (indexedAt differs from createdAt by more than 30 seconds). 106 + */ 107 + export function isEdited(createdAt: string, indexedAt: string): boolean { 108 + const created = new Date(createdAt).getTime() 109 + const indexed = new Date(indexedAt).getTime() 110 + if (Number.isNaN(created) || Number.isNaN(indexed)) return false 111 + return indexed - created > 30_000 112 + }