mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent-world): show Solana wallet address chip in Tiny Place sidebar header (#3838)
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Tests for WalletAddressChip — the always-visible wallet address chip in the
|
||||
* Agent World sidebar header.
|
||||
*
|
||||
* All addresses are GENERIC placeholders (never real wallet addresses).
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
// Pull the mock reference after vi.mock is hoisted.
|
||||
import { fetchWalletStatus } from '../../services/walletApi';
|
||||
import WalletAddressChip from './WalletAddressChip';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mock — walletApi
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() }));
|
||||
|
||||
const mockFetchWalletStatus = vi.mocked(fetchWalletStatus);
|
||||
|
||||
// Generic placeholder wallet status — 44-char base58 Solana address.
|
||||
const SOLANA_ADDRESS = 'AAAAAA1111112222223333334444445555556789BB';
|
||||
|
||||
function makeStatus(address: string | null = SOLANA_ADDRESS) {
|
||||
return {
|
||||
configured: address !== null,
|
||||
onboardingCompleted: address !== null,
|
||||
consentGranted: true,
|
||||
secretStored: true,
|
||||
source: 'generated' as const,
|
||||
mnemonicWordCount: 12,
|
||||
accounts:
|
||||
address !== null
|
||||
? [
|
||||
{
|
||||
chain: 'evm' as const,
|
||||
address: '0xGENERICevm0000',
|
||||
derivationPath: "m/44'/60'/0'/0/0",
|
||||
},
|
||||
{ chain: 'solana' as const, address, derivationPath: "m/44'/501'/0'/0'" },
|
||||
]
|
||||
: [],
|
||||
updatedAtMs: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clipboard mock (jsdom doesn't implement navigator.clipboard)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock implementation between tests so a never-resolving promise from
|
||||
// one test doesn't bleed into the next (vitest config has mockReset: false).
|
||||
mockFetchWalletStatus.mockReset();
|
||||
// Clear clipboard call history so per-test assertions don't see stale calls.
|
||||
clipboardWriteText.mockClear();
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: clipboardWriteText },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('WalletAddressChip', () => {
|
||||
test('renders a loading skeleton before wallet resolves', () => {
|
||||
// Never resolves during this test — promise stays pending.
|
||||
mockFetchWalletStatus.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
render(<WalletAddressChip />);
|
||||
|
||||
const chip = screen.getByTestId('wallet-address-chip');
|
||||
expect(chip).toBeInTheDocument();
|
||||
// In loading state the chip is the pulse element itself (no address text).
|
||||
expect(chip).not.toHaveTextContent(SOLANA_ADDRESS);
|
||||
expect(chip.className).toContain('animate-pulse');
|
||||
});
|
||||
|
||||
test('renders truncated address (6…4) in ready state', async () => {
|
||||
mockFetchWalletStatus.mockResolvedValue(makeStatus());
|
||||
|
||||
render(<WalletAddressChip />);
|
||||
|
||||
// Truncated: first 6 + last 4 of SOLANA_ADDRESS
|
||||
const expected = `${SOLANA_ADDRESS.slice(0, 6)}…${SOLANA_ADDRESS.slice(-4)}`;
|
||||
await screen.findByText(expected);
|
||||
|
||||
const chip = screen.getByTestId('wallet-address-chip');
|
||||
expect(chip).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('full address is in the title attribute of the truncated span', async () => {
|
||||
mockFetchWalletStatus.mockResolvedValue(makeStatus());
|
||||
|
||||
render(<WalletAddressChip />);
|
||||
|
||||
await waitFor(() => {
|
||||
const span = screen.getByTitle(SOLANA_ADDRESS);
|
||||
expect(span).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('copy button calls clipboard.writeText with the full address', async () => {
|
||||
mockFetchWalletStatus.mockResolvedValue(makeStatus());
|
||||
|
||||
render(<WalletAddressChip />);
|
||||
|
||||
// Wait for ready state.
|
||||
await screen.findByTitle(SOLANA_ADDRESS);
|
||||
|
||||
const copyBtn = screen.getByRole('button', { name: /copy address/i });
|
||||
await userEvent.click(copyBtn);
|
||||
|
||||
expect(clipboardWriteText).toHaveBeenCalledWith(SOLANA_ADDRESS);
|
||||
});
|
||||
|
||||
test('copy button shows "Copied" feedback after click and reverts', async () => {
|
||||
// Fake timers so we can fast-forward the 2s reset; shouldAdvanceTime keeps
|
||||
// findBy/waitFor polling alive, and userEvent drives the same clock.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
mockFetchWalletStatus.mockResolvedValue(makeStatus());
|
||||
|
||||
render(<WalletAddressChip />);
|
||||
await screen.findByTitle(SOLANA_ADDRESS);
|
||||
|
||||
const copyBtn = screen.getByRole('button', { name: /copy address/i });
|
||||
await user.click(copyBtn);
|
||||
|
||||
// After click the aria-label should flip to "Copied".
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /copied/i })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
// After the 2s reset timer fires, it should revert to "Copy address".
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /copy address/i })).toBeInTheDocument()
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('shows a retryable "Wallet unavailable" state when fetchWalletStatus rejects', async () => {
|
||||
// A transient RPC/transport failure must NOT be reported as "not set up" —
|
||||
// a configured wallet would otherwise be mislabelled until the route remounts.
|
||||
mockFetchWalletStatus.mockRejectedValue(new Error('core rpc unavailable'));
|
||||
|
||||
render(<WalletAddressChip />);
|
||||
|
||||
await screen.findByText(/wallet unavailable/i);
|
||||
|
||||
const chip = screen.getByTestId('wallet-address-chip');
|
||||
expect(chip).not.toHaveTextContent(SOLANA_ADDRESS);
|
||||
// The error chip is itself a retry button — and it must NOT claim "not set up".
|
||||
expect(screen.queryByText(/wallet not set up/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('error state recovers to ready after a successful retry', async () => {
|
||||
// First fetch fails (transient), the retry click succeeds.
|
||||
mockFetchWalletStatus
|
||||
.mockRejectedValueOnce(new Error('core rpc unavailable'))
|
||||
.mockResolvedValueOnce(makeStatus());
|
||||
|
||||
render(<WalletAddressChip />);
|
||||
|
||||
const retryBtn = await screen.findByRole('button', { name: /retry/i });
|
||||
await userEvent.click(retryBtn);
|
||||
|
||||
// After the retry resolves, the truncated address is shown.
|
||||
await screen.findByTitle(SOLANA_ADDRESS);
|
||||
expect(screen.queryByText(/wallet unavailable/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows "Wallet not set up" when no solana account exists in accounts list', async () => {
|
||||
// Wallet configured but only EVM — no Solana entry.
|
||||
mockFetchWalletStatus.mockResolvedValue(makeStatus(null));
|
||||
|
||||
render(<WalletAddressChip />);
|
||||
|
||||
await screen.findByText(/wallet not set up/i);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('copy button has descriptive aria-label visible to assistive technology', async () => {
|
||||
mockFetchWalletStatus.mockResolvedValue(makeStatus());
|
||||
|
||||
render(<WalletAddressChip />);
|
||||
await screen.findByTitle(SOLANA_ADDRESS);
|
||||
|
||||
// The button must be discoverable by assistive technology.
|
||||
expect(screen.getByRole('button', { name: /copy address/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* WalletAddressChip — persistent wallet address display for the Agent World
|
||||
* sidebar header.
|
||||
*
|
||||
* Fetches the wallet status on mount, extracts the Solana account address
|
||||
* (= tiny.place cryptoId), and renders a compact monospace chip with a
|
||||
* copy-to-clipboard button. Visible from every Agent World section without
|
||||
* requiring navigation.
|
||||
*
|
||||
* States:
|
||||
* loading — pulse skeleton while wallet_status is in-flight.
|
||||
* ready — truncated 6…4 address + copy button.
|
||||
* locked — muted "Wallet not set up" label (no copy).
|
||||
*
|
||||
* Copy pattern mirrors WalletBalancesPanel (2s checkmark feedback).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { fetchWalletStatus } from '../../services/walletApi';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Address helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Truncate a crypto address to first 6 + last 4: `AbCdEf…wxyz`. */
|
||||
function truncateAddress(address: string): string {
|
||||
if (address.length <= 12) return address;
|
||||
return `${address.slice(0, 6)}…${address.slice(-4)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon primitives (inline SVG, 16×16, no external deps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WalletIcon = () => (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<path d="M20 7H4a2 2 0 00-2 2v10a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2z" />
|
||||
<path d="M16 3H8a2 2 0 00-2 2v2h12V5a2 2 0 00-2-2z" />
|
||||
<circle cx="16" cy="14" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component state type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ChipState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; address: string }
|
||||
| { status: 'locked' }
|
||||
| { status: 'error' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WalletAddressChip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function WalletAddressChip() {
|
||||
const { t } = useT();
|
||||
const [state, setState] = useState<ChipState>({ status: 'loading' });
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Monotonic request id so a slow/aborted fetch can't clobber a newer one
|
||||
// (e.g. when the user taps "retry" while a prior request is still in flight).
|
||||
const latestRequestIdRef = useRef(0);
|
||||
|
||||
// Fetch wallet status and resolve the chip state. Distinguishes the genuine
|
||||
// "not set up" case (a successful wallet_status with no Solana account) from a
|
||||
// transient RPC/transport failure — the latter must NOT masquerade as a locked
|
||||
// wallet for a user who already has one configured.
|
||||
const loadStatus = useCallback(async () => {
|
||||
const requestId = ++latestRequestIdRef.current;
|
||||
try {
|
||||
const status = await fetchWalletStatus();
|
||||
const solana = (status.accounts ?? []).find(a => a.chain === 'solana');
|
||||
if (requestId !== latestRequestIdRef.current) return;
|
||||
if (solana?.address) {
|
||||
setState({ status: 'ready', address: solana.address });
|
||||
} else {
|
||||
// Successful response, but the wallet has no Solana account yet.
|
||||
setState({ status: 'locked' });
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== latestRequestIdRef.current) return;
|
||||
// Core RPC unavailable / timeout / transport error — surface a retryable
|
||||
// error state rather than mislabelling a configured wallet as "not set up".
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.debug('[walletAddressChip] wallet_status fetch failed:', message);
|
||||
setState({ status: 'error' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch wallet status on mount.
|
||||
useEffect(() => {
|
||||
// loadStatus only calls setState after an awaited fetch (never synchronously),
|
||||
// so it does not cause the cascading renders this rule guards against.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadStatus();
|
||||
return () => {
|
||||
// Invalidate any in-flight request so its resolution is ignored.
|
||||
latestRequestIdRef.current += 1;
|
||||
};
|
||||
}, [loadStatus]);
|
||||
|
||||
// Cleanup copy-reset timer on unmount.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copyResetTimerRef.current !== null) {
|
||||
clearTimeout(copyResetTimerRef.current);
|
||||
copyResetTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(async (address: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopied(true);
|
||||
if (copyResetTimerRef.current !== null) {
|
||||
clearTimeout(copyResetTimerRef.current);
|
||||
}
|
||||
copyResetTimerRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
copyResetTimerRef.current = null;
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Clipboard unavailable in this webview context; silently skip.
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Loading skeleton ─────────────────────────────────────────────────────
|
||||
if (state.status === 'loading') {
|
||||
return (
|
||||
<div data-testid="wallet-address-chip" className="flex items-center gap-1.5 animate-pulse">
|
||||
<div className="h-3.5 w-3.5 rounded bg-stone-300 dark:bg-neutral-600 shrink-0" />
|
||||
<div className="h-3 w-24 rounded bg-stone-300 dark:bg-neutral-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error — transient RPC/transport failure, offer a retry ────────────────
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wallet-address-chip"
|
||||
onClick={() => {
|
||||
// Show the loading skeleton again while the retry is in flight.
|
||||
setState({ status: 'loading' });
|
||||
void loadStatus();
|
||||
}}
|
||||
aria-label={t('agentWorld.walletRetry')}
|
||||
title={t('agentWorld.walletRetry')}
|
||||
className="flex items-center gap-1.5 text-stone-400 transition-colors hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary-500">
|
||||
<WalletIcon />
|
||||
<span className="text-[11px] leading-none">{t('agentWorld.walletUnavailable')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Locked / not configured ───────────────────────────────────────────────
|
||||
if (state.status === 'locked') {
|
||||
return (
|
||||
<div
|
||||
data-testid="wallet-address-chip"
|
||||
className="flex items-center gap-1.5 text-stone-400 dark:text-neutral-500">
|
||||
<WalletIcon />
|
||||
<span className="text-[11px] leading-none">{t('agentWorld.walletNotConfigured')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ready — show truncated address + copy button ──────────────────────────
|
||||
const { address } = state;
|
||||
const truncated = truncateAddress(address);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="wallet-address-chip"
|
||||
className="flex items-center gap-1.5 text-stone-500 dark:text-neutral-400">
|
||||
<WalletIcon />
|
||||
<span className="font-mono text-[11px] leading-none tracking-tight" title={address}>
|
||||
{truncated}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copied ? t('agentWorld.addressCopied') : t('agentWorld.copyAddress')}
|
||||
title={copied ? t('agentWorld.addressCopied') : t('agentWorld.copyAddress')}
|
||||
onClick={() => void handleCopy(address)}
|
||||
className="shrink-0 rounded p-0.5 text-stone-400 transition-colors hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary-500">
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-
|
||||
import { SidebarContent } from '../../components/layout/shell/SidebarSlot';
|
||||
import TwoPaneNav from '../../components/layout/TwoPaneNav';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import WalletAddressChip from '../components/WalletAddressChip';
|
||||
import BountiesSection from './BountiesSection';
|
||||
import DirectorySection from './DirectorySection';
|
||||
import ExploreSection from './ExploreSection';
|
||||
@@ -131,9 +132,12 @@ export default function AgentWorld() {
|
||||
},
|
||||
]}
|
||||
header={
|
||||
<p className="min-w-0 text-[11px] leading-relaxed text-stone-500 dark:text-neutral-400">
|
||||
{t('agentWorld.description')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p className="min-w-0 text-[11px] leading-relaxed text-stone-500 dark:text-neutral-400">
|
||||
{t('agentWorld.description')}
|
||||
</p>
|
||||
<WalletAddressChip />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': 'الملفات الشخصية',
|
||||
'agentWorld.marketplace': 'السوق',
|
||||
'agentWorld.messaging': 'الرسائل',
|
||||
'agentWorld.walletNotConfigured': 'المحفظة غير مُهيَّأة',
|
||||
'agentWorld.copyAddress': 'نسخ العنوان',
|
||||
'agentWorld.addressCopied': 'تم النسخ',
|
||||
'agentWorld.walletUnavailable': 'المحفظة غير متاحة',
|
||||
'agentWorld.walletRetry': 'إعادة محاولة تحميل المحفظة',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'نظرة عامة على الشبكة',
|
||||
'explore.trendingCommunities': 'المجتمعات الرائجة',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': 'প্রোফাইল',
|
||||
'agentWorld.marketplace': 'মার্কেটপ্লেস',
|
||||
'agentWorld.messaging': 'বার্তা',
|
||||
'agentWorld.walletNotConfigured': 'ওয়ালেট সেট আপ হয়নি',
|
||||
'agentWorld.copyAddress': 'ঠিকানা কপি করুন',
|
||||
'agentWorld.addressCopied': 'কপি হয়েছে',
|
||||
'agentWorld.walletUnavailable': 'ওয়ালেট অনুপলব্ধ',
|
||||
'agentWorld.walletRetry': 'ওয়ালেট পুনরায় লোড করুন',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'নেটওয়ার্ক ওভারভিউ',
|
||||
'explore.trendingCommunities': 'ট্রেন্ডিং কমিউনিটি',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': 'Profile',
|
||||
'agentWorld.marketplace': 'Marktplatz',
|
||||
'agentWorld.messaging': 'Nachrichten',
|
||||
'agentWorld.walletNotConfigured': 'Wallet nicht eingerichtet',
|
||||
'agentWorld.copyAddress': 'Adresse kopieren',
|
||||
'agentWorld.addressCopied': 'Kopiert',
|
||||
'agentWorld.walletUnavailable': 'Wallet nicht verfügbar',
|
||||
'agentWorld.walletRetry': 'Wallet erneut laden',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'Netzwerkübersicht',
|
||||
'explore.trendingCommunities': 'Trending-Communities',
|
||||
|
||||
@@ -37,6 +37,11 @@ const en: TranslationMap = {
|
||||
'agentWorld.profiles': 'Profiles',
|
||||
'agentWorld.marketplace': 'Marketplace',
|
||||
'agentWorld.messaging': 'Messages',
|
||||
'agentWorld.walletNotConfigured': 'Wallet not set up',
|
||||
'agentWorld.copyAddress': 'Copy address',
|
||||
'agentWorld.addressCopied': 'Copied',
|
||||
'agentWorld.walletUnavailable': 'Wallet unavailable',
|
||||
'agentWorld.walletRetry': 'Retry loading wallet',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'Network Overview',
|
||||
'explore.trendingCommunities': 'Trending Communities',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': 'Perfiles',
|
||||
'agentWorld.marketplace': 'Mercado',
|
||||
'agentWorld.messaging': 'Mensajes',
|
||||
'agentWorld.walletNotConfigured': 'Cartera no configurada',
|
||||
'agentWorld.copyAddress': 'Copiar dirección',
|
||||
'agentWorld.addressCopied': 'Copiada',
|
||||
'agentWorld.walletUnavailable': 'Cartera no disponible',
|
||||
'agentWorld.walletRetry': 'Reintentar cargar la cartera',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'Resumen de red',
|
||||
'explore.trendingCommunities': 'Comunidades en tendencia',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': 'Profils',
|
||||
'agentWorld.marketplace': 'Marché',
|
||||
'agentWorld.messaging': 'Messages',
|
||||
'agentWorld.walletNotConfigured': 'Portefeuille non configuré',
|
||||
'agentWorld.copyAddress': "Copier l'adresse",
|
||||
'agentWorld.addressCopied': 'Copié',
|
||||
'agentWorld.walletUnavailable': 'Portefeuille indisponible',
|
||||
'agentWorld.walletRetry': 'Réessayer de charger le portefeuille',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'Aperçu du réseau',
|
||||
'explore.trendingCommunities': 'Communautés tendance',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': 'प्रोफ़ाइल',
|
||||
'agentWorld.marketplace': 'मार्केटप्लेस',
|
||||
'agentWorld.messaging': 'संदेश',
|
||||
'agentWorld.walletNotConfigured': 'वॉलेट सेट नहीं है',
|
||||
'agentWorld.copyAddress': 'पता कॉपी करें',
|
||||
'agentWorld.addressCopied': 'कॉपी हो गया',
|
||||
'agentWorld.walletUnavailable': 'वॉलेट उपलब्ध नहीं',
|
||||
'agentWorld.walletRetry': 'वॉलेट फिर से लोड करें',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'नेटवर्क अवलोकन',
|
||||
'explore.trendingCommunities': 'ट्रेंडिंग समुदाय',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': 'Profil',
|
||||
'agentWorld.marketplace': 'Pasar',
|
||||
'agentWorld.messaging': 'Pesan',
|
||||
'agentWorld.walletNotConfigured': 'Dompet belum diatur',
|
||||
'agentWorld.copyAddress': 'Salin alamat',
|
||||
'agentWorld.addressCopied': 'Disalin',
|
||||
'agentWorld.walletUnavailable': 'Dompet tidak tersedia',
|
||||
'agentWorld.walletRetry': 'Coba muat dompet lagi',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'Ikhtisar Jaringan',
|
||||
'explore.trendingCommunities': 'Komunitas Trending',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': 'Profili',
|
||||
'agentWorld.marketplace': 'Mercato',
|
||||
'agentWorld.messaging': 'Messaggi',
|
||||
'agentWorld.walletNotConfigured': 'Wallet non configurato',
|
||||
'agentWorld.copyAddress': 'Copia indirizzo',
|
||||
'agentWorld.addressCopied': 'Copiato',
|
||||
'agentWorld.walletUnavailable': 'Wallet non disponibile',
|
||||
'agentWorld.walletRetry': 'Riprova a caricare il wallet',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'Panoramica della rete',
|
||||
'explore.trendingCommunities': 'Comunità di tendenza',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': '프로필',
|
||||
'agentWorld.marketplace': '마켓플레이스',
|
||||
'agentWorld.messaging': '메시지',
|
||||
'agentWorld.walletNotConfigured': '지갑이 설정되지 않음',
|
||||
'agentWorld.copyAddress': '주소 복사',
|
||||
'agentWorld.addressCopied': '복사됨',
|
||||
'agentWorld.walletUnavailable': '지갑을 사용할 수 없음',
|
||||
'agentWorld.walletRetry': '지갑 다시 불러오기',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': '네트워크 개요',
|
||||
'explore.trendingCommunities': '트렌딩 커뮤니티',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': 'Profile',
|
||||
'agentWorld.marketplace': 'Rynek',
|
||||
'agentWorld.messaging': 'Wiadomości',
|
||||
'agentWorld.walletNotConfigured': 'Portfel nie jest skonfigurowany',
|
||||
'agentWorld.copyAddress': 'Kopiuj adres',
|
||||
'agentWorld.addressCopied': 'Skopiowano',
|
||||
'agentWorld.walletUnavailable': 'Portfel niedostępny',
|
||||
'agentWorld.walletRetry': 'Spróbuj ponownie załadować portfel',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'Przegląd sieci',
|
||||
'explore.trendingCommunities': 'Popularne społeczności',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': 'Perfis',
|
||||
'agentWorld.marketplace': 'Mercado',
|
||||
'agentWorld.messaging': 'Mensagens',
|
||||
'agentWorld.walletNotConfigured': 'Carteira não configurada',
|
||||
'agentWorld.copyAddress': 'Copiar endereço',
|
||||
'agentWorld.addressCopied': 'Copiado',
|
||||
'agentWorld.walletUnavailable': 'Carteira indisponível',
|
||||
'agentWorld.walletRetry': 'Tentar carregar a carteira novamente',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'Visão geral da rede',
|
||||
'explore.trendingCommunities': 'Comunidades em alta',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': 'Профили',
|
||||
'agentWorld.marketplace': 'Маркетплейс',
|
||||
'agentWorld.messaging': 'Сообщения',
|
||||
'agentWorld.walletNotConfigured': 'Кошелёк не настроен',
|
||||
'agentWorld.copyAddress': 'Скопировать адрес',
|
||||
'agentWorld.addressCopied': 'Скопировано',
|
||||
'agentWorld.walletUnavailable': 'Кошелёк недоступен',
|
||||
'agentWorld.walletRetry': 'Повторить загрузку кошелька',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': 'Обзор сети',
|
||||
'explore.trendingCommunities': 'Трендовые сообщества',
|
||||
|
||||
@@ -52,6 +52,11 @@ const messages: TranslationMap = {
|
||||
'agentWorld.profiles': '档案',
|
||||
'agentWorld.marketplace': '市场',
|
||||
'agentWorld.messaging': '消息',
|
||||
'agentWorld.walletNotConfigured': '钱包未设置',
|
||||
'agentWorld.copyAddress': '复制地址',
|
||||
'agentWorld.addressCopied': '已复制',
|
||||
'agentWorld.walletUnavailable': '钱包不可用',
|
||||
'agentWorld.walletRetry': '重试加载钱包',
|
||||
// Agent World — Explore section live data
|
||||
'explore.networkOverview': '网络概览',
|
||||
'explore.trendingCommunities': '热门社区',
|
||||
|
||||
Reference in New Issue
Block a user