Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(settings): add per-community overrides and muted content rendering (#30)

Complete Web M8 gaps: per-community preference overrides in Settings
(maturity, muted words, blocked users per community) and a
MutedContentWrapper component that collapses posts matching muted
words with accessible expand/collapse (aria-expanded, aria-live).

authored by

Guido X Jansen and committed by
GitHub
0dbaca35 d05561e5

+598 -9
+81 -4
src/app/settings/page.test.tsx
··· 3 3 */ 4 4 5 5 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 6 - import { render, screen, waitFor } from '@testing-library/react' 6 + import { render, screen, waitFor, within } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 7 8 import { axe } from 'vitest-axe' 8 9 import SettingsPage from './page' 9 10 ··· 82 83 it('renders muted words input', async () => { 83 84 render(<SettingsPage />) 84 85 await waitFor(() => { 85 - expect(screen.getByLabelText(/muted words/i)).toBeInTheDocument() 86 + expect(screen.getByLabelText('Muted words')).toBeInTheDocument() 86 87 }) 87 88 }) 88 89 ··· 125 126 expect(screen.getByLabelText(/maturity level/i)).toBeInTheDocument() 126 127 }) 127 128 128 - // Muted words should be populated from mock data 129 - const mutedWordsInput = screen.getByLabelText(/muted words/i) as HTMLTextAreaElement 129 + // Muted words should be populated from mock data (global, not community-specific) 130 + const mutedWordsInput = screen.getByLabelText('Muted words') as HTMLTextAreaElement 130 131 expect(mutedWordsInput.value).toBe('spam, offensive') 131 132 }) 132 133 ··· 137 138 }) 138 139 const results = await axe(container) 139 140 expect(results).toHaveNoViolations() 141 + }) 142 + 143 + // --- Per-Community Overrides --- 144 + 145 + describe('Per-Community Overrides', () => { 146 + it('renders per-community overrides section', async () => { 147 + render(<SettingsPage />) 148 + await waitFor(() => { 149 + expect(screen.getByText(/per-community overrides/i)).toBeInTheDocument() 150 + }) 151 + }) 152 + 153 + it('loads and displays community list from API', async () => { 154 + render(<SettingsPage />) 155 + await waitFor(() => { 156 + expect(screen.getByText('Barazo Test Community')).toBeInTheDocument() 157 + expect(screen.getByText('Gaming Forum')).toBeInTheDocument() 158 + }) 159 + }) 160 + 161 + it('shows maturity override for each community', async () => { 162 + render(<SettingsPage />) 163 + await waitFor(() => { 164 + expect(screen.getByText('Gaming Forum')).toBeInTheDocument() 165 + }) 166 + // Gaming Forum has maturity override set to 'mature' 167 + const gamingSection = screen.getByText('Gaming Forum').closest('details')! 168 + const maturitySelect = within(gamingSection).getByLabelText(/maturity override/i) 169 + expect(maturitySelect).toBeInTheDocument() 170 + }) 171 + 172 + it('shows community-specific muted words', async () => { 173 + render(<SettingsPage />) 174 + await waitFor(() => { 175 + expect(screen.getByText('Gaming Forum')).toBeInTheDocument() 176 + }) 177 + // Expand Gaming Forum section to see community-specific fields 178 + const gamingSummary = screen.getByText('Gaming Forum') 179 + await userEvent.click(gamingSummary) 180 + const gamingSection = gamingSummary.closest('details')! 181 + const mutedWordsInput = within(gamingSection).getByLabelText( 182 + /community muted words/i 183 + ) as HTMLTextAreaElement 184 + expect(mutedWordsInput.value).toBe('spoiler') 185 + }) 186 + 187 + it('shows community-specific blocked users', async () => { 188 + render(<SettingsPage />) 189 + await waitFor(() => { 190 + expect(screen.getByText('Gaming Forum')).toBeInTheDocument() 191 + }) 192 + const gamingSummary = screen.getByText('Gaming Forum') 193 + await userEvent.click(gamingSummary) 194 + const gamingSection = gamingSummary.closest('details')! 195 + const blockedInput = within(gamingSection).getByLabelText( 196 + /community blocked users/i 197 + ) as HTMLTextAreaElement 198 + expect(blockedInput.value).toBe('did:plc:user-dave-004') 199 + }) 200 + 201 + it('shows empty state when user has no community overrides', async () => { 202 + // This test verifies the section renders even with empty data 203 + render(<SettingsPage />) 204 + await waitFor(() => { 205 + expect(screen.getByText(/per-community overrides/i)).toBeInTheDocument() 206 + }) 207 + }) 208 + 209 + it('passes axe accessibility check with community overrides', async () => { 210 + const { container } = render(<SettingsPage />) 211 + await waitFor(() => { 212 + expect(screen.getByText('Gaming Forum')).toBeInTheDocument() 213 + }) 214 + const results = await axe(container) 215 + expect(results).toHaveNoViolations() 216 + }) 140 217 }) 141 218 })
+179 -5
src/app/settings/page.tsx
··· 1 1 /** 2 2 * User settings page. 3 3 * URL: /settings 4 - * Content safety, cross-posting defaults, notification preferences. 4 + * Content safety, cross-posting defaults, notification preferences, 5 + * per-community overrides. 5 6 * Client component (form state). 6 7 * @see specs/prd-web.md Section M8 (Settings page) 7 8 */ ··· 13 14 import { Breadcrumbs } from '@/components/breadcrumbs' 14 15 import { AgeGateDialog } from '@/components/age-gate-dialog' 15 16 import { cn } from '@/lib/utils' 16 - import { getPreferences, updatePreferences } from '@/lib/api/client' 17 + import { 18 + getPreferences, 19 + updatePreferences, 20 + getCommunityPreferences, 21 + updateCommunityPreference, 22 + } from '@/lib/api/client' 23 + import type { CommunityPreferenceOverride } from '@/lib/api/types' 17 24 import { useAuth } from '@/hooks/use-auth' 18 25 19 26 type MaturityLevel = 'sfw' | 'sfw-mature' ··· 28 35 notifyReactions: boolean 29 36 } 30 37 38 + interface CommunityOverrideValues { 39 + communityDid: string 40 + communityName: string 41 + maturityLevel: 'inherit' | 'sfw' | 'mature' 42 + mutedWords: string 43 + blockedDids: string 44 + } 45 + 31 46 export default function SettingsPage() { 32 47 const { getAccessToken } = useAuth() 33 48 const [values, setValues] = useState<SettingsValues>({ ··· 39 54 notifyMentions: true, 40 55 notifyReactions: false, 41 56 }) 57 + const [communityOverrides, setCommunityOverrides] = useState<CommunityOverrideValues[]>([]) 42 58 const [saving, setSaving] = useState(false) 43 59 const [loading, setLoading] = useState(true) 44 60 const [error, setError] = useState<string | null>(null) ··· 54 70 return 55 71 } 56 72 57 - getPreferences(token) 58 - .then((prefs) => { 73 + Promise.all([getPreferences(token), getCommunityPreferences(token)]) 74 + .then(([prefs, communityPrefs]) => { 59 75 setValues({ 60 76 maturityLevel: prefs.maturityLevel === 'mature' ? 'sfw-mature' : 'sfw', 61 77 mutedWords: prefs.mutedWords.join(', '), ··· 66 82 notifyReactions: false, 67 83 }) 68 84 setDeclaredAge(prefs.declaredAge) 85 + setCommunityOverrides( 86 + communityPrefs.communities.map( 87 + (c: CommunityPreferenceOverride): CommunityOverrideValues => ({ 88 + communityDid: c.communityDid, 89 + communityName: c.communityName, 90 + maturityLevel: c.maturityLevel, 91 + mutedWords: c.mutedWords.join(', '), 92 + blockedDids: c.blockedDids.join(', '), 93 + }) 94 + ) 95 + ) 69 96 }) 70 97 .catch(() => setError('Failed to load preferences')) 71 98 .finally(() => setLoading(false)) 72 99 }, [getAccessToken]) 73 100 101 + const handleCommunityChange = useCallback( 102 + (communityDid: string, field: keyof CommunityOverrideValues, value: string) => { 103 + setCommunityOverrides((prev) => 104 + prev.map((c) => (c.communityDid === communityDid ? { ...c, [field]: value } : c)) 105 + ) 106 + }, 107 + [] 108 + ) 109 + 74 110 const handleSave = useCallback( 75 111 async (e: React.FormEvent) => { 76 112 e.preventDefault() ··· 98 134 .map((w) => w.trim()) 99 135 .filter(Boolean) 100 136 137 + // Save global preferences 101 138 await updatePreferences( 102 139 { 103 140 maturityLevel: values.maturityLevel === 'sfw-mature' ? 'mature' : 'sfw', ··· 107 144 }, 108 145 token 109 146 ) 147 + 148 + // Save per-community overrides 149 + await Promise.all( 150 + communityOverrides.map((c) => 151 + updateCommunityPreference( 152 + c.communityDid, 153 + { 154 + maturityLevel: c.maturityLevel, 155 + mutedWords: c.mutedWords 156 + .split(',') 157 + .map((w) => w.trim()) 158 + .filter(Boolean), 159 + blockedDids: c.blockedDids 160 + .split(',') 161 + .map((d) => d.trim()) 162 + .filter(Boolean), 163 + }, 164 + token 165 + ) 166 + ) 167 + ) 168 + 110 169 setSuccess(true) 111 170 } catch { 112 171 setError('Failed to save preferences') ··· 114 173 setSaving(false) 115 174 } 116 175 }, 117 - [values, declaredAge, getAccessToken] 176 + [values, communityOverrides, declaredAge, getAccessToken] 118 177 ) 119 178 120 179 return ( ··· 199 258 Posts containing these words will be collapsed. Comma-separated. 200 259 </p> 201 260 </div> 261 + </fieldset> 262 + 263 + {/* Per-Community Overrides */} 264 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 265 + <legend className="px-2 text-sm font-semibold text-foreground"> 266 + Per-Community Overrides 267 + </legend> 268 + 269 + {communityOverrides.length === 0 ? ( 270 + <p className="text-sm text-muted-foreground"> 271 + No community memberships found. Join a community to configure per-community 272 + settings. 273 + </p> 274 + ) : ( 275 + <div className="space-y-3"> 276 + {communityOverrides.map((community) => ( 277 + <details 278 + key={community.communityDid} 279 + className="rounded-md border border-border" 280 + > 281 + <summary className="cursor-pointer px-3 py-2 text-sm font-medium text-foreground hover:bg-muted/50"> 282 + {community.communityName} 283 + </summary> 284 + 285 + <div className="space-y-3 border-t border-border px-3 py-3"> 286 + <div className="space-y-1"> 287 + <label 288 + htmlFor={`maturity-${community.communityDid}`} 289 + className="block text-xs font-medium text-foreground" 290 + > 291 + Maturity override 292 + </label> 293 + <select 294 + id={`maturity-${community.communityDid}`} 295 + value={community.maturityLevel} 296 + onChange={(e) => 297 + handleCommunityChange( 298 + community.communityDid, 299 + 'maturityLevel', 300 + e.target.value 301 + ) 302 + } 303 + className={cn( 304 + 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground', 305 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 306 + )} 307 + > 308 + <option value="inherit">Inherit global setting</option> 309 + <option value="sfw">SFW only</option> 310 + <option value="mature">SFW + Mature</option> 311 + </select> 312 + </div> 313 + 314 + <div className="space-y-1"> 315 + <label 316 + htmlFor={`muted-words-${community.communityDid}`} 317 + className="block text-xs font-medium text-foreground" 318 + > 319 + Community muted words 320 + </label> 321 + <textarea 322 + id={`muted-words-${community.communityDid}`} 323 + value={community.mutedWords} 324 + onChange={(e) => 325 + handleCommunityChange( 326 + community.communityDid, 327 + 'mutedWords', 328 + e.target.value 329 + ) 330 + } 331 + placeholder="Additional muted words for this community" 332 + rows={2} 333 + className={cn( 334 + 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground', 335 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 336 + )} 337 + /> 338 + <p className="text-xs text-muted-foreground"> 339 + These are in addition to your global muted words. Comma-separated. 340 + </p> 341 + </div> 342 + 343 + <div className="space-y-1"> 344 + <label 345 + htmlFor={`blocked-${community.communityDid}`} 346 + className="block text-xs font-medium text-foreground" 347 + > 348 + Community blocked users 349 + </label> 350 + <textarea 351 + id={`blocked-${community.communityDid}`} 352 + value={community.blockedDids} 353 + onChange={(e) => 354 + handleCommunityChange( 355 + community.communityDid, 356 + 'blockedDids', 357 + e.target.value 358 + ) 359 + } 360 + placeholder="DIDs of users to block in this community" 361 + rows={2} 362 + className={cn( 363 + 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground', 364 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 365 + )} 366 + /> 367 + <p className="text-xs text-muted-foreground"> 368 + Block specific users only in this community. Comma-separated DIDs. 369 + </p> 370 + </div> 371 + </div> 372 + </details> 373 + ))} 374 + </div> 375 + )} 202 376 </fieldset> 203 377 204 378 {/* Cross-Posting */}
+158
src/components/muted-content-wrapper.test.tsx
··· 1 + /** 2 + * Tests for MutedContentWrapper component. 3 + * Collapses content matching muted words with accessible expand/collapse. 4 + * @see specs/prd-web.md Section M8 5 + */ 6 + 7 + import { describe, it, expect } from 'vitest' 8 + import { render, screen } from '@testing-library/react' 9 + import userEvent from '@testing-library/user-event' 10 + import { axe } from 'vitest-axe' 11 + import { MutedContentWrapper } from './muted-content-wrapper' 12 + 13 + describe('MutedContentWrapper', () => { 14 + it('renders children when no muted words match', () => { 15 + render( 16 + <MutedContentWrapper content="Hello world" mutedWords={['spam']}> 17 + <p>Hello world</p> 18 + </MutedContentWrapper> 19 + ) 20 + expect(screen.getByText('Hello world')).toBeInTheDocument() 21 + expect(screen.queryByText(/content hidden/i)).not.toBeInTheDocument() 22 + }) 23 + 24 + it('collapses content when a muted word matches', () => { 25 + render( 26 + <MutedContentWrapper content="This is spam content" mutedWords={['spam']}> 27 + <p>This is spam content</p> 28 + </MutedContentWrapper> 29 + ) 30 + expect(screen.queryByText('This is spam content')).not.toBeInTheDocument() 31 + expect(screen.getByText(/content hidden \(muted word: spam\)/i)).toBeInTheDocument() 32 + }) 33 + 34 + it('matches muted words case-insensitively', () => { 35 + render( 36 + <MutedContentWrapper content="This is SPAM content" mutedWords={['spam']}> 37 + <p>This is SPAM content</p> 38 + </MutedContentWrapper> 39 + ) 40 + expect(screen.getByText(/content hidden \(muted word: spam\)/i)).toBeInTheDocument() 41 + }) 42 + 43 + it('shows the first matching muted word in the label', () => { 44 + render( 45 + <MutedContentWrapper content="offensive spam text" mutedWords={['spam', 'offensive']}> 46 + <p>offensive spam text</p> 47 + </MutedContentWrapper> 48 + ) 49 + // Should show whichever word matched first in the muted words list 50 + expect(screen.getByText(/content hidden \(muted word: spam\)/i)).toBeInTheDocument() 51 + }) 52 + 53 + it('expands content on click', async () => { 54 + const user = userEvent.setup() 55 + render( 56 + <MutedContentWrapper content="spam post here" mutedWords={['spam']}> 57 + <p>spam post here</p> 58 + </MutedContentWrapper> 59 + ) 60 + 61 + // Content hidden initially 62 + expect(screen.queryByText('spam post here')).not.toBeInTheDocument() 63 + 64 + // Click to expand 65 + await user.click(screen.getByRole('button')) 66 + expect(screen.getByText('spam post here')).toBeInTheDocument() 67 + }) 68 + 69 + it('collapses content again on second click', async () => { 70 + const user = userEvent.setup() 71 + render( 72 + <MutedContentWrapper content="spam post here" mutedWords={['spam']}> 73 + <p>spam post here</p> 74 + </MutedContentWrapper> 75 + ) 76 + 77 + // Expand 78 + await user.click(screen.getByRole('button')) 79 + expect(screen.getByText('spam post here')).toBeInTheDocument() 80 + 81 + // Collapse again 82 + await user.click(screen.getByRole('button')) 83 + expect(screen.queryByText('spam post here')).not.toBeInTheDocument() 84 + }) 85 + 86 + it('sets aria-expanded correctly', async () => { 87 + const user = userEvent.setup() 88 + render( 89 + <MutedContentWrapper content="spam content" mutedWords={['spam']}> 90 + <p>spam content</p> 91 + </MutedContentWrapper> 92 + ) 93 + 94 + const button = screen.getByRole('button') 95 + expect(button).toHaveAttribute('aria-expanded', 'false') 96 + 97 + await user.click(button) 98 + expect(button).toHaveAttribute('aria-expanded', 'true') 99 + }) 100 + 101 + it('renders children directly when mutedWords is empty', () => { 102 + render( 103 + <MutedContentWrapper content="Any content" mutedWords={[]}> 104 + <p>Any content</p> 105 + </MutedContentWrapper> 106 + ) 107 + expect(screen.getByText('Any content')).toBeInTheDocument() 108 + }) 109 + 110 + it('matches whole words only (not substrings)', () => { 111 + render( 112 + <MutedContentWrapper content="This is a classic example" mutedWords={['ass']}> 113 + <p>This is a classic example</p> 114 + </MutedContentWrapper> 115 + ) 116 + // "classic" contains "ass" but should not match as a whole word 117 + expect(screen.getByText('This is a classic example')).toBeInTheDocument() 118 + expect(screen.queryByText(/content hidden/i)).not.toBeInTheDocument() 119 + }) 120 + 121 + it('announces state change to screen readers via aria-live', async () => { 122 + const user = userEvent.setup() 123 + render( 124 + <MutedContentWrapper content="spam content" mutedWords={['spam']}> 125 + <p>spam content</p> 126 + </MutedContentWrapper> 127 + ) 128 + 129 + // Should have a live region for state announcements 130 + const liveRegion = screen.getByRole('status') 131 + expect(liveRegion).toBeInTheDocument() 132 + 133 + await user.click(screen.getByRole('button')) 134 + expect(liveRegion).toHaveTextContent(/content revealed/i) 135 + }) 136 + 137 + it('passes axe accessibility check when collapsed', async () => { 138 + const { container } = render( 139 + <MutedContentWrapper content="spam content" mutedWords={['spam']}> 140 + <p>spam content</p> 141 + </MutedContentWrapper> 142 + ) 143 + const results = await axe(container) 144 + expect(results).toHaveNoViolations() 145 + }) 146 + 147 + it('passes axe accessibility check when expanded', async () => { 148 + const user = userEvent.setup() 149 + const { container } = render( 150 + <MutedContentWrapper content="spam content" mutedWords={['spam']}> 151 + <p>spam content</p> 152 + </MutedContentWrapper> 153 + ) 154 + await user.click(screen.getByRole('button')) 155 + const results = await axe(container) 156 + expect(results).toHaveNoViolations() 157 + }) 158 + })
+80
src/components/muted-content-wrapper.tsx
··· 1 + /** 2 + * MutedContentWrapper - Collapses content matching user's muted words. 3 + * Shows "Content hidden (muted word: {word})" label, expandable on click. 4 + * Accessible: aria-expanded, screen reader announcements via aria-live. 5 + * @see specs/prd-web.md Section M8 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, type ReactNode } from 'react' 11 + import { EyeSlash, Eye } from '@phosphor-icons/react' 12 + import { cn } from '@/lib/utils' 13 + 14 + interface MutedContentWrapperProps { 15 + content: string 16 + mutedWords: string[] 17 + children: ReactNode 18 + className?: string 19 + } 20 + 21 + /** 22 + * Finds the first muted word that matches as a whole word in the content. 23 + * Case-insensitive, whole-word matching only (won't match "ass" in "classic"). 24 + */ 25 + function findMatchingMutedWord(content: string, mutedWords: string[]): string | null { 26 + for (const word of mutedWords) { 27 + if (!word.trim()) continue 28 + const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 29 + const regex = new RegExp(`\\b${escaped}\\b`, 'i') 30 + if (regex.test(content)) { 31 + return word 32 + } 33 + } 34 + return null 35 + } 36 + 37 + export function MutedContentWrapper({ 38 + content, 39 + mutedWords, 40 + children, 41 + className, 42 + }: MutedContentWrapperProps) { 43 + const [expanded, setExpanded] = useState(false) 44 + 45 + const matchedWord = findMatchingMutedWord(content, mutedWords) 46 + 47 + if (!matchedWord) { 48 + return <>{children}</> 49 + } 50 + 51 + return ( 52 + <div className={cn('relative', className)}> 53 + <button 54 + type="button" 55 + onClick={() => setExpanded((prev) => !prev)} 56 + aria-expanded={expanded} 57 + className={cn( 58 + 'flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors', 59 + 'hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 60 + !expanded && 'bg-muted/30 border border-border' 61 + )} 62 + > 63 + {expanded ? ( 64 + <Eye size={16} weight="regular" aria-hidden="true" /> 65 + ) : ( 66 + <EyeSlash size={16} weight="regular" aria-hidden="true" /> 67 + )} 68 + {expanded 69 + ? `Content hidden (muted word: ${matchedWord}) — click to hide` 70 + : `Content hidden (muted word: ${matchedWord})`} 71 + </button> 72 + 73 + <span role="status" aria-live="polite" className="sr-only"> 74 + {expanded ? 'Content revealed' : 'Content hidden'} 75 + </span> 76 + 77 + {expanded && <div className="mt-2">{children}</div>} 78 + </div> 79 + ) 80 + }
+32
src/lib/api/client.ts
··· 11 11 CategoriesResponse, 12 12 CategoryTreeNode, 13 13 CategoryWithTopicCount, 14 + CommunityPreferencesResponse, 14 15 CommunitySettings, 15 16 CommunityStats, 17 + CommunityPreferenceOverride, 16 18 CreateTopicInput, 17 19 PublicSettings, 18 20 Topic, 19 21 TopicsResponse, 22 + UpdateCommunityPreferenceInput, 20 23 UpdatePreferencesInput, 21 24 UpdateTopicInput, 22 25 UserPreferences, ··· 588 591 headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 589 592 body: { declaredAge }, 590 593 }) 594 + } 595 + 596 + // --- Per-Community Preference endpoints --- 597 + 598 + export function getCommunityPreferences( 599 + accessToken: string, 600 + options?: FetchOptions 601 + ): Promise<CommunityPreferencesResponse> { 602 + return apiFetch<CommunityPreferencesResponse>('/api/users/me/preferences/communities', { 603 + ...options, 604 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 605 + }) 606 + } 607 + 608 + export function updateCommunityPreference( 609 + communityDid: string, 610 + input: UpdateCommunityPreferenceInput, 611 + accessToken: string, 612 + options?: FetchOptions 613 + ): Promise<CommunityPreferenceOverride> { 614 + return apiFetch<CommunityPreferenceOverride>( 615 + `/api/users/me/preferences/communities/${encodeURIComponent(communityDid)}`, 616 + { 617 + ...options, 618 + method: 'PUT', 619 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 620 + body: input, 621 + } 622 + ) 591 623 } 592 624 593 625 // --- Block/Mute endpoints ---
+20
src/lib/api/types.ts
··· 419 419 crossPostFrontpage?: boolean 420 420 } 421 421 422 + // --- Per-Community Preference Overrides --- 423 + 424 + export interface CommunityPreferenceOverride { 425 + communityDid: string 426 + communityName: string 427 + maturityLevel: 'inherit' | 'sfw' | 'mature' 428 + mutedWords: string[] 429 + blockedDids: string[] 430 + } 431 + 432 + export interface CommunityPreferencesResponse { 433 + communities: CommunityPreferenceOverride[] 434 + } 435 + 436 + export interface UpdateCommunityPreferenceInput { 437 + maturityLevel?: 'inherit' | 'sfw' | 'mature' 438 + mutedWords?: string[] 439 + blockedDids?: string[] 440 + } 441 + 422 442 export interface AgeDeclarationResponse { 423 443 success: boolean 424 444 declaredAge: number
+20
src/mocks/data.ts
··· 8 8 AuthUser, 9 9 CategoryTreeNode, 10 10 CategoryWithTopicCount, 11 + CommunityPreferenceOverride, 11 12 Topic, 12 13 Reply, 13 14 Notification, ··· 853 854 }, 854 855 settings: { webhookUrl: '' }, 855 856 installedAt: YESTERDAY, 857 + }, 858 + ] 859 + 860 + // --- Per-Community Preference Overrides --- 861 + 862 + export const mockCommunityPreferences: CommunityPreferenceOverride[] = [ 863 + { 864 + communityDid: COMMUNITY_DID, 865 + communityName: 'Barazo Test Community', 866 + maturityLevel: 'inherit', 867 + mutedWords: [], 868 + blockedDids: [], 869 + }, 870 + { 871 + communityDid: 'did:plc:other-community-456', 872 + communityName: 'Gaming Forum', 873 + maturityLevel: 'mature', 874 + mutedWords: ['spoiler'], 875 + blockedDids: ['did:plc:user-dave-004'], 856 876 }, 857 877 ] 858 878
+28
src/mocks/handlers.ts
··· 24 24 mockAdminUsers, 25 25 mockPlugins, 26 26 mockUserPreferences, 27 + mockCommunityPreferences, 27 28 mockOnboardingFields, 28 29 } from './data' 29 30 ··· 511 512 const body = (await request.json()) as Record<string, unknown> 512 513 return HttpResponse.json({ ...mockUserPreferences, ...body }) 513 514 }), 515 + 516 + // GET /api/users/me/preferences/communities 517 + http.get(`${API_URL}/api/users/me/preferences/communities`, ({ request }) => { 518 + const auth = request.headers.get('Authorization') 519 + if (!auth?.startsWith('Bearer ')) { 520 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 521 + } 522 + return HttpResponse.json({ communities: mockCommunityPreferences }) 523 + }), 524 + 525 + // PUT /api/users/me/preferences/communities/:communityDid 526 + http.put( 527 + `${API_URL}/api/users/me/preferences/communities/:communityDid`, 528 + async ({ request, params }) => { 529 + const auth = request.headers.get('Authorization') 530 + if (!auth?.startsWith('Bearer ')) { 531 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 532 + } 533 + const communityDid = decodeURIComponent(params['communityDid'] as string) 534 + const existing = mockCommunityPreferences.find((c) => c.communityDid === communityDid) 535 + if (!existing) { 536 + return HttpResponse.json({ error: 'Not found' }, { status: 404 }) 537 + } 538 + const body = (await request.json()) as Record<string, unknown> 539 + return HttpResponse.json({ ...existing, ...body }) 540 + } 541 + ), 514 542 515 543 // POST /api/users/me/age-declaration 516 544 http.post(`${API_URL}/api/users/me/age-declaration`, async ({ request }) => {