diff --git a/src/components/block-mute-button.test.tsx b/src/components/block-mute-button.test.tsx index 7d82101..6ad6703 100644 --- a/src/components/block-mute-button.test.tsx +++ b/src/components/block-mute-button.test.tsx @@ -52,6 +52,15 @@ beforeEach(() => { key: vi.fn(), }) mockStorage['accessToken'] = 'test-token' + mockGetAccessToken.mockReturnValue('mock-access-token') + + // Mock native dialog methods for JSDOM + HTMLDialogElement.prototype.showModal = vi.fn(function (this: HTMLDialogElement) { + this.setAttribute('open', '') + }) + HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { + this.removeAttribute('open') + }) }) afterEach(() => { @@ -112,7 +121,8 @@ describe('BlockMuteButton', () => { expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Unmute this user') }) - it('calls onToggle after successful block', async () => { + it('calls onToggle after successful block (dialog already dismissed)', async () => { + mockStorage['barazo_block_explained'] = '1' const onToggle = vi.fn() render( { }) }) - it('calls onToggle after successful mute', async () => { + it('calls onToggle after successful mute (dialog already dismissed)', async () => { + mockStorage['barazo_mute_explained'] = '1' const onToggle = vi.fn() render( { }) it('does not call onToggle without auth token', async () => { + mockStorage['barazo_block_explained'] = '1' mockGetAccessToken.mockReturnValue(null) const onToggle = vi.fn() render( @@ -169,4 +181,204 @@ describe('BlockMuteButton', () => { await new Promise((r) => setTimeout(r, 100)) expect(onToggle).not.toHaveBeenCalled() }) + + describe('first-use confirmation dialog', () => { + it('opens block confirmation dialog on first block click', async () => { + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByText('Block')) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Block this user?')).toBeInTheDocument() + }) + + it('calls API and sets localStorage when confirming block dialog', async () => { + const onToggle = vi.fn() + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByText('Block')) + await user.click(screen.getByRole('button', { name: /^block$/i })) + + await waitFor(() => { + expect(onToggle).toHaveBeenCalledWith(true) + }) + expect(mockStorage['barazo_block_explained']).toBe('1') + }) + + it('does not call API when canceling block dialog', async () => { + const onToggle = vi.fn() + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByText('Block')) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await new Promise((r) => setTimeout(r, 100)) + expect(onToggle).not.toHaveBeenCalled() + expect(mockStorage['barazo_block_explained']).toBeUndefined() + }) + + it('skips dialog when localStorage flag is set for block', async () => { + mockStorage['barazo_block_explained'] = '1' + const onToggle = vi.fn() + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByText('Block')) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => { + expect(onToggle).toHaveBeenCalledWith(true) + }) + }) + + it('never shows dialog for unblock', async () => { + const onToggle = vi.fn() + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByText('Unblock')) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => { + expect(onToggle).toHaveBeenCalledWith(false) + }) + }) + + it('opens mute confirmation dialog on first mute click', async () => { + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByText('Mute')) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Mute this user?')).toBeInTheDocument() + }) + + it('calls API and sets localStorage when confirming mute dialog', async () => { + const onToggle = vi.fn() + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByText('Mute')) + await user.click(screen.getByRole('button', { name: /^mute$/i })) + + await waitFor(() => { + expect(onToggle).toHaveBeenCalledWith(true) + }) + expect(mockStorage['barazo_mute_explained']).toBe('1') + }) + + it('does not call API when canceling mute dialog', async () => { + const onToggle = vi.fn() + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByText('Mute')) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await new Promise((r) => setTimeout(r, 100)) + expect(onToggle).not.toHaveBeenCalled() + expect(mockStorage['barazo_mute_explained']).toBeUndefined() + }) + + it('skips dialog when localStorage flag is set for mute', async () => { + mockStorage['barazo_mute_explained'] = '1' + const onToggle = vi.fn() + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByText('Mute')) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => { + expect(onToggle).toHaveBeenCalledWith(true) + }) + }) + + it('never shows dialog for unmute', async () => { + const onToggle = vi.fn() + render( + + ) + + const user = userEvent.setup() + await user.click(screen.getByText('Unmute')) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => { + expect(onToggle).toHaveBeenCalledWith(false) + }) + }) + }) }) diff --git a/src/components/block-mute-button.tsx b/src/components/block-mute-button.tsx index b3f59f1..e534203 100644 --- a/src/components/block-mute-button.tsx +++ b/src/components/block-mute-button.tsx @@ -2,12 +2,13 @@ * Block/mute toggle button for user actions. * Used in user profiles and post context menus. * Shows a login prompt toast for unauthenticated users. + * On first use, displays a confirmation dialog explaining the action. * @see specs/prd-web.md Section M8 */ 'use client' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Prohibit, SpeakerSimpleSlash, WarningCircle } from '@phosphor-icons/react' import { cn } from '@/lib/utils' import { blockUser, unblockUser, muteUser, unmuteUser } from '@/lib/api/client' @@ -22,6 +23,11 @@ interface BlockMuteButtonProps { className?: string } +const STORAGE_KEYS = { + block: 'barazo_block_explained', + mute: 'barazo_mute_explained', +} as const + export function BlockMuteButton({ targetDid, action, @@ -33,8 +39,21 @@ export function BlockMuteButton({ const { requireAuth } = useRequireAuth() const [loading, setLoading] = useState(false) const [error, setError] = useState(false) + const [dialogOpen, setDialogOpen] = useState(false) + const dialogRef = useRef(null) - const handleClick = () => { + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + if (dialogOpen) { + dialog.showModal() + } else if (dialog.open) { + dialog.close() + } + }, [dialogOpen]) + + const executeAction = () => { requireAuth(async () => { setLoading(true) setError(false) @@ -68,6 +87,31 @@ export function BlockMuteButton({ }) } + const handleClick = () => { + if (isActive) { + executeAction() + return + } + + const storageKey = STORAGE_KEYS[action] + if (!localStorage.getItem(storageKey)) { + setDialogOpen(true) + return + } + + executeAction() + } + + const handleConfirm = () => { + localStorage.setItem(STORAGE_KEYS[action], '1') + setDialogOpen(false) + executeAction() + } + + const handleCancel = () => { + setDialogOpen(false) + } + const Icon = action === 'block' ? Prohibit : SpeakerSimpleSlash const label = action === 'block' ? (isActive ? 'Unblock' : 'Block') : isActive ? 'Unmute' : 'Mute' @@ -98,6 +142,102 @@ export function BlockMuteButton({ Action failed )} + + {dialogOpen && ( + +
+ {action === 'block' ? ( + <> +

+ Block this user? +

+
+

+ Blocking completely removes someone from your experience on this forum: +

+
    +
  • + + Their posts and replies are hidden from your feed +
  • +
  • + + They won't appear in search results +
  • +
+

+ You can unblock them anytime from their profile or your settings. +

+
+ + ) : ( + <> +

+ Mute this user? +

+
+

+ Muting reduces someone's visibility without fully removing them: +

+
    +
  • + + Their posts are collapsed but you can expand them to read +
  • +
  • + + They won't know they've been muted +
  • +
+

+ You can unmute them anytime from their profile or your settings. +

+
+ + )} + +
+ + +
+
+
+ )} ) } diff --git a/src/components/settings/content-safety-section.tsx b/src/components/settings/content-safety-section.tsx index fe488c0..cb1eba9 100644 --- a/src/components/settings/content-safety-section.tsx +++ b/src/components/settings/content-safety-section.tsx @@ -35,6 +35,15 @@ export function ContentSafetySection({
Content safety +

+ Block vs. mute + {' \u2014 '} + Blocking hides a user's content entirely — their posts and replies disappear from + your feed and search results. Muting collapses their posts so you can still expand and read + them if you choose. Muted users won't know they've been muted. Manage blocked + users below, or block/mute anyone from their profile. +

+