From 185c0509738a178db84216bc316f163c68861e22 Mon Sep 17 00:00:00 2001 From: mrboxs Date: Sun, 8 Nov 2026 19:55:38 +0700 Subject: [PATCH] fix(web): generate missing user avatars from names --- apps/web/package.json | 2 + apps/web/src/components/user-avatar.tsx | 18 ++---- apps/web/src/hooks/use-generate-avatar.ts | 21 +++++++ apps/web/tests/user-avatar.test.tsx | 75 +++++++++++++++++++++++ bun.lock | 6 ++ 5 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/hooks/use-generate-avatar.ts create mode 100644 apps/web/tests/user-avatar.test.tsx diff --git a/apps/web/package.json b/apps/web/package.json index b692cff7..79e16eb8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:", diff --git a/apps/web/src/components/user-avatar.tsx b/apps/web/src/components/user-avatar.tsx index f3b93e60..0b15e454 100644 --- a/apps/web/src/components/user-avatar.tsx +++ b/apps/web/src/components/user-avatar.tsx @@ -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, @@ -16,18 +17,7 @@ export const UserAvatar = ({ name: string size?: ComponentProps['size'] }) => { - if (!image) { - return ( - - - {name.slice(0, 2).toUpperCase()} - - - ) - } + const generatedAvatar = useGenerateAvatar({ seed: name }) return ( diff --git a/apps/web/src/hooks/use-generate-avatar.ts b/apps/web/src/hooks/use-generate-avatar.ts new file mode 100644 index 00000000..1bb0b39e --- /dev/null +++ b/apps/web/src/hooks/use-generate-avatar.ts @@ -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] + ) diff --git a/apps/web/tests/user-avatar.test.tsx b/apps/web/tests/user-avatar.test.tsx new file mode 100644 index 00000000..46f89aae --- /dev/null +++ b/apps/web/tests/user-avatar.test.tsx @@ -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() + + 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() + + 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() + + expect(screen.getByText('AL')).toBeTruthy() + expect(screen.queryByRole('img')).toBeNull() + }) +}) diff --git a/bun.lock b/bun.lock index fffae0c0..f7f61444 100644 --- a/bun.lock +++ b/bun.lock @@ -49,6 +49,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:", @@ -564,6 +566,10 @@ "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], + "@dicebear/core": ["@dicebear/core@10.7.0", "", {}, "sha512-JUOzKOd8RUByJJo8ZL+C7YE/pvEsWewfpbWUmR8OIQZWv/PvUQHnohdevySC3icyyxe6EIU/8B/rQgA42us1QA=="], + + "@dicebear/styles": ["@dicebear/styles@10.6.0", "", {}, "sha512-KhxbEVI+z8TaenX58+ZhIRFBt7adggu/F+8g6ZYLu43H2/lgLuWA3Kg8vHBrCVWdfTTTt1p2wUhQomIkkypTuQ=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "https://registry-bridge.viteplus.dev/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], "@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "https://registry-bridge.viteplus.dev/@dotenvx/primitives/-/primitives-0.8.0.tgz", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="],