Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 56 additions & 15 deletions src/components/Avatar/AvatarPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { useToast } from '@/components/0_Bruddle/Toast'
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/Global/Drawer'
import { useAuth } from '@/context/authContext'
import { twMerge } from '@/utils/tw'
import { badgeAvatarKeys, offerBasics } from './avatar.utils'
import { badgeAvatarKeys, letterAvatarKeys, offerBasics } from './avatar.utils'
import { isLetterAvatarKey, storeLetterAvatar } from './avatar-letter.storage'
import { useAvatarKey } from './useAvatarKey'
import { roveAvatarTiles } from './avatarPicker.utils'
import { UserAvatar } from './UserAvatar'

Expand All @@ -18,11 +20,20 @@ interface AvatarPickerProps {
}

/**
* The profile avatar picker (TASK-22142): what the user's badges unlocked,
* then one row of the basics everyone has. A tap saves at once; the dice
* rerolls the offered row and never the pick; "use my initial" clears it.
* The API validates the pick against the same pool, so a locked key never
* lands even if the manifest and the catalog drift.
* The profile avatar picker (TASK-22142): the a-z initials everyone has, then
* what the user's badges unlocked, then one row of the basics. A tap saves at
* once; the dice rerolls the offered row and never the pick. The API validates
* the pick against the same pool, so a locked key never lands even if the
* manifest and the catalog drift.
*
* The initials grid replaced a "use my initial instead" text button. That
* button wrote `avatarKey: null`, which renders the first letter of the
* USERNAME and follows it on rename; a `letter.<a-z>` pick is a real pick and
* stays put. `null` remains the day-0 state of someone who never opened this.
*
* A letter the API still rejects (until peanut-api-ts#1529 ships) falls back to
* a device-local mirror rather than an error toast — see avatar-letter.storage.
* Sticker picks have no fallback by design: their unlock is enforced server-side.
*/
export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) {
const t = useTranslations('avatar')
Expand All @@ -32,7 +43,8 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) {

const userId = user?.user.userId
const username = user?.user.username ?? undefined
const saved = user?.user.avatarKey ?? null
// the effective pick: the server's, or the device-local letter fallback
const saved = useAvatarKey(user?.user.avatarKey, userId)
const badges = user?.user.badges ?? []
const held = badges.map((badge) => badge.code)
const badgeName = Object.fromEntries(badges.map((badge) => [badge.code, badge.name]))
Expand All @@ -59,9 +71,13 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) {
wanted.current = undefined
try {
const { error } = await updateUserById({ userId, avatarKey: key })
if (error) toast({ type: 'error', message: t('saveFailed') })
if (error) rememberOrReport(key)
// the server now holds the pick, so a mirror could only
// shadow it — this is also what promotes a letter to the
// durable copy the day the API starts accepting one
else storeLetterAvatar(userId, null)
} catch {
toast({ type: 'error', message: t('saveFailed') })
rememberOrReport(key)
}
}
await fetchUser()
Expand All @@ -72,13 +88,27 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) {
}
}

/**
* A rejected letter is not a user-facing failure: the pick is kept on this
* device and upgrades itself on the next write the server does accept. A
* rejected sticker has nowhere to go, so it still reports.
*/
const rememberOrReport = (key: string | null) => {
// stamped with the server key it stands in for, so a pick made on another
// device supersedes it as soon as this one refetches
if (isLetterAvatarKey(key)) storeLetterAvatar(userId, key, user?.user.avatarKey ?? null)
else toast({ type: 'error', message: t('saveFailed') })
}

const save = (key: string | null) => {
if (!userId) return
setPending(key)
wanted.current = key
if (!draining.current) void drain()
}

const letters = letterAvatarKeys()

