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
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"@blocknote/core": "^0.57.0",
"@blocknote/react": "^0.57.0",
"@blocknote/shadcn": "^0.57.0",
"@dicebear/core": "^10.7.0",
"@dicebear/styles": "^10.6.0",
"@orpc/client": "catalog:",
"@orpc/server": "catalog:",
"@orpc/tanstack-query": "catalog:",
Expand Down
18 changes: 4 additions & 14 deletions apps/web/src/components/user-avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
AvatarImage,
} from '@altstack/ui/components/avatar'

/** Shows an account image at the requested size, with initials as a fallback. */
import { useGenerateAvatar } from '#/hooks/use-generate-avatar'

export const UserAvatar = ({
image,
name,
Expand All @@ -16,26 +17,15 @@ export const UserAvatar = ({
name: string
size?: ComponentProps<typeof Avatar>['size']
}) => {
if (!image) {
return (
<Avatar
size={size}
className="rounded-[min(var(--radius-md),12px)] after:rounded-[min(var(--radius-md),12px)]"
>
<AvatarFallback className="rounded-[min(var(--radius-md),12px)]">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
)
}
const generatedAvatar = useGenerateAvatar({ seed: name })

return (
<Avatar
size={size}
className="rounded-[min(var(--radius-md),12px)] after:rounded-[min(var(--radius-md),12px)]"
>
<AvatarImage
src={image}
src={image?.trim() ? image : generatedAvatar}
alt={name}
className="rounded-[min(var(--radius-md),12px)]"
/>
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/hooks/use-generate-avatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Style, Avatar } from '@dicebear/core'
import initialFace from '@dicebear/styles/initial-face.json' with { type: 'json' }
import notionistsNeutral from '@dicebear/styles/notionists-neutral.json' with { type: 'json' }
import { useMemo } from 'react'

const avatarStyles = {
initialFace: new Style(initialFace),
notionistsNeutral: new Style(notionistsNeutral),
}

export const useGenerateAvatar = ({
seed,
style = 'notionistsNeutral',
}: {
seed: string
style?: 'initialFace' | 'notionistsNeutral'
}) =>
useMemo(
() => new Avatar(avatarStyles[style], { seed }).toDataUri(),
[seed, style]
)
75 changes: 75 additions & 0 deletions apps/web/tests/user-avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { cleanup, render, renderHook, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'

import { UserAvatar } from '#/components/user-avatar'
import { useGenerateAvatar } from '#/hooks/use-generate-avatar'

beforeEach(() => {
vi.spyOn(HTMLImageElement.prototype, 'complete', 'get').mockReturnValue(true)
vi.spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get').mockReturnValue(
128
)
})

afterEach(() => {
cleanup()
vi.restoreAllMocks()
})

describe('generated user avatars', () => {
it.each([undefined, null, '', ' '])(
'generates an avatar for image=%s using the name',
async (image) => {
const { result } = renderHook(() => useGenerateAvatar({ seed: 'Alice' }))
render(<UserAvatar name="Alice" image={image} />)

const avatar = await screen.findByRole('img', { name: 'Alice' })
expect(avatar.getAttribute('src')).toBe(result.current)
expect(result.current).toMatch(/^data:image\/svg\+xml/)
}
)

it('preserves an existing profile photo', async () => {
const image = 'https://example.com/alice.png'
render(<UserAvatar name="Alice" image={image} />)

expect(
(await screen.findByRole('img', { name: 'Alice' })).getAttribute('src')
).toBe(image)
})

it('produces a stable avatar for a name and updates when the name changes', () => {
const { result, rerender } = renderHook(
({ seed }) => useGenerateAvatar({ seed }),
{ initialProps: { seed: 'Alice' } }
)
const aliceAvatar = result.current
rerender({ seed: 'Bob' })
expect(result.current).not.toBe(aliceAvatar)
rerender({ seed: 'Alice' })
expect(result.current).toBe(aliceAvatar)
})

it('supports both styles and updates when the style changes', () => {
const { result, rerender } = renderHook(
({ style }: { style: 'initialFace' | 'notionistsNeutral' }) =>
useGenerateAvatar({ seed: 'Alice', style }),
{ initialProps: { style: 'notionistsNeutral' } }
)
const notionistAvatar = result.current
rerender({ style: 'initialFace' })
expect(result.current).toMatch(/^data:image\/svg\+xml/)
expect(result.current).not.toBe(notionistAvatar)
})

it('keeps initials available if the profile photo fails to load', () => {
vi.spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get').mockReturnValue(
0
)
render(<UserAvatar name="Alice" image="https://example.com/missing.png" />)

expect(screen.getByText('AL')).toBeTruthy()
expect(screen.queryByRole('img')).toBeNull()
})
})
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading