Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(profile): edit profile page with PDS sync indicators (#98)

Add /u/[handle]/edit page for per-community profile customization.
Users can override display name and bio with indicators showing
whether each field is synced from AT Protocol or custom. Includes
"Edit profile" button on ProfileHeader (own profile only).

authored by

Guido X Jansen and committed by
GitHub
b848d872 3a62e20e

+547 -2
+277
src/app/u/[handle]/edit/page.test.tsx
··· 1 + /** 2 + * Tests for the Edit Profile page. 3 + * Covers auth gating, own-profile check, form rendering, 4 + * PDS sync indicators, save/cancel, and field reset. 5 + */ 6 + 7 + import { describe, it, expect, vi, beforeEach } from 'vitest' 8 + import { render, screen, waitFor } from '@testing-library/react' 9 + import userEvent from '@testing-library/user-event' 10 + import { EditProfilePage } from './page' 11 + import type { CommunityProfile } from '@/lib/api/types' 12 + 13 + // --------------------------------------------------------------------------- 14 + // Mocks 15 + // --------------------------------------------------------------------------- 16 + 17 + const mockPush = vi.fn() 18 + const mockReplace = vi.fn() 19 + 20 + vi.mock('next/navigation', () => ({ 21 + useRouter: () => ({ 22 + push: mockPush, 23 + replace: mockReplace, 24 + back: vi.fn(), 25 + }), 26 + redirect: vi.fn(), 27 + })) 28 + 29 + const mockUseAuth = vi.fn() 30 + vi.mock('@/hooks/use-auth', () => ({ 31 + useAuth: () => mockUseAuth(), 32 + })) 33 + 34 + const mockUseCommunityProfile = vi.fn() 35 + vi.mock('@/hooks/use-community-profile', () => ({ 36 + useCommunityProfile: () => mockUseCommunityProfile(), 37 + })) 38 + 39 + vi.mock('@/hooks/use-toast', () => ({ 40 + useToast: () => ({ 41 + toast: vi.fn(), 42 + dismiss: vi.fn(), 43 + }), 44 + })) 45 + 46 + // --------------------------------------------------------------------------- 47 + // Helpers 48 + // --------------------------------------------------------------------------- 49 + 50 + function createCommunityProfile(overrides?: Partial<CommunityProfile>): CommunityProfile { 51 + return { 52 + did: 'did:plc:user-alice-001', 53 + handle: 'alice.bsky.social', 54 + displayName: 'Alice', 55 + avatarUrl: 'https://cdn.bsky.social/avatar/alice.jpg', 56 + bannerUrl: null, 57 + bio: 'Community admin and AT Protocol enthusiast.', 58 + communityDid: 'did:plc:test-community-123', 59 + hasOverride: false, 60 + source: { 61 + displayName: 'Alice', 62 + avatarUrl: 'https://cdn.bsky.social/avatar/alice.jpg', 63 + bannerUrl: null, 64 + bio: 'Community admin and AT Protocol enthusiast.', 65 + }, 66 + ...overrides, 67 + } 68 + } 69 + 70 + function defaultAuth(overrides?: Record<string, unknown>) { 71 + return { 72 + user: { did: 'did:plc:user-alice-001', handle: 'alice.bsky.social' }, 73 + isAuthenticated: true, 74 + isLoading: false, 75 + getAccessToken: () => 'mock-token', 76 + login: vi.fn(), 77 + logout: vi.fn(), 78 + setSessionFromCallback: vi.fn(), 79 + authFetch: vi.fn(), 80 + ...overrides, 81 + } 82 + } 83 + 84 + function defaultCommunityProfile(overrides?: Record<string, unknown>) { 85 + return { 86 + communityDid: 'did:plc:test-community-123', 87 + profile: createCommunityProfile(), 88 + loading: false, 89 + saving: false, 90 + error: null, 91 + success: false, 92 + displayName: '', 93 + bio: '', 94 + setDisplayName: vi.fn(), 95 + setBio: vi.fn(), 96 + handleSave: vi.fn(), 97 + handleReset: vi.fn(), 98 + handleAvatarUpload: vi.fn(), 99 + handleBannerUpload: vi.fn(), 100 + handleAvatarRemove: vi.fn(), 101 + handleBannerRemove: vi.fn(), 102 + showResetConfirm: false, 103 + setShowResetConfirm: vi.fn(), 104 + ...overrides, 105 + } 106 + } 107 + 108 + // --------------------------------------------------------------------------- 109 + // Tests 110 + // --------------------------------------------------------------------------- 111 + 112 + describe('EditProfilePage', () => { 113 + beforeEach(() => { 114 + vi.clearAllMocks() 115 + mockUseAuth.mockReturnValue(defaultAuth()) 116 + mockUseCommunityProfile.mockReturnValue(defaultCommunityProfile()) 117 + }) 118 + 119 + describe('auth gating', () => { 120 + it('redirects to home when not authenticated', async () => { 121 + mockUseAuth.mockReturnValue( 122 + defaultAuth({ user: null, isAuthenticated: false, getAccessToken: () => null }) 123 + ) 124 + 125 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 126 + 127 + await waitFor(() => { 128 + expect(mockReplace).toHaveBeenCalledWith('/') 129 + }) 130 + }) 131 + 132 + it('does not redirect when authenticated', () => { 133 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 134 + expect(mockReplace).not.toHaveBeenCalled() 135 + }) 136 + }) 137 + 138 + describe('own profile check', () => { 139 + it('redirects when viewing another user profile', async () => { 140 + render(<EditProfilePage params={{ handle: 'bob.bsky.social' }} />) 141 + 142 + await waitFor(() => { 143 + expect(mockReplace).toHaveBeenCalledWith('/u/bob.bsky.social') 144 + }) 145 + }) 146 + }) 147 + 148 + describe('form rendering', () => { 149 + it('renders display name input', () => { 150 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 151 + expect(screen.getByLabelText(/display name/i)).toBeInTheDocument() 152 + }) 153 + 154 + it('renders bio textarea', () => { 155 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 156 + expect(screen.getByLabelText(/bio/i)).toBeInTheDocument() 157 + }) 158 + 159 + it('renders save button', () => { 160 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 161 + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() 162 + }) 163 + 164 + it('renders cancel link', () => { 165 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 166 + const cancel = screen.getByRole('link', { name: /cancel/i }) 167 + expect(cancel).toHaveAttribute('href', '/u/alice.bsky.social') 168 + }) 169 + }) 170 + 171 + describe('PDS sync indicators', () => { 172 + it('shows "Synced from AT Protocol" when no override exists', () => { 173 + mockUseCommunityProfile.mockReturnValue( 174 + defaultCommunityProfile({ 175 + profile: createCommunityProfile({ hasOverride: false }), 176 + displayName: '', 177 + bio: '', 178 + }) 179 + ) 180 + 181 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 182 + const indicators = screen.getAllByText(/synced from at protocol/i) 183 + expect(indicators.length).toBeGreaterThanOrEqual(1) 184 + }) 185 + 186 + it('shows "Custom for this community" when override exists', () => { 187 + mockUseCommunityProfile.mockReturnValue( 188 + defaultCommunityProfile({ 189 + profile: createCommunityProfile({ hasOverride: true }), 190 + displayName: 'Custom Alice', 191 + bio: 'Custom bio', 192 + }) 193 + ) 194 + 195 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 196 + const indicators = screen.getAllByText(/custom for this community/i) 197 + expect(indicators.length).toBeGreaterThanOrEqual(1) 198 + }) 199 + }) 200 + 201 + describe('save and cancel', () => { 202 + it('calls handleSave on form submit', async () => { 203 + const handleSave = vi.fn((e: React.FormEvent) => e.preventDefault()) 204 + mockUseCommunityProfile.mockReturnValue(defaultCommunityProfile({ handleSave })) 205 + 206 + const user = userEvent.setup() 207 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 208 + 209 + await user.click(screen.getByRole('button', { name: /save/i })) 210 + expect(handleSave).toHaveBeenCalled() 211 + }) 212 + 213 + it('disables save button while saving', () => { 214 + mockUseCommunityProfile.mockReturnValue(defaultCommunityProfile({ saving: true })) 215 + 216 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 217 + expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled() 218 + }) 219 + }) 220 + 221 + describe('reset to AT Protocol', () => { 222 + it('shows reset buttons for each field when override exists', () => { 223 + mockUseCommunityProfile.mockReturnValue( 224 + defaultCommunityProfile({ 225 + profile: createCommunityProfile({ hasOverride: true }), 226 + displayName: 'Custom Alice', 227 + bio: 'Custom bio', 228 + }) 229 + ) 230 + 231 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 232 + const resetButtons = screen.getAllByRole('button', { name: /reset to at protocol/i }) 233 + expect(resetButtons.length).toBe(2) 234 + }) 235 + 236 + it('calls setDisplayName with empty string when reset display name', async () => { 237 + const setDisplayName = vi.fn() 238 + mockUseCommunityProfile.mockReturnValue( 239 + defaultCommunityProfile({ 240 + profile: createCommunityProfile({ hasOverride: true }), 241 + displayName: 'Custom Alice', 242 + bio: '', 243 + setDisplayName, 244 + }) 245 + ) 246 + 247 + const user = userEvent.setup() 248 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 249 + 250 + const resetButtons = screen.getAllByRole('button', { name: /reset to at protocol/i }) 251 + await user.click(resetButtons[0]!) 252 + expect(setDisplayName).toHaveBeenCalledWith('') 253 + }) 254 + }) 255 + 256 + describe('loading state', () => { 257 + it('shows loading state while profile loads', () => { 258 + mockUseCommunityProfile.mockReturnValue( 259 + defaultCommunityProfile({ loading: true, profile: null }) 260 + ) 261 + 262 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 263 + expect(screen.getByText(/loading/i)).toBeInTheDocument() 264 + }) 265 + }) 266 + 267 + describe('error state', () => { 268 + it('shows error message', () => { 269 + mockUseCommunityProfile.mockReturnValue( 270 + defaultCommunityProfile({ error: 'Failed to load community profile.' }) 271 + ) 272 + 273 + render(<EditProfilePage params={{ handle: 'alice.bsky.social' }} />) 274 + expect(screen.getByText(/failed to load community profile/i)).toBeInTheDocument() 275 + }) 276 + }) 277 + })
+217
src/app/u/[handle]/edit/page.tsx
··· 1 + /** 2 + * Edit Profile page -- per-community profile overrides. 3 + * URL: /u/[handle]/edit 4 + * Auth-gated. Only accessible for own profile. 5 + * @see specs/prd-web.md Section M8 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useEffect, useState } from 'react' 11 + import Link from 'next/link' 12 + import { useRouter } from 'next/navigation' 13 + import { ArrowCounterClockwise } from '@phosphor-icons/react' 14 + import { cn } from '@/lib/utils' 15 + import { ForumLayout } from '@/components/layout/forum-layout' 16 + import { Breadcrumbs } from '@/components/breadcrumbs' 17 + import { useAuth } from '@/hooks/use-auth' 18 + import { useCommunityProfile } from '@/hooks/use-community-profile' 19 + 20 + const DISPLAY_NAME_MAX = 256 21 + const BIO_MAX = 2048 22 + 23 + interface EditProfilePageProps { 24 + params: Promise<{ handle: string }> | { handle: string } 25 + } 26 + 27 + export function EditProfilePage({ params }: EditProfilePageProps) { 28 + const router = useRouter() 29 + const { user, isAuthenticated, isLoading: authLoading } = useAuth() 30 + const { 31 + profile, 32 + loading, 33 + saving, 34 + error, 35 + success, 36 + displayName, 37 + bio, 38 + setDisplayName, 39 + setBio, 40 + handleSave, 41 + } = useCommunityProfile() 42 + 43 + const [handle, setHandle] = useState<string | null>(null) 44 + 45 + // Resolve Next.js async params 46 + useEffect(() => { 47 + async function resolveParams() { 48 + const resolved = params instanceof Promise ? await params : params 49 + setHandle(resolved.handle) 50 + } 51 + void resolveParams() 52 + }, [params]) 53 + 54 + // Auth gate: redirect unauthenticated users 55 + useEffect(() => { 56 + if (authLoading) return 57 + if (!isAuthenticated || !user) { 58 + router.replace('/') 59 + } 60 + }, [authLoading, isAuthenticated, user, router]) 61 + 62 + // Own-profile gate: redirect if viewing someone else's profile 63 + useEffect(() => { 64 + if (!handle || !user) return 65 + if (user.handle !== handle) { 66 + router.replace(`/u/${handle}`) 67 + } 68 + }, [handle, user, router]) 69 + 70 + // Don't render until we know the user is authenticated and it's their profile 71 + if (authLoading || !user || !handle) { 72 + return ( 73 + <ForumLayout> 74 + <p className="text-sm text-muted-foreground">Loading...</p> 75 + </ForumLayout> 76 + ) 77 + } 78 + 79 + if (user.handle !== handle) { 80 + return null 81 + } 82 + 83 + if (loading) { 84 + return ( 85 + <ForumLayout> 86 + <p className="text-sm text-muted-foreground">Loading...</p> 87 + </ForumLayout> 88 + ) 89 + } 90 + 91 + if (error) { 92 + return ( 93 + <ForumLayout> 94 + <div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-center"> 95 + <p className="text-sm text-destructive">{error}</p> 96 + </div> 97 + </ForumLayout> 98 + ) 99 + } 100 + 101 + const hasOverride = profile?.hasOverride ?? false 102 + const sourceDisplayName = profile?.source.displayName ?? '' 103 + const sourceBio = profile?.source.bio ?? '' 104 + 105 + const displayNameOverridden = hasOverride && displayName.trim() !== '' 106 + const bioOverridden = hasOverride && bio.trim() !== '' 107 + 108 + const inputClasses = cn( 109 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 110 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 111 + ) 112 + 113 + return ( 114 + <ForumLayout> 115 + <div className="mx-auto max-w-2xl space-y-6"> 116 + <Breadcrumbs 117 + items={[ 118 + { label: 'Home', href: '/' }, 119 + { label: handle, href: `/u/${handle}` }, 120 + { label: 'Edit profile' }, 121 + ]} 122 + /> 123 + 124 + <h1 className="text-2xl font-bold text-foreground">Edit profile</h1> 125 + 126 + <form onSubmit={handleSave} className="space-y-6"> 127 + {/* Display name */} 128 + <div className="space-y-1"> 129 + <label 130 + htmlFor="edit-display-name" 131 + className="block text-sm font-medium text-foreground" 132 + > 133 + Display name 134 + </label> 135 + <input 136 + id="edit-display-name" 137 + type="text" 138 + value={displayName} 139 + onChange={(e) => setDisplayName(e.target.value)} 140 + placeholder={sourceDisplayName || 'Display name'} 141 + maxLength={DISPLAY_NAME_MAX} 142 + className={inputClasses} 143 + /> 144 + <div className="flex items-center justify-between"> 145 + <p className="text-xs text-muted-foreground"> 146 + {displayNameOverridden ? 'Custom for this community' : 'Synced from AT Protocol'} 147 + </p> 148 + {displayNameOverridden && ( 149 + <button 150 + type="button" 151 + onClick={() => setDisplayName('')} 152 + className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground" 153 + > 154 + <ArrowCounterClockwise size={12} aria-hidden="true" /> 155 + Reset to AT Protocol 156 + </button> 157 + )} 158 + </div> 159 + </div> 160 + 161 + {/* Bio */} 162 + <div className="space-y-1"> 163 + <label htmlFor="edit-bio" className="block text-sm font-medium text-foreground"> 164 + Bio 165 + </label> 166 + <textarea 167 + id="edit-bio" 168 + value={bio} 169 + onChange={(e) => setBio(e.target.value)} 170 + placeholder={sourceBio || 'Tell the community about yourself'} 171 + maxLength={BIO_MAX} 172 + rows={4} 173 + className={inputClasses} 174 + /> 175 + <div className="flex items-center justify-between"> 176 + <p className="text-xs text-muted-foreground"> 177 + {bioOverridden ? 'Custom for this community' : 'Synced from AT Protocol'} 178 + </p> 179 + {bioOverridden && ( 180 + <button 181 + type="button" 182 + onClick={() => setBio('')} 183 + className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground" 184 + > 185 + <ArrowCounterClockwise size={12} aria-hidden="true" /> 186 + Reset to AT Protocol 187 + </button> 188 + )} 189 + </div> 190 + </div> 191 + 192 + {/* Success message */} 193 + {success && <p className="text-sm text-green-600">Profile updated.</p>} 194 + 195 + {/* Actions */} 196 + <div className="flex items-center gap-3"> 197 + <button 198 + type="submit" 199 + disabled={saving} 200 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" 201 + > 202 + {saving ? 'Saving...' : 'Save changes'} 203 + </button> 204 + <Link 205 + href={`/u/${handle}`} 206 + className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted" 207 + > 208 + Cancel 209 + </Link> 210 + </div> 211 + </form> 212 + </div> 213 + </ForumLayout> 214 + ) 215 + } 216 + 217 + export default EditProfilePage
+37
src/components/profile/profile-header.test.tsx
··· 192 192 render(<ProfileHeader profile={createProfile()} {...defaultProps} />) 193 193 expect(screen.queryByText(/activity across all communities/i)).not.toBeInTheDocument() 194 194 }) 195 + 196 + describe('edit profile button', () => { 197 + it('shows "Edit profile" link when viewing own profile', () => { 198 + render( 199 + <ProfileHeader 200 + profile={createProfile({ did: 'did:plc:test-user' })} 201 + {...defaultProps} 202 + viewerDid="did:plc:test-user" 203 + /> 204 + ) 205 + const link = screen.getByRole('link', { name: /edit profile/i }) 206 + expect(link).toBeInTheDocument() 207 + expect(link).toHaveAttribute('href', '/u/test.bsky.social/edit') 208 + }) 209 + 210 + it('hides "Edit profile" link when viewing another user profile', () => { 211 + render( 212 + <ProfileHeader 213 + profile={createProfile({ did: 'did:plc:test-user' })} 214 + {...defaultProps} 215 + viewerDid="did:plc:other-user" 216 + /> 217 + ) 218 + expect(screen.queryByRole('link', { name: /edit profile/i })).not.toBeInTheDocument() 219 + }) 220 + 221 + it('hides "Edit profile" link when not authenticated', () => { 222 + render( 223 + <ProfileHeader 224 + profile={createProfile({ did: 'did:plc:test-user' })} 225 + {...defaultProps} 226 + viewerDid={null} 227 + /> 228 + ) 229 + expect(screen.queryByRole('link', { name: /edit profile/i })).not.toBeInTheDocument() 230 + }) 231 + }) 195 232 })
+16 -2
src/components/profile/profile-header.tsx
··· 6 6 7 7 'use client' 8 8 9 + import Link from 'next/link' 9 10 import Image from 'next/image' 10 - import { User, CalendarBlank, ChatCircle, ArrowUp } from '@phosphor-icons/react' 11 + import { User, CalendarBlank, ChatCircle, ArrowUp, PencilSimple } from '@phosphor-icons/react' 11 12 import { ReputationBadge } from '@/components/reputation-badge' 12 13 import { BlockMuteButton } from '@/components/block-mute-button' 13 14 import { ProfileStats } from '@/components/profile/profile-stats' ··· 69 70 )} 70 71 71 72 <div className="min-w-0 flex-1"> 72 - <h1 className="text-2xl font-bold text-foreground">{profile.displayName ?? handle}</h1> 73 + <div className="flex items-center gap-2"> 74 + <h1 className="text-2xl font-bold text-foreground"> 75 + {profile.displayName ?? handle} 76 + </h1> 77 + {isOwnProfile && ( 78 + <Link 79 + href={`/u/${handle}/edit`} 80 + className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-muted hover:text-foreground" 81 + > 82 + <PencilSimple size={16} aria-hidden="true" /> 83 + Edit profile 84 + </Link> 85 + )} 86 + </div> 73 87 {profile.displayName && <p className="text-lg text-muted-foreground">@{handle}</p>} 74 88 75 89 {/* Bio */}