Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(block-mute): add first-use confirmation dialog and settings explainer (#127)

Show a native dialog explaining the difference between blocking and muting
on first use. The dialog appears when activating block/mute (not when
deactivating) and is suppressed after the user confirms via a localStorage
flag. Also add permanent help text to the Content Safety settings section.

authored by

Guido X Jansen and committed by
GitHub
02e2d4d7 8c030046

+365 -4
+214 -2
src/components/block-mute-button.test.tsx
··· 52 52 key: vi.fn(), 53 53 }) 54 54 mockStorage['accessToken'] = 'test-token' 55 + mockGetAccessToken.mockReturnValue('mock-access-token') 56 + 57 + // Mock native dialog methods for JSDOM 58 + HTMLDialogElement.prototype.showModal = vi.fn(function (this: HTMLDialogElement) { 59 + this.setAttribute('open', '') 60 + }) 61 + HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { 62 + this.removeAttribute('open') 63 + }) 55 64 }) 56 65 57 66 afterEach(() => { ··· 112 121 expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Unmute this user') 113 122 }) 114 123 115 - it('calls onToggle after successful block', async () => { 124 + it('calls onToggle after successful block (dialog already dismissed)', async () => { 125 + mockStorage['barazo_block_explained'] = '1' 116 126 const onToggle = vi.fn() 117 127 render( 118 128 <BlockMuteButton ··· 131 141 }) 132 142 }) 133 143 134 - it('calls onToggle after successful mute', async () => { 144 + it('calls onToggle after successful mute (dialog already dismissed)', async () => { 145 + mockStorage['barazo_mute_explained'] = '1' 135 146 const onToggle = vi.fn() 136 147 render( 137 148 <BlockMuteButton ··· 151 162 }) 152 163 153 164 it('does not call onToggle without auth token', async () => { 165 + mockStorage['barazo_block_explained'] = '1' 154 166 mockGetAccessToken.mockReturnValue(null) 155 167 const onToggle = vi.fn() 156 168 render( ··· 168 180 // Wait a tick and verify onToggle was NOT called 169 181 await new Promise((r) => setTimeout(r, 100)) 170 182 expect(onToggle).not.toHaveBeenCalled() 183 + }) 184 + 185 + describe('first-use confirmation dialog', () => { 186 + it('opens block confirmation dialog on first block click', async () => { 187 + render( 188 + <BlockMuteButton 189 + targetDid="did:plc:target123" 190 + action="block" 191 + isActive={false} 192 + onToggle={vi.fn()} 193 + /> 194 + ) 195 + 196 + const user = userEvent.setup() 197 + await user.click(screen.getByText('Block')) 198 + 199 + expect(screen.getByRole('dialog')).toBeInTheDocument() 200 + expect(screen.getByText('Block this user?')).toBeInTheDocument() 201 + }) 202 + 203 + it('calls API and sets localStorage when confirming block dialog', async () => { 204 + const onToggle = vi.fn() 205 + render( 206 + <BlockMuteButton 207 + targetDid="did:plc:target123" 208 + action="block" 209 + isActive={false} 210 + onToggle={onToggle} 211 + /> 212 + ) 213 + 214 + const user = userEvent.setup() 215 + await user.click(screen.getByText('Block')) 216 + await user.click(screen.getByRole('button', { name: /^block$/i })) 217 + 218 + await waitFor(() => { 219 + expect(onToggle).toHaveBeenCalledWith(true) 220 + }) 221 + expect(mockStorage['barazo_block_explained']).toBe('1') 222 + }) 223 + 224 + it('does not call API when canceling block dialog', async () => { 225 + const onToggle = vi.fn() 226 + render( 227 + <BlockMuteButton 228 + targetDid="did:plc:target123" 229 + action="block" 230 + isActive={false} 231 + onToggle={onToggle} 232 + /> 233 + ) 234 + 235 + const user = userEvent.setup() 236 + await user.click(screen.getByText('Block')) 237 + await user.click(screen.getByRole('button', { name: 'Cancel' })) 238 + 239 + await new Promise((r) => setTimeout(r, 100)) 240 + expect(onToggle).not.toHaveBeenCalled() 241 + expect(mockStorage['barazo_block_explained']).toBeUndefined() 242 + }) 243 + 244 + it('skips dialog when localStorage flag is set for block', async () => { 245 + mockStorage['barazo_block_explained'] = '1' 246 + const onToggle = vi.fn() 247 + render( 248 + <BlockMuteButton 249 + targetDid="did:plc:target123" 250 + action="block" 251 + isActive={false} 252 + onToggle={onToggle} 253 + /> 254 + ) 255 + 256 + const user = userEvent.setup() 257 + await user.click(screen.getByText('Block')) 258 + 259 + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() 260 + await waitFor(() => { 261 + expect(onToggle).toHaveBeenCalledWith(true) 262 + }) 263 + }) 264 + 265 + it('never shows dialog for unblock', async () => { 266 + const onToggle = vi.fn() 267 + render( 268 + <BlockMuteButton 269 + targetDid="did:plc:target123" 270 + action="block" 271 + isActive={true} 272 + onToggle={onToggle} 273 + /> 274 + ) 275 + 276 + const user = userEvent.setup() 277 + await user.click(screen.getByText('Unblock')) 278 + 279 + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() 280 + await waitFor(() => { 281 + expect(onToggle).toHaveBeenCalledWith(false) 282 + }) 283 + }) 284 + 285 + it('opens mute confirmation dialog on first mute click', async () => { 286 + render( 287 + <BlockMuteButton 288 + targetDid="did:plc:target123" 289 + action="mute" 290 + isActive={false} 291 + onToggle={vi.fn()} 292 + /> 293 + ) 294 + 295 + const user = userEvent.setup() 296 + await user.click(screen.getByText('Mute')) 297 + 298 + expect(screen.getByRole('dialog')).toBeInTheDocument() 299 + expect(screen.getByText('Mute this user?')).toBeInTheDocument() 300 + }) 301 + 302 + it('calls API and sets localStorage when confirming mute dialog', async () => { 303 + const onToggle = vi.fn() 304 + render( 305 + <BlockMuteButton 306 + targetDid="did:plc:target123" 307 + action="mute" 308 + isActive={false} 309 + onToggle={onToggle} 310 + /> 311 + ) 312 + 313 + const user = userEvent.setup() 314 + await user.click(screen.getByText('Mute')) 315 + await user.click(screen.getByRole('button', { name: /^mute$/i })) 316 + 317 + await waitFor(() => { 318 + expect(onToggle).toHaveBeenCalledWith(true) 319 + }) 320 + expect(mockStorage['barazo_mute_explained']).toBe('1') 321 + }) 322 + 323 + it('does not call API when canceling mute dialog', async () => { 324 + const onToggle = vi.fn() 325 + render( 326 + <BlockMuteButton 327 + targetDid="did:plc:target123" 328 + action="mute" 329 + isActive={false} 330 + onToggle={onToggle} 331 + /> 332 + ) 333 + 334 + const user = userEvent.setup() 335 + await user.click(screen.getByText('Mute')) 336 + await user.click(screen.getByRole('button', { name: 'Cancel' })) 337 + 338 + await new Promise((r) => setTimeout(r, 100)) 339 + expect(onToggle).not.toHaveBeenCalled() 340 + expect(mockStorage['barazo_mute_explained']).toBeUndefined() 341 + }) 342 + 343 + it('skips dialog when localStorage flag is set for mute', async () => { 344 + mockStorage['barazo_mute_explained'] = '1' 345 + const onToggle = vi.fn() 346 + render( 347 + <BlockMuteButton 348 + targetDid="did:plc:target123" 349 + action="mute" 350 + isActive={false} 351 + onToggle={onToggle} 352 + /> 353 + ) 354 + 355 + const user = userEvent.setup() 356 + await user.click(screen.getByText('Mute')) 357 + 358 + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() 359 + await waitFor(() => { 360 + expect(onToggle).toHaveBeenCalledWith(true) 361 + }) 362 + }) 363 + 364 + it('never shows dialog for unmute', async () => { 365 + const onToggle = vi.fn() 366 + render( 367 + <BlockMuteButton 368 + targetDid="did:plc:target123" 369 + action="mute" 370 + isActive={true} 371 + onToggle={onToggle} 372 + /> 373 + ) 374 + 375 + const user = userEvent.setup() 376 + await user.click(screen.getByText('Unmute')) 377 + 378 + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() 379 + await waitFor(() => { 380 + expect(onToggle).toHaveBeenCalledWith(false) 381 + }) 382 + }) 171 383 }) 172 384 })
+142 -2
src/components/block-mute-button.tsx
··· 2 2 * Block/mute toggle button for user actions. 3 3 * Used in user profiles and post context menus. 4 4 * Shows a login prompt toast for unauthenticated users. 5 + * On first use, displays a confirmation dialog explaining the action. 5 6 * @see specs/prd-web.md Section M8 6 7 */ 7 8 8 9 'use client' 9 10 10 - import { useState } from 'react' 11 + import { useEffect, useRef, useState } from 'react' 11 12 import { Prohibit, SpeakerSimpleSlash, WarningCircle } from '@phosphor-icons/react' 12 13 import { cn } from '@/lib/utils' 13 14 import { blockUser, unblockUser, muteUser, unmuteUser } from '@/lib/api/client' ··· 22 23 className?: string 23 24 } 24 25 26 + const STORAGE_KEYS = { 27 + block: 'barazo_block_explained', 28 + mute: 'barazo_mute_explained', 29 + } as const 30 + 25 31 export function BlockMuteButton({ 26 32 targetDid, 27 33 action, ··· 33 39 const { requireAuth } = useRequireAuth() 34 40 const [loading, setLoading] = useState(false) 35 41 const [error, setError] = useState(false) 42 + const [dialogOpen, setDialogOpen] = useState(false) 43 + const dialogRef = useRef<HTMLDialogElement>(null) 44 + 45 + useEffect(() => { 46 + const dialog = dialogRef.current 47 + if (!dialog) return 36 48 37 - const handleClick = () => { 49 + if (dialogOpen) { 50 + dialog.showModal() 51 + } else if (dialog.open) { 52 + dialog.close() 53 + } 54 + }, [dialogOpen]) 55 + 56 + const executeAction = () => { 38 57 requireAuth(async () => { 39 58 setLoading(true) 40 59 setError(false) ··· 68 87 }) 69 88 } 70 89 90 + const handleClick = () => { 91 + if (isActive) { 92 + executeAction() 93 + return 94 + } 95 + 96 + const storageKey = STORAGE_KEYS[action] 97 + if (!localStorage.getItem(storageKey)) { 98 + setDialogOpen(true) 99 + return 100 + } 101 + 102 + executeAction() 103 + } 104 + 105 + const handleConfirm = () => { 106 + localStorage.setItem(STORAGE_KEYS[action], '1') 107 + setDialogOpen(false) 108 + executeAction() 109 + } 110 + 111 + const handleCancel = () => { 112 + setDialogOpen(false) 113 + } 114 + 71 115 const Icon = action === 'block' ? Prohibit : SpeakerSimpleSlash 72 116 const label = action === 'block' ? (isActive ? 'Unblock' : 'Block') : isActive ? 'Unmute' : 'Mute' 73 117 ··· 97 141 <WarningCircle size={12} aria-hidden="true" /> 98 142 Action failed 99 143 </span> 144 + )} 145 + 146 + {dialogOpen && ( 147 + <dialog 148 + ref={dialogRef} 149 + onClose={handleCancel} 150 + aria-labelledby="block-mute-dialog-title" 151 + aria-describedby="block-mute-dialog-description" 152 + className={cn( 153 + 'w-full max-w-md rounded-lg border border-border bg-background p-0 shadow-lg', 154 + 'backdrop:bg-black/50' 155 + )} 156 + > 157 + <div className="space-y-4 p-6"> 158 + {action === 'block' ? ( 159 + <> 160 + <h2 id="block-mute-dialog-title" className="text-lg font-semibold text-foreground"> 161 + Block this user? 162 + </h2> 163 + <div id="block-mute-dialog-description" className="space-y-2"> 164 + <p className="text-sm text-muted-foreground"> 165 + Blocking completely removes someone from your experience on this forum: 166 + </p> 167 + <ul className="space-y-1 text-sm text-muted-foreground"> 168 + <li className="flex items-start gap-2"> 169 + <span className="mt-0.5" aria-hidden="true"> 170 + - 171 + </span> 172 + <span>Their posts and replies are hidden from your feed</span> 173 + </li> 174 + <li className="flex items-start gap-2"> 175 + <span className="mt-0.5" aria-hidden="true"> 176 + - 177 + </span> 178 + <span>They won&apos;t appear in search results</span> 179 + </li> 180 + </ul> 181 + <p className="text-sm text-muted-foreground"> 182 + You can unblock them anytime from their profile or your settings. 183 + </p> 184 + </div> 185 + </> 186 + ) : ( 187 + <> 188 + <h2 id="block-mute-dialog-title" className="text-lg font-semibold text-foreground"> 189 + Mute this user? 190 + </h2> 191 + <div id="block-mute-dialog-description" className="space-y-2"> 192 + <p className="text-sm text-muted-foreground"> 193 + Muting reduces someone&apos;s visibility without fully removing them: 194 + </p> 195 + <ul className="space-y-1 text-sm text-muted-foreground"> 196 + <li className="flex items-start gap-2"> 197 + <span className="mt-0.5" aria-hidden="true"> 198 + - 199 + </span> 200 + <span>Their posts are collapsed but you can expand them to read</span> 201 + </li> 202 + <li className="flex items-start gap-2"> 203 + <span className="mt-0.5" aria-hidden="true"> 204 + - 205 + </span> 206 + <span>They won&apos;t know they&apos;ve been muted</span> 207 + </li> 208 + </ul> 209 + <p className="text-sm text-muted-foreground"> 210 + You can unmute them anytime from their profile or your settings. 211 + </p> 212 + </div> 213 + </> 214 + )} 215 + 216 + <div className="flex justify-end gap-3 pt-2"> 217 + <button 218 + type="button" 219 + onClick={handleCancel} 220 + className={cn( 221 + 'rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors', 222 + 'hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' 223 + )} 224 + > 225 + Cancel 226 + </button> 227 + <button 228 + type="button" 229 + onClick={handleConfirm} 230 + className={cn( 231 + 'rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors', 232 + 'hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' 233 + )} 234 + > 235 + {label} 236 + </button> 237 + </div> 238 + </div> 239 + </dialog> 100 240 )} 101 241 </div> 102 242 )
+9
src/components/settings/content-safety-section.tsx
··· 35 35 <fieldset className="space-y-4 rounded-lg border border-border p-4"> 36 36 <legend className="px-2 text-sm font-semibold text-foreground">Content safety</legend> 37 37 38 + <p className="text-xs text-muted-foreground"> 39 + <span className="font-medium text-foreground">Block vs. mute</span> 40 + {' \u2014 '} 41 + Blocking hides a user&apos;s content entirely &mdash; their posts and replies disappear from 42 + your feed and search results. Muting collapses their posts so you can still expand and read 43 + them if you choose. Muted users won&apos;t know they&apos;ve been muted. Manage blocked 44 + users below, or block/mute anyone from their profile. 45 + </p> 46 + 38 47 <div className="space-y-1"> 39 48 <label htmlFor="maturity-level" className="block text-sm font-medium text-foreground"> 40 49 Maturity level