diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b6c96e7..11c7889 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Source_Code_Pro } from 'next/font/google' import './globals.css' import { ThemeProvider } from '@/components/theme-provider' import { AuthProvider } from '@/context/auth-context' +import { AppToastProvider } from '@/context/toast-context' const sourceCodePro = Source_Code_Pro({ subsets: ['latin'], @@ -51,7 +52,9 @@ export default function RootLayout({ enableSystem={true} disableTransitionOnChange > - {children} + + {children} + diff --git a/src/app/u/[handle]/page.test.tsx b/src/app/u/[handle]/page.test.tsx index a983b6f..49aa239 100644 --- a/src/app/u/[handle]/page.test.tsx +++ b/src/app/u/[handle]/page.test.tsx @@ -21,6 +21,14 @@ vi.mock('@/hooks/use-auth', () => ({ }), })) +// Mock useToast hook (used by BlockMuteButton via useRequireAuth) +vi.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ + toast: vi.fn(), + dismiss: vi.fn(), + }), +})) + // Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index 70abbdf..277f9ea 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -14,6 +14,7 @@ import { Breadcrumbs } from '@/components/breadcrumbs' import { ProfileHeader } from '@/components/profile/profile-header' import { ProfileSkeleton } from '@/components/profile/profile-skeleton' import { getUserProfile } from '@/lib/api/client' +import { useAuth } from '@/hooks/use-auth' import type { UserProfile } from '@/lib/api/types' interface UserProfilePageProps { @@ -32,6 +33,7 @@ export default function UserProfilePage({ params }: UserProfilePageProps) { const [error, setError] = useState(null) const [isBlocked, setIsBlocked] = useState(false) const [isMuted, setIsMuted] = useState(false) + const { user } = useAuth() // Resolve Next.js async params useEffect(() => { @@ -134,6 +136,7 @@ export default function UserProfilePage({ params }: UserProfilePageProps) { isMuted={isMuted} onBlockToggle={setIsBlocked} onMuteToggle={setIsMuted} + viewerDid={user?.did ?? null} /> {/* Recent activity */} diff --git a/src/components/block-mute-button.test.tsx b/src/components/block-mute-button.test.tsx index f5c71a9..0744bbe 100644 --- a/src/components/block-mute-button.test.tsx +++ b/src/components/block-mute-button.test.tsx @@ -8,6 +8,7 @@ import userEvent from '@testing-library/user-event' import { BlockMuteButton } from './block-mute-button' const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token') +const mockToast = vi.fn() vi.mock('@/hooks/use-auth', () => ({ useAuth: () => ({ @@ -27,6 +28,13 @@ vi.mock('@/hooks/use-auth', () => ({ }), })) +vi.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ + toast: mockToast, + dismiss: vi.fn(), + }), +})) + // Mock localStorage const mockStorage: Record = {} diff --git a/src/components/block-mute-button.tsx b/src/components/block-mute-button.tsx index e3c0bc7..b3f59f1 100644 --- a/src/components/block-mute-button.tsx +++ b/src/components/block-mute-button.tsx @@ -1,6 +1,7 @@ /** * Block/mute toggle button for user actions. * Used in user profiles and post context menus. + * Shows a login prompt toast for unauthenticated users. * @see specs/prd-web.md Section M8 */ @@ -11,6 +12,7 @@ import { Prohibit, SpeakerSimpleSlash, WarningCircle } from '@phosphor-icons/rea import { cn } from '@/lib/utils' import { blockUser, unblockUser, muteUser, unmuteUser } from '@/lib/api/client' import { useAuth } from '@/hooks/use-auth' +import { useRequireAuth } from '@/hooks/use-require-auth' interface BlockMuteButtonProps { targetDid: string @@ -28,39 +30,42 @@ export function BlockMuteButton({ className, }: BlockMuteButtonProps) { const { getAccessToken } = useAuth() + const { requireAuth } = useRequireAuth() const [loading, setLoading] = useState(false) const [error, setError] = useState(false) - const handleClick = async () => { - setLoading(true) - setError(false) + const handleClick = () => { + requireAuth(async () => { + setLoading(true) + setError(false) - const token = getAccessToken() - if (!token) { - setLoading(false) - return - } + const token = getAccessToken() + if (!token) { + setLoading(false) + return + } - try { - if (action === 'block') { - if (isActive) { - await unblockUser(targetDid, token) - } else { - await blockUser(targetDid, token) - } - } else { - if (isActive) { - await unmuteUser(targetDid, token) + try { + if (action === 'block') { + if (isActive) { + await unblockUser(targetDid, token) + } else { + await blockUser(targetDid, token) + } } else { - await muteUser(targetDid, token) + if (isActive) { + await unmuteUser(targetDid, token) + } else { + await muteUser(targetDid, token) + } } + onToggle(!isActive) + } catch { + setError(true) + } finally { + setLoading(false) } - onToggle(!isActive) - } catch { - setError(true) - } finally { - setLoading(false) - } + }) } const Icon = action === 'block' ? Prohibit : SpeakerSimpleSlash diff --git a/src/components/profile/profile-header.tsx b/src/components/profile/profile-header.tsx index 3ddaac5..c69e932 100644 --- a/src/components/profile/profile-header.tsx +++ b/src/components/profile/profile-header.tsx @@ -1,5 +1,6 @@ /** * ProfileHeader - Displays user profile card with banner, avatar, bio, stats, and actions. + * Hides block/mute buttons when viewing own profile. * @see specs/prd-web.md Section M8 */ @@ -21,6 +22,8 @@ interface ProfileHeaderProps { isMuted: boolean onBlockToggle: (blocked: boolean) => void onMuteToggle: (muted: boolean) => void + /** DID of the currently authenticated viewer (null if logged out) */ + viewerDid: string | null } export function ProfileHeader({ @@ -33,7 +36,10 @@ export function ProfileHeader({ isMuted, onBlockToggle, onMuteToggle, + viewerDid, }: ProfileHeaderProps) { + const isOwnProfile = viewerDid !== null && viewerDid === profile.did + return (
{/* Banner */} @@ -79,21 +85,23 @@ export function ProfileHeader({
- {/* Block/Mute actions */} -
- - -
+ {/* Block/Mute actions (hidden on own profile) */} + {!isOwnProfile && ( +
+ + +
+ )} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..29af6c9 --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,111 @@ +'use client' + +import * as React from 'react' +import * as ToastPrimitives from '@radix-ui/react-toast' +import { X } from '@phosphor-icons/react' + +import { cn } from '@/lib/utils' + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const Toast = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + variant?: 'default' | 'destructive' + } +>(({ className, variant = 'default', ...props }, ref) => ( + +)) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastClose = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +const ToastAction = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +export { + ToastProvider, + ToastViewport, + Toast, + ToastClose, + ToastTitle, + ToastDescription, + ToastAction, +} + +export type ToastActionElement = React.ReactElement diff --git a/src/context/toast-context.tsx b/src/context/toast-context.tsx new file mode 100644 index 0000000..1992781 --- /dev/null +++ b/src/context/toast-context.tsx @@ -0,0 +1,78 @@ +'use client' + +import { createContext, useCallback, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { + ToastProvider as RadixToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} from '@/components/ui/toast' + +export interface ToastMessage { + id: string + title: string + description?: string + variant?: 'default' | 'destructive' + action?: { + label: string + onClick: () => void + altText: string + } +} + +export interface ToastContextValue { + toast: (message: Omit) => void + dismiss: (id: string) => void +} + +export const ToastContext = createContext(null) + +let toastCount = 0 + +export function AppToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const toast = useCallback((message: Omit) => { + const id = String(++toastCount) + setToasts((prev) => [...prev, { ...message, id }]) + }, []) + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, []) + + const value = useMemo(() => ({ toast, dismiss }), [toast, dismiss]) + + return ( + + + {children} + {toasts.map((t) => ( + { + if (!open) dismiss(t.id) + }} + > +
+ {t.title} + {t.description && {t.description}} +
+ {t.action && ( + + {t.action.label} + + )} + +
+ ))} + +
+
+ ) +} diff --git a/src/hooks/use-require-auth.test.ts b/src/hooks/use-require-auth.test.ts new file mode 100644 index 0000000..4a784c2 --- /dev/null +++ b/src/hooks/use-require-auth.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for useRequireAuth hook. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useRequireAuth } from './use-require-auth' +import { useAuth } from '@/hooks/use-auth' + +const mockToast = vi.fn() + +vi.mock('@/hooks/use-auth', () => ({ + useAuth: vi.fn(), +})) + +vi.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ + toast: mockToast, + dismiss: vi.fn(), + }), +})) + +const mockedUseAuth = vi.mocked(useAuth) + +beforeEach(() => { + vi.clearAllMocks() + // Default: logged out + mockedUseAuth.mockReturnValue({ + user: null, + isAuthenticated: false, + isLoading: false, + crossPostScopesGranted: false, + getAccessToken: vi.fn(() => null), + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + requestCrossPostAuth: vi.fn(), + authFetch: vi.fn(), + }) +}) + +describe('useRequireAuth', () => { + it('shows login toast when not authenticated', () => { + const { result } = renderHook(() => useRequireAuth()) + const action = vi.fn() + + act(() => { + result.current.requireAuth(action) + }) + + expect(action).not.toHaveBeenCalled() + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Login required', + description: 'You need to log in before you can perform this action.', + }) + ) + }) + + it('includes login action in toast', () => { + const { result } = renderHook(() => useRequireAuth()) + + act(() => { + result.current.requireAuth(vi.fn()) + }) + + expect(mockToast.mock.calls[0]).toBeDefined() + const toastArg = mockToast.mock.calls[0]![0] as { + action?: { label: string; altText: string; onClick: () => void } + } + expect(toastArg.action).toBeDefined() + expect(toastArg.action?.label).toBe('Log in') + }) + + it('executes action when authenticated', () => { + mockedUseAuth.mockReturnValue({ + user: { did: 'did:plc:test', handle: 'test.bsky.social', displayName: null, avatarUrl: null }, + isAuthenticated: true, + isLoading: false, + crossPostScopesGranted: false, + getAccessToken: vi.fn(() => 'mock-token'), + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + requestCrossPostAuth: vi.fn(), + authFetch: vi.fn(), + }) + + const { result } = renderHook(() => useRequireAuth()) + const action = vi.fn() + + act(() => { + result.current.requireAuth(action) + }) + + expect(action).toHaveBeenCalled() + expect(mockToast).not.toHaveBeenCalled() + }) + + it('exposes isAuthenticated from auth context', () => { + const { result } = renderHook(() => useRequireAuth()) + expect(result.current.isAuthenticated).toBe(false) + }) +}) diff --git a/src/hooks/use-require-auth.ts b/src/hooks/use-require-auth.ts new file mode 100644 index 0000000..7a3ee9f --- /dev/null +++ b/src/hooks/use-require-auth.ts @@ -0,0 +1,38 @@ +'use client' + +import { useCallback } from 'react' +import { useAuth } from '@/hooks/use-auth' +import { useToast } from '@/hooks/use-toast' + +/** + * Returns a guard function that checks auth state before performing an action. + * If the user is not authenticated, shows a toast prompting them to log in. + * If authenticated, executes the callback. + */ +export function useRequireAuth() { + const { isAuthenticated } = useAuth() + const { toast } = useToast() + + const requireAuth = useCallback( + (action: () => void | Promise) => { + if (!isAuthenticated) { + toast({ + title: 'Login required', + description: 'You need to log in before you can perform this action.', + action: { + label: 'Log in', + altText: 'Go to login page', + onClick: () => { + window.location.href = `/login?returnTo=${encodeURIComponent(window.location.pathname)}` + }, + }, + }) + return + } + void action() + }, + [isAuthenticated, toast] + ) + + return { requireAuth, isAuthenticated } +} diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 0000000..6455761 --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,13 @@ +'use client' + +import { useContext } from 'react' +import { ToastContext } from '@/context/toast-context' +import type { ToastContextValue } from '@/context/toast-context' + +export function useToast(): ToastContextValue { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within a ToastProvider') + } + return context +}