Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(profile): display AT Protocol labels on profile card (#99)

* feat(types): add labels to UserProfile type and update mocks

* test(profile-labels): add failing tests for label pill component

7 tests covering: empty array returns null, self-label neutral styling,
self-label warning styling, moderator label with ShieldCheck icon,
moderator warning label with ShieldWarning icon, ! prefix stripping,
and multiple labels rendering.

* feat(profile-labels): implement label pill component with 4 visual variants

Self-labels: neutral (bg-muted) or warning (orange). Moderator labels:
info with ShieldCheck (purple) or warning with ShieldWarning (red).
Strips ! prefix from display text. Returns null for empty arrays.

* feat(profile-header): integrate AT Protocol labels display

authored by

Guido X Jansen and committed by
GitHub
784e2e8f 2b0035ab

+183
+18
src/components/profile/profile-header.test.tsx
··· 59 59 atprotoPostsCount: 0, 60 60 hasBlueskyProfile: false, 61 61 communityCount: 1, 62 + labels: [], 62 63 activity: { 63 64 topicCount: 0, 64 65 replyCount: 0, ··· 285 286 ) 286 287 expect(screen.queryByRole('link', { name: /edit profile/i })).not.toBeInTheDocument() 287 288 }) 289 + }) 290 + 291 + it('renders labels section when labels are present', () => { 292 + render( 293 + <ProfileHeader 294 + profile={createProfile({ 295 + labels: [{ val: 'adult-content', src: 'did:plc:test-user', isSelfLabel: true }], 296 + })} 297 + {...defaultProps} 298 + /> 299 + ) 300 + expect(screen.getByText('adult-content')).toBeInTheDocument() 301 + }) 302 + 303 + it('does not render labels section when labels are empty', () => { 304 + render(<ProfileHeader profile={createProfile({ labels: [] })} {...defaultProps} />) 305 + expect(screen.queryByText('adult-content')).not.toBeInTheDocument() 288 306 }) 289 307 })
+4
src/components/profile/profile-header.tsx
··· 23 23 import { BlockMuteButton } from '@/components/block-mute-button' 24 24 import { ProfileStats } from '@/components/profile/profile-stats' 25 25 import { formatBio } from '@/lib/format-bio' 26 + import { ProfileLabels } from '@/components/profile/profile-labels' 26 27 import { formatCount } from '@/lib/format-count' 27 28 import type { UserProfile } from '@/lib/api/types' 28 29 ··· 99 100 </Link> 100 101 )} 101 102 </div> 103 + 104 + {/* AT Protocol labels */} 105 + {profile.labels.length > 0 && <ProfileLabels labels={profile.labels} />} 102 106 103 107 {/* Bio */} 104 108 {profile.bio && (
+83
src/components/profile/profile-labels.test.tsx
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + import { ProfileLabels } from './profile-labels' 4 + 5 + describe('ProfileLabels', () => { 6 + it('returns null when labels array is empty', () => { 7 + const { container } = render(<ProfileLabels labels={[]} />) 8 + expect(container.firstChild).toBeNull() 9 + }) 10 + 11 + it('renders a self-label with neutral styling', () => { 12 + render( 13 + <ProfileLabels labels={[{ val: 'adult-content', src: 'did:plc:user1', isSelfLabel: true }]} /> 14 + ) 15 + 16 + const pill = screen.getByText('adult-content') 17 + expect(pill).toBeInTheDocument() 18 + expect(pill.className).toContain('bg-muted') 19 + expect(pill.className).toContain('text-muted-foreground') 20 + }) 21 + 22 + it('renders a self-label with warning styling for known warning values', () => { 23 + render(<ProfileLabels labels={[{ val: 'porn', src: 'did:plc:user1', isSelfLabel: true }]} />) 24 + 25 + const pill = screen.getByText('porn') 26 + expect(pill).toBeInTheDocument() 27 + expect(pill.className).toMatch(/bg-\[var\(--orange-3\)\]/) 28 + expect(pill.className).toMatch(/text-\[var\(--orange-11\)\]/) 29 + }) 30 + 31 + it('renders a moderator label with ShieldCheck icon', () => { 32 + render( 33 + <ProfileLabels 34 + labels={[{ val: 'verified', src: 'did:plc:mod-service', isSelfLabel: false }]} 35 + /> 36 + ) 37 + 38 + const pill = screen.getByText('verified') 39 + expect(pill).toBeInTheDocument() 40 + expect(pill.className).toMatch(/bg-\[var\(--purple-3\)\]/) 41 + expect(pill.className).toMatch(/text-\[var\(--purple-11\)\]/) 42 + const icon = pill.querySelector('svg') 43 + expect(icon).toBeInTheDocument() 44 + }) 45 + 46 + it('renders a moderator warning label with ShieldWarning icon', () => { 47 + render( 48 + <ProfileLabels 49 + labels={[{ val: 'impersonation', src: 'did:plc:mod-service', isSelfLabel: false }]} 50 + /> 51 + ) 52 + 53 + const pill = screen.getByText('impersonation') 54 + expect(pill).toBeInTheDocument() 55 + expect(pill.className).toMatch(/bg-\[var\(--red-3\)\]/) 56 + expect(pill.className).toMatch(/text-\[var\(--red-11\)\]/) 57 + const icon = pill.querySelector('svg') 58 + expect(icon).toBeInTheDocument() 59 + }) 60 + 61 + it('strips ! prefix from display text', () => { 62 + render( 63 + <ProfileLabels labels={[{ val: '!warn', src: 'did:plc:mod-service', isSelfLabel: false }]} /> 64 + ) 65 + 66 + expect(screen.getByText('warn')).toBeInTheDocument() 67 + expect(screen.queryByText('!warn')).not.toBeInTheDocument() 68 + }) 69 + 70 + it('renders multiple labels', () => { 71 + render( 72 + <ProfileLabels 73 + labels={[ 74 + { val: 'adult-content', src: 'did:plc:user1', isSelfLabel: true }, 75 + { val: '!warn', src: 'did:plc:mod', isSelfLabel: false }, 76 + ]} 77 + /> 78 + ) 79 + 80 + expect(screen.getByText('adult-content')).toBeInTheDocument() 81 + expect(screen.getByText('warn')).toBeInTheDocument() 82 + }) 83 + })
+71
src/components/profile/profile-labels.tsx
··· 1 + import { ShieldCheck, ShieldWarning } from '@phosphor-icons/react' 2 + import { cn } from '@/lib/utils' 3 + 4 + interface ProfileLabelsProps { 5 + labels: Array<{ 6 + val: string 7 + src: string 8 + isSelfLabel: boolean 9 + }> 10 + } 11 + 12 + const WARNING_LABELS = new Set([ 13 + '!warn', 14 + '!hide', 15 + '!no-unauthenticated', 16 + 'porn', 17 + 'sexual', 18 + 'nudity', 19 + 'gore', 20 + 'impersonation', 21 + ]) 22 + 23 + function isWarningLabel(val: string): boolean { 24 + return val.startsWith('!') || WARNING_LABELS.has(val) 25 + } 26 + 27 + export function ProfileLabels({ labels }: ProfileLabelsProps) { 28 + if (labels.length === 0) { 29 + return null 30 + } 31 + 32 + return ( 33 + <div className="mt-1 flex flex-wrap gap-1.5"> 34 + {labels.map((label) => { 35 + const isWarning = isWarningLabel(label.val) 36 + const displayText = label.val.startsWith('!') ? label.val.slice(1) : label.val 37 + 38 + let pillClasses: string 39 + let icon: React.ReactNode = null 40 + 41 + if (label.isSelfLabel) { 42 + pillClasses = isWarning 43 + ? 'bg-[var(--orange-3)] text-[var(--orange-11)]' 44 + : 'bg-muted text-muted-foreground' 45 + } else { 46 + pillClasses = isWarning 47 + ? 'bg-[var(--red-3)] text-[var(--red-11)]' 48 + : 'bg-[var(--purple-3)] text-[var(--purple-11)]' 49 + icon = isWarning ? ( 50 + <ShieldWarning size={12} aria-hidden="true" /> 51 + ) : ( 52 + <ShieldCheck size={12} aria-hidden="true" /> 53 + ) 54 + } 55 + 56 + return ( 57 + <span 58 + key={`${label.src}-${label.val}`} 59 + className={cn( 60 + 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium', 61 + pillClasses 62 + )} 63 + > 64 + {icon} 65 + {displayText} 66 + </span> 67 + ) 68 + })} 69 + </div> 70 + ) 71 + }
+5
src/lib/api/types.ts
··· 573 573 atprotoPostsCount: number 574 574 hasBlueskyProfile: boolean 575 575 communityCount: number 576 + labels: Array<{ 577 + val: string 578 + src: string 579 + isSelfLabel: boolean 580 + }> 576 581 activity: { 577 582 topicCount: number 578 583 replyCount: number
+2
src/mocks/data.ts
··· 1041 1041 atprotoPostsCount: 230, 1042 1042 hasBlueskyProfile: true, 1043 1043 communityCount: 2, 1044 + labels: [{ val: 'adult-content', src: 'did:plc:user-alice-001', isSelfLabel: true }], 1044 1045 activity: { 1045 1046 topicCount: 15, 1046 1047 replyCount: 42, ··· 1069 1070 atprotoPostsCount: 45, 1070 1071 hasBlueskyProfile: true, 1071 1072 communityCount: 1, 1073 + labels: [], 1072 1074 activity: { 1073 1075 topicCount: 8, 1074 1076 replyCount: 31,