feat(agent-world): show Solana wallet address chip in Tiny Place sidebar header (#3838)

This commit is contained in:
Cyrus Gray
2026-06-20 00:02:25 +05:30
committed by GitHub
parent 540e8fb6c0
commit 8ae3cef789
17 changed files with 520 additions and 3 deletions
@@ -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>
);
}
+7 -3
View File
@@ -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>
+5
View File
@@ -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': 'المجتمعات الرائجة',
+5
View File
@@ -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': 'ট্রেন্ডিং কমিউনিটি',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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': 'ट्रेंडिंग समुदाय',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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': '트렌딩 커뮤니티',
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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': 'Трендовые сообщества',
+5
View File
@@ -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': '热门社区',