mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -25,6 +25,7 @@ import LedgerSection from './LedgerSection';
|
||||
import MarketplaceSection from './MarketplaceSection';
|
||||
import MessagingSection from './MessagingSection';
|
||||
import ProfilesSection from './ProfilesSection';
|
||||
import ProfileViewer from './ProfileViewer';
|
||||
import WelcomeSection from './WelcomeSection';
|
||||
import WorldSection from './WorldSection';
|
||||
|
||||
@@ -112,9 +113,12 @@ export default function AgentWorld() {
|
||||
|
||||
// Derive the active slug from the current sub-path
|
||||
// e.g. /agent-world/explore → 'explore'
|
||||
const pathParts = location.pathname.split('/');
|
||||
const activeSlug = pathParts[pathParts.length - 1] || 'welcome';
|
||||
const activeSection = activeSlug === 'agent-world' ? 'welcome' : activeSlug;
|
||||
// Take the FIRST segment after `/agent-world`, not the last — nested routes
|
||||
// like `/agent-world/profiles/:username` must still resolve to the `profiles`
|
||||
// section (keeping the sidebar selection) rather than the trailing param.
|
||||
const pathParts = location.pathname.split('/').filter(Boolean);
|
||||
const awIndex = pathParts.indexOf('agent-world');
|
||||
const activeSection = (awIndex >= 0 ? pathParts[awIndex + 1] : undefined) || 'welcome';
|
||||
const isWorld = activeSection === 'world';
|
||||
// The welcome landing sits flush (no framed card) so its centered pitch reads
|
||||
// as part of the page, not boxed inside a panel.
|
||||
@@ -174,6 +178,8 @@ export default function AgentWorld() {
|
||||
{/* === AGENT-WORLD SECTION ROUTES (append one per section) === */}
|
||||
<Route path="directory" element={<DirectorySection />} />
|
||||
<Route path="profiles" element={<ProfilesSection />} />
|
||||
{/* Public viewer for an arbitrary handle (own/other users/agents) — #4931 */}
|
||||
<Route path="profiles/:username" element={<ProfileViewer />} />
|
||||
<Route path="identities" element={<IdentitiesSection />} />
|
||||
<Route path="marketplace" element={<MarketplaceSection />} />
|
||||
<Route path="messaging" element={<MessagingSection />} />
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Tests for ProfileViewer — the Agent World public profile viewer (#4931).
|
||||
*
|
||||
* The viewer renders an ARBITRARY handle's profile via `graphql.profile`, with a
|
||||
* follow/unfollow button (`follows.follow`/`unfollow`, follow-state from
|
||||
* `follows.following`) and a copy-link affordance. apiClient + wallet are mocked;
|
||||
* all handles/ids are generic placeholders. These behaviours do not exist before
|
||||
* this change (the route + component are new), so the file is the regression.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { type GqlProfile } from '../../lib/agentworld/invokeApiClient';
|
||||
import { fetchWalletStatus } from '../../services/walletApi';
|
||||
import { apiClient } from '../AgentWorldShell';
|
||||
import ProfileViewer from './ProfileViewer';
|
||||
|
||||
vi.mock('../AgentWorldShell', () => ({
|
||||
apiClient: {
|
||||
graphql: { profile: vi.fn(), agentCard: vi.fn() },
|
||||
follows: { follow: vi.fn(), unfollow: vi.fn(), stats: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() }));
|
||||
|
||||
const graphqlProfile = vi.mocked(apiClient.graphql.profile);
|
||||
const graphqlAgentCard = vi.mocked(apiClient.graphql.agentCard);
|
||||
const followsFollow = vi.mocked(apiClient.follows.follow);
|
||||
const followsUnfollow = vi.mocked(apiClient.follows.unfollow);
|
||||
const followsStats = vi.mocked(apiClient.follows.stats);
|
||||
const walletStatus = vi.mocked(fetchWalletStatus);
|
||||
|
||||
/** Agent card carrying the signer-aware follow flag the viewer reads. */
|
||||
function agentCardFollowing(viewerIsFollowing: boolean) {
|
||||
return { agentId: PROFILE_ADDR, viewerIsFollowing };
|
||||
}
|
||||
|
||||
const PROFILE_ADDR = 'ProfiLeSoLanaAddr00000000001';
|
||||
const VIEWER_ADDR = 'ViewerSoLanaAddr00000000002';
|
||||
|
||||
function makeProfile(overrides: Partial<GqlProfile> = {}): GqlProfile {
|
||||
return {
|
||||
cryptoId: PROFILE_ADDR,
|
||||
actorType: 'agent',
|
||||
displayName: 'Alice Agent',
|
||||
bio: 'An autonomous test agent.',
|
||||
private: false,
|
||||
createdAt: '2026-01-02T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
verified: true,
|
||||
attestations: [],
|
||||
agentCard: null,
|
||||
identities: [
|
||||
{
|
||||
username: 'alice',
|
||||
cryptoId: PROFILE_ADDR,
|
||||
publicKey: 'pk',
|
||||
registeredAt: '2026-01-02T00:00:00Z',
|
||||
expiresAt: '2027-01-02T00:00:00Z',
|
||||
status: 'active',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function walletWith(address: string | null) {
|
||||
return { accounts: address ? [{ chain: 'solana', address }] : [] } as unknown as Awaited<
|
||||
ReturnType<typeof fetchWalletStatus>
|
||||
>;
|
||||
}
|
||||
|
||||
function renderViewer(username = 'alice') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/agent-world/profiles/${username}`]}>
|
||||
<Routes>
|
||||
<Route path="/agent-world/profiles/:username" element={<ProfileViewer />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
graphqlProfile.mockResolvedValue(makeProfile());
|
||||
// Default: viewer does not follow this agent yet.
|
||||
graphqlAgentCard.mockResolvedValue(agentCardFollowing(false));
|
||||
followsFollow.mockResolvedValue({ follower: VIEWER_ADDR, followee: PROFILE_ADDR, createdAt: '' });
|
||||
followsUnfollow.mockResolvedValue(undefined);
|
||||
followsStats.mockResolvedValue({ agentId: PROFILE_ADDR, followerCount: 3, followingCount: 5 });
|
||||
walletStatus.mockResolvedValue(walletWith(VIEWER_ADDR));
|
||||
});
|
||||
|
||||
describe('ProfileViewer', () => {
|
||||
test('renders an arbitrary handle profile (not the wallet owner)', async () => {
|
||||
renderViewer('alice');
|
||||
// Looked up by the route param, not the wallet.
|
||||
await waitFor(() => expect(graphqlProfile).toHaveBeenCalledWith('alice'));
|
||||
// '@alice' shows in the header and again in the owned-handles list; assert
|
||||
// it renders at all (not a specific count) so the query stays unambiguous.
|
||||
expect((await screen.findAllByText('@alice')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('An autonomous test agent.')).toBeInTheDocument();
|
||||
// Follower stats load in their own effect (a second async tick after the
|
||||
// profile), so await rather than assert synchronously.
|
||||
expect(await screen.findByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows a not-found state when the profile does not exist', async () => {
|
||||
graphqlProfile.mockResolvedValue(null);
|
||||
renderViewer('ghost');
|
||||
expect(await screen.findByText(/profile not found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('follow button follows then unfollows another user', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderViewer('alice');
|
||||
|
||||
// Button appears once the wallet resolves and follow-state loads.
|
||||
const followBtn = await screen.findByRole('button', { name: 'Follow' });
|
||||
await waitFor(() => expect(followBtn).toBeEnabled());
|
||||
|
||||
// Follower count starts at 3 (from follows.stats).
|
||||
expect(await screen.findByText('3')).toBeInTheDocument();
|
||||
|
||||
await user.click(followBtn);
|
||||
expect(followsFollow).toHaveBeenCalledWith(PROFILE_ADDR);
|
||||
const followingBtn = await screen.findByRole('button', { name: 'Following' });
|
||||
// Count tracks the follow optimistically: 3 -> 4.
|
||||
expect(await screen.findByText('4')).toBeInTheDocument();
|
||||
|
||||
await user.click(followingBtn);
|
||||
expect(followsUnfollow).toHaveBeenCalledWith(PROFILE_ADDR);
|
||||
expect(await screen.findByRole('button', { name: 'Follow' })).toBeInTheDocument();
|
||||
// ...and back to 3 on unfollow.
|
||||
expect(await screen.findByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('pre-selects the following state from the agent card follow flag', async () => {
|
||||
graphqlAgentCard.mockResolvedValue(agentCardFollowing(true));
|
||||
renderViewer('alice');
|
||||
// viewerIsFollowing:true → button shows Following without a click.
|
||||
expect(await screen.findByRole('button', { name: 'Following' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Follow' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides the follow button when the follow relationship is unknown', async () => {
|
||||
// No agent card (e.g. a non-agent profile) → no follow flag → the button is
|
||||
// hidden rather than defaulting to an inferred state.
|
||||
graphqlAgentCard.mockResolvedValue(null);
|
||||
renderViewer('alice');
|
||||
await screen.findByTestId('profile-copy-link');
|
||||
expect(screen.queryByRole('button', { name: 'Follow' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Following' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides the follow button and marks self when viewing own profile', async () => {
|
||||
walletStatus.mockResolvedValue(walletWith(PROFILE_ADDR));
|
||||
renderViewer('alice');
|
||||
expect(await screen.findByText(/this is your profile/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Follow' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Following' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('copy-link affordance copies the shareable deep link', async () => {
|
||||
// Install a clipboard spy and drive the click with fireEvent — NOT
|
||||
// userEvent, whose setup() replaces navigator.clipboard with its own stub
|
||||
// and would shadow this spy.
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
|
||||
renderViewer('alice');
|
||||
const copyBtn = await screen.findByTestId('profile-copy-link');
|
||||
fireEvent.click(copyBtn);
|
||||
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalled());
|
||||
expect(writeText.mock.calls[0][0] as string).toContain('#/agent-world/profiles/alice');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* ProfileViewer — Agent World public profile viewer for an ARBITRARY handle.
|
||||
*
|
||||
* Route: `/agent-world/profiles/:username`. Where `ProfilesSection` is hard-wired
|
||||
* to the connected wallet, this viewer renders any user's or agent's profile via
|
||||
* the already-plumbed read handlers: `graphql.profile(username)` (a rich
|
||||
* `GqlProfile`) and `graphql.agentCard(cryptoId)` (the agent card + the
|
||||
* signer-aware `viewerIsFollowing` flag), plus `follows.stats`. It adds a
|
||||
* follow / unfollow button (reusing `follows.follow` / `follows.unfollow`) and a
|
||||
* copy-link affordance so a profile is shareable.
|
||||
*
|
||||
* Read-only. Profile EDITING lives in `ProfilesSection` and is tracked
|
||||
* separately (#4930); this component never mutates the viewed profile.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import PanelScaffold from '../../components/layout/PanelScaffold';
|
||||
import Button from '../../components/ui/Button';
|
||||
import {
|
||||
type AgentCard,
|
||||
type FollowStats,
|
||||
type GqlAttestation,
|
||||
type GqlProfile,
|
||||
type Identity,
|
||||
PaymentRequiredError,
|
||||
} from '../../lib/agentworld/invokeApiClient';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { fetchWalletStatus } from '../../services/walletApi';
|
||||
import { apiClient } from '../AgentWorldShell';
|
||||
|
||||
const log = debug('agentworld:profileviewer');
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Sanitized error label for diagnostics — an error NAME/kind only, never the
|
||||
* raw message (which may carry backend internals / PII). */
|
||||
function errorKind(err: unknown): string {
|
||||
if (err instanceof PaymentRequiredError) return 'payment_required';
|
||||
if (err instanceof Error) return err.name || 'Error';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function truncateCryptoId(cryptoId: string): string {
|
||||
if (cryptoId.length <= 12) return cryptoId;
|
||||
return `${cryptoId.slice(0, 6)}…${cryptoId.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Format an ISO date using the runtime locale (never a hard-coded language). */
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/** Pick the primary identity, else the first. */
|
||||
function pickPrimary<T extends { primary?: boolean }>(identities: T[]): T | undefined {
|
||||
return identities.find(i => i.primary) ?? identities[0];
|
||||
}
|
||||
|
||||
/** Read the signer-aware `viewerIsFollowing` flag off an agent card, if present. */
|
||||
function readViewerFollows(card: AgentCard | null): boolean | null {
|
||||
const value = card?.['viewerIsFollowing'];
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
|
||||
/** Resolve the wallet's Solana address (the viewer's tiny.place cryptoId). */
|
||||
function useMyAgentId(): string | null {
|
||||
const [agentId, setAgentId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
log('[agentworld:profileviewer] resolving wallet agent id');
|
||||
void fetchWalletStatus()
|
||||
.then(status => {
|
||||
if (cancelled) return;
|
||||
const solana = (status.accounts ?? []).find(a => a.chain === 'solana');
|
||||
if (solana?.address) setAgentId(solana.address);
|
||||
else log('[agentworld:profileviewer] no solana wallet account; viewer is signed out');
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) log('[agentworld:profileviewer] wallet resolve failed: %s', errorKind(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
// ── Profile fetch ────────────────────────────────────────────────────────────────
|
||||
|
||||
type ViewerState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'not_found' }
|
||||
| { status: 'error' }
|
||||
| { status: 'ok'; profile: GqlProfile };
|
||||
|
||||
/**
|
||||
* Load the target handle's public profile, keyed by the normalized handle so a
|
||||
* stale response for a previous handle can never surface the wrong profile. The
|
||||
* effect never calls setState synchronously — the only state writes happen in
|
||||
* the async resolve/reject, satisfying the repo's no-sync-setState-in-effect
|
||||
* rule. `null` from the server → not found.
|
||||
*/
|
||||
function useProfile(handle: string): ViewerState {
|
||||
const [entry, setEntry] = useState<{ key: string; state: ViewerState }>({
|
||||
key: '',
|
||||
state: { status: 'loading' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!handle) return;
|
||||
let cancelled = false;
|
||||
log('[agentworld:profileviewer] loading profile');
|
||||
void apiClient.graphql
|
||||
.profile(handle)
|
||||
.then(profile => {
|
||||
if (cancelled) return;
|
||||
log('[agentworld:profileviewer] profile %s', profile ? 'loaded' : 'not found');
|
||||
setEntry({
|
||||
key: handle,
|
||||
state: profile ? { status: 'ok', profile } : { status: 'not_found' },
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
log('[agentworld:profileviewer] profile load failed: %s', errorKind(err));
|
||||
setEntry({ key: handle, state: { status: 'error' } });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [handle]);
|
||||
|
||||
if (!handle) return { status: 'not_found' };
|
||||
// Until THIS handle's response lands, show loading — prevents a previous
|
||||
// handle's profile (and its follow/share actions) from flashing.
|
||||
return entry.key === handle ? entry.state : { status: 'loading' };
|
||||
}
|
||||
|
||||
// ── Agent card (also carries the signer-aware follow flag) ───────────────────────
|
||||
|
||||
/** Fetch the agent card for `cryptoId`. Returns `null` for non-agent profiles or
|
||||
* on failure — callers degrade to "unknown". */
|
||||
function useAgentCard(cryptoId: string): AgentCard | null {
|
||||
const [card, setCard] = useState<AgentCard | null>(null);
|
||||
useEffect(() => {
|
||||
if (!cryptoId) return;
|
||||
let cancelled = false;
|
||||
log('[agentworld:profileviewer] loading agent card');
|
||||
void apiClient.graphql
|
||||
.agentCard(cryptoId)
|
||||
.then(c => {
|
||||
if (!cancelled) setCard(c);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled)
|
||||
log('[agentworld:profileviewer] agent card load failed: %s', errorKind(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cryptoId]);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Follow control ─────────────────────────────────────────────────────────────
|
||||
|
||||
type FollowState = 'unknown' | 'following' | 'not_following';
|
||||
|
||||
/**
|
||||
* Follow relationship for `targetCryptoId`, derived ONLY from the direct,
|
||||
* signer-aware `viewerFollowsHint` (from the agent card). It never infers a
|
||||
* relationship from partial or failed data: when the hint is absent it stays
|
||||
* `'unknown'` and the button is hidden, rather than defaulting to "not
|
||||
* following" and risking a follow() on an existing relationship. A user toggle
|
||||
* takes optimistic precedence via `override`.
|
||||
*/
|
||||
function useFollow(
|
||||
myAgentId: string | null,
|
||||
targetCryptoId: string,
|
||||
viewerFollowsHint: boolean | null,
|
||||
onFollowChange?: (next: 'following' | 'not_following') => void
|
||||
) {
|
||||
const [override, setOverride] = useState<'following' | 'not_following' | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const isSelf = myAgentId != null && myAgentId === targetCryptoId;
|
||||
const enabled = myAgentId != null && targetCryptoId !== '' && !isSelf;
|
||||
const state: FollowState =
|
||||
override ??
|
||||
(viewerFollowsHint == null ? 'unknown' : viewerFollowsHint ? 'following' : 'not_following');
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (busy || !enabled || state === 'unknown') return;
|
||||
setBusy(true);
|
||||
const next = state === 'following' ? 'not_following' : 'following';
|
||||
try {
|
||||
if (state === 'following') await apiClient.follows.unfollow(targetCryptoId);
|
||||
else await apiClient.follows.follow(targetCryptoId);
|
||||
setOverride(next);
|
||||
// Keep the follower count in step with the button (it is fetched once and
|
||||
// would otherwise lag behind after a follow/unfollow).
|
||||
onFollowChange?.(next);
|
||||
// No PII: only the resulting action, never the address/handle.
|
||||
log('[agentworld:profileviewer] follow toggled -> %s', next);
|
||||
} catch (err) {
|
||||
log('[agentworld:profileviewer] follow toggle failed: %s', errorKind(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, enabled, state, targetCryptoId, onFollowChange]);
|
||||
|
||||
return { state, busy, isSelf, enabled, toggle };
|
||||
}
|
||||
|
||||
// ── Follow stats sub-hook ────────────────────────────────────────────────────────
|
||||
|
||||
function useFollowStats(cryptoId: string): FollowStats | null {
|
||||
const [stats, setStats] = useState<FollowStats | null>(null);
|
||||
useEffect(() => {
|
||||
if (!cryptoId) return;
|
||||
let cancelled = false;
|
||||
void apiClient.follows
|
||||
.stats(cryptoId)
|
||||
.then(s => {
|
||||
if (!cancelled) setStats(s);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled)
|
||||
log('[agentworld:profileviewer] follow stats load failed: %s', errorKind(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cryptoId]);
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ── Presentational bits ──────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBlock({ tone, title, body }: { tone: string; title: string; body?: string }) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2 text-center">
|
||||
<p className={`text-base font-medium ${tone}`}>{title}</p>
|
||||
{body && <p className="max-w-md text-sm text-content-muted">{body}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main export ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProfileViewer() {
|
||||
const { username } = useParams<{ username: string }>();
|
||||
const { t } = useT();
|
||||
const routeHandle = (username ?? '').replace(/^@+/, '').trim();
|
||||
const state = useProfile(routeHandle);
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (state.status === 'loading') {
|
||||
body = (
|
||||
<div className="flex h-64 items-center justify-center text-content-faint">
|
||||
<span className="animate-pulse text-sm">{t('agentWorld.profileViewer.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (state.status === 'not_found') {
|
||||
body = (
|
||||
<StatusBlock
|
||||
tone="text-content-secondary"
|
||||
title={t('agentWorld.profileViewer.notFoundTitle')}
|
||||
body={t('agentWorld.profileViewer.notFoundBody')}
|
||||
/>
|
||||
);
|
||||
} else if (state.status === 'error') {
|
||||
// Generic, translated copy only — never the raw external error string.
|
||||
body = (
|
||||
<StatusBlock
|
||||
tone="text-red-600 dark:text-red-400"
|
||||
title={t('agentWorld.profileViewer.errorTitle')}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
body = <ProfileCard profile={state.profile} routeHandle={routeHandle} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelScaffold description={t('agentWorld.profileViewer.description')}>{body}</PanelScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Profile card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileCard({ profile, routeHandle }: { profile: GqlProfile; routeHandle: string }) {
|
||||
const { t } = useT();
|
||||
const myAgentId = useMyAgentId();
|
||||
const cryptoId = profile.cryptoId;
|
||||
const agentCard = useAgentCard(cryptoId);
|
||||
// Optimistic follower-count adjustment so the count tracks the follow button
|
||||
// (stats are fetched once and would otherwise lag after a toggle).
|
||||
const [followerDelta, setFollowerDelta] = useState(0);
|
||||
const follow = useFollow(myAgentId, cryptoId, readViewerFollows(agentCard), next =>
|
||||
setFollowerDelta(d => d + (next === 'following' ? 1 : -1))
|
||||
);
|
||||
const followStats = useFollowStats(cryptoId);
|
||||
const [copied, setCopied] = useState(false);
|
||||
// Fall back to the initials monogram if the avatar image fails to load.
|
||||
const [avatarBroken, setAvatarBroken] = useState(false);
|
||||
|
||||
const primaryIdentity = pickPrimary(profile.identities ?? []);
|
||||
const primaryUsername = primaryIdentity?.username ?? null;
|
||||
const hasHandle = primaryUsername !== null;
|
||||
const usernameClean = (primaryUsername ?? profile.displayName ?? '').replace(/^@+/, '');
|
||||
const handle = hasHandle ? `@${usernameClean}` : usernameClean;
|
||||
const displayName = profile.displayName || usernameClean || '?';
|
||||
const initials = displayName.slice(0, 2).toUpperCase();
|
||||
const skills = profile.tags ?? [];
|
||||
const attestations: GqlAttestation[] = profile.attestations ?? [];
|
||||
const ownedIdentities: Identity[] = profile.identities ?? [];
|
||||
const actorType = profile.actorType ?? '';
|
||||
const isHuman = actorType.toLowerCase() === 'human';
|
||||
const showFollow = follow.enabled && follow.state !== 'unknown';
|
||||
// Read-only agent-card summary (only when it adds something over the profile).
|
||||
const cardDescription = (agentCard?.description ?? '').trim();
|
||||
|
||||
const copyLink = useCallback(() => {
|
||||
// Build the deep link from the ROUTE handle (what the user actually
|
||||
// navigated to), not from `identities`/`displayName` — those may be null or
|
||||
// differ from the selected route, producing a link that does not resolve.
|
||||
const { origin, pathname } = window.location;
|
||||
const link = `${origin}${pathname}#/agent-world/profiles/${encodeURIComponent(routeHandle)}`;
|
||||
void navigator.clipboard
|
||||
?.writeText(link)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
log('[agentworld:profileviewer] copied share link');
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
log('[agentworld:profileviewer] copy link failed: %s', errorKind(err));
|
||||
});
|
||||
}, [routeHandle]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-surface p-4">
|
||||
{/* Header row: identity + actions */}
|
||||
<div className="flex items-start gap-4">
|
||||
{profile.avatarUrl && !avatarBroken ? (
|
||||
<img
|
||||
src={profile.avatarUrl}
|
||||
alt={displayName}
|
||||
onError={() => setAvatarBroken(true)}
|
||||
className="h-14 w-14 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-primary-600 flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-lg font-semibold text-content-inverted">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-content">
|
||||
<span className="truncate">{handle}</span>
|
||||
{profile.verified && (
|
||||
<span className="text-xs text-blue-500" title="Verified">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
{actorType && (
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
isHuman
|
||||
? 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||
: 'bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-300'
|
||||
}`}>
|
||||
{isHuman
|
||||
? t('agentWorld.profileViewer.humanBadge')
|
||||
: t('agentWorld.profileViewer.agentBadge')}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{cryptoId && (
|
||||
<p className="mt-0.5 font-mono text-xs text-content-muted" title={cryptoId}>
|
||||
{truncateCryptoId(cryptoId)}
|
||||
</p>
|
||||
)}
|
||||
{profile.bio && (
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-content-secondary">{profile.bio}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
{follow.isSelf ? (
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('agentWorld.profileViewer.ownProfile')}
|
||||
</span>
|
||||
) : showFollow ? (
|
||||
<Button
|
||||
variant={follow.state === 'following' ? 'secondary' : 'primary'}
|
||||
size="sm"
|
||||
disabled={follow.busy}
|
||||
onClick={() => void follow.toggle()}>
|
||||
{follow.state === 'following'
|
||||
? t('agentWorld.profileViewer.following')
|
||||
: t('agentWorld.profileViewer.follow')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="tertiary" size="sm" onClick={copyLink} data-testid="profile-copy-link">
|
||||
{copied
|
||||
? t('agentWorld.profileViewer.linkCopied')
|
||||
: t('agentWorld.profileViewer.copyLink')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cardDescription && cardDescription !== (profile.bio ?? '').trim() && (
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
<h4 className="mb-2 text-xs font-medium text-content">
|
||||
{t('agentWorld.profileViewer.agentCard')}
|
||||
</h4>
|
||||
<p className="text-xs leading-relaxed text-content-secondary">{cardDescription}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skills.length > 0 && (
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
<h4 className="mb-2 text-xs font-medium text-content">
|
||||
{t('agentWorld.profileViewer.skills')}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{skills.map(skill => (
|
||||
<span
|
||||
key={skill}
|
||||
className="rounded-full bg-surface-subtle px-2 py-0.5 text-xs text-content-secondary">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attestations.length > 0 && (
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
<h4 className="mb-2 text-xs font-medium text-content">
|
||||
{t('agentWorld.profileViewer.verifiedAccounts')}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attestations.map(a => (
|
||||
<span
|
||||
key={a.attestationId}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
{a.platform}: {a.handle}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ownedIdentities.length > 0 && (
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
<h4 className="mb-2 text-xs font-medium text-content">
|
||||
{t('agentWorld.profileViewer.handlesOwned')}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ownedIdentities.map(id => (
|
||||
<span
|
||||
key={id.username}
|
||||
className="rounded-full border border-line px-2 py-0.5 text-xs text-content-secondary">
|
||||
@{id.username.replace(/^@+/, '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{followStats && (
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
<div className="flex gap-6">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{Math.max(0, followStats.followerCount + followerDelta)}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-content-muted">
|
||||
{t('agentWorld.profileViewer.followers')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{followStats.followingCount}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-content-muted">
|
||||
{t('agentWorld.profileViewer.followingCount')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.createdAt && (
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
<span className="text-xs text-content-muted">
|
||||
{t('agentWorld.profileViewer.joined')} {formatDate(profile.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -443,6 +443,25 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': 'جارٍ تحميل المزيد…',
|
||||
'agentWorld.feed.loadMoreError': 'تعذّر تحميل المزيد من المنشورات. حاول مرة أخرى.',
|
||||
'agentWorld.ledger': 'السجل',
|
||||
'agentWorld.profileViewer.description': 'الملف الشخصي العام',
|
||||
'agentWorld.profileViewer.agentCard': 'بطاقة الوكيل',
|
||||
'agentWorld.profileViewer.loading': 'جارٍ تحميل الملف الشخصي…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'الملف الشخصي غير موجود',
|
||||
'agentWorld.profileViewer.notFoundBody': 'لا يوجد ملف شخصي منشور لهذا المعرّف حتى الآن.',
|
||||
'agentWorld.profileViewer.errorTitle': 'تعذّر تحميل الملف الشخصي',
|
||||
'agentWorld.profileViewer.follow': 'متابعة',
|
||||
'agentWorld.profileViewer.following': 'تتابعه',
|
||||
'agentWorld.profileViewer.copyLink': 'نسخ الرابط',
|
||||
'agentWorld.profileViewer.linkCopied': 'تم نسخ الرابط',
|
||||
'agentWorld.profileViewer.skills': 'المهارات',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'الحسابات الموثّقة',
|
||||
'agentWorld.profileViewer.handlesOwned': 'المعرّفات المملوكة',
|
||||
'agentWorld.profileViewer.followers': 'متابِعون',
|
||||
'agentWorld.profileViewer.followingCount': 'يتابع',
|
||||
'agentWorld.profileViewer.joined': 'انضم في',
|
||||
'agentWorld.profileViewer.ownProfile': 'هذا ملفك الشخصي',
|
||||
'agentWorld.profileViewer.agentBadge': 'وكيل',
|
||||
'agentWorld.profileViewer.humanBadge': 'إنسان',
|
||||
'agentWorld.ledger.loadMore': 'تحميل المزيد',
|
||||
'agentWorld.ledger.loadingMore': 'جارٍ تحميل المزيد…',
|
||||
'agentWorld.ledger.loadMoreError': 'تعذّر تحميل المزيد من المعاملات. حاول مرة أخرى.',
|
||||
|
||||
@@ -459,6 +459,25 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': 'আরও লোড হচ্ছে…',
|
||||
'agentWorld.feed.loadMoreError': 'আরও পোস্ট লোড করা যায়নি। আবার চেষ্টা করুন।',
|
||||
'agentWorld.ledger': 'লেজার',
|
||||
'agentWorld.profileViewer.description': 'সর্বজনীন প্রোফাইল',
|
||||
'agentWorld.profileViewer.agentCard': 'এজেন্ট কার্ড',
|
||||
'agentWorld.profileViewer.loading': 'প্রোফাইল লোড হচ্ছে…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'প্রোফাইল পাওয়া যায়নি',
|
||||
'agentWorld.profileViewer.notFoundBody': 'এই হ্যান্ডেলের জন্য এখনও কোনো প্রকাশিত প্রোফাইল নেই।',
|
||||
'agentWorld.profileViewer.errorTitle': 'প্রোফাইল লোড করা যায়নি',
|
||||
'agentWorld.profileViewer.follow': 'অনুসরণ',
|
||||
'agentWorld.profileViewer.following': 'অনুসরণ করছেন',
|
||||
'agentWorld.profileViewer.copyLink': 'লিঙ্ক কপি করুন',
|
||||
'agentWorld.profileViewer.linkCopied': 'লিঙ্ক কপি হয়েছে',
|
||||
'agentWorld.profileViewer.skills': 'দক্ষতা',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'যাচাই করা অ্যাকাউন্ট',
|
||||
'agentWorld.profileViewer.handlesOwned': 'মালিকানাধীন হ্যান্ডেল',
|
||||
'agentWorld.profileViewer.followers': 'অনুসারী',
|
||||
'agentWorld.profileViewer.followingCount': 'অনুসরণ করছেন',
|
||||
'agentWorld.profileViewer.joined': 'যোগ দিয়েছেন',
|
||||
'agentWorld.profileViewer.ownProfile': 'এটি আপনার প্রোফাইল',
|
||||
'agentWorld.profileViewer.agentBadge': 'এজেন্ট',
|
||||
'agentWorld.profileViewer.humanBadge': 'মানুষ',
|
||||
'agentWorld.ledger.loadMore': 'আরও লোড করুন',
|
||||
'agentWorld.ledger.loadingMore': 'আরও লোড হচ্ছে…',
|
||||
'agentWorld.ledger.loadMoreError': 'আরও লেনদেন লোড করা যায়নি। আবার চেষ্টা করুন।',
|
||||
|
||||
@@ -484,6 +484,26 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadMoreError':
|
||||
'Weitere Beiträge konnten nicht geladen werden. Bitte erneut versuchen.',
|
||||
'agentWorld.ledger': 'Kontobuch',
|
||||
'agentWorld.profileViewer.description': 'Öffentliches Profil',
|
||||
'agentWorld.profileViewer.agentCard': 'Agentenkarte',
|
||||
'agentWorld.profileViewer.loading': 'Profil wird geladen…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'Profil nicht gefunden',
|
||||
'agentWorld.profileViewer.notFoundBody':
|
||||
'Für dieses Handle gibt es noch kein veröffentlichtes Profil.',
|
||||
'agentWorld.profileViewer.errorTitle': 'Profil konnte nicht geladen werden',
|
||||
'agentWorld.profileViewer.follow': 'Folgen',
|
||||
'agentWorld.profileViewer.following': 'Folge ich',
|
||||
'agentWorld.profileViewer.copyLink': 'Link kopieren',
|
||||
'agentWorld.profileViewer.linkCopied': 'Link kopiert',
|
||||
'agentWorld.profileViewer.skills': 'Fähigkeiten',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'Verifizierte Konten',
|
||||
'agentWorld.profileViewer.handlesOwned': 'Eigene Handles',
|
||||
'agentWorld.profileViewer.followers': 'Follower',
|
||||
'agentWorld.profileViewer.followingCount': 'folgt',
|
||||
'agentWorld.profileViewer.joined': 'Beigetreten',
|
||||
'agentWorld.profileViewer.ownProfile': 'Das ist dein Profil',
|
||||
'agentWorld.profileViewer.agentBadge': 'Agent',
|
||||
'agentWorld.profileViewer.humanBadge': 'Mensch',
|
||||
'agentWorld.ledger.loadMore': 'Mehr laden',
|
||||
'agentWorld.ledger.loadingMore': 'Wird geladen…',
|
||||
'agentWorld.ledger.loadMoreError':
|
||||
|
||||
@@ -188,6 +188,25 @@ const en: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': 'Loading more…',
|
||||
'agentWorld.feed.loadMoreError': 'Could not load more posts. Try again.',
|
||||
'agentWorld.ledger': 'Ledger',
|
||||
'agentWorld.profileViewer.description': 'Public profile',
|
||||
'agentWorld.profileViewer.agentCard': 'Agent card',
|
||||
'agentWorld.profileViewer.loading': 'Loading profile…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'Profile not found',
|
||||
'agentWorld.profileViewer.notFoundBody': 'No published profile exists for this handle yet.',
|
||||
'agentWorld.profileViewer.errorTitle': 'Failed to load profile',
|
||||
'agentWorld.profileViewer.follow': 'Follow',
|
||||
'agentWorld.profileViewer.following': 'Following',
|
||||
'agentWorld.profileViewer.copyLink': 'Copy link',
|
||||
'agentWorld.profileViewer.linkCopied': 'Link copied',
|
||||
'agentWorld.profileViewer.skills': 'Skills',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'Verified accounts',
|
||||
'agentWorld.profileViewer.handlesOwned': 'Handles owned',
|
||||
'agentWorld.profileViewer.followers': 'followers',
|
||||
'agentWorld.profileViewer.followingCount': 'following',
|
||||
'agentWorld.profileViewer.joined': 'Joined',
|
||||
'agentWorld.profileViewer.ownProfile': 'This is your profile',
|
||||
'agentWorld.profileViewer.agentBadge': 'Agent',
|
||||
'agentWorld.profileViewer.humanBadge': 'Human',
|
||||
'agentWorld.ledger.loadMore': 'Load more',
|
||||
'agentWorld.ledger.loadingMore': 'Loading more…',
|
||||
'agentWorld.ledger.loadMoreError': 'Could not load more transactions. Try again.',
|
||||
|
||||
@@ -469,6 +469,26 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': 'Cargando más…',
|
||||
'agentWorld.feed.loadMoreError': 'No se pudieron cargar más publicaciones. Inténtalo de nuevo.',
|
||||
'agentWorld.ledger': 'Libro mayor',
|
||||
'agentWorld.profileViewer.description': 'Perfil público',
|
||||
'agentWorld.profileViewer.agentCard': 'Tarjeta del agente',
|
||||
'agentWorld.profileViewer.loading': 'Cargando perfil…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'Perfil no encontrado',
|
||||
'agentWorld.profileViewer.notFoundBody':
|
||||
'Todavía no existe un perfil publicado para este identificador.',
|
||||
'agentWorld.profileViewer.errorTitle': 'No se pudo cargar el perfil',
|
||||
'agentWorld.profileViewer.follow': 'Seguir',
|
||||
'agentWorld.profileViewer.following': 'Siguiendo',
|
||||
'agentWorld.profileViewer.copyLink': 'Copiar enlace',
|
||||
'agentWorld.profileViewer.linkCopied': 'Enlace copiado',
|
||||
'agentWorld.profileViewer.skills': 'Habilidades',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'Cuentas verificadas',
|
||||
'agentWorld.profileViewer.handlesOwned': 'Identificadores en propiedad',
|
||||
'agentWorld.profileViewer.followers': 'seguidores',
|
||||
'agentWorld.profileViewer.followingCount': 'siguiendo',
|
||||
'agentWorld.profileViewer.joined': 'Se unió',
|
||||
'agentWorld.profileViewer.ownProfile': 'Este es tu perfil',
|
||||
'agentWorld.profileViewer.agentBadge': 'Agente',
|
||||
'agentWorld.profileViewer.humanBadge': 'Humano',
|
||||
'agentWorld.ledger.loadMore': 'Cargar más',
|
||||
'agentWorld.ledger.loadingMore': 'Cargando más…',
|
||||
'agentWorld.ledger.loadMoreError': 'No se pudieron cargar más transacciones. Inténtalo de nuevo.',
|
||||
|
||||
@@ -479,6 +479,25 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': 'Chargement…',
|
||||
'agentWorld.feed.loadMoreError': 'Impossible de charger plus de publications. Réessayez.',
|
||||
'agentWorld.ledger': 'Grand livre',
|
||||
'agentWorld.profileViewer.description': 'Profil public',
|
||||
'agentWorld.profileViewer.agentCard': 'Fiche agent',
|
||||
'agentWorld.profileViewer.loading': 'Chargement du profil…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'Profil introuvable',
|
||||
'agentWorld.profileViewer.notFoundBody': 'Aucun profil publié pour ce handle pour le moment.',
|
||||
'agentWorld.profileViewer.errorTitle': 'Échec du chargement du profil',
|
||||
'agentWorld.profileViewer.follow': 'Suivre',
|
||||
'agentWorld.profileViewer.following': 'Suivi',
|
||||
'agentWorld.profileViewer.copyLink': 'Copier le lien',
|
||||
'agentWorld.profileViewer.linkCopied': 'Lien copié',
|
||||
'agentWorld.profileViewer.skills': 'Compétences',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'Comptes vérifiés',
|
||||
'agentWorld.profileViewer.handlesOwned': 'Handles détenus',
|
||||
'agentWorld.profileViewer.followers': 'abonnés',
|
||||
'agentWorld.profileViewer.followingCount': 'abonnements',
|
||||
'agentWorld.profileViewer.joined': 'Inscrit le',
|
||||
'agentWorld.profileViewer.ownProfile': 'Ceci est votre profil',
|
||||
'agentWorld.profileViewer.agentBadge': 'Agent',
|
||||
'agentWorld.profileViewer.humanBadge': 'Humain',
|
||||
'agentWorld.ledger.loadMore': 'Charger plus',
|
||||
'agentWorld.ledger.loadingMore': 'Chargement…',
|
||||
'agentWorld.ledger.loadMoreError': 'Impossible de charger plus de transactions. Réessayez.',
|
||||
|
||||
@@ -459,6 +459,25 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': 'और लोड हो रहा है…',
|
||||
'agentWorld.feed.loadMoreError': 'अधिक पोस्ट लोड नहीं हो सके। फिर से प्रयास करें।',
|
||||
'agentWorld.ledger': 'खाता बही',
|
||||
'agentWorld.profileViewer.description': 'सार्वजनिक प्रोफ़ाइल',
|
||||
'agentWorld.profileViewer.agentCard': 'एजेंट कार्ड',
|
||||
'agentWorld.profileViewer.loading': 'प्रोफ़ाइल लोड हो रही है…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'प्रोफ़ाइल नहीं मिली',
|
||||
'agentWorld.profileViewer.notFoundBody': 'इस हैंडल के लिए अभी तक कोई प्रकाशित प्रोफ़ाइल नहीं है।',
|
||||
'agentWorld.profileViewer.errorTitle': 'प्रोफ़ाइल लोड नहीं हो सकी',
|
||||
'agentWorld.profileViewer.follow': 'फ़ॉलो करें',
|
||||
'agentWorld.profileViewer.following': 'फ़ॉलो कर रहे हैं',
|
||||
'agentWorld.profileViewer.copyLink': 'लिंक कॉपी करें',
|
||||
'agentWorld.profileViewer.linkCopied': 'लिंक कॉपी हो गया',
|
||||
'agentWorld.profileViewer.skills': 'कौशल',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'सत्यापित खाते',
|
||||
'agentWorld.profileViewer.handlesOwned': 'स्वामित्व वाले हैंडल',
|
||||
'agentWorld.profileViewer.followers': 'फ़ॉलोअर्स',
|
||||
'agentWorld.profileViewer.followingCount': 'फ़ॉलोइंग',
|
||||
'agentWorld.profileViewer.joined': 'शामिल हुए',
|
||||
'agentWorld.profileViewer.ownProfile': 'यह आपकी प्रोफ़ाइल है',
|
||||
'agentWorld.profileViewer.agentBadge': 'एजेंट',
|
||||
'agentWorld.profileViewer.humanBadge': 'मानव',
|
||||
'agentWorld.ledger.loadMore': 'और लोड करें',
|
||||
'agentWorld.ledger.loadingMore': 'और लोड हो रहा है…',
|
||||
'agentWorld.ledger.loadMoreError': 'अधिक लेन-देन लोड नहीं हो सके। फिर से प्रयास करें।',
|
||||
|
||||
@@ -465,6 +465,25 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': 'Memuat lagi…',
|
||||
'agentWorld.feed.loadMoreError': 'Tidak dapat memuat postingan lainnya. Coba lagi.',
|
||||
'agentWorld.ledger': 'Buku Besar',
|
||||
'agentWorld.profileViewer.description': 'Profil publik',
|
||||
'agentWorld.profileViewer.agentCard': 'Kartu agen',
|
||||
'agentWorld.profileViewer.loading': 'Memuat profil…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'Profil tidak ditemukan',
|
||||
'agentWorld.profileViewer.notFoundBody': 'Belum ada profil yang dipublikasikan untuk handel ini.',
|
||||
'agentWorld.profileViewer.errorTitle': 'Gagal memuat profil',
|
||||
'agentWorld.profileViewer.follow': 'Ikuti',
|
||||
'agentWorld.profileViewer.following': 'Mengikuti',
|
||||
'agentWorld.profileViewer.copyLink': 'Salin tautan',
|
||||
'agentWorld.profileViewer.linkCopied': 'Tautan disalin',
|
||||
'agentWorld.profileViewer.skills': 'Keterampilan',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'Akun terverifikasi',
|
||||
'agentWorld.profileViewer.handlesOwned': 'Handel yang dimiliki',
|
||||
'agentWorld.profileViewer.followers': 'pengikut',
|
||||
'agentWorld.profileViewer.followingCount': 'mengikuti',
|
||||
'agentWorld.profileViewer.joined': 'Bergabung',
|
||||
'agentWorld.profileViewer.ownProfile': 'Ini profil Anda',
|
||||
'agentWorld.profileViewer.agentBadge': 'Agen',
|
||||
'agentWorld.profileViewer.humanBadge': 'Manusia',
|
||||
'agentWorld.ledger.loadMore': 'Muat lebih banyak',
|
||||
'agentWorld.ledger.loadingMore': 'Memuat lagi…',
|
||||
'agentWorld.ledger.loadMoreError': 'Tidak dapat memuat transaksi lainnya. Coba lagi.',
|
||||
|
||||
@@ -472,6 +472,26 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': 'Caricamento…',
|
||||
'agentWorld.feed.loadMoreError': 'Impossibile caricare altri post. Riprova.',
|
||||
'agentWorld.ledger': 'Registro',
|
||||
'agentWorld.profileViewer.description': 'Profilo pubblico',
|
||||
'agentWorld.profileViewer.agentCard': 'Scheda agente',
|
||||
'agentWorld.profileViewer.loading': 'Caricamento profilo…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'Profilo non trovato',
|
||||
'agentWorld.profileViewer.notFoundBody':
|
||||
'Non esiste ancora un profilo pubblicato per questo handle.',
|
||||
'agentWorld.profileViewer.errorTitle': 'Impossibile caricare il profilo',
|
||||
'agentWorld.profileViewer.follow': 'Segui',
|
||||
'agentWorld.profileViewer.following': 'Stai già seguendo',
|
||||
'agentWorld.profileViewer.copyLink': 'Copia link',
|
||||
'agentWorld.profileViewer.linkCopied': 'Link copiato',
|
||||
'agentWorld.profileViewer.skills': 'Competenze',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'Account verificati',
|
||||
'agentWorld.profileViewer.handlesOwned': 'Handle posseduti',
|
||||
'agentWorld.profileViewer.followers': 'follower',
|
||||
'agentWorld.profileViewer.followingCount': 'seguiti',
|
||||
'agentWorld.profileViewer.joined': 'Iscritto',
|
||||
'agentWorld.profileViewer.ownProfile': 'Questo è il tuo profilo',
|
||||
'agentWorld.profileViewer.agentBadge': 'Agente',
|
||||
'agentWorld.profileViewer.humanBadge': 'Umano',
|
||||
'agentWorld.ledger.loadMore': 'Carica altro',
|
||||
'agentWorld.ledger.loadingMore': 'Caricamento…',
|
||||
'agentWorld.ledger.loadMoreError': 'Impossibile caricare altre transazioni. Riprova.',
|
||||
|
||||
@@ -452,6 +452,25 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': '더 불러오는 중…',
|
||||
'agentWorld.feed.loadMoreError': '게시물을 더 불러오지 못했습니다. 다시 시도하세요.',
|
||||
'agentWorld.ledger': '원장',
|
||||
'agentWorld.profileViewer.description': '공개 프로필',
|
||||
'agentWorld.profileViewer.agentCard': '에이전트 카드',
|
||||
'agentWorld.profileViewer.loading': '프로필 불러오는 중…',
|
||||
'agentWorld.profileViewer.notFoundTitle': '프로필을 찾을 수 없음',
|
||||
'agentWorld.profileViewer.notFoundBody': '이 핸들에 대해 게시된 프로필이 아직 없습니다.',
|
||||
'agentWorld.profileViewer.errorTitle': '프로필을 불러오지 못했습니다',
|
||||
'agentWorld.profileViewer.follow': '팔로우',
|
||||
'agentWorld.profileViewer.following': '팔로잉',
|
||||
'agentWorld.profileViewer.copyLink': '링크 복사',
|
||||
'agentWorld.profileViewer.linkCopied': '링크 복사됨',
|
||||
'agentWorld.profileViewer.skills': '기술',
|
||||
'agentWorld.profileViewer.verifiedAccounts': '인증된 계정',
|
||||
'agentWorld.profileViewer.handlesOwned': '보유한 핸들',
|
||||
'agentWorld.profileViewer.followers': '팔로워',
|
||||
'agentWorld.profileViewer.followingCount': '팔로잉',
|
||||
'agentWorld.profileViewer.joined': '가입일',
|
||||
'agentWorld.profileViewer.ownProfile': '내 프로필입니다',
|
||||
'agentWorld.profileViewer.agentBadge': '에이전트',
|
||||
'agentWorld.profileViewer.humanBadge': '사람',
|
||||
'agentWorld.ledger.loadMore': '더 보기',
|
||||
'agentWorld.ledger.loadingMore': '더 불러오는 중…',
|
||||
'agentWorld.ledger.loadMoreError': '거래를 더 불러오지 못했습니다. 다시 시도하세요.',
|
||||
|
||||
@@ -470,6 +470,26 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': 'Ładowanie…',
|
||||
'agentWorld.feed.loadMoreError': 'Nie udało się załadować kolejnych postów. Spróbuj ponownie.',
|
||||
'agentWorld.ledger': 'Księga',
|
||||
'agentWorld.profileViewer.description': 'Profil publiczny',
|
||||
'agentWorld.profileViewer.agentCard': 'Karta agenta',
|
||||
'agentWorld.profileViewer.loading': 'Ładowanie profilu…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'Nie znaleziono profilu',
|
||||
'agentWorld.profileViewer.notFoundBody':
|
||||
'Dla tej nazwy użytkownika nie ma jeszcze opublikowanego profilu.',
|
||||
'agentWorld.profileViewer.errorTitle': 'Nie udało się załadować profilu',
|
||||
'agentWorld.profileViewer.follow': 'Obserwuj',
|
||||
'agentWorld.profileViewer.following': 'Obserwujesz',
|
||||
'agentWorld.profileViewer.copyLink': 'Kopiuj link',
|
||||
'agentWorld.profileViewer.linkCopied': 'Link skopiowany',
|
||||
'agentWorld.profileViewer.skills': 'Umiejętności',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'Zweryfikowane konta',
|
||||
'agentWorld.profileViewer.handlesOwned': 'Posiadane nazwy użytkownika',
|
||||
'agentWorld.profileViewer.followers': 'obserwujący',
|
||||
'agentWorld.profileViewer.followingCount': 'obserwowani',
|
||||
'agentWorld.profileViewer.joined': 'Dołączono',
|
||||
'agentWorld.profileViewer.ownProfile': 'To jest Twój profil',
|
||||
'agentWorld.profileViewer.agentBadge': 'Agent',
|
||||
'agentWorld.profileViewer.humanBadge': 'Człowiek',
|
||||
'agentWorld.ledger.loadMore': 'Załaduj więcej',
|
||||
'agentWorld.ledger.loadingMore': 'Ładowanie…',
|
||||
'agentWorld.ledger.loadMoreError':
|
||||
|
||||
@@ -464,6 +464,26 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': 'Carregando mais…',
|
||||
'agentWorld.feed.loadMoreError': 'Não foi possível carregar mais publicações. Tente novamente.',
|
||||
'agentWorld.ledger': 'Livro-razão',
|
||||
'agentWorld.profileViewer.description': 'Perfil público',
|
||||
'agentWorld.profileViewer.agentCard': 'Cartão do agente',
|
||||
'agentWorld.profileViewer.loading': 'Carregando perfil…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'Perfil não encontrado',
|
||||
'agentWorld.profileViewer.notFoundBody':
|
||||
'Ainda não existe um perfil publicado para este identificador.',
|
||||
'agentWorld.profileViewer.errorTitle': 'Falha ao carregar o perfil',
|
||||
'agentWorld.profileViewer.follow': 'Seguir',
|
||||
'agentWorld.profileViewer.following': 'Seguindo',
|
||||
'agentWorld.profileViewer.copyLink': 'Copiar link',
|
||||
'agentWorld.profileViewer.linkCopied': 'Link copiado',
|
||||
'agentWorld.profileViewer.skills': 'Habilidades',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'Contas verificadas',
|
||||
'agentWorld.profileViewer.handlesOwned': 'Handles próprios',
|
||||
'agentWorld.profileViewer.followers': 'seguidores',
|
||||
'agentWorld.profileViewer.followingCount': 'seguindo',
|
||||
'agentWorld.profileViewer.joined': 'Entrou',
|
||||
'agentWorld.profileViewer.ownProfile': 'Este é o seu perfil',
|
||||
'agentWorld.profileViewer.agentBadge': 'Agente',
|
||||
'agentWorld.profileViewer.humanBadge': 'Humano',
|
||||
'agentWorld.ledger.loadMore': 'Carregar mais',
|
||||
'agentWorld.ledger.loadingMore': 'Carregando mais…',
|
||||
'agentWorld.ledger.loadMoreError': 'Não foi possível carregar mais transações. Tente novamente.',
|
||||
|
||||
@@ -464,6 +464,26 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': 'Загрузка…',
|
||||
'agentWorld.feed.loadMoreError': 'Не удалось загрузить больше публикаций. Попробуйте ещё раз.',
|
||||
'agentWorld.ledger': 'Реестр',
|
||||
'agentWorld.profileViewer.description': 'Публичный профиль',
|
||||
'agentWorld.profileViewer.agentCard': 'Карточка агента',
|
||||
'agentWorld.profileViewer.loading': 'Загрузка профиля…',
|
||||
'agentWorld.profileViewer.notFoundTitle': 'Профиль не найден',
|
||||
'agentWorld.profileViewer.notFoundBody':
|
||||
'Для этого идентификатора пока нет опубликованного профиля.',
|
||||
'agentWorld.profileViewer.errorTitle': 'Не удалось загрузить профиль',
|
||||
'agentWorld.profileViewer.follow': 'Подписаться',
|
||||
'agentWorld.profileViewer.following': 'Вы подписаны',
|
||||
'agentWorld.profileViewer.copyLink': 'Скопировать ссылку',
|
||||
'agentWorld.profileViewer.linkCopied': 'Ссылка скопирована',
|
||||
'agentWorld.profileViewer.skills': 'Навыки',
|
||||
'agentWorld.profileViewer.verifiedAccounts': 'Подтверждённые аккаунты',
|
||||
'agentWorld.profileViewer.handlesOwned': 'Собственные хэндлы',
|
||||
'agentWorld.profileViewer.followers': 'подписчиков',
|
||||
'agentWorld.profileViewer.followingCount': 'подписок',
|
||||
'agentWorld.profileViewer.joined': 'Дата присоединения',
|
||||
'agentWorld.profileViewer.ownProfile': 'Это ваш профиль',
|
||||
'agentWorld.profileViewer.agentBadge': 'Агент',
|
||||
'agentWorld.profileViewer.humanBadge': 'Человек',
|
||||
'agentWorld.ledger.loadMore': 'Загрузить ещё',
|
||||
'agentWorld.ledger.loadingMore': 'Загрузка…',
|
||||
'agentWorld.ledger.loadMoreError': 'Не удалось загрузить больше транзакций. Попробуйте ещё раз.',
|
||||
|
||||
@@ -428,6 +428,25 @@ const messages: TranslationMap = {
|
||||
'agentWorld.feed.loadingMore': '正在加载…',
|
||||
'agentWorld.feed.loadMoreError': '无法加载更多帖子,请重试。',
|
||||
'agentWorld.ledger': '账本',
|
||||
'agentWorld.profileViewer.description': '公开资料',
|
||||
'agentWorld.profileViewer.agentCard': '智能体卡片',
|
||||
'agentWorld.profileViewer.loading': '正在加载资料…',
|
||||
'agentWorld.profileViewer.notFoundTitle': '未找到资料',
|
||||
'agentWorld.profileViewer.notFoundBody': '此账号句柄尚未发布资料。',
|
||||
'agentWorld.profileViewer.errorTitle': '无法加载资料',
|
||||
'agentWorld.profileViewer.follow': '关注',
|
||||
'agentWorld.profileViewer.following': '已关注',
|
||||
'agentWorld.profileViewer.copyLink': '复制链接',
|
||||
'agentWorld.profileViewer.linkCopied': '链接已复制',
|
||||
'agentWorld.profileViewer.skills': '技能',
|
||||
'agentWorld.profileViewer.verifiedAccounts': '已验证账户',
|
||||
'agentWorld.profileViewer.handlesOwned': '拥有的句柄',
|
||||
'agentWorld.profileViewer.followers': '关注者',
|
||||
'agentWorld.profileViewer.followingCount': '正在关注',
|
||||
'agentWorld.profileViewer.joined': '加入于',
|
||||
'agentWorld.profileViewer.ownProfile': '这是你的资料',
|
||||
'agentWorld.profileViewer.agentBadge': '智能体',
|
||||
'agentWorld.profileViewer.humanBadge': '人类',
|
||||
'agentWorld.ledger.loadMore': '加载更多',
|
||||
'agentWorld.ledger.loadingMore': '正在加载…',
|
||||
'agentWorld.ledger.loadMoreError': '无法加载更多交易,请重试。',
|
||||
|
||||
Reference in New Issue
Block a user