Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat: enhanced profile page with AT Protocol stats and bio formatting (#95)

* feat(types): add AT Protocol stats and community-scoped activity to UserProfile

* feat(bio): add bio formatting with linebreaks and autolinked URLs

* feat(profile): render AT Protocol stats, Bluesky link, and community-scoped activity

authored by

Guido X Jansen and committed by
GitHub
72e2eea0 e96e15ed

+402 -4
+23
src/app/u/[handle]/page.test.tsx
··· 84 84 expect(screen.getByText(/api 404/i)).toBeInTheDocument() 85 85 }) 86 86 }) 87 + 88 + it('renders community-scoped profile with globalActivity', async () => { 89 + render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 90 + // Alice's mock data has globalActivity (communityCount: 2) 91 + await waitFor(() => { 92 + expect(screen.getByText(/activity across all communities/i)).toBeInTheDocument() 93 + }) 94 + }) 95 + 96 + it('renders AT Protocol stats', async () => { 97 + render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 98 + await waitFor(() => { 99 + expect(screen.getByText(/150 followers/i)).toBeInTheDocument() 100 + }) 101 + }) 102 + 103 + it('renders Bluesky link', async () => { 104 + render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 105 + await waitFor(() => { 106 + const link = screen.getByRole('link', { name: /view on bluesky/i }) 107 + expect(link).toHaveAttribute('href', 'https://bsky.app/profile/alice.bsky.social') 108 + }) 109 + }) 87 110 })
+6 -2
src/app/u/[handle]/page.tsx
··· 13 13 import { Breadcrumbs } from '@/components/breadcrumbs' 14 14 import { ProfileHeader } from '@/components/profile/profile-header' 15 15 import { ProfileSkeleton } from '@/components/profile/profile-skeleton' 16 - import { getUserProfile } from '@/lib/api/client' 16 + import { getUserProfile, getPublicSettings } from '@/lib/api/client' 17 17 import { useAuth } from '@/hooks/use-auth' 18 18 import type { UserProfile } from '@/lib/api/types' 19 19 ··· 55 55 setLoading(true) 56 56 setError(null) 57 57 try { 58 - const data = await getUserProfile(handle!, undefined, { 58 + const publicSettings = await getPublicSettings({ 59 + signal: controller.signal, 60 + }).catch(() => null) 61 + const communityDid = publicSettings?.communityDid ?? undefined 62 + const data = await getUserProfile(handle!, communityDid, { 59 63 signal: controller.signal, 60 64 }) 61 65 if (!cancelled) {
+195
src/components/profile/profile-header.test.tsx
··· 1 + import { describe, it, expect, vi } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + import { ProfileHeader } from './profile-header' 4 + import type { UserProfile } from '@/lib/api/types' 5 + 6 + // Mock next/image 7 + vi.mock('next/image', () => ({ 8 + default: function MockImage(props: Record<string, unknown>) { 9 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 10 + return <img {...props} /> 11 + }, 12 + })) 13 + 14 + // Mock useAuth hook (used by BlockMuteButton) 15 + vi.mock('@/hooks/use-auth', () => ({ 16 + useAuth: () => ({ 17 + user: null, 18 + isAuthenticated: false, 19 + isLoading: false, 20 + getAccessToken: () => null, 21 + login: vi.fn(), 22 + logout: vi.fn(), 23 + setSessionFromCallback: vi.fn(), 24 + authFetch: vi.fn(), 25 + }), 26 + })) 27 + 28 + // Mock useToast hook (used by BlockMuteButton via useRequireAuth) 29 + vi.mock('@/hooks/use-toast', () => ({ 30 + useToast: () => ({ 31 + toast: vi.fn(), 32 + dismiss: vi.fn(), 33 + }), 34 + })) 35 + 36 + // Mock next/navigation 37 + vi.mock('next/navigation', () => ({ 38 + useRouter: () => ({ 39 + push: vi.fn(), 40 + replace: vi.fn(), 41 + back: vi.fn(), 42 + }), 43 + redirect: vi.fn(), 44 + })) 45 + 46 + function createProfile(overrides?: Partial<UserProfile>): UserProfile { 47 + return { 48 + did: 'did:plc:test-user', 49 + handle: 'test.bsky.social', 50 + displayName: 'Test User', 51 + avatarUrl: 'https://cdn.bsky.social/avatar/test.jpg', 52 + bannerUrl: null, 53 + bio: null, 54 + role: 'user', 55 + firstSeenAt: '2026-02-14T12:00:00.000Z', 56 + lastActiveAt: '2026-02-14T12:00:00.000Z', 57 + followersCount: 0, 58 + followsCount: 0, 59 + atprotoPostsCount: 0, 60 + hasBlueskyProfile: false, 61 + communityCount: 1, 62 + activity: { 63 + topicCount: 0, 64 + replyCount: 0, 65 + reactionsReceived: 0, 66 + votesReceived: 0, 67 + }, 68 + ...overrides, 69 + } 70 + } 71 + 72 + const defaultProps = { 73 + handle: 'test.bsky.social', 74 + reputationScore: 0, 75 + postCount: 0, 76 + joinDate: 'February 14, 2026', 77 + isBlocked: false, 78 + isMuted: false, 79 + onBlockToggle: vi.fn(), 80 + onMuteToggle: vi.fn(), 81 + viewerDid: null, 82 + } 83 + 84 + describe('ProfileHeader', () => { 85 + it('renders display name', () => { 86 + render(<ProfileHeader profile={createProfile()} {...defaultProps} />) 87 + expect(screen.getByRole('heading', { name: /test user/i })).toBeInTheDocument() 88 + }) 89 + 90 + it('renders handle', () => { 91 + render(<ProfileHeader profile={createProfile()} {...defaultProps} />) 92 + expect(screen.getByText(/@test\.bsky\.social/)).toBeInTheDocument() 93 + }) 94 + 95 + it('renders bio with linebreaks', () => { 96 + const { container } = render( 97 + <ProfileHeader profile={createProfile({ bio: 'Line 1\nLine 2' })} {...defaultProps} /> 98 + ) 99 + // The bio div uses dangerouslySetInnerHTML, select by div.mt-2.text-muted-foreground 100 + const bioDiv = container.querySelector('div.mt-2.text-sm.text-muted-foreground') 101 + expect(bioDiv?.innerHTML).toContain('<br>') 102 + }) 103 + 104 + it('renders bio with autolinked URLs', () => { 105 + render( 106 + <ProfileHeader 107 + profile={createProfile({ bio: 'Visit https://example.com' })} 108 + {...defaultProps} 109 + /> 110 + ) 111 + const link = screen.getByRole('link', { name: /https:\/\/example\.com/i }) 112 + expect(link).toHaveAttribute('href', 'https://example.com') 113 + }) 114 + 115 + it('sanitizes XSS in bio', () => { 116 + const { container } = render( 117 + <ProfileHeader 118 + profile={createProfile({ bio: '<script>alert("xss")</script>' })} 119 + {...defaultProps} 120 + /> 121 + ) 122 + expect(container.querySelector('script')).toBeNull() 123 + }) 124 + 125 + it('renders avatar from URL', () => { 126 + render(<ProfileHeader profile={createProfile()} {...defaultProps} />) 127 + const img = screen.getByAltText("Test User's avatar") 128 + expect(img).toBeInTheDocument() 129 + }) 130 + 131 + it('renders fallback avatar when no URL', () => { 132 + render(<ProfileHeader profile={createProfile({ avatarUrl: null })} {...defaultProps} />) 133 + expect(screen.queryByAltText("Test User's avatar")).not.toBeInTheDocument() 134 + }) 135 + 136 + it('renders followers and following counts', () => { 137 + render( 138 + <ProfileHeader 139 + profile={createProfile({ followersCount: 150, followsCount: 75 })} 140 + {...defaultProps} 141 + /> 142 + ) 143 + expect(screen.getByText(/150 followers/i)).toBeInTheDocument() 144 + expect(screen.getByText(/75 following/i)).toBeInTheDocument() 145 + }) 146 + 147 + it('renders Bluesky link when hasBlueskyProfile is true', () => { 148 + render(<ProfileHeader profile={createProfile({ hasBlueskyProfile: true })} {...defaultProps} />) 149 + const link = screen.getByRole('link', { name: /view on bluesky/i }) 150 + expect(link).toHaveAttribute('href', 'https://bsky.app/profile/test.bsky.social') 151 + }) 152 + 153 + it('does not render Bluesky link when hasBlueskyProfile is false', () => { 154 + render( 155 + <ProfileHeader profile={createProfile({ hasBlueskyProfile: false })} {...defaultProps} /> 156 + ) 157 + expect(screen.queryByRole('link', { name: /view on bluesky/i })).not.toBeInTheDocument() 158 + }) 159 + 160 + it('renders votesReceived in stats', () => { 161 + render( 162 + <ProfileHeader 163 + profile={createProfile({ 164 + activity: { topicCount: 5, replyCount: 10, reactionsReceived: 20, votesReceived: 15 }, 165 + })} 166 + {...defaultProps} 167 + postCount={15} 168 + /> 169 + ) 170 + expect(screen.getByText(/15 votes/i)).toBeInTheDocument() 171 + }) 172 + 173 + it('renders globalActivity section when present', () => { 174 + render( 175 + <ProfileHeader 176 + profile={createProfile({ 177 + globalActivity: { 178 + topicCount: 25, 179 + replyCount: 60, 180 + reactionsReceived: 120, 181 + votesReceived: 55, 182 + }, 183 + })} 184 + {...defaultProps} 185 + /> 186 + ) 187 + expect(screen.getByText(/activity across all communities/i)).toBeInTheDocument() 188 + expect(screen.getByText(/25 topics/i)).toBeInTheDocument() 189 + }) 190 + 191 + it('does not render globalActivity section when absent', () => { 192 + render(<ProfileHeader profile={createProfile()} {...defaultProps} />) 193 + expect(screen.queryByText(/activity across all communities/i)).not.toBeInTheDocument() 194 + }) 195 + })
+30 -2
src/components/profile/profile-header.tsx
··· 7 7 'use client' 8 8 9 9 import Image from 'next/image' 10 - import { User, CalendarBlank, ChatCircle } from '@phosphor-icons/react' 10 + import { User, CalendarBlank, ChatCircle, ArrowUp } from '@phosphor-icons/react' 11 11 import { ReputationBadge } from '@/components/reputation-badge' 12 12 import { BlockMuteButton } from '@/components/block-mute-button' 13 + import { ProfileStats } from '@/components/profile/profile-stats' 14 + import { formatBio } from '@/lib/format-bio' 13 15 import type { UserProfile } from '@/lib/api/types' 14 16 15 17 interface ProfileHeaderProps { ··· 71 73 {profile.displayName && <p className="text-lg text-muted-foreground">@{handle}</p>} 72 74 73 75 {/* Bio */} 74 - {profile.bio && <p className="mt-2 text-sm text-muted-foreground">{profile.bio}</p>} 76 + {profile.bio && ( 77 + <div 78 + className="mt-2 text-sm text-muted-foreground" 79 + dangerouslySetInnerHTML={{ __html: formatBio(profile.bio) }} 80 + /> 81 + )} 75 82 76 83 <div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> 77 84 <ReputationBadge score={reputationScore} /> ··· 80 87 {postCount} {postCount === 1 ? 'post' : 'posts'} 81 88 </span> 82 89 <span className="flex items-center gap-1"> 90 + <ArrowUp size={16} aria-hidden="true" /> 91 + {profile.activity.votesReceived}{' '} 92 + {profile.activity.votesReceived === 1 ? 'vote' : 'votes'} 93 + </span> 94 + <span className="flex items-center gap-1"> 83 95 <CalendarBlank size={16} aria-hidden="true" /> 84 96 Joined {joinDate} 85 97 </span> 86 98 </div> 99 + 100 + <ProfileStats profile={profile} handle={handle} /> 87 101 88 102 {/* Block/Mute actions (hidden on own profile) */} 89 103 {!isOwnProfile && ( ··· 100 114 isActive={isMuted} 101 115 onToggle={onMuteToggle} 102 116 /> 117 + </div> 118 + )} 119 + 120 + {profile.globalActivity && ( 121 + <div className="mt-4 rounded-md bg-muted/50 p-3"> 122 + <p className="text-xs font-medium text-muted-foreground"> 123 + Activity across all communities 124 + </p> 125 + <div className="mt-1 flex flex-wrap gap-3 text-sm text-muted-foreground"> 126 + <span>{profile.globalActivity.topicCount} topics</span> 127 + <span>{profile.globalActivity.replyCount} replies</span> 128 + <span>{profile.globalActivity.reactionsReceived} reactions</span> 129 + <span>{profile.globalActivity.votesReceived} votes</span> 130 + </div> 103 131 </div> 104 132 )} 105 133 </div>
+42
src/components/profile/profile-stats.tsx
··· 1 + /** 2 + * ProfileStats - Renders AT Protocol stats (followers, following, posts) and Bluesky link. 3 + * Extracted from ProfileHeader to keep components under ~150 lines. 4 + * @see specs/prd-web.md Section M8 5 + */ 6 + 7 + import { Users, ArrowSquareOut } from '@phosphor-icons/react' 8 + import type { UserProfile } from '@/lib/api/types' 9 + 10 + interface ProfileStatsProps { 11 + profile: UserProfile 12 + handle: string 13 + } 14 + 15 + export function ProfileStats({ profile, handle }: ProfileStatsProps) { 16 + return ( 17 + <> 18 + {/* AT Protocol stats */} 19 + <div className="mt-2 flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> 20 + <span className="flex items-center gap-1"> 21 + <Users size={16} aria-hidden="true" /> 22 + {profile.followersCount} {profile.followersCount === 1 ? 'follower' : 'followers'} 23 + </span> 24 + <span>{profile.followsCount} following</span> 25 + <span>{profile.atprotoPostsCount} AT Proto posts</span> 26 + </div> 27 + 28 + {/* Bluesky link */} 29 + {profile.hasBlueskyProfile && ( 30 + <a 31 + href={`https://bsky.app/profile/${handle}`} 32 + target="_blank" 33 + rel="noopener noreferrer" 34 + className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline" 35 + > 36 + View on Bluesky 37 + <ArrowSquareOut size={14} aria-hidden="true" /> 38 + </a> 39 + )} 40 + </> 41 + ) 42 + }
+12
src/lib/api/types.ts
··· 568 568 role: string 569 569 firstSeenAt: string 570 570 lastActiveAt: string 571 + followersCount: number 572 + followsCount: number 573 + atprotoPostsCount: number 574 + hasBlueskyProfile: boolean 575 + communityCount: number 571 576 activity: { 572 577 topicCount: number 573 578 replyCount: number 574 579 reactionsReceived: number 580 + votesReceived: number 581 + } 582 + globalActivity?: { 583 + topicCount: number 584 + replyCount: number 585 + reactionsReceived: number 586 + votesReceived: number 575 587 } 576 588 } 577 589
+44
src/lib/format-bio.test.ts
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { formatBio } from './format-bio' 3 + 4 + describe('formatBio', () => { 5 + it('converts newlines to <br> tags', () => { 6 + const result = formatBio('Line 1\nLine 2') 7 + expect(result).toContain('<br') 8 + expect(result).toContain('Line 1') 9 + expect(result).toContain('Line 2') 10 + }) 11 + 12 + it('autolinks URLs with rel="noopener noreferrer"', () => { 13 + const result = formatBio('Visit https://example.com for more') 14 + expect(result).toContain('<a href="https://example.com"') 15 + expect(result).toContain('rel="noopener noreferrer"') 16 + }) 17 + 18 + it('escapes HTML tags', () => { 19 + const result = formatBio('<script>alert("xss")</script>') 20 + expect(result).not.toContain('<script>') 21 + }) 22 + 23 + it('handles mixed content (text + URLs + newlines)', () => { 24 + const result = formatBio('Hello!\nVisit https://example.com\nBye') 25 + expect(result).toContain('<br') 26 + expect(result).toContain('<a href="https://example.com"') 27 + expect(result).toContain('Hello!') 28 + expect(result).toContain('Bye') 29 + }) 30 + 31 + it('returns empty string for empty input', () => { 32 + expect(formatBio('')).toBe('') 33 + }) 34 + 35 + it('handles http URLs', () => { 36 + const result = formatBio('Visit http://example.com') 37 + expect(result).toContain('<a href="http://example.com"') 38 + }) 39 + 40 + it('does not link partial URLs', () => { 41 + const result = formatBio('Not a link: example.com') 42 + expect(result).not.toContain('<a') 43 + }) 44 + })
+32
src/lib/format-bio.ts
··· 1 + import { sanitize } from 'isomorphic-dompurify' 2 + 3 + /** 4 + * Formats a bio string: escapes HTML, autolinks URLs, converts newlines to <br>, 5 + * then sanitizes with DOMPurify (only <a> and <br> allowed). 6 + */ 7 + export function formatBio(bio: string): string { 8 + if (!bio) return '' 9 + 10 + // Step 1: Escape HTML entities 11 + let result = bio 12 + .replace(/&/g, '&amp;') 13 + .replace(/</g, '&lt;') 14 + .replace(/>/g, '&gt;') 15 + .replace(/"/g, '&quot;') 16 + .replace(/'/g, '&#39;') 17 + 18 + // Step 2: Autolink URLs (only http:// and https://) 19 + result = result.replace( 20 + /https?:\/\/[^\s<]+/g, 21 + (url) => `<a href="${url}" rel="noopener noreferrer">${url}</a>` 22 + ) 23 + 24 + // Step 3: Convert newlines to <br> 25 + result = result.replace(/\n/g, '<br>') 26 + 27 + // Step 4: Sanitize (only allow <a> and <br>) 28 + return sanitize(result, { 29 + ALLOWED_TAGS: ['a', 'br'], 30 + ALLOWED_ATTR: ['href', 'rel'], 31 + }) 32 + }
+18
src/mocks/data.ts
··· 1036 1036 role: 'admin', 1037 1037 firstSeenAt: TWO_DAYS_AGO, 1038 1038 lastActiveAt: NOW, 1039 + followersCount: 150, 1040 + followsCount: 75, 1041 + atprotoPostsCount: 230, 1042 + hasBlueskyProfile: true, 1043 + communityCount: 2, 1039 1044 activity: { 1040 1045 topicCount: 15, 1041 1046 replyCount: 42, 1042 1047 reactionsReceived: 89, 1048 + votesReceived: 34, 1049 + }, 1050 + globalActivity: { 1051 + topicCount: 25, 1052 + replyCount: 60, 1053 + reactionsReceived: 120, 1054 + votesReceived: 55, 1043 1055 }, 1044 1056 }, 1045 1057 'bob.bsky.social': { ··· 1052 1064 role: 'moderator', 1053 1065 firstSeenAt: TWO_DAYS_AGO, 1054 1066 lastActiveAt: YESTERDAY, 1067 + followersCount: 30, 1068 + followsCount: 20, 1069 + atprotoPostsCount: 45, 1070 + hasBlueskyProfile: true, 1071 + communityCount: 1, 1055 1072 activity: { 1056 1073 topicCount: 8, 1057 1074 replyCount: 31, 1058 1075 reactionsReceived: 45, 1076 + votesReceived: 12, 1059 1077 }, 1060 1078 }, 1061 1079 }