diff --git a/src/components/profile/profile-header.test.tsx b/src/components/profile/profile-header.test.tsx index 99953e3..c282a5e 100644 --- a/src/components/profile/profile-header.test.tsx +++ b/src/components/profile/profile-header.test.tsx @@ -59,6 +59,7 @@ function createProfile(overrides?: Partial): UserProfile { atprotoPostsCount: 0, hasBlueskyProfile: false, communityCount: 1, + labels: [], activity: { topicCount: 0, replyCount: 0, @@ -286,4 +287,21 @@ describe('ProfileHeader', () => { expect(screen.queryByRole('link', { name: /edit profile/i })).not.toBeInTheDocument() }) }) + + it('renders labels section when labels are present', () => { + render( + + ) + expect(screen.getByText('adult-content')).toBeInTheDocument() + }) + + it('does not render labels section when labels are empty', () => { + render() + expect(screen.queryByText('adult-content')).not.toBeInTheDocument() + }) }) diff --git a/src/components/profile/profile-header.tsx b/src/components/profile/profile-header.tsx index 61b20d9..3e84e82 100644 --- a/src/components/profile/profile-header.tsx +++ b/src/components/profile/profile-header.tsx @@ -23,6 +23,7 @@ import { ReputationBadge } from '@/components/reputation-badge' import { BlockMuteButton } from '@/components/block-mute-button' import { ProfileStats } from '@/components/profile/profile-stats' import { formatBio } from '@/lib/format-bio' +import { ProfileLabels } from '@/components/profile/profile-labels' import { formatCount } from '@/lib/format-count' import type { UserProfile } from '@/lib/api/types' @@ -100,6 +101,9 @@ export function ProfileHeader({ )} + {/* AT Protocol labels */} + {profile.labels.length > 0 && } + {/* Bio */} {profile.bio && (
{ + it('returns null when labels array is empty', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders a self-label with neutral styling', () => { + render( + + ) + + const pill = screen.getByText('adult-content') + expect(pill).toBeInTheDocument() + expect(pill.className).toContain('bg-muted') + expect(pill.className).toContain('text-muted-foreground') + }) + + it('renders a self-label with warning styling for known warning values', () => { + render() + + const pill = screen.getByText('porn') + expect(pill).toBeInTheDocument() + expect(pill.className).toMatch(/bg-\[var\(--orange-3\)\]/) + expect(pill.className).toMatch(/text-\[var\(--orange-11\)\]/) + }) + + it('renders a moderator label with ShieldCheck icon', () => { + render( + + ) + + const pill = screen.getByText('verified') + expect(pill).toBeInTheDocument() + expect(pill.className).toMatch(/bg-\[var\(--purple-3\)\]/) + expect(pill.className).toMatch(/text-\[var\(--purple-11\)\]/) + const icon = pill.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + + it('renders a moderator warning label with ShieldWarning icon', () => { + render( + + ) + + const pill = screen.getByText('impersonation') + expect(pill).toBeInTheDocument() + expect(pill.className).toMatch(/bg-\[var\(--red-3\)\]/) + expect(pill.className).toMatch(/text-\[var\(--red-11\)\]/) + const icon = pill.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + + it('strips ! prefix from display text', () => { + render( + + ) + + expect(screen.getByText('warn')).toBeInTheDocument() + expect(screen.queryByText('!warn')).not.toBeInTheDocument() + }) + + it('renders multiple labels', () => { + render( + + ) + + expect(screen.getByText('adult-content')).toBeInTheDocument() + expect(screen.getByText('warn')).toBeInTheDocument() + }) +}) diff --git a/src/components/profile/profile-labels.tsx b/src/components/profile/profile-labels.tsx new file mode 100644 index 0000000..ab66aac --- /dev/null +++ b/src/components/profile/profile-labels.tsx @@ -0,0 +1,71 @@ +import { ShieldCheck, ShieldWarning } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface ProfileLabelsProps { + labels: Array<{ + val: string + src: string + isSelfLabel: boolean + }> +} + +const WARNING_LABELS = new Set([ + '!warn', + '!hide', + '!no-unauthenticated', + 'porn', + 'sexual', + 'nudity', + 'gore', + 'impersonation', +]) + +function isWarningLabel(val: string): boolean { + return val.startsWith('!') || WARNING_LABELS.has(val) +} + +export function ProfileLabels({ labels }: ProfileLabelsProps) { + if (labels.length === 0) { + return null + } + + return ( +
+ {labels.map((label) => { + const isWarning = isWarningLabel(label.val) + const displayText = label.val.startsWith('!') ? label.val.slice(1) : label.val + + let pillClasses: string + let icon: React.ReactNode = null + + if (label.isSelfLabel) { + pillClasses = isWarning + ? 'bg-[var(--orange-3)] text-[var(--orange-11)]' + : 'bg-muted text-muted-foreground' + } else { + pillClasses = isWarning + ? 'bg-[var(--red-3)] text-[var(--red-11)]' + : 'bg-[var(--purple-3)] text-[var(--purple-11)]' + icon = isWarning ? ( +
+ ) +} diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 69b04d8..11649b3 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -573,6 +573,11 @@ export interface UserProfile { atprotoPostsCount: number hasBlueskyProfile: boolean communityCount: number + labels: Array<{ + val: string + src: string + isSelfLabel: boolean + }> activity: { topicCount: number replyCount: number diff --git a/src/mocks/data.ts b/src/mocks/data.ts index 3537fa9..a85c141 100644 --- a/src/mocks/data.ts +++ b/src/mocks/data.ts @@ -1041,6 +1041,7 @@ export const mockUserProfiles: Record = { atprotoPostsCount: 230, hasBlueskyProfile: true, communityCount: 2, + labels: [{ val: 'adult-content', src: 'did:plc:user-alice-001', isSelfLabel: true }], activity: { topicCount: 15, replyCount: 42, @@ -1069,6 +1070,7 @@ export const mockUserProfiles: Record = { atprotoPostsCount: 45, hasBlueskyProfile: true, communityCount: 1, + labels: [], activity: { topicCount: 8, replyCount: 31,