Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat: P2.1/P2.2 settings API, age gate, block/mute UI (#18)

* feat(api): add preference, age declaration, block/mute client functions

Add UserPreferences, UpdatePreferencesInput, AgeDeclarationResponse types.
Add getPreferences, updatePreferences, declareAge, blockUser, unblockUser,
muteUser, unmuteUser client functions.

* feat(settings): wire settings page to real API endpoints

Load preferences on mount via GET /api/users/me/preferences, save via
PUT /api/users/me/preferences, handle age gate prompt for mature content
access. Add loading skeleton, error/success alerts, and localStorage
token check. Add MSW handlers for preferences, age-declaration,
block/mute endpoints. Update tests with localStorage mock.

* feat(settings): add age gate dialog for mature content access

Shows confirmation dialog when user tries to enable Mature content.
Calls POST /api/users/me/age-declaration on confirm. WCAG 2.2 AA
compliant with proper ARIA attributes.

* feat(block-mute): add block/mute UI button and integrate into profiles

Reusable BlockMuteButton component with block/unblock and mute/unmute
toggle. Integrated into user profile page with state management.

authored by

Guido X Jansen and committed by
GitHub
b0625cf9 30fd94bd

+952 -142
+74 -16
src/app/settings/page.test.tsx
··· 2 2 * Tests for settings page. 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, afterEach } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 7 import { axe } from 'vitest-axe' 8 8 import SettingsPage from './page' 9 9 ··· 16 16 redirect: vi.fn(), 17 17 })) 18 18 19 + // Mock localStorage for jsdom environment 20 + const localStorageMock = (() => { 21 + let store: Record<string, string> = {} 22 + return { 23 + getItem: vi.fn((key: string) => store[key] ?? null), 24 + setItem: vi.fn((key: string, value: string) => { 25 + store[key] = value 26 + }), 27 + removeItem: vi.fn((key: string) => { 28 + delete store[key] 29 + }), 30 + clear: vi.fn(() => { 31 + store = {} 32 + }), 33 + get length() { 34 + return Object.keys(store).length 35 + }, 36 + key: vi.fn((index: number) => Object.keys(store)[index] ?? null), 37 + } 38 + })() 39 + 19 40 describe('SettingsPage', () => { 20 - it('renders settings heading', () => { 41 + beforeEach(() => { 42 + Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true }) 43 + localStorageMock.clear() 44 + }) 45 + 46 + afterEach(() => { 47 + vi.restoreAllMocks() 48 + }) 49 + 50 + it('renders settings heading', async () => { 21 51 render(<SettingsPage />) 22 52 expect(screen.getByRole('heading', { name: /settings/i })).toBeInTheDocument() 23 53 }) 24 54 25 - it('renders content safety section', () => { 55 + it('renders content safety section when not authenticated', async () => { 26 56 render(<SettingsPage />) 27 - expect(screen.getByText(/content safety/i)).toBeInTheDocument() 57 + // Without a token, loading finishes immediately and form renders with defaults 58 + await waitFor(() => { 59 + expect(screen.getByText(/content safety/i)).toBeInTheDocument() 60 + }) 28 61 expect(screen.getByLabelText(/maturity level/i)).toBeInTheDocument() 29 62 }) 30 63 31 - it('renders muted words input', () => { 64 + it('renders muted words input', async () => { 32 65 render(<SettingsPage />) 33 - expect(screen.getByLabelText(/muted words/i)).toBeInTheDocument() 66 + await waitFor(() => { 67 + expect(screen.getByLabelText(/muted words/i)).toBeInTheDocument() 68 + }) 34 69 }) 35 70 36 - it('renders cross-posting section', () => { 71 + it('renders cross-posting section', async () => { 37 72 render(<SettingsPage />) 38 - expect(screen.getByText(/cross-posting/i)).toBeInTheDocument() 73 + await waitFor(() => { 74 + expect(screen.getByText(/cross-posting/i)).toBeInTheDocument() 75 + }) 39 76 expect(screen.getByLabelText(/bluesky/i)).toBeInTheDocument() 40 77 expect(screen.getByLabelText(/frontpage/i)).toBeInTheDocument() 41 78 }) 42 79 43 - it('renders notification preferences section', () => { 80 + it('renders notification preferences section', async () => { 44 81 render(<SettingsPage />) 45 - // Find the fieldset legend specifically (not the header notification bell's ARIA text) 46 - const legends = screen.getAllByText(/notifications/i) 47 - expect(legends.some((el) => el.tagName === 'LEGEND')).toBe(true) 82 + await waitFor(() => { 83 + // Find the fieldset legend specifically (not the header notification bell's ARIA text) 84 + const legends = screen.getAllByText(/notifications/i) 85 + expect(legends.some((el) => el.tagName === 'LEGEND')).toBe(true) 86 + }) 48 87 }) 49 88 50 - it('renders save button', () => { 89 + it('renders save button', async () => { 51 90 render(<SettingsPage />) 52 - expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() 91 + await waitFor(() => { 92 + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() 93 + }) 53 94 }) 54 95 55 - it('renders breadcrumbs', () => { 96 + it('renders breadcrumbs', async () => { 56 97 render(<SettingsPage />) 57 98 expect(screen.getByText('Home')).toBeInTheDocument() 58 99 }) 59 100 101 + it('loads preferences from API when authenticated', async () => { 102 + localStorageMock.setItem('accessToken', 'mock-token-123') 103 + render(<SettingsPage />) 104 + 105 + // Should show loading skeleton initially, then load preferences 106 + await waitFor(() => { 107 + expect(screen.getByLabelText(/maturity level/i)).toBeInTheDocument() 108 + }) 109 + 110 + // Muted words should be populated from mock data 111 + const mutedWordsInput = screen.getByLabelText(/muted words/i) as HTMLTextAreaElement 112 + expect(mutedWordsInput.value).toBe('spam, offensive') 113 + }) 114 + 60 115 it('passes axe accessibility check', async () => { 61 116 const { container } = render(<SettingsPage />) 117 + await waitFor(() => { 118 + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() 119 + }) 62 120 const results = await axe(container) 63 121 expect(results).toHaveNoViolations() 64 122 })
+240 -126
src/app/settings/page.tsx
··· 8 8 9 9 'use client' 10 10 11 - import { useState } from 'react' 11 + import { useState, useEffect, useCallback } from 'react' 12 12 import { ForumLayout } from '@/components/layout/forum-layout' 13 13 import { Breadcrumbs } from '@/components/breadcrumbs' 14 + import { AgeGateDialog } from '@/components/age-gate-dialog' 14 15 import { cn } from '@/lib/utils' 16 + import { getPreferences, updatePreferences } from '@/lib/api/client' 15 17 16 18 type MaturityLevel = 'sfw' | 'sfw-mature' 17 19 ··· 36 38 notifyReactions: false, 37 39 }) 38 40 const [saving, setSaving] = useState(false) 41 + const [loading, setLoading] = useState(true) 42 + const [error, setError] = useState<string | null>(null) 43 + const [success, setSuccess] = useState(false) 44 + const [ageDeclarationAt, setAgeDeclarationAt] = useState<string | null>(null) 45 + const [showAgeGate, setShowAgeGate] = useState(false) 39 46 40 - const handleSave = (e: React.FormEvent) => { 41 - e.preventDefault() 42 - setSaving(true) 43 - // TODO: Save settings via API 44 - setTimeout(() => setSaving(false), 500) 45 - } 47 + // Load preferences on mount 48 + useEffect(() => { 49 + const token = localStorage.getItem('accessToken') 50 + if (!token) { 51 + setLoading(false) 52 + return 53 + } 54 + 55 + getPreferences(token) 56 + .then((prefs) => { 57 + setValues({ 58 + maturityLevel: prefs.maturityLevel === 'mature' ? 'sfw-mature' : 'sfw', 59 + mutedWords: prefs.mutedWords.join(', '), 60 + crossPostBluesky: prefs.crossPostBluesky, 61 + crossPostFrontpage: prefs.crossPostFrontpage, 62 + notifyReplies: true, 63 + notifyMentions: true, 64 + notifyReactions: false, 65 + }) 66 + setAgeDeclarationAt(prefs.ageDeclarationAt) 67 + }) 68 + .catch(() => setError('Failed to load preferences')) 69 + .finally(() => setLoading(false)) 70 + }, []) 71 + 72 + const handleSave = useCallback( 73 + async (e: React.FormEvent) => { 74 + e.preventDefault() 75 + setSaving(true) 76 + setError(null) 77 + setSuccess(false) 78 + 79 + const token = localStorage.getItem('accessToken') 80 + if (!token) { 81 + setError('Not authenticated') 82 + setSaving(false) 83 + return 84 + } 85 + 86 + // If switching to mature and no age declaration, show age gate 87 + if (values.maturityLevel === 'sfw-mature' && !ageDeclarationAt) { 88 + setShowAgeGate(true) 89 + setSaving(false) 90 + return 91 + } 92 + 93 + try { 94 + const mutedWords = values.mutedWords 95 + .split(',') 96 + .map((w) => w.trim()) 97 + .filter(Boolean) 98 + 99 + await updatePreferences( 100 + { 101 + maturityLevel: values.maturityLevel === 'sfw-mature' ? 'mature' : 'sfw', 102 + mutedWords, 103 + crossPostBluesky: values.crossPostBluesky, 104 + crossPostFrontpage: values.crossPostFrontpage, 105 + }, 106 + token 107 + ) 108 + setSuccess(true) 109 + } catch { 110 + setError('Failed to save preferences') 111 + } finally { 112 + setSaving(false) 113 + } 114 + }, 115 + [values, ageDeclarationAt] 116 + ) 46 117 47 118 return ( 48 119 <ForumLayout> ··· 51 122 52 123 <h1 className="text-2xl font-bold text-foreground">Settings</h1> 53 124 54 - <form onSubmit={handleSave} className="max-w-2xl space-y-8" noValidate> 55 - {/* Content Safety */} 56 - <fieldset className="space-y-4 rounded-lg border border-border p-4"> 57 - <legend className="px-2 text-sm font-semibold text-foreground">Content Safety</legend> 58 - 59 - <div className="space-y-1"> 60 - <label htmlFor="maturity-level" className="block text-sm font-medium text-foreground"> 61 - Maturity level 62 - </label> 63 - <select 64 - id="maturity-level" 65 - value={values.maturityLevel} 66 - onChange={(e) => 67 - setValues({ ...values, maturityLevel: e.target.value as MaturityLevel }) 68 - } 69 - className={cn( 70 - 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground', 71 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 72 - )} 125 + {loading ? ( 126 + <div className="max-w-2xl animate-pulse space-y-4"> 127 + <div className="h-32 rounded-lg bg-muted" /> 128 + <div className="h-24 rounded-lg bg-muted" /> 129 + <div className="h-24 rounded-lg bg-muted" /> 130 + </div> 131 + ) : ( 132 + <form onSubmit={handleSave} className="max-w-2xl space-y-8" noValidate> 133 + {error && ( 134 + <p 135 + className="rounded-md bg-destructive/10 px-4 py-2 text-sm text-destructive" 136 + role="alert" 73 137 > 74 - <option value="sfw">SFW only</option> 75 - <option value="sfw-mature">SFW + Mature</option> 76 - </select> 77 - <p className="text-xs text-muted-foreground"> 78 - Controls which content you can see. Mature content requires age confirmation. 138 + {error} 79 139 </p> 80 - </div> 140 + )} 81 141 82 - <div className="space-y-1"> 83 - <label htmlFor="muted-words" className="block text-sm font-medium text-foreground"> 84 - Muted words 85 - </label> 86 - <textarea 87 - id="muted-words" 88 - value={values.mutedWords} 89 - onChange={(e) => setValues({ ...values, mutedWords: e.target.value })} 90 - placeholder="Enter words separated by commas" 91 - rows={3} 92 - className={cn( 93 - 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 94 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 95 - )} 96 - /> 97 - <p className="text-xs text-muted-foreground"> 98 - Posts containing these words will be collapsed. Comma-separated. 142 + {success && ( 143 + <p 144 + className="rounded-md bg-green-500/10 px-4 py-2 text-sm text-green-700 dark:text-green-400" 145 + role="status" 146 + > 147 + Settings saved successfully. 99 148 </p> 100 - </div> 101 - </fieldset> 149 + )} 102 150 103 - {/* Cross-Posting */} 104 - <fieldset className="space-y-4 rounded-lg border border-border p-4"> 105 - <legend className="px-2 text-sm font-semibold text-foreground">Cross-Posting</legend> 106 - <div className="space-y-3"> 107 - <label className="flex items-center gap-2"> 108 - <input 109 - type="checkbox" 110 - checked={values.crossPostBluesky} 111 - onChange={(e) => setValues({ ...values, crossPostBluesky: e.target.checked })} 112 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 113 - /> 114 - <span className="text-sm text-foreground"> 115 - Share new topics on Bluesky by default 116 - </span> 117 - </label> 118 - <label className="flex items-center gap-2"> 119 - <input 120 - type="checkbox" 121 - checked={values.crossPostFrontpage} 122 - onChange={(e) => setValues({ ...values, crossPostFrontpage: e.target.checked })} 123 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 124 - /> 125 - <span className="text-sm text-foreground"> 126 - Share new topics on Frontpage by default 127 - </span> 128 - </label> 129 - </div> 130 - </fieldset> 151 + {/* Content Safety */} 152 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 153 + <legend className="px-2 text-sm font-semibold text-foreground">Content Safety</legend> 131 154 132 - {/* Notifications */} 133 - <fieldset className="space-y-4 rounded-lg border border-border p-4"> 134 - <legend className="px-2 text-sm font-semibold text-foreground">Notifications</legend> 135 - <div className="space-y-3"> 136 - <label className="flex items-center gap-2"> 137 - <input 138 - type="checkbox" 139 - checked={values.notifyReplies} 140 - onChange={(e) => setValues({ ...values, notifyReplies: e.target.checked })} 141 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 155 + <div className="space-y-1"> 156 + <label 157 + htmlFor="maturity-level" 158 + className="block text-sm font-medium text-foreground" 159 + > 160 + Maturity level 161 + </label> 162 + <select 163 + id="maturity-level" 164 + value={values.maturityLevel} 165 + onChange={(e) => 166 + setValues({ ...values, maturityLevel: e.target.value as MaturityLevel }) 167 + } 168 + className={cn( 169 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground', 170 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 171 + )} 172 + > 173 + <option value="sfw">SFW only</option> 174 + <option value="sfw-mature">SFW + Mature</option> 175 + </select> 176 + <p className="text-xs text-muted-foreground"> 177 + Controls which content you can see. Mature content requires age confirmation. 178 + </p> 179 + </div> 180 + 181 + <div className="space-y-1"> 182 + <label htmlFor="muted-words" className="block text-sm font-medium text-foreground"> 183 + Muted words 184 + </label> 185 + <textarea 186 + id="muted-words" 187 + value={values.mutedWords} 188 + onChange={(e) => setValues({ ...values, mutedWords: e.target.value })} 189 + placeholder="Enter words separated by commas" 190 + rows={3} 191 + className={cn( 192 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 193 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 194 + )} 142 195 /> 143 - <span className="text-sm text-foreground">Replies to my posts</span> 144 - </label> 145 - <label className="flex items-center gap-2"> 146 - <input 147 - type="checkbox" 148 - checked={values.notifyMentions} 149 - onChange={(e) => setValues({ ...values, notifyMentions: e.target.checked })} 150 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 151 - /> 152 - <span className="text-sm text-foreground">Mentions of my handle</span> 153 - </label> 154 - <label className="flex items-center gap-2"> 155 - <input 156 - type="checkbox" 157 - checked={values.notifyReactions} 158 - onChange={(e) => setValues({ ...values, notifyReactions: e.target.checked })} 159 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 160 - /> 161 - <span className="text-sm text-foreground">Reactions on my posts</span> 162 - </label> 196 + <p className="text-xs text-muted-foreground"> 197 + Posts containing these words will be collapsed. Comma-separated. 198 + </p> 199 + </div> 200 + </fieldset> 201 + 202 + {/* Cross-Posting */} 203 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 204 + <legend className="px-2 text-sm font-semibold text-foreground">Cross-Posting</legend> 205 + <div className="space-y-3"> 206 + <label className="flex items-center gap-2"> 207 + <input 208 + type="checkbox" 209 + checked={values.crossPostBluesky} 210 + onChange={(e) => setValues({ ...values, crossPostBluesky: e.target.checked })} 211 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 212 + /> 213 + <span className="text-sm text-foreground"> 214 + Share new topics on Bluesky by default 215 + </span> 216 + </label> 217 + <label className="flex items-center gap-2"> 218 + <input 219 + type="checkbox" 220 + checked={values.crossPostFrontpage} 221 + onChange={(e) => setValues({ ...values, crossPostFrontpage: e.target.checked })} 222 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 223 + /> 224 + <span className="text-sm text-foreground"> 225 + Share new topics on Frontpage by default 226 + </span> 227 + </label> 228 + </div> 229 + </fieldset> 230 + 231 + {/* Notifications */} 232 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 233 + <legend className="px-2 text-sm font-semibold text-foreground">Notifications</legend> 234 + <div className="space-y-3"> 235 + <label className="flex items-center gap-2"> 236 + <input 237 + type="checkbox" 238 + checked={values.notifyReplies} 239 + onChange={(e) => setValues({ ...values, notifyReplies: e.target.checked })} 240 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 241 + /> 242 + <span className="text-sm text-foreground">Replies to my posts</span> 243 + </label> 244 + <label className="flex items-center gap-2"> 245 + <input 246 + type="checkbox" 247 + checked={values.notifyMentions} 248 + onChange={(e) => setValues({ ...values, notifyMentions: e.target.checked })} 249 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 250 + /> 251 + <span className="text-sm text-foreground">Mentions of my handle</span> 252 + </label> 253 + <label className="flex items-center gap-2"> 254 + <input 255 + type="checkbox" 256 + checked={values.notifyReactions} 257 + onChange={(e) => setValues({ ...values, notifyReactions: e.target.checked })} 258 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 259 + /> 260 + <span className="text-sm text-foreground">Reactions on my posts</span> 261 + </label> 262 + </div> 263 + </fieldset> 264 + 265 + {/* Save */} 266 + <div className="flex justify-end"> 267 + <button 268 + type="submit" 269 + disabled={saving} 270 + className={cn( 271 + 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors', 272 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 273 + 'disabled:cursor-not-allowed disabled:opacity-50' 274 + )} 275 + > 276 + {saving ? 'Saving...' : 'Save Settings'} 277 + </button> 163 278 </div> 164 - </fieldset> 279 + </form> 280 + )} 281 + </div> 165 282 166 - {/* Save */} 167 - <div className="flex justify-end"> 168 - <button 169 - type="submit" 170 - disabled={saving} 171 - className={cn( 172 - 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors', 173 - 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 174 - 'disabled:cursor-not-allowed disabled:opacity-50' 175 - )} 176 - > 177 - {saving ? 'Saving...' : 'Save Settings'} 178 - </button> 179 - </div> 180 - </form> 181 - </div> 283 + <AgeGateDialog 284 + open={showAgeGate} 285 + onConfirm={(ageAt) => { 286 + setAgeDeclarationAt(ageAt) 287 + setShowAgeGate(false) 288 + // Re-trigger save now that age is declared 289 + void handleSave({ preventDefault: () => {} } as React.FormEvent) 290 + }} 291 + onCancel={() => { 292 + setShowAgeGate(false) 293 + setValues((prev) => ({ ...prev, maturityLevel: 'sfw' })) 294 + }} 295 + /> 182 296 </ForumLayout> 183 297 ) 184 298 }
+19
src/app/u/[handle]/page.tsx
··· 14 14 import { Breadcrumbs } from '@/components/breadcrumbs' 15 15 import { ReputationBadge } from '@/components/reputation-badge' 16 16 import { BanIndicator } from '@/components/ban-indicator' 17 + import { BlockMuteButton } from '@/components/block-mute-button' 17 18 18 19 interface UserProfilePageProps { 19 20 params: Promise<{ handle: string }> | { handle: string } ··· 21 22 22 23 export default function UserProfilePage({ params }: UserProfilePageProps) { 23 24 const [handle, setHandle] = useState<string | null>(null) 25 + const [isBlocked, setIsBlocked] = useState(false) 26 + const [isMuted, setIsMuted] = useState(false) 24 27 25 28 useEffect(() => { 26 29 async function resolveParams() { ··· 103 106 {mockProfile.bannedFromOtherCommunities === 1 ? 'community' : 'communities'} 104 107 </p> 105 108 )} 109 + 110 + {/* Block/Mute actions */} 111 + <div className="mt-3 flex gap-2"> 112 + <BlockMuteButton 113 + targetDid={mockProfile.did} 114 + action="block" 115 + isActive={isBlocked} 116 + onToggle={setIsBlocked} 117 + /> 118 + <BlockMuteButton 119 + targetDid={mockProfile.did} 120 + action="mute" 121 + isActive={isMuted} 122 + onToggle={setIsMuted} 123 + /> 124 + </div> 106 125 </div> 107 126 </div> 108 127 </div>
+91
src/components/age-gate-dialog.test.tsx
··· 1 + /** 2 + * Tests for AgeGateDialog component. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { AgeGateDialog } from './age-gate-dialog' 9 + 10 + // Mock localStorage 11 + const mockStorage: Record<string, string> = {} 12 + 13 + beforeEach(() => { 14 + vi.stubGlobal('localStorage', { 15 + getItem: vi.fn((key: string) => mockStorage[key] ?? null), 16 + setItem: vi.fn((key: string, value: string) => { 17 + mockStorage[key] = value 18 + }), 19 + removeItem: vi.fn((key: string) => { 20 + delete mockStorage[key] 21 + }), 22 + clear: vi.fn(), 23 + length: 0, 24 + key: vi.fn(), 25 + }) 26 + mockStorage['accessToken'] = 'test-token' 27 + }) 28 + 29 + afterEach(() => { 30 + vi.restoreAllMocks() 31 + Object.keys(mockStorage).forEach((key) => delete mockStorage[key]) 32 + }) 33 + 34 + describe('AgeGateDialog', () => { 35 + it('renders nothing when not open', () => { 36 + const { container } = render( 37 + <AgeGateDialog open={false} onConfirm={vi.fn()} onCancel={vi.fn()} /> 38 + ) 39 + expect(container.innerHTML).toBe('') 40 + }) 41 + 42 + it('renders dialog when open', () => { 43 + render(<AgeGateDialog open={true} onConfirm={vi.fn()} onCancel={vi.fn()} />) 44 + expect(screen.getByText('Age Confirmation Required')).toBeInTheDocument() 45 + expect(screen.getByText('I confirm I am 16 or older')).toBeInTheDocument() 46 + expect(screen.getByText('Cancel')).toBeInTheDocument() 47 + }) 48 + 49 + it('has correct ARIA attributes', () => { 50 + render(<AgeGateDialog open={true} onConfirm={vi.fn()} onCancel={vi.fn()} />) 51 + const dialog = screen.getByRole('dialog') 52 + expect(dialog).toHaveAttribute('aria-modal', 'true') 53 + expect(dialog).toHaveAttribute('aria-labelledby', 'age-gate-title') 54 + }) 55 + 56 + it('calls onCancel when Cancel is clicked', async () => { 57 + const onCancel = vi.fn() 58 + render(<AgeGateDialog open={true} onConfirm={vi.fn()} onCancel={onCancel} />) 59 + 60 + const user = userEvent.setup() 61 + await user.click(screen.getByText('Cancel')) 62 + 63 + expect(onCancel).toHaveBeenCalledOnce() 64 + }) 65 + 66 + it('calls onConfirm with timestamp when confirmed', async () => { 67 + const onConfirm = vi.fn() 68 + render(<AgeGateDialog open={true} onConfirm={onConfirm} onCancel={vi.fn()} />) 69 + 70 + const user = userEvent.setup() 71 + await user.click(screen.getByText('I confirm I am 16 or older')) 72 + 73 + await waitFor(() => { 74 + expect(onConfirm).toHaveBeenCalledOnce() 75 + }) 76 + // The mock handler returns an ageDeclarationAt string 77 + expect(onConfirm).toHaveBeenCalledWith(expect.any(String)) 78 + }) 79 + 80 + it('shows error when not authenticated', async () => { 81 + delete mockStorage['accessToken'] 82 + render(<AgeGateDialog open={true} onConfirm={vi.fn()} onCancel={vi.fn()} />) 83 + 84 + const user = userEvent.setup() 85 + await user.click(screen.getByText('I confirm I am 16 or older')) 86 + 87 + await waitFor(() => { 88 + expect(screen.getByRole('alert')).toHaveTextContent('Not authenticated') 89 + }) 90 + }) 91 + })
+100
src/components/age-gate-dialog.tsx
··· 1 + /** 2 + * Age gate confirmation dialog. 3 + * Shown when user tries to enable Mature content without prior age declaration. 4 + * Calls POST /api/users/me/age-declaration on confirm. 5 + * @see decisions/features-and-ux.md "Content Maturity & User Safety" 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState } from 'react' 11 + import { cn } from '@/lib/utils' 12 + import { declareAge } from '@/lib/api/client' 13 + 14 + interface AgeGateDialogProps { 15 + open: boolean 16 + onConfirm: (ageDeclarationAt: string) => void 17 + onCancel: () => void 18 + } 19 + 20 + export function AgeGateDialog({ open, onConfirm, onCancel }: AgeGateDialogProps) { 21 + const [confirming, setConfirming] = useState(false) 22 + const [error, setError] = useState<string | null>(null) 23 + 24 + if (!open) return null 25 + 26 + const handleConfirm = async () => { 27 + setConfirming(true) 28 + setError(null) 29 + 30 + const token = localStorage.getItem('accessToken') 31 + if (!token) { 32 + setError('Not authenticated') 33 + setConfirming(false) 34 + return 35 + } 36 + 37 + try { 38 + const result = await declareAge(token) 39 + onConfirm(result.ageDeclarationAt) 40 + } catch { 41 + setError('Failed to confirm age') 42 + } finally { 43 + setConfirming(false) 44 + } 45 + } 46 + 47 + return ( 48 + <div 49 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 50 + role="dialog" 51 + aria-modal="true" 52 + aria-labelledby="age-gate-title" 53 + > 54 + <div className="mx-4 w-full max-w-md rounded-lg bg-background p-6 shadow-lg"> 55 + <h2 id="age-gate-title" className="text-lg font-semibold text-foreground"> 56 + Age Confirmation Required 57 + </h2> 58 + <p className="mt-2 text-sm text-muted-foreground"> 59 + To view Mature content, you must confirm that you are at least 16 years old. This is a 60 + one-time declaration stored with your account. 61 + </p> 62 + <p className="mt-2 text-sm text-muted-foreground"> 63 + Mature content may include strong language, graphic descriptions, and sensitive topics 64 + (politics, drugs, violence). It does not include explicit sexual content. 65 + </p> 66 + 67 + {error && ( 68 + <p className="mt-2 text-sm text-destructive" role="alert"> 69 + {error} 70 + </p> 71 + )} 72 + 73 + <div className="mt-6 flex justify-end gap-3"> 74 + <button 75 + type="button" 76 + onClick={onCancel} 77 + className={cn( 78 + 'rounded-md border border-border px-4 py-2 text-sm text-foreground', 79 + 'hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 80 + )} 81 + > 82 + Cancel 83 + </button> 84 + <button 85 + type="button" 86 + onClick={handleConfirm} 87 + disabled={confirming} 88 + className={cn( 89 + 'rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground', 90 + 'hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 91 + 'disabled:cursor-not-allowed disabled:opacity-50' 92 + )} 93 + > 94 + {confirming ? 'Confirming...' : 'I confirm I am 16 or older'} 95 + </button> 96 + </div> 97 + </div> 98 + </div> 99 + ) 100 + }
+144
src/components/block-mute-button.test.tsx
··· 1 + /** 2 + * Tests for BlockMuteButton component. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { BlockMuteButton } from './block-mute-button' 9 + 10 + // Mock localStorage 11 + const mockStorage: Record<string, string> = {} 12 + 13 + beforeEach(() => { 14 + vi.stubGlobal('localStorage', { 15 + getItem: vi.fn((key: string) => mockStorage[key] ?? null), 16 + setItem: vi.fn((key: string, value: string) => { 17 + mockStorage[key] = value 18 + }), 19 + removeItem: vi.fn((key: string) => { 20 + delete mockStorage[key] 21 + }), 22 + clear: vi.fn(), 23 + length: 0, 24 + key: vi.fn(), 25 + }) 26 + mockStorage['accessToken'] = 'test-token' 27 + }) 28 + 29 + afterEach(() => { 30 + vi.restoreAllMocks() 31 + Object.keys(mockStorage).forEach((key) => delete mockStorage[key]) 32 + }) 33 + 34 + describe('BlockMuteButton', () => { 35 + it('renders block button in inactive state', () => { 36 + render( 37 + <BlockMuteButton 38 + targetDid="did:plc:target123" 39 + action="block" 40 + isActive={false} 41 + onToggle={vi.fn()} 42 + /> 43 + ) 44 + expect(screen.getByText('Block')).toBeInTheDocument() 45 + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Block this user') 46 + }) 47 + 48 + it('renders unblock button in active state', () => { 49 + render( 50 + <BlockMuteButton 51 + targetDid="did:plc:target123" 52 + action="block" 53 + isActive={true} 54 + onToggle={vi.fn()} 55 + /> 56 + ) 57 + expect(screen.getByText('Unblock')).toBeInTheDocument() 58 + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Unblock this user') 59 + }) 60 + 61 + it('renders mute button in inactive state', () => { 62 + render( 63 + <BlockMuteButton 64 + targetDid="did:plc:target123" 65 + action="mute" 66 + isActive={false} 67 + onToggle={vi.fn()} 68 + /> 69 + ) 70 + expect(screen.getByText('Mute')).toBeInTheDocument() 71 + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Mute this user') 72 + }) 73 + 74 + it('renders unmute button in active state', () => { 75 + render( 76 + <BlockMuteButton 77 + targetDid="did:plc:target123" 78 + action="mute" 79 + isActive={true} 80 + onToggle={vi.fn()} 81 + /> 82 + ) 83 + expect(screen.getByText('Unmute')).toBeInTheDocument() 84 + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Unmute this user') 85 + }) 86 + 87 + it('calls onToggle after successful block', async () => { 88 + const onToggle = vi.fn() 89 + render( 90 + <BlockMuteButton 91 + targetDid="did:plc:target123" 92 + action="block" 93 + isActive={false} 94 + onToggle={onToggle} 95 + /> 96 + ) 97 + 98 + const user = userEvent.setup() 99 + await user.click(screen.getByText('Block')) 100 + 101 + await waitFor(() => { 102 + expect(onToggle).toHaveBeenCalledWith(true) 103 + }) 104 + }) 105 + 106 + it('calls onToggle after successful mute', async () => { 107 + const onToggle = vi.fn() 108 + render( 109 + <BlockMuteButton 110 + targetDid="did:plc:target123" 111 + action="mute" 112 + isActive={false} 113 + onToggle={onToggle} 114 + /> 115 + ) 116 + 117 + const user = userEvent.setup() 118 + await user.click(screen.getByText('Mute')) 119 + 120 + await waitFor(() => { 121 + expect(onToggle).toHaveBeenCalledWith(true) 122 + }) 123 + }) 124 + 125 + it('does not call onToggle without auth token', async () => { 126 + delete mockStorage['accessToken'] 127 + const onToggle = vi.fn() 128 + render( 129 + <BlockMuteButton 130 + targetDid="did:plc:target123" 131 + action="block" 132 + isActive={false} 133 + onToggle={onToggle} 134 + /> 135 + ) 136 + 137 + const user = userEvent.setup() 138 + await user.click(screen.getByText('Block')) 139 + 140 + // Wait a tick and verify onToggle was NOT called 141 + await new Promise((r) => setTimeout(r, 100)) 142 + expect(onToggle).not.toHaveBeenCalled() 143 + }) 144 + })
+85
src/components/block-mute-button.tsx
··· 1 + /** 2 + * Block/mute toggle button for user actions. 3 + * Used in user profiles and post context menus. 4 + * @see specs/prd-web.md Section M8 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState } from 'react' 10 + import { Prohibit, SpeakerSimpleSlash } from '@phosphor-icons/react' 11 + import { cn } from '@/lib/utils' 12 + import { blockUser, unblockUser, muteUser, unmuteUser } from '@/lib/api/client' 13 + 14 + interface BlockMuteButtonProps { 15 + targetDid: string 16 + action: 'block' | 'mute' 17 + isActive: boolean 18 + onToggle: (newState: boolean) => void 19 + className?: string 20 + } 21 + 22 + export function BlockMuteButton({ 23 + targetDid, 24 + action, 25 + isActive, 26 + onToggle, 27 + className, 28 + }: BlockMuteButtonProps) { 29 + const [loading, setLoading] = useState(false) 30 + 31 + const handleClick = async () => { 32 + setLoading(true) 33 + 34 + const token = localStorage.getItem('accessToken') 35 + if (!token) { 36 + setLoading(false) 37 + return 38 + } 39 + 40 + try { 41 + if (action === 'block') { 42 + if (isActive) { 43 + await unblockUser(targetDid, token) 44 + } else { 45 + await blockUser(targetDid, token) 46 + } 47 + } else { 48 + if (isActive) { 49 + await unmuteUser(targetDid, token) 50 + } else { 51 + await muteUser(targetDid, token) 52 + } 53 + } 54 + onToggle(!isActive) 55 + } catch { 56 + // Silently fail - the UI state won't change 57 + } finally { 58 + setLoading(false) 59 + } 60 + } 61 + 62 + const Icon = action === 'block' ? Prohibit : SpeakerSimpleSlash 63 + const label = action === 'block' ? (isActive ? 'Unblock' : 'Block') : isActive ? 'Unmute' : 'Mute' 64 + 65 + return ( 66 + <button 67 + type="button" 68 + onClick={handleClick} 69 + disabled={loading} 70 + className={cn( 71 + 'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors', 72 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 73 + 'disabled:cursor-not-allowed disabled:opacity-50', 74 + isActive 75 + ? 'bg-destructive/10 text-destructive hover:bg-destructive/20' 76 + : 'bg-muted text-muted-foreground hover:bg-muted/80', 77 + className 78 + )} 79 + aria-label={`${label} this user`} 80 + > 81 + <Icon size={14} weight={isActive ? 'fill' : 'regular'} aria-hidden="true" /> 82 + {loading ? `${label.slice(0, -1)}ing...` : label} 83 + </button> 84 + ) 85 + }
+90
src/lib/api/client.ts
··· 5 5 */ 6 6 7 7 import type { 8 + AgeDeclarationResponse, 8 9 CategoriesResponse, 9 10 CategoryTreeNode, 10 11 CategoryWithTopicCount, ··· 13 14 CreateTopicInput, 14 15 Topic, 15 16 TopicsResponse, 17 + UpdatePreferencesInput, 16 18 UpdateTopicInput, 19 + UserPreferences, 17 20 RepliesResponse, 18 21 SearchResponse, 19 22 NotificationsResponse, ··· 489 492 options?: FetchOptions 490 493 ): Promise<void> { 491 494 return apiFetch<void>(`/api/plugins/${encodeURIComponent(id)}`, { 495 + ...options, 496 + method: 'DELETE', 497 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 498 + }) 499 + } 500 + 501 + // --- User Preference endpoints --- 502 + 503 + export function getPreferences( 504 + accessToken: string, 505 + options?: FetchOptions 506 + ): Promise<UserPreferences> { 507 + return apiFetch<UserPreferences>('/api/users/me/preferences', { 508 + ...options, 509 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 510 + }) 511 + } 512 + 513 + export function updatePreferences( 514 + input: UpdatePreferencesInput, 515 + accessToken: string, 516 + options?: FetchOptions 517 + ): Promise<UserPreferences> { 518 + return apiFetch<UserPreferences>('/api/users/me/preferences', { 519 + ...options, 520 + method: 'PUT', 521 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 522 + body: input, 523 + }) 524 + } 525 + 526 + export function declareAge( 527 + accessToken: string, 528 + options?: FetchOptions 529 + ): Promise<AgeDeclarationResponse> { 530 + return apiFetch<AgeDeclarationResponse>('/api/users/me/age-declaration', { 531 + ...options, 532 + method: 'POST', 533 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 534 + body: { confirm: true }, 535 + }) 536 + } 537 + 538 + // --- Block/Mute endpoints --- 539 + 540 + export function blockUser( 541 + did: string, 542 + accessToken: string, 543 + options?: FetchOptions 544 + ): Promise<{ success: boolean }> { 545 + return apiFetch<{ success: boolean }>(`/api/users/me/block/${encodeURIComponent(did)}`, { 546 + ...options, 547 + method: 'POST', 548 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 549 + }) 550 + } 551 + 552 + export function unblockUser( 553 + did: string, 554 + accessToken: string, 555 + options?: FetchOptions 556 + ): Promise<{ success: boolean }> { 557 + return apiFetch<{ success: boolean }>(`/api/users/me/block/${encodeURIComponent(did)}`, { 558 + ...options, 559 + method: 'DELETE', 560 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 561 + }) 562 + } 563 + 564 + export function muteUser( 565 + did: string, 566 + accessToken: string, 567 + options?: FetchOptions 568 + ): Promise<{ success: boolean }> { 569 + return apiFetch<{ success: boolean }>(`/api/users/me/mute/${encodeURIComponent(did)}`, { 570 + ...options, 571 + method: 'POST', 572 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 573 + }) 574 + } 575 + 576 + export function unmuteUser( 577 + did: string, 578 + accessToken: string, 579 + options?: FetchOptions 580 + ): Promise<{ success: boolean }> { 581 + return apiFetch<{ success: boolean }>(`/api/users/me/mute/${encodeURIComponent(did)}`, { 492 582 ...options, 493 583 method: 'DELETE', 494 584 headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` },
+27
src/lib/api/types.ts
··· 382 382 plugins: Plugin[] 383 383 } 384 384 385 + // --- User Preferences --- 386 + 387 + export interface UserPreferences { 388 + maturityLevel: 'sfw' | 'mature' 389 + ageDeclarationAt: string | null 390 + mutedWords: string[] 391 + blockedDids: string[] 392 + mutedDids: string[] 393 + crossPostBluesky: boolean 394 + crossPostFrontpage: boolean 395 + updatedAt: string 396 + } 397 + 398 + export interface UpdatePreferencesInput { 399 + maturityLevel?: 'sfw' | 'mature' 400 + mutedWords?: string[] 401 + blockedDids?: string[] 402 + mutedDids?: string[] 403 + crossPostBluesky?: boolean 404 + crossPostFrontpage?: boolean 405 + } 406 + 407 + export interface AgeDeclarationResponse { 408 + success: boolean 409 + ageDeclarationAt: string 410 + } 411 + 385 412 // --- Shared --- 386 413 387 414 export type MaturityRating = 'safe' | 'mature' | 'adult'
+14
src/mocks/data.ts
··· 19 19 CommunitySettings, 20 20 CommunityStats, 21 21 Plugin, 22 + UserPreferences, 22 23 } from '@/lib/api/types' 23 24 24 25 const COMMUNITY_DID = 'did:plc:test-community-123' ··· 825 826 installedAt: YESTERDAY, 826 827 }, 827 828 ] 829 + 830 + // --- User Preferences --- 831 + 832 + export const mockUserPreferences: UserPreferences = { 833 + maturityLevel: 'sfw', 834 + ageDeclarationAt: null, 835 + mutedWords: ['spam', 'offensive'], 836 + blockedDids: [], 837 + mutedDids: [], 838 + crossPostBluesky: true, 839 + crossPostFrontpage: false, 840 + updatedAt: NOW, 841 + }
+68
src/mocks/handlers.ts
··· 21 21 mockReportedUsers, 22 22 mockAdminUsers, 23 23 mockPlugins, 24 + mockUserPreferences, 24 25 } from './data' 25 26 26 27 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' ··· 438 439 return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 439 440 } 440 441 return new HttpResponse(null, { status: 204 }) 442 + }), 443 + 444 + // GET /api/users/me/preferences 445 + http.get(`${API_URL}/api/users/me/preferences`, ({ request }) => { 446 + const auth = request.headers.get('Authorization') 447 + if (!auth?.startsWith('Bearer ')) { 448 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 449 + } 450 + return HttpResponse.json(mockUserPreferences) 451 + }), 452 + 453 + // PUT /api/users/me/preferences 454 + http.put(`${API_URL}/api/users/me/preferences`, async ({ request }) => { 455 + const auth = request.headers.get('Authorization') 456 + if (!auth?.startsWith('Bearer ')) { 457 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 458 + } 459 + const body = (await request.json()) as Record<string, unknown> 460 + return HttpResponse.json({ ...mockUserPreferences, ...body }) 461 + }), 462 + 463 + // POST /api/users/me/age-declaration 464 + http.post(`${API_URL}/api/users/me/age-declaration`, ({ request }) => { 465 + const auth = request.headers.get('Authorization') 466 + if (!auth?.startsWith('Bearer ')) { 467 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 468 + } 469 + return HttpResponse.json({ 470 + success: true, 471 + ageDeclarationAt: new Date().toISOString(), 472 + }) 473 + }), 474 + 475 + // POST /api/users/me/block/:did 476 + http.post(`${API_URL}/api/users/me/block/:did`, ({ request }) => { 477 + const auth = request.headers.get('Authorization') 478 + if (!auth?.startsWith('Bearer ')) { 479 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 480 + } 481 + return HttpResponse.json({ success: true }) 482 + }), 483 + 484 + // DELETE /api/users/me/block/:did 485 + http.delete(`${API_URL}/api/users/me/block/:did`, ({ request }) => { 486 + const auth = request.headers.get('Authorization') 487 + if (!auth?.startsWith('Bearer ')) { 488 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 489 + } 490 + return HttpResponse.json({ success: true }) 491 + }), 492 + 493 + // POST /api/users/me/mute/:did 494 + http.post(`${API_URL}/api/users/me/mute/:did`, ({ request }) => { 495 + const auth = request.headers.get('Authorization') 496 + if (!auth?.startsWith('Bearer ')) { 497 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 498 + } 499 + return HttpResponse.json({ success: true }) 500 + }), 501 + 502 + // DELETE /api/users/me/mute/:did 503 + http.delete(`${API_URL}/api/users/me/mute/:did`, ({ request }) => { 504 + const auth = request.headers.get('Authorization') 505 + if (!auth?.startsWith('Bearer ')) { 506 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 507 + } 508 + return HttpResponse.json({ success: true }) 441 509 }), 442 510 ]