diff --git a/src/app/settings/page.test.tsx b/src/app/settings/page.test.tsx index f7c4933..098f263 100644 --- a/src/app/settings/page.test.tsx +++ b/src/app/settings/page.test.tsx @@ -3,7 +3,8 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' import SettingsPage from './page' @@ -82,7 +83,7 @@ describe('SettingsPage', () => { it('renders muted words input', async () => { render() await waitFor(() => { - expect(screen.getByLabelText(/muted words/i)).toBeInTheDocument() + expect(screen.getByLabelText('Muted words')).toBeInTheDocument() }) }) @@ -125,8 +126,8 @@ describe('SettingsPage', () => { expect(screen.getByLabelText(/maturity level/i)).toBeInTheDocument() }) - // Muted words should be populated from mock data - const mutedWordsInput = screen.getByLabelText(/muted words/i) as HTMLTextAreaElement + // Muted words should be populated from mock data (global, not community-specific) + const mutedWordsInput = screen.getByLabelText('Muted words') as HTMLTextAreaElement expect(mutedWordsInput.value).toBe('spam, offensive') }) @@ -138,4 +139,80 @@ describe('SettingsPage', () => { const results = await axe(container) expect(results).toHaveNoViolations() }) + + // --- Per-Community Overrides --- + + describe('Per-Community Overrides', () => { + it('renders per-community overrides section', async () => { + render() + await waitFor(() => { + expect(screen.getByText(/per-community overrides/i)).toBeInTheDocument() + }) + }) + + it('loads and displays community list from API', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Barazo Test Community')).toBeInTheDocument() + expect(screen.getByText('Gaming Forum')).toBeInTheDocument() + }) + }) + + it('shows maturity override for each community', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Gaming Forum')).toBeInTheDocument() + }) + // Gaming Forum has maturity override set to 'mature' + const gamingSection = screen.getByText('Gaming Forum').closest('details')! + const maturitySelect = within(gamingSection).getByLabelText(/maturity override/i) + expect(maturitySelect).toBeInTheDocument() + }) + + it('shows community-specific muted words', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Gaming Forum')).toBeInTheDocument() + }) + // Expand Gaming Forum section to see community-specific fields + const gamingSummary = screen.getByText('Gaming Forum') + await userEvent.click(gamingSummary) + const gamingSection = gamingSummary.closest('details')! + const mutedWordsInput = within(gamingSection).getByLabelText( + /community muted words/i + ) as HTMLTextAreaElement + expect(mutedWordsInput.value).toBe('spoiler') + }) + + it('shows community-specific blocked users', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Gaming Forum')).toBeInTheDocument() + }) + const gamingSummary = screen.getByText('Gaming Forum') + await userEvent.click(gamingSummary) + const gamingSection = gamingSummary.closest('details')! + const blockedInput = within(gamingSection).getByLabelText( + /community blocked users/i + ) as HTMLTextAreaElement + expect(blockedInput.value).toBe('did:plc:user-dave-004') + }) + + it('shows empty state when user has no community overrides', async () => { + // This test verifies the section renders even with empty data + render() + await waitFor(() => { + expect(screen.getByText(/per-community overrides/i)).toBeInTheDocument() + }) + }) + + it('passes axe accessibility check with community overrides', async () => { + const { container } = render() + await waitFor(() => { + expect(screen.getByText('Gaming Forum')).toBeInTheDocument() + }) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + }) }) diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 9cc4d61..146e603 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,7 +1,8 @@ /** * User settings page. * URL: /settings - * Content safety, cross-posting defaults, notification preferences. + * Content safety, cross-posting defaults, notification preferences, + * per-community overrides. * Client component (form state). * @see specs/prd-web.md Section M8 (Settings page) */ @@ -13,7 +14,13 @@ import { ForumLayout } from '@/components/layout/forum-layout' import { Breadcrumbs } from '@/components/breadcrumbs' import { AgeGateDialog } from '@/components/age-gate-dialog' import { cn } from '@/lib/utils' -import { getPreferences, updatePreferences } from '@/lib/api/client' +import { + getPreferences, + updatePreferences, + getCommunityPreferences, + updateCommunityPreference, +} from '@/lib/api/client' +import type { CommunityPreferenceOverride } from '@/lib/api/types' import { useAuth } from '@/hooks/use-auth' type MaturityLevel = 'sfw' | 'sfw-mature' @@ -28,6 +35,14 @@ interface SettingsValues { notifyReactions: boolean } +interface CommunityOverrideValues { + communityDid: string + communityName: string + maturityLevel: 'inherit' | 'sfw' | 'mature' + mutedWords: string + blockedDids: string +} + export default function SettingsPage() { const { getAccessToken } = useAuth() const [values, setValues] = useState({ @@ -39,6 +54,7 @@ export default function SettingsPage() { notifyMentions: true, notifyReactions: false, }) + const [communityOverrides, setCommunityOverrides] = useState([]) const [saving, setSaving] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -54,8 +70,8 @@ export default function SettingsPage() { return } - getPreferences(token) - .then((prefs) => { + Promise.all([getPreferences(token), getCommunityPreferences(token)]) + .then(([prefs, communityPrefs]) => { setValues({ maturityLevel: prefs.maturityLevel === 'mature' ? 'sfw-mature' : 'sfw', mutedWords: prefs.mutedWords.join(', '), @@ -66,11 +82,31 @@ export default function SettingsPage() { notifyReactions: false, }) setDeclaredAge(prefs.declaredAge) + setCommunityOverrides( + communityPrefs.communities.map( + (c: CommunityPreferenceOverride): CommunityOverrideValues => ({ + communityDid: c.communityDid, + communityName: c.communityName, + maturityLevel: c.maturityLevel, + mutedWords: c.mutedWords.join(', '), + blockedDids: c.blockedDids.join(', '), + }) + ) + ) }) .catch(() => setError('Failed to load preferences')) .finally(() => setLoading(false)) }, [getAccessToken]) + const handleCommunityChange = useCallback( + (communityDid: string, field: keyof CommunityOverrideValues, value: string) => { + setCommunityOverrides((prev) => + prev.map((c) => (c.communityDid === communityDid ? { ...c, [field]: value } : c)) + ) + }, + [] + ) + const handleSave = useCallback( async (e: React.FormEvent) => { e.preventDefault() @@ -98,6 +134,7 @@ export default function SettingsPage() { .map((w) => w.trim()) .filter(Boolean) + // Save global preferences await updatePreferences( { maturityLevel: values.maturityLevel === 'sfw-mature' ? 'mature' : 'sfw', @@ -107,6 +144,28 @@ export default function SettingsPage() { }, token ) + + // Save per-community overrides + await Promise.all( + communityOverrides.map((c) => + updateCommunityPreference( + c.communityDid, + { + maturityLevel: c.maturityLevel, + mutedWords: c.mutedWords + .split(',') + .map((w) => w.trim()) + .filter(Boolean), + blockedDids: c.blockedDids + .split(',') + .map((d) => d.trim()) + .filter(Boolean), + }, + token + ) + ) + ) + setSuccess(true) } catch { setError('Failed to save preferences') @@ -114,7 +173,7 @@ export default function SettingsPage() { setSaving(false) } }, - [values, declaredAge, getAccessToken] + [values, communityOverrides, declaredAge, getAccessToken] ) return ( @@ -201,6 +260,121 @@ export default function SettingsPage() { + {/* Per-Community Overrides */} +
+ + Per-Community Overrides + + + {communityOverrides.length === 0 ? ( +

+ No community memberships found. Join a community to configure per-community + settings. +

+ ) : ( +
+ {communityOverrides.map((community) => ( +
+ + {community.communityName} + + +
+
+ + +
+ +
+ +