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
20 changes: 20 additions & 0 deletions frontend/src/context/AuthContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ test('updateProfile sends auth header and updates user', async () => {
expect(global.testAuth.currentUser).toEqual({ name: 'Updated' });
});

test('updateProfile leaves auth loading false while the profile write is pending', async () => {
axios.get.mockResolvedValue({ data: { name: 'Init' } });
axios.post.mockResolvedValueOnce({ data: { token: 'tok', user: { name: 'Init' } } });
await act(async () => { await global.testAuth.login('a', 'b'); });

let finishUpdate: (() => void) | undefined;
axios.put.mockReturnValueOnce(new Promise((resolve) => {
finishUpdate = () => resolve({ data: { name: 'Updated' } });
}));
let update: Promise<unknown>;
act(() => { update = global.testAuth.updateProfile({ foo: 'bar' }); });

expect(global.testAuth.loading).toBe(false);
await act(async () => {
finishUpdate?.();
await update;
});
expect(global.testAuth.loading).toBe(false);
});

test('updateProfile retains multipart support for a file payload', async () => {
axios.post.mockResolvedValueOnce({ data: { token: 'tok', user: { name: 'Init' } } });
await act(async () => { await global.testAuth.login('a', 'b'); });
Expand Down
3 changes: 0 additions & 3 deletions frontend/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {

const updateProfile = async (profile: FormData | Record<string, string>): Promise<unknown> => {
try {
setLoading(true);
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (profile instanceof FormData) headers['Content-Type'] = 'multipart/form-data';
const res = await axios.put<User>('/api/users/profile', profile, {
Expand All @@ -162,8 +161,6 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const e = err as { response?: { data?: { msg?: string } } };
setError(e.response?.data?.msg || 'Failed to update profile');
throw err;
} finally {
setLoading(false);
}
};

Expand Down
26 changes: 26 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,32 @@
"text": "Checking your access…"
}
},
"settings": {
"avatar": {
"regenerate": "Regenerate",
"uploadPhoto": "Upload photo",
"description": "No face and no skin tone until you choose one. Regenerate for another, or upload a photo.",
"descriptionAfterChoice": "Regenerate for another, or upload a photo.",
"uploadTitle": "Upload photo",
"cropPreview": "Avatar crop preview",
"loadingImage": "Loading image…",
"zoom": "Zoom",
"cropInstructions": "Drag to place it. The square is what everyone sees.",
"previewSizes": "Avatar size previews",
"previewAt": "{{size}} px",
"imageTypeRequired": "Choose an image file.",
"imageCouldNotLoad": "This image could not be loaded. Choose another image.",
"cropCouldNotSave": "This image could not be prepared. Try again.",
"cancel": "Cancel",
"savePhoto": "Save photo",
"savingPhoto": "Saving…",
"photoSaved": "Photo saved.",
"regenerated": "Avatar changed.",
"uploadFailed": "Could not upload your photo. Try again.",
"profileSaveFailed": "Could not save your avatar. Try again.",
"regenerateFailed": "Could not change your avatar. Try again."
}
},
"activity": {
"title": "Activity",
"subtitle": "what needs you, then what moved",
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,32 @@
"text": "正在检查你的访问权限…"
}
},
"settings": {
"avatar": {
"regenerate": "换一个",
"uploadPhoto": "上传照片",
"description": "选择前不会显示面部或肤色。你可以换一个造型,或上传照片。",
"descriptionAfterChoice": "你可以换一个造型,或上传照片。",
"uploadTitle": "上传照片",
"cropPreview": "头像裁剪预览",
"loadingImage": "正在加载图片…",
"zoom": "缩放",
"cropInstructions": "拖动图片调整位置。方框中的部分会显示为头像。",
"previewSizes": "头像尺寸预览",
"previewAt": "{{size}}像素",
"imageTypeRequired": "请选择图片文件。",
"imageCouldNotLoad": "无法加载这张图片,请选择其他图片。",
"cropCouldNotSave": "无法处理这张图片,请重试。",
"cancel": "取消",
"savePhoto": "保存照片",
"savingPhoto": "正在保存…",
"photoSaved": "照片已保存。",
"regenerated": "头像已更换。",
"uploadFailed": "无法上传照片,请重试。",
"profileSaveFailed": "无法保存头像,请重试。",
"regenerateFailed": "无法更换头像,请重试。"
}
},
"activity": {
"title": "动态",
"subtitle": "需要你处理的事项,以及刚刚推进的工作",
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/utils/avatarUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { normalizeUploadUrl } from './apiBaseUrl';
// eslint-disable-next-line import/no-cycle
import { characterAvatarFor, AvatarKind, PICKER_CELL_COUNT } from '../v2/utils/avatars';
import {
characterAvatarFor, paperAvatarFor, AvatarKind, PICKER_CELL_COUNT,
} from '../v2/utils/avatars';

interface AvatarOption {
id: string;
Expand Down Expand Up @@ -33,11 +35,11 @@ export const getAvatarColor = (avatarId: string | undefined | null): string => {
// week: no per-pick API cost, same face forever, and SVG that scales to any
// surface.
//
// Storage scheme: profilePicture = 'bigsmile:<seed>'. The seed — not image
// bytes or a URL — is the identity, so every renderer regenerates the exact
// face locally and nothing 404s, expires, or needs hosting. getAvatarSrc
// resolves the scheme, which means every existing consumer (v1 Avatar src,
// V2Avatar) renders picked faces with zero per-component changes.
// Character storage scheme: profilePicture = 'bigsmile:<seed>' or
// 'bottts:<seed>'. The seed — not image bytes or a URL — is the identity, so
// every renderer regenerates the exact face locally and nothing 404s, expires,
// or needs hosting. Paper looks use the separate persisted
// 'paper:<userId>-v<n>' scheme, where n is the exact 0-based look index.
export const FACE_PRESET_PREFIX = 'bigsmile:';
// Agents use the same scheme with their own species (Sam: owners can edit an
// agent's avatar too). One resolver, two prefixes — the prefix IS the kind.
Expand Down Expand Up @@ -92,6 +94,10 @@ const isLikelyImageUrl = (value: string | undefined | null): boolean => {
export const getAvatarSrc = (avatarId: string | undefined | null): string | null | undefined => {
if (!avatarId) return null;
if (avatarOptions.some((option) => option.id === avatarId)) return null;
const paperPreset = /^paper:(.+)-v(0|[1-9]\d*)$/.exec(avatarId);
if (paperPreset) {
return paperAvatarFor(paperPreset[1], Number(paperPreset[2]));
}
// Picked character preset (face or robot): regenerate locally from the
// stored seed. Data-URI out, so every <img>-based consumer renders it with
// no network involved.
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/v2/__tests__/V2Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import V2Avatar from '../components/V2Avatar';
import { paperAvatarFor } from '../utils/avatars';

const svgFromDataUri = (src: string | null): string => decodeURIComponent(
String(src).replace(/^data:image\/svg\+xml;utf8,/, ''),
Expand Down Expand Up @@ -73,6 +74,16 @@ describe('V2Avatar', () => {
);
});

test('renders the exact stored Paper look from its 0-based profile marker', () => {
const marker = 'paper:paper-user-v17';
render(<V2Avatar name="Ada" src={marker} kind="human" seed="paper-user" />);

expect(screen.getByRole('img', { name: 'Ada' })).toHaveAttribute(
'src',
paperAvatarFor('paper-user', 17),
);
});

test('falls back to Paper when a human photo fails to load', () => {
render(
<V2Avatar name="Ada" src="/api/uploads/ada.png" kind="human" seed="paper-user" />,
Expand Down
Loading
Loading