Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(ux): hide block/mute on own profile, add login-required toast (#93)

* feat(ux): hide block/mute on own profile, add login-required toast

- Hide block/mute buttons when viewing own profile (DID comparison)
- Add toast notification system (Radix UI Toast, already a dependency)
- Add useRequireAuth hook: shows "login required" toast with login link
when unauthenticated users try to perform auth-gated actions
- BlockMuteButton now uses useRequireAuth instead of silently failing
- Pattern is reusable for future auth-gated interactions (reactions,
replies, reports)

* style: fix prettier formatting in toast component

authored by

Guido X Jansen and committed by
GitHub
f500becb 8a2739c6

+420 -41
+4 -1
src/app/layout.tsx
··· 3 3 import './globals.css' 4 4 import { ThemeProvider } from '@/components/theme-provider' 5 5 import { AuthProvider } from '@/context/auth-context' 6 + import { AppToastProvider } from '@/context/toast-context' 6 7 7 8 const sourceCodePro = Source_Code_Pro({ 8 9 subsets: ['latin'], ··· 51 52 enableSystem={true} 52 53 disableTransitionOnChange 53 54 > 54 - <AuthProvider>{children}</AuthProvider> 55 + <AuthProvider> 56 + <AppToastProvider>{children}</AppToastProvider> 57 + </AuthProvider> 55 58 </ThemeProvider> 56 59 </body> 57 60 </html>
+8
src/app/u/[handle]/page.test.tsx
··· 21 21 }), 22 22 })) 23 23 24 + // Mock useToast hook (used by BlockMuteButton via useRequireAuth) 25 + vi.mock('@/hooks/use-toast', () => ({ 26 + useToast: () => ({ 27 + toast: vi.fn(), 28 + dismiss: vi.fn(), 29 + }), 30 + })) 31 + 24 32 // Mock next/navigation 25 33 vi.mock('next/navigation', () => ({ 26 34 useRouter: () => ({
+3
src/app/u/[handle]/page.tsx
··· 14 14 import { ProfileHeader } from '@/components/profile/profile-header' 15 15 import { ProfileSkeleton } from '@/components/profile/profile-skeleton' 16 16 import { getUserProfile } from '@/lib/api/client' 17 + import { useAuth } from '@/hooks/use-auth' 17 18 import type { UserProfile } from '@/lib/api/types' 18 19 19 20 interface UserProfilePageProps { ··· 32 33 const [error, setError] = useState<string | null>(null) 33 34 const [isBlocked, setIsBlocked] = useState(false) 34 35 const [isMuted, setIsMuted] = useState(false) 36 + const { user } = useAuth() 35 37 36 38 // Resolve Next.js async params 37 39 useEffect(() => { ··· 134 136 isMuted={isMuted} 135 137 onBlockToggle={setIsBlocked} 136 138 onMuteToggle={setIsMuted} 139 + viewerDid={user?.did ?? null} 137 140 /> 138 141 139 142 {/* Recent activity */}
+8
src/components/block-mute-button.test.tsx
··· 8 8 import { BlockMuteButton } from './block-mute-button' 9 9 10 10 const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token') 11 + const mockToast = vi.fn() 11 12 12 13 vi.mock('@/hooks/use-auth', () => ({ 13 14 useAuth: () => ({ ··· 24 25 logout: vi.fn(), 25 26 setSessionFromCallback: vi.fn(), 26 27 authFetch: vi.fn(), 28 + }), 29 + })) 30 + 31 + vi.mock('@/hooks/use-toast', () => ({ 32 + useToast: () => ({ 33 + toast: mockToast, 34 + dismiss: vi.fn(), 27 35 }), 28 36 })) 29 37
+30 -25
src/components/block-mute-button.tsx
··· 1 1 /** 2 2 * Block/mute toggle button for user actions. 3 3 * Used in user profiles and post context menus. 4 + * Shows a login prompt toast for unauthenticated users. 4 5 * @see specs/prd-web.md Section M8 5 6 */ 6 7 ··· 11 12 import { cn } from '@/lib/utils' 12 13 import { blockUser, unblockUser, muteUser, unmuteUser } from '@/lib/api/client' 13 14 import { useAuth } from '@/hooks/use-auth' 15 + import { useRequireAuth } from '@/hooks/use-require-auth' 14 16 15 17 interface BlockMuteButtonProps { 16 18 targetDid: string ··· 28 30 className, 29 31 }: BlockMuteButtonProps) { 30 32 const { getAccessToken } = useAuth() 33 + const { requireAuth } = useRequireAuth() 31 34 const [loading, setLoading] = useState(false) 32 35 const [error, setError] = useState(false) 33 36 34 - const handleClick = async () => { 35 - setLoading(true) 36 - setError(false) 37 + const handleClick = () => { 38 + requireAuth(async () => { 39 + setLoading(true) 40 + setError(false) 37 41 38 - const token = getAccessToken() 39 - if (!token) { 40 - setLoading(false) 41 - return 42 - } 42 + const token = getAccessToken() 43 + if (!token) { 44 + setLoading(false) 45 + return 46 + } 43 47 44 - try { 45 - if (action === 'block') { 46 - if (isActive) { 47 - await unblockUser(targetDid, token) 48 + try { 49 + if (action === 'block') { 50 + if (isActive) { 51 + await unblockUser(targetDid, token) 52 + } else { 53 + await blockUser(targetDid, token) 54 + } 48 55 } else { 49 - await blockUser(targetDid, token) 56 + if (isActive) { 57 + await unmuteUser(targetDid, token) 58 + } else { 59 + await muteUser(targetDid, token) 60 + } 50 61 } 51 - } else { 52 - if (isActive) { 53 - await unmuteUser(targetDid, token) 54 - } else { 55 - await muteUser(targetDid, token) 56 - } 62 + onToggle(!isActive) 63 + } catch { 64 + setError(true) 65 + } finally { 66 + setLoading(false) 57 67 } 58 - onToggle(!isActive) 59 - } catch { 60 - setError(true) 61 - } finally { 62 - setLoading(false) 63 - } 68 + }) 64 69 } 65 70 66 71 const Icon = action === 'block' ? Prohibit : SpeakerSimpleSlash
+23 -15
src/components/profile/profile-header.tsx
··· 1 1 /** 2 2 * ProfileHeader - Displays user profile card with banner, avatar, bio, stats, and actions. 3 + * Hides block/mute buttons when viewing own profile. 3 4 * @see specs/prd-web.md Section M8 4 5 */ 5 6 ··· 21 22 isMuted: boolean 22 23 onBlockToggle: (blocked: boolean) => void 23 24 onMuteToggle: (muted: boolean) => void 25 + /** DID of the currently authenticated viewer (null if logged out) */ 26 + viewerDid: string | null 24 27 } 25 28 26 29 export function ProfileHeader({ ··· 33 36 isMuted, 34 37 onBlockToggle, 35 38 onMuteToggle, 39 + viewerDid, 36 40 }: ProfileHeaderProps) { 41 + const isOwnProfile = viewerDid !== null && viewerDid === profile.did 42 + 37 43 return ( 38 44 <div className="overflow-hidden rounded-lg border border-border bg-card"> 39 45 {/* Banner */} ··· 79 85 </span> 80 86 </div> 81 87 82 - {/* Block/Mute actions */} 83 - <div className="mt-3 flex gap-2"> 84 - <BlockMuteButton 85 - targetDid={profile.did} 86 - action="block" 87 - isActive={isBlocked} 88 - onToggle={onBlockToggle} 89 - /> 90 - <BlockMuteButton 91 - targetDid={profile.did} 92 - action="mute" 93 - isActive={isMuted} 94 - onToggle={onMuteToggle} 95 - /> 96 - </div> 88 + {/* Block/Mute actions (hidden on own profile) */} 89 + {!isOwnProfile && ( 90 + <div className="mt-3 flex gap-2"> 91 + <BlockMuteButton 92 + targetDid={profile.did} 93 + action="block" 94 + isActive={isBlocked} 95 + onToggle={onBlockToggle} 96 + /> 97 + <BlockMuteButton 98 + targetDid={profile.did} 99 + action="mute" 100 + isActive={isMuted} 101 + onToggle={onMuteToggle} 102 + /> 103 + </div> 104 + )} 97 105 </div> 98 106 </div> 99 107 </div>
+111
src/components/ui/toast.tsx
··· 1 + 'use client' 2 + 3 + import * as React from 'react' 4 + import * as ToastPrimitives from '@radix-ui/react-toast' 5 + import { X } from '@phosphor-icons/react' 6 + 7 + import { cn } from '@/lib/utils' 8 + 9 + const ToastProvider = ToastPrimitives.Provider 10 + 11 + const ToastViewport = React.forwardRef< 12 + React.ComponentRef<typeof ToastPrimitives.Viewport>, 13 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> 14 + >(({ className, ...props }, ref) => ( 15 + <ToastPrimitives.Viewport 16 + ref={ref} 17 + className={cn( 18 + 'fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:max-w-[420px]', 19 + className 20 + )} 21 + {...props} 22 + /> 23 + )) 24 + ToastViewport.displayName = ToastPrimitives.Viewport.displayName 25 + 26 + const Toast = React.forwardRef< 27 + React.ComponentRef<typeof ToastPrimitives.Root>, 28 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & { 29 + variant?: 'default' | 'destructive' 30 + } 31 + >(({ className, variant = 'default', ...props }, ref) => ( 32 + <ToastPrimitives.Root 33 + ref={ref} 34 + className={cn( 35 + 'group pointer-events-auto relative flex w-full items-center gap-3 overflow-hidden rounded-lg border p-4 shadow-lg transition-all', 36 + 'data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none', 37 + 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-bottom-full', 38 + variant === 'default' && 'border-border bg-card text-foreground', 39 + variant === 'destructive' && 40 + 'border-destructive/50 bg-destructive text-destructive-foreground', 41 + className 42 + )} 43 + {...props} 44 + /> 45 + )) 46 + Toast.displayName = ToastPrimitives.Root.displayName 47 + 48 + const ToastClose = React.forwardRef< 49 + React.ComponentRef<typeof ToastPrimitives.Close>, 50 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> 51 + >(({ className, ...props }, ref) => ( 52 + <ToastPrimitives.Close 53 + ref={ref} 54 + className={cn( 55 + 'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100', 56 + className 57 + )} 58 + toast-close="" 59 + {...props} 60 + > 61 + <X size={16} weight="bold" /> 62 + </ToastPrimitives.Close> 63 + )) 64 + ToastClose.displayName = ToastPrimitives.Close.displayName 65 + 66 + const ToastTitle = React.forwardRef< 67 + React.ComponentRef<typeof ToastPrimitives.Title>, 68 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> 69 + >(({ className, ...props }, ref) => ( 70 + <ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} /> 71 + )) 72 + ToastTitle.displayName = ToastPrimitives.Title.displayName 73 + 74 + const ToastDescription = React.forwardRef< 75 + React.ComponentRef<typeof ToastPrimitives.Description>, 76 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> 77 + >(({ className, ...props }, ref) => ( 78 + <ToastPrimitives.Description 79 + ref={ref} 80 + className={cn('text-sm opacity-90', className)} 81 + {...props} 82 + /> 83 + )) 84 + ToastDescription.displayName = ToastPrimitives.Description.displayName 85 + 86 + const ToastAction = React.forwardRef< 87 + React.ComponentRef<typeof ToastPrimitives.Action>, 88 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> 89 + >(({ className, ...props }, ref) => ( 90 + <ToastPrimitives.Action 91 + ref={ref} 92 + className={cn( 93 + 'inline-flex shrink-0 items-center justify-center rounded-md border bg-transparent px-3 py-1 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 94 + className 95 + )} 96 + {...props} 97 + /> 98 + )) 99 + ToastAction.displayName = ToastPrimitives.Action.displayName 100 + 101 + export { 102 + ToastProvider, 103 + ToastViewport, 104 + Toast, 105 + ToastClose, 106 + ToastTitle, 107 + ToastDescription, 108 + ToastAction, 109 + } 110 + 111 + export type ToastActionElement = React.ReactElement<typeof ToastAction>
+78
src/context/toast-context.tsx
··· 1 + 'use client' 2 + 3 + import { createContext, useCallback, useMemo, useState } from 'react' 4 + import type { ReactNode } from 'react' 5 + import { 6 + ToastProvider as RadixToastProvider, 7 + ToastViewport, 8 + Toast, 9 + ToastTitle, 10 + ToastDescription, 11 + ToastClose, 12 + ToastAction, 13 + } from '@/components/ui/toast' 14 + 15 + export interface ToastMessage { 16 + id: string 17 + title: string 18 + description?: string 19 + variant?: 'default' | 'destructive' 20 + action?: { 21 + label: string 22 + onClick: () => void 23 + altText: string 24 + } 25 + } 26 + 27 + export interface ToastContextValue { 28 + toast: (message: Omit<ToastMessage, 'id'>) => void 29 + dismiss: (id: string) => void 30 + } 31 + 32 + export const ToastContext = createContext<ToastContextValue | null>(null) 33 + 34 + let toastCount = 0 35 + 36 + export function AppToastProvider({ children }: { children: ReactNode }) { 37 + const [toasts, setToasts] = useState<ToastMessage[]>([]) 38 + 39 + const toast = useCallback((message: Omit<ToastMessage, 'id'>) => { 40 + const id = String(++toastCount) 41 + setToasts((prev) => [...prev, { ...message, id }]) 42 + }, []) 43 + 44 + const dismiss = useCallback((id: string) => { 45 + setToasts((prev) => prev.filter((t) => t.id !== id)) 46 + }, []) 47 + 48 + const value = useMemo<ToastContextValue>(() => ({ toast, dismiss }), [toast, dismiss]) 49 + 50 + return ( 51 + <ToastContext.Provider value={value}> 52 + <RadixToastProvider swipeDirection="right"> 53 + {children} 54 + {toasts.map((t) => ( 55 + <Toast 56 + key={t.id} 57 + variant={t.variant} 58 + onOpenChange={(open) => { 59 + if (!open) dismiss(t.id) 60 + }} 61 + > 62 + <div className="flex-1"> 63 + <ToastTitle>{t.title}</ToastTitle> 64 + {t.description && <ToastDescription>{t.description}</ToastDescription>} 65 + </div> 66 + {t.action && ( 67 + <ToastAction altText={t.action.altText} onClick={t.action.onClick}> 68 + {t.action.label} 69 + </ToastAction> 70 + )} 71 + <ToastClose /> 72 + </Toast> 73 + ))} 74 + <ToastViewport /> 75 + </RadixToastProvider> 76 + </ToastContext.Provider> 77 + ) 78 + }
+104
src/hooks/use-require-auth.test.ts
··· 1 + /** 2 + * Tests for useRequireAuth hook. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } from 'vitest' 6 + import { renderHook, act } from '@testing-library/react' 7 + import { useRequireAuth } from './use-require-auth' 8 + import { useAuth } from '@/hooks/use-auth' 9 + 10 + const mockToast = vi.fn() 11 + 12 + vi.mock('@/hooks/use-auth', () => ({ 13 + useAuth: vi.fn(), 14 + })) 15 + 16 + vi.mock('@/hooks/use-toast', () => ({ 17 + useToast: () => ({ 18 + toast: mockToast, 19 + dismiss: vi.fn(), 20 + }), 21 + })) 22 + 23 + const mockedUseAuth = vi.mocked(useAuth) 24 + 25 + beforeEach(() => { 26 + vi.clearAllMocks() 27 + // Default: logged out 28 + mockedUseAuth.mockReturnValue({ 29 + user: null, 30 + isAuthenticated: false, 31 + isLoading: false, 32 + crossPostScopesGranted: false, 33 + getAccessToken: vi.fn(() => null), 34 + login: vi.fn(), 35 + logout: vi.fn(), 36 + setSessionFromCallback: vi.fn(), 37 + requestCrossPostAuth: vi.fn(), 38 + authFetch: vi.fn(), 39 + }) 40 + }) 41 + 42 + describe('useRequireAuth', () => { 43 + it('shows login toast when not authenticated', () => { 44 + const { result } = renderHook(() => useRequireAuth()) 45 + const action = vi.fn() 46 + 47 + act(() => { 48 + result.current.requireAuth(action) 49 + }) 50 + 51 + expect(action).not.toHaveBeenCalled() 52 + expect(mockToast).toHaveBeenCalledWith( 53 + expect.objectContaining({ 54 + title: 'Login required', 55 + description: 'You need to log in before you can perform this action.', 56 + }) 57 + ) 58 + }) 59 + 60 + it('includes login action in toast', () => { 61 + const { result } = renderHook(() => useRequireAuth()) 62 + 63 + act(() => { 64 + result.current.requireAuth(vi.fn()) 65 + }) 66 + 67 + expect(mockToast.mock.calls[0]).toBeDefined() 68 + const toastArg = mockToast.mock.calls[0]![0] as { 69 + action?: { label: string; altText: string; onClick: () => void } 70 + } 71 + expect(toastArg.action).toBeDefined() 72 + expect(toastArg.action?.label).toBe('Log in') 73 + }) 74 + 75 + it('executes action when authenticated', () => { 76 + mockedUseAuth.mockReturnValue({ 77 + user: { did: 'did:plc:test', handle: 'test.bsky.social', displayName: null, avatarUrl: null }, 78 + isAuthenticated: true, 79 + isLoading: false, 80 + crossPostScopesGranted: false, 81 + getAccessToken: vi.fn(() => 'mock-token'), 82 + login: vi.fn(), 83 + logout: vi.fn(), 84 + setSessionFromCallback: vi.fn(), 85 + requestCrossPostAuth: vi.fn(), 86 + authFetch: vi.fn(), 87 + }) 88 + 89 + const { result } = renderHook(() => useRequireAuth()) 90 + const action = vi.fn() 91 + 92 + act(() => { 93 + result.current.requireAuth(action) 94 + }) 95 + 96 + expect(action).toHaveBeenCalled() 97 + expect(mockToast).not.toHaveBeenCalled() 98 + }) 99 + 100 + it('exposes isAuthenticated from auth context', () => { 101 + const { result } = renderHook(() => useRequireAuth()) 102 + expect(result.current.isAuthenticated).toBe(false) 103 + }) 104 + })
+38
src/hooks/use-require-auth.ts
··· 1 + 'use client' 2 + 3 + import { useCallback } from 'react' 4 + import { useAuth } from '@/hooks/use-auth' 5 + import { useToast } from '@/hooks/use-toast' 6 + 7 + /** 8 + * Returns a guard function that checks auth state before performing an action. 9 + * If the user is not authenticated, shows a toast prompting them to log in. 10 + * If authenticated, executes the callback. 11 + */ 12 + export function useRequireAuth() { 13 + const { isAuthenticated } = useAuth() 14 + const { toast } = useToast() 15 + 16 + const requireAuth = useCallback( 17 + (action: () => void | Promise<void>) => { 18 + if (!isAuthenticated) { 19 + toast({ 20 + title: 'Login required', 21 + description: 'You need to log in before you can perform this action.', 22 + action: { 23 + label: 'Log in', 24 + altText: 'Go to login page', 25 + onClick: () => { 26 + window.location.href = `/login?returnTo=${encodeURIComponent(window.location.pathname)}` 27 + }, 28 + }, 29 + }) 30 + return 31 + } 32 + void action() 33 + }, 34 + [isAuthenticated, toast] 35 + ) 36 + 37 + return { requireAuth, isAuthenticated } 38 + }
+13
src/hooks/use-toast.ts
··· 1 + 'use client' 2 + 3 + import { useContext } from 'react' 4 + import { ToastContext } from '@/context/toast-context' 5 + import type { ToastContextValue } from '@/context/toast-context' 6 + 7 + export function useToast(): ToastContextValue { 8 + const context = useContext(ToastContext) 9 + if (!context) { 10 + throw new Error('useToast must be used within a ToastProvider') 11 + } 12 + return context 13 + }