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} + + + + + + Maturity override + + + handleCommunityChange( + community.communityDid, + 'maturityLevel', + e.target.value + ) + } + className={cn( + 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' + )} + > + Inherit global setting + SFW only + SFW + Mature + + + + + + Community muted words + + + handleCommunityChange( + community.communityDid, + 'mutedWords', + e.target.value + ) + } + placeholder="Additional muted words for this community" + rows={2} + className={cn( + 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' + )} + /> + + These are in addition to your global muted words. Comma-separated. + + + + + + Community blocked users + + + handleCommunityChange( + community.communityDid, + 'blockedDids', + e.target.value + ) + } + placeholder="DIDs of users to block in this community" + rows={2} + className={cn( + 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' + )} + /> + + Block specific users only in this community. Comma-separated DIDs. + + + + + ))} + + )} + + {/* Cross-Posting */} Cross-Posting diff --git a/src/components/muted-content-wrapper.test.tsx b/src/components/muted-content-wrapper.test.tsx new file mode 100644 index 0000000..cd54220 --- /dev/null +++ b/src/components/muted-content-wrapper.test.tsx @@ -0,0 +1,158 @@ +/** + * Tests for MutedContentWrapper component. + * Collapses content matching muted words with accessible expand/collapse. + * @see specs/prd-web.md Section M8 + */ + +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import { MutedContentWrapper } from './muted-content-wrapper' + +describe('MutedContentWrapper', () => { + it('renders children when no muted words match', () => { + render( + + Hello world + + ) + expect(screen.getByText('Hello world')).toBeInTheDocument() + expect(screen.queryByText(/content hidden/i)).not.toBeInTheDocument() + }) + + it('collapses content when a muted word matches', () => { + render( + + This is spam content + + ) + expect(screen.queryByText('This is spam content')).not.toBeInTheDocument() + expect(screen.getByText(/content hidden \(muted word: spam\)/i)).toBeInTheDocument() + }) + + it('matches muted words case-insensitively', () => { + render( + + This is SPAM content + + ) + expect(screen.getByText(/content hidden \(muted word: spam\)/i)).toBeInTheDocument() + }) + + it('shows the first matching muted word in the label', () => { + render( + + offensive spam text + + ) + // Should show whichever word matched first in the muted words list + expect(screen.getByText(/content hidden \(muted word: spam\)/i)).toBeInTheDocument() + }) + + it('expands content on click', async () => { + const user = userEvent.setup() + render( + + spam post here + + ) + + // Content hidden initially + expect(screen.queryByText('spam post here')).not.toBeInTheDocument() + + // Click to expand + await user.click(screen.getByRole('button')) + expect(screen.getByText('spam post here')).toBeInTheDocument() + }) + + it('collapses content again on second click', async () => { + const user = userEvent.setup() + render( + + spam post here + + ) + + // Expand + await user.click(screen.getByRole('button')) + expect(screen.getByText('spam post here')).toBeInTheDocument() + + // Collapse again + await user.click(screen.getByRole('button')) + expect(screen.queryByText('spam post here')).not.toBeInTheDocument() + }) + + it('sets aria-expanded correctly', async () => { + const user = userEvent.setup() + render( + + spam content + + ) + + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-expanded', 'false') + + await user.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + }) + + it('renders children directly when mutedWords is empty', () => { + render( + + Any content + + ) + expect(screen.getByText('Any content')).toBeInTheDocument() + }) + + it('matches whole words only (not substrings)', () => { + render( + + This is a classic example + + ) + // "classic" contains "ass" but should not match as a whole word + expect(screen.getByText('This is a classic example')).toBeInTheDocument() + expect(screen.queryByText(/content hidden/i)).not.toBeInTheDocument() + }) + + it('announces state change to screen readers via aria-live', async () => { + const user = userEvent.setup() + render( + + spam content + + ) + + // Should have a live region for state announcements + const liveRegion = screen.getByRole('status') + expect(liveRegion).toBeInTheDocument() + + await user.click(screen.getByRole('button')) + expect(liveRegion).toHaveTextContent(/content revealed/i) + }) + + it('passes axe accessibility check when collapsed', async () => { + const { container } = render( + + spam content + + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + + it('passes axe accessibility check when expanded', async () => { + const user = userEvent.setup() + const { container } = render( + + spam content + + ) + await user.click(screen.getByRole('button')) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/muted-content-wrapper.tsx b/src/components/muted-content-wrapper.tsx new file mode 100644 index 0000000..bf74822 --- /dev/null +++ b/src/components/muted-content-wrapper.tsx @@ -0,0 +1,80 @@ +/** + * MutedContentWrapper - Collapses content matching user's muted words. + * Shows "Content hidden (muted word: {word})" label, expandable on click. + * Accessible: aria-expanded, screen reader announcements via aria-live. + * @see specs/prd-web.md Section M8 + */ + +'use client' + +import { useState, type ReactNode } from 'react' +import { EyeSlash, Eye } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface MutedContentWrapperProps { + content: string + mutedWords: string[] + children: ReactNode + className?: string +} + +/** + * Finds the first muted word that matches as a whole word in the content. + * Case-insensitive, whole-word matching only (won't match "ass" in "classic"). + */ +function findMatchingMutedWord(content: string, mutedWords: string[]): string | null { + for (const word of mutedWords) { + if (!word.trim()) continue + const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const regex = new RegExp(`\\b${escaped}\\b`, 'i') + if (regex.test(content)) { + return word + } + } + return null +} + +export function MutedContentWrapper({ + content, + mutedWords, + children, + className, +}: MutedContentWrapperProps) { + const [expanded, setExpanded] = useState(false) + + const matchedWord = findMatchingMutedWord(content, mutedWords) + + if (!matchedWord) { + return <>{children}> + } + + return ( + + setExpanded((prev) => !prev)} + aria-expanded={expanded} + className={cn( + 'flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors', + 'hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + !expanded && 'bg-muted/30 border border-border' + )} + > + {expanded ? ( + + ) : ( + + )} + {expanded + ? `Content hidden (muted word: ${matchedWord}) — click to hide` + : `Content hidden (muted word: ${matchedWord})`} + + + + {expanded ? 'Content revealed' : 'Content hidden'} + + + {expanded && {children}} + + ) +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 49cf869..43b73e8 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -11,12 +11,15 @@ import type { CategoriesResponse, CategoryTreeNode, CategoryWithTopicCount, + CommunityPreferencesResponse, CommunitySettings, CommunityStats, + CommunityPreferenceOverride, CreateTopicInput, PublicSettings, Topic, TopicsResponse, + UpdateCommunityPreferenceInput, UpdatePreferencesInput, UpdateTopicInput, UserPreferences, @@ -590,6 +593,35 @@ export function declareAge( }) } +// --- Per-Community Preference endpoints --- + +export function getCommunityPreferences( + accessToken: string, + options?: FetchOptions +): Promise { + return apiFetch('/api/users/me/preferences/communities', { + ...options, + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, + }) +} + +export function updateCommunityPreference( + communityDid: string, + input: UpdateCommunityPreferenceInput, + accessToken: string, + options?: FetchOptions +): Promise { + return apiFetch( + `/api/users/me/preferences/communities/${encodeURIComponent(communityDid)}`, + { + ...options, + method: 'PUT', + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, + body: input, + } + ) +} + // --- Block/Mute endpoints --- export function blockUser( diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 1549cb0..547f4b4 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -419,6 +419,26 @@ export interface UpdatePreferencesInput { crossPostFrontpage?: boolean } +// --- Per-Community Preference Overrides --- + +export interface CommunityPreferenceOverride { + communityDid: string + communityName: string + maturityLevel: 'inherit' | 'sfw' | 'mature' + mutedWords: string[] + blockedDids: string[] +} + +export interface CommunityPreferencesResponse { + communities: CommunityPreferenceOverride[] +} + +export interface UpdateCommunityPreferenceInput { + maturityLevel?: 'inherit' | 'sfw' | 'mature' + mutedWords?: string[] + blockedDids?: string[] +} + export interface AgeDeclarationResponse { success: boolean declaredAge: number diff --git a/src/mocks/data.ts b/src/mocks/data.ts index 3a53ce2..368cdb8 100644 --- a/src/mocks/data.ts +++ b/src/mocks/data.ts @@ -8,6 +8,7 @@ import type { AuthUser, CategoryTreeNode, CategoryWithTopicCount, + CommunityPreferenceOverride, Topic, Reply, Notification, @@ -856,6 +857,25 @@ export const mockPlugins: Plugin[] = [ }, ] +// --- Per-Community Preference Overrides --- + +export const mockCommunityPreferences: CommunityPreferenceOverride[] = [ + { + communityDid: COMMUNITY_DID, + communityName: 'Barazo Test Community', + maturityLevel: 'inherit', + mutedWords: [], + blockedDids: [], + }, + { + communityDid: 'did:plc:other-community-456', + communityName: 'Gaming Forum', + maturityLevel: 'mature', + mutedWords: ['spoiler'], + blockedDids: ['did:plc:user-dave-004'], + }, +] + // --- User Preferences --- export const mockUserPreferences: UserPreferences = { diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 5300ef8..ec98116 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -24,6 +24,7 @@ import { mockAdminUsers, mockPlugins, mockUserPreferences, + mockCommunityPreferences, mockOnboardingFields, } from './data' @@ -512,6 +513,33 @@ export const handlers = [ return HttpResponse.json({ ...mockUserPreferences, ...body }) }), + // GET /api/users/me/preferences/communities + http.get(`${API_URL}/api/users/me/preferences/communities`, ({ request }) => { + const auth = request.headers.get('Authorization') + if (!auth?.startsWith('Bearer ')) { + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + return HttpResponse.json({ communities: mockCommunityPreferences }) + }), + + // PUT /api/users/me/preferences/communities/:communityDid + http.put( + `${API_URL}/api/users/me/preferences/communities/:communityDid`, + async ({ request, params }) => { + const auth = request.headers.get('Authorization') + if (!auth?.startsWith('Bearer ')) { + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const communityDid = decodeURIComponent(params['communityDid'] as string) + const existing = mockCommunityPreferences.find((c) => c.communityDid === communityDid) + if (!existing) { + return HttpResponse.json({ error: 'Not found' }, { status: 404 }) + } + const body = (await request.json()) as Record + return HttpResponse.json({ ...existing, ...body }) + } + ), + // POST /api/users/me/age-declaration http.post(`${API_URL}/api/users/me/age-declaration`, async ({ request }) => { const auth = request.headers.get('Authorization')
+ No community memberships found. Join a community to configure per-community + settings. +
+ These are in addition to your global muted words. Comma-separated. +
+ Block specific users only in this community. Comma-separated DIDs. +
Hello world
This is spam content
This is SPAM content
offensive spam text
spam post here
spam content
Any content
This is a classic example