// the offered row of five basics: dealt on open, redealt by the dice
const [offer, setOffer] = useState<string[]>([])
useEffect(() => {
Expand All @@ -91,9 +121,15 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) {
// human labels: "Bug Whisperer · beetle" for a badge avatar, the slug for a basic
const label = (key: string) => {
const [kind, code, slug] = key.split('.')
return kind === 'badge' ? `${badgeName[code] ?? code} · ${slug}` : code
if (kind === 'badge') return `${badgeName[code] ?? code} · ${slug}`
return kind === 'letter' ? code.toUpperCase() : code
}

// Five columns for every group, initials included. Seven fitted the 26
// letters in four rows but left ~42px per track at 375px and ~34px at 320px,
// under both the 48px tile and the 44px touch target — and the roving helper
// steps by AVATAR_PICKER_COLUMNS, so a second column count would also have
// desynced arrow keys from the visual rows.
const tiles = (keys: string[], groupLabel: string) => {
const focusIndex = Math.max(0, keys.indexOf(pick ?? ''))
return (
Expand Down Expand Up @@ -129,12 +165,20 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) {

return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="p-4">
{/* The horizontal padding belongs to the SCROLL AREA, not to the panel
around it: the panel's padding sits outside the overflow-auto box,
so a w-full button's 4px offset shadow fell past the scroll edge
and got clipped. The matching pb-2 below covers the bottom. */}
<DrawerContent className="py-4" scrollAreaClassName="px-4">
<DrawerHeader className="p-0 pb-4 text-left">
<DrawerTitle className="text-heading-s text-foreground-primary">{t('title')}</DrawerTitle>
<DrawerDescription>{t('description')}</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 pb-2">
<section className="flex flex-col gap-2">
<div className="text-label-m text-foreground-secondary uppercase">{t('initials')}</div>
{tiles(letters, t('initials'))}
</section>
<section className="flex flex-col gap-2">
<div className="flex items-baseline justify-between text-label-m text-foreground-secondary uppercase">
<span>{t('fromBadges')}</span>
Expand All @@ -159,9 +203,6 @@ export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) {
<Button variant="purple" className="w-full" onClick={() => onOpenChange(false)}>
{tCommon('done')}
</Button>
<Button variant="transparent" className="w-full" onClick={() => save(null)}>
{t('useInitial')}
</Button>
</div>
</div>
</DrawerContent>
Expand Down
66 changes: 61 additions & 5 deletions src/components/Avatar/__tests__/AvatarPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import type { ComponentProps, ReactNode } from 'react'
import { renderWithIntl } from '@/test-utils/intl'
import { AvatarPicker } from '../AvatarPicker'
import { readLetterAvatar, resetLetterAvatarCache } from '../avatar-letter.storage'

jest.mock('next/image', () => ({
__esModule: true,
Expand Down Expand Up @@ -66,6 +67,8 @@ function fakeServer() {

beforeEach(() => {
jest.clearAllMocks()
window.localStorage.clear()
resetLetterAvatarCache()
mockUpdateUserById.mockResolvedValue({ data: {} })
mockFetchUser.mockResolvedValue(null)
mockUser = {
Expand All @@ -82,7 +85,10 @@ describe('AvatarPicker', () => {
it('lists one row of five basics and only the avatars of badges the user holds', () => {
renderWithIntl(<AvatarPicker open onOpenChange={jest.fn()} />)

expect(screen.getAllByRole('radio')).toHaveLength(8)
// scoped per group: the 26 initials are always on top of these
expect(
screen.getByRole('radiogroup', { name: 'From your badges' }).querySelectorAll('[role="radio"]')
).toHaveLength(3)
// human labels, not keys: badge name + slug, or the slug alone
expect(radio(A)).toBeInTheDocument()
expect(screen.getByRole('radiogroup', { name: 'Basics' }).querySelectorAll('[role="radio"]')).toHaveLength(5)
Expand All @@ -94,7 +100,7 @@ describe('AvatarPicker', () => {
mockUser.user.badges = []
renderWithIntl(<AvatarPicker open onOpenChange={jest.fn()} />)

expect(screen.getAllByRole('radio')).toHaveLength(5)
expect(screen.getByRole('radiogroup', { name: 'Basics' }).querySelectorAll('[role="radio"]')).toHaveLength(5)
expect(screen.getByText('Earn a badge and its avatars appear here.')).toBeInTheDocument()
})

Expand Down Expand Up @@ -221,13 +227,63 @@ describe('AvatarPicker', () => {
random.mockRestore()
})

it('clears the pick back to the initial', () => {
it('offers every letter as its own pick, ahead of the sticker groups', () => {
renderWithIntl(<AvatarPicker open onOpenChange={jest.fn()} />)
const groups = screen.getAllByRole('radiogroup').map((el) => el.getAttribute('aria-label'))

expect(groups[0]).toBe('Initials')
expect(screen.getByRole('radiogroup', { name: 'Initials' }).querySelectorAll('[role="radio"]')).toHaveLength(26)
expect(screen.getByRole('radio', { name: 'A' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Z' })).toBeInTheDocument()
})

it('keeps a letter this API build still rejects, on the device, without an error toast', async () => {
const server = fakeServer()
renderWithIntl(<AvatarPicker open onOpenChange={jest.fn()} />)

fireEvent.click(screen.getByRole('radio', { name: 'K' }))
await server.settle(0, { error: 'body/avatarKey must match pattern' })

// the pick survives the rejection and the user is not told off for it
await waitFor(() => expect(readLetterAvatar('u1')?.key).toBe('letter.k'))
expect(mockToast).not.toHaveBeenCalled()
await waitFor(() => expect(radio('K')).toHaveAttribute('aria-checked', 'true'))
})

it('still reports a rejected sticker — those have no device-local fallback', async () => {
const server = fakeServer()
renderWithIntl(<AvatarPicker open onOpenChange={jest.fn()} />)

fireEvent.click(radio(A))
await server.settle(0, { error: 'Avatar not unlocked' })

expect(mockToast).toHaveBeenCalledWith({ type: 'error', message: 'Could not save your avatar. Try again.' })
expect(readLetterAvatar('u1')).toBeNull()
})

it('a server write that lands drops the mirror, so the durable copy wins', async () => {
const server = fakeServer()
renderWithIntl(<AvatarPicker open onOpenChange={jest.fn()} />)

fireEvent.click(screen.getByRole('radio', { name: 'K' }))
await server.settle(0, { error: 'body/avatarKey must match pattern' })
await waitFor(() => expect(readLetterAvatar('u1')?.key).toBe('letter.k'))

// the API now accepts it (peanut-api-ts#1529 deployed)
fireEvent.click(screen.getByRole('radio', { name: 'M' }))
await server.settle(1)

await waitFor(() => expect(readLetterAvatar('u1')).toBeNull())
expect(server.committed()).toBe('letter.m')
})

it('a letter is a real pick, not a clear back to the username initial', () => {
mockUser.user.avatarKey = 'basic.apple'
renderWithIntl(<AvatarPicker open onOpenChange={jest.fn()} />)

fireEvent.click(screen.getByRole('button', { name: 'Use my initial instead' }))
fireEvent.click(screen.getByRole('radio', { name: 'K' }))

expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', avatarKey: null })
expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', avatarKey: 'letter.k' })
})

it('closes on done', () => {
Expand Down
6 changes: 5 additions & 1 deletion src/components/Avatar/__tests__/avatar.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ describe('avatar catalog', () => {
'badge.BUG_WHISPERER.shell',
'badge.BUG_WHISPERER.peek',
])
expect(avatarPool(['OFFRAMP_USER'])).toHaveLength(23)
// 26 letters + 20 basics + the 3 OFFRAMP_USER avatars
expect(avatarPool(['OFFRAMP_USER'])).toHaveLength(49)
})

it('maps keys to their art and rejects anything the manifest does not know', () => {
Expand All @@ -56,6 +57,9 @@ describe('avatar catalog', () => {
expect(avatarSrc('basic.peanut')).toBeNull()
expect(avatarSrc('badge.BUG_WHISPERER.nope')).toBeNull()
expect(avatarSrc('badge.FIRST_INVITE.beetle')).toBeNull()
expect(avatarSrc('letter.k')).toBe('/avatars/letter/k.webp')
expect(avatarSrc('letter.K')).toBeNull()
expect(avatarSrc('letter.ab')).toBeNull()
expect(avatarSrc('../etc/passwd')).toBeNull()
// plain JSON object: prototype names must not read as badges
expect(avatarSrc('badge.constructor.x')).toBeNull()
Expand Down
74 changes: 74 additions & 0 deletions src/components/Avatar/__tests__/useAvatarKey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* The hook takes the server key as an ARGUMENT rather than reading it. There is
* no single source to read: useHomeFlow takes the user from the redux store and
* the profile surfaces take it from authContext. A first cut read authContext
* internally and returned null on the home screen, where redux held the pick.
*/
import { act, renderHook } from '@testing-library/react'
import { resetLetterAvatarCache, storeLetterAvatar } from '../avatar-letter.storage'
import { useAvatarKey } from '../useAvatarKey'

beforeEach(() => {
window.localStorage.clear()
resetLetterAvatarCache()
})

describe('useAvatarKey', () => {
it('returns whatever server key the caller passes, from whichever store it came', () => {
expect(renderHook(() => useAvatarKey('basic.frog', 'u1')).result.current).toBe('basic.frog')
expect(renderHook(() => useAvatarKey(null, 'u1')).result.current).toBeNull()
expect(renderHook(() => useAvatarKey(undefined, undefined)).result.current).toBeNull()
})

it('prefers a device-local letter over the server pick it stood in for', () => {
storeLetterAvatar('u1', 'letter.k', null)

expect(renderHook(() => useAvatarKey(null, 'u1')).result.current).toBe('letter.k')
})

it('yields to a pick made on another device — the mirror only shadows its own server value', () => {
// stored while the server held nothing
storeLetterAvatar('u1', 'letter.k', null)

// ...then device B saved basic.frog and this device refetched
expect(renderHook(() => useAvatarKey('basic.frog', 'u1')).result.current).toBe('basic.frog')
})

it('still shadows the exact server value it was written against', () => {
storeLetterAvatar('u1', 'letter.k', 'basic.apple')

expect(renderHook(() => useAvatarKey('basic.apple', 'u1')).result.current).toBe('letter.k')
})

it('scopes the mirror per account — a second login does not inherit the first initial', () => {
storeLetterAvatar('u1', 'letter.k', null)

expect(renderHook(() => useAvatarKey(null, 'u2')).result.current).toBeNull()
})

it('re-renders live when the picker writes, so the header updates behind the drawer', () => {
const { result } = renderHook(() => useAvatarKey(null, 'u1'))
expect(result.current).toBeNull()

act(() => storeLetterAvatar('u1', 'letter.m', null))
expect(result.current).toBe('letter.m')

act(() => storeLetterAvatar('u1', null))
expect(result.current).toBeNull()
})

it('ignores a mirror value that is not a single lowercase letter key', () => {
window.localStorage.setItem('peanut:avatarLetter:u1', 'badge.FOUNDING_PIONEER.crown')
resetLetterAvatarCache()

expect(renderHook(() => useAvatarKey(null, 'u1')).result.current).toBeNull()
})

it('reads the bare-string shape an earlier build wrote as a mirror of "no server pick"', () => {
window.localStorage.setItem('peanut:avatarLetter:u1', 'letter.k')
resetLetterAvatarCache()

expect(renderHook(() => useAvatarKey(null, 'u1')).result.current).toBe('letter.k')
expect(renderHook(() => useAvatarKey('basic.frog', 'u1')).result.current).toBe('basic.frog')
})
})
Loading
Loading