From 438126318807272335264b7e77a4d55c806d51f6 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 16 Jul 2026 19:16:32 +0530 Subject: [PATCH 1/3] feat(tinyplace): add own-profile editing (name/bio/avatar) to Agent World MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfilesSection was display-only: the users.updateProfile write path was plumbed at every layer (apiClient → RPC handler → SDK update_profile) but no component ever called it, so name/bio/avatar were read-only. Add a ProfileEditForm (own profile only) wired to apiClient.users.updateProfile(cryptoId, { displayName, bio, avatarEmail }). Writable fields are prefilled from the authoritative users.get record — including avatarEmail, which the display-side GqlProfile does not carry (the shown avatar URL is derived server-side from the Gravatar email). On success the profile refetches through the existing reload path so saved values render. New agentWorld.profile.* i18n keys added to en.ts and all 13 locales (no em dashes). Three regression tests cover the edit button, the save round-trip (updateProfile called + refetch shows the new value), and the save-failure path. Closes #4930 --- .../agentworld/pages/ProfilesSection.test.tsx | 83 +++++++++ app/src/agentworld/pages/ProfilesSection.tsx | 173 ++++++++++++++++++ app/src/lib/i18n/ar.ts | 9 + app/src/lib/i18n/bn.ts | 9 + app/src/lib/i18n/de.ts | 10 + app/src/lib/i18n/en.ts | 9 + app/src/lib/i18n/es.ts | 9 + app/src/lib/i18n/fr.ts | 9 + app/src/lib/i18n/hi.ts | 9 + app/src/lib/i18n/id.ts | 9 + app/src/lib/i18n/it.ts | 9 + app/src/lib/i18n/ko.ts | 9 + app/src/lib/i18n/pl.ts | 9 + app/src/lib/i18n/pt.ts | 9 + app/src/lib/i18n/ru.ts | 9 + app/src/lib/i18n/zh-CN.ts | 9 + 16 files changed, 383 insertions(+) diff --git a/app/src/agentworld/pages/ProfilesSection.test.tsx b/app/src/agentworld/pages/ProfilesSection.test.tsx index 5f8a449a7f..567ac2f42f 100644 --- a/app/src/agentworld/pages/ProfilesSection.test.tsx +++ b/app/src/agentworld/pages/ProfilesSection.test.tsx @@ -21,6 +21,7 @@ vi.mock('../AgentWorldShell', () => ({ follows: { stats: vi.fn() }, registry: { export: vi.fn(), assignPrimary: vi.fn() }, graphql: { user: vi.fn() }, + users: { get: vi.fn(), updateProfile: vi.fn() }, }, })); vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() })); @@ -31,6 +32,8 @@ const followStats = vi.mocked(apiClient.follows.stats); const registryExport = vi.mocked(apiClient.registry.export); const assignPrimary = vi.mocked(apiClient.registry.assignPrimary); const graphqlUser = vi.mocked(apiClient.graphql.user); +const usersGet = vi.mocked(apiClient.users.get); +const updateProfile = vi.mocked(apiClient.users.updateProfile); const SOLANA_ADDR = 'WaLLetSoLanaAddr0123456789'; @@ -51,6 +54,26 @@ beforeEach(() => { graphqlUser.mockResolvedValue(null); reverse.mockResolvedValue({ cryptoId: SOLANA_ADDR, identities: [] }); followStats.mockResolvedValue({ agentId: '', followerCount: 0, followingCount: 0 }); + // Own-profile edit path (#4930): users.get seeds the writable fields, and + // updateProfile echoes back the saved User. + usersGet.mockResolvedValue({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Test Agent', + bio: '', + emailVerified: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } as unknown as Awaited>); + updateProfile.mockResolvedValue({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Test Agent', + bio: '', + emailVerified: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } as unknown as Awaited>); }); // ── Loading ─────────────────────────────────────────────────────────────────── @@ -483,3 +506,63 @@ describe('graphql-enriched profile card', () => { expect(screen.queryByText('Verified Accounts')).not.toBeInTheDocument(); }); }); + +// ── Own-profile editing (#4930) ────────────────────────────────────────────── + +describe('own-profile editing', () => { + test('offers an Edit profile button on the own graphql profile', async () => { + graphqlUser.mockResolvedValueOnce(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })); + render(); + expect(await screen.findByText('Agent Alice')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit profile/i })).toBeInTheDocument(); + }); + + test('saves name + bio via users.updateProfile, then refetches so saved values show', async () => { + graphqlUser + .mockResolvedValueOnce(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })) + .mockResolvedValue(makeProfile({ displayName: 'Agent Alice v2', bio: 'New bio' })); + usersGet.mockResolvedValue({ + cryptoId: SOLANA_ADDR, + actorType: 'agent', + displayName: 'Agent Alice', + bio: 'Old bio', + emailVerified: false, + createdAt: '', + updatedAt: '', + } as unknown as Awaited>); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /edit profile/i })); + + const nameInput = await screen.findByRole('textbox', { name: /display name/i }); + // Prefilled from users.get (the authoritative writable record). + await waitFor(() => expect(nameInput).toHaveValue('Agent Alice')); + const bioInput = screen.getByRole('textbox', { name: /^bio$/i }); + + fireEvent.change(nameInput, { target: { value: 'Agent Alice v2' } }); + fireEvent.change(bioInput, { target: { value: 'New bio' } }); + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + await waitFor(() => + expect(updateProfile).toHaveBeenCalledWith( + SOLANA_ADDR, + expect.objectContaining({ displayName: 'Agent Alice v2', bio: 'New bio' }) + ) + ); + // Refetch renders the saved values. + expect(await screen.findByText('Agent Alice v2')).toBeInTheDocument(); + }); + + test('keeps the form open and surfaces an error when save fails', async () => { + graphqlUser.mockResolvedValue(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' })); + updateProfile.mockRejectedValueOnce(new Error('network down')); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /edit profile/i })); + fireEvent.click(await screen.findByRole('button', { name: /^save$/i })); + + expect(await screen.findByText(/could not save your profile/i)).toBeInTheDocument(); + expect(screen.getByTestId('profile-edit-form')).toBeInTheDocument(); + expect(updateProfile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/agentworld/pages/ProfilesSection.tsx b/app/src/agentworld/pages/ProfilesSection.tsx index e98d315220..f381d84f1e 100644 --- a/app/src/agentworld/pages/ProfilesSection.tsx +++ b/app/src/agentworld/pages/ProfilesSection.tsx @@ -7,6 +7,7 @@ * "register a handle" prompt when the wallet owns none, and a wallet-locked * notice when the wallet isn't set up. */ +import debug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import PanelScaffold from '../../components/layout/PanelScaffold'; @@ -18,9 +19,12 @@ import { type IdentityExport, PaymentRequiredError, } from '../../lib/agentworld/invokeApiClient'; +import { useT } from '../../lib/i18n/I18nContext'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; +const log = debug('agentworld:profile'); + /** A handle registered to the wallet (subset of the directory.reverse identity). */ interface OwnedIdentity { username?: string; @@ -168,7 +172,151 @@ function useMyIdentity(reloadKey: number): ProfileState { // ── Sub-components ──────────────────────────────────────────────────────────── +/** + * Own-profile edit form (name / bio / avatar), wired to the already-plumbed + * `users.updateProfile` write path (#4930). Prefills the writable fields from + * the authoritative `users.get` record — including `avatarEmail`, which the + * display-side `GqlProfile` does not carry (the shown avatar URL is derived + * server-side from the Gravatar email). On success the parent refetches so the + * saved values render. + */ +function ProfileEditForm({ + cryptoId, + initialDisplayName, + initialBio, + onCancel, + onSaved, +}: { + cryptoId: string; + initialDisplayName: string; + initialBio: string; + onCancel: () => void; + onSaved: () => void; +}) { + const { t } = useT(); + const [displayName, setDisplayName] = useState(initialDisplayName); + const [bio, setBio] = useState(initialBio); + const [avatarEmail, setAvatarEmail] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Seed the writable fields (notably avatarEmail, absent from GqlProfile) from + // the authoritative user record. Non-fatal: on failure we keep the values + // already seeded from the displayed profile. + useEffect(() => { + let cancelled = false; + void apiClient.users + .get(cryptoId) + .then(user => { + if (cancelled) return; + if (typeof user?.displayName === 'string') setDisplayName(user.displayName); + if (typeof user?.bio === 'string') setBio(user.bio); + if (typeof user?.avatarEmail === 'string') setAvatarEmail(user.avatarEmail); + }) + .catch((err: unknown) => { + log('prefill from users.get failed: %s', String(err)); + }); + return () => { + cancelled = true; + }; + }, [cryptoId]); + + const handleSave = useCallback(async () => { + setSaving(true); + setError(null); + log('saving profile update'); + try { + await apiClient.users.updateProfile(cryptoId, { + displayName: displayName.trim(), + bio: bio.trim(), + // Only send avatarEmail when the user provided one, so an untouched + // (empty) field never blanks an existing avatar. It is a Gravatar + // email, not an image URL. + ...(avatarEmail.trim() ? { avatarEmail: avatarEmail.trim() } : {}), + }); + log('profile update saved'); + onSaved(); + } catch (err) { + log('profile update failed: %s', String(err)); + setError(t('agentWorld.profile.saveError', 'Could not save your profile. Try again.')); + } finally { + setSaving(false); + } + }, [avatarEmail, bio, cryptoId, displayName, onSaved, t]); + + const inputClass = + 'w-full rounded-md border border-line bg-surface px-2.5 py-1.5 text-sm text-content ' + + 'placeholder:text-content-faint focus:border-primary-500 focus:outline-none'; + + return ( +
{ + e.preventDefault(); + void handleSave(); + }}> + + +