diff --git a/.env.example b/.env.example index 81e28aa0c..2e55d3e64 100644 --- a/.env.example +++ b/.env.example @@ -251,6 +251,14 @@ OPENHUMAN_TELEGRAM_BOT_USERNAME=openhuman_bot # TINYPLACE_API_BASE_URL when testing tiny.place on devnet. # OPENHUMAN_SOLANA_CLUSTER=devnet +# [optional] tiny.place's Solana settlement RPC. ONLY tiny.place payment +# broadcasts use this (general wallet ops keep OPENHUMAN_WALLET_RPC_SOLANA): they +# try this endpoint first and fall back to the public cluster RPC if it is +# unreachable. Defaults to /solana/rpc +# (i.e. https://api.tiny.place/solana/rpc), so it tracks prod/staging +# automatically — set this only to point settlement at a different RPC. +# TINYPLACE_SOLANA_RPC_URL=https://api.tiny.place/solana/rpc + # --------------------------------------------------------------------------- # x402 Machine Payments # --------------------------------------------------------------------------- @@ -365,7 +373,8 @@ RUST_BACKTRACE=1 # tiny.place Agent World integration # --------------------------------------------------------------------------- # [optional] Base URL for the tiny.place backend API. -# Default: https://staging-api.tiny.place +# Default: https://api.tiny.place (production) +# Set to https://staging-api.tiny.place to test against staging. # TINYPLACE_API_BASE_URL=https://staging-api.tiny.place # --------------------------------------------------------------------------- diff --git a/Cargo.lock b/Cargo.lock index c57ed585d..e2b90b40f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7913,9 +7913,9 @@ dependencies = [ [[package]] name = "tinyplace" -version = "0.6.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46921f859da27a14a5526b289b32399c5c328c26de1a1dcb3f68b0f9d19310ea" +checksum = "abf658d89a0e986a4dc5cabfbd005ba63455c70348aca60d836e333010acc9e2" dependencies = [ "aes", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index ba6dba226..3fb48d702 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ crate-type = ["rlib"] [dependencies] # tiny.place A2A social network SDK — published on crates.io (tinyhumansai/tiny.place) -tinyplace = "0.6.0" +tinyplace = "0.10.0" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_repr = "0.1" diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 7cd72bd3f..f09863cbf 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -8803,9 +8803,9 @@ dependencies = [ [[package]] name = "tinyplace" -version = "0.6.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46921f859da27a14a5526b289b32399c5c328c26de1a1dcb3f68b0f9d19310ea" +checksum = "abf658d89a0e986a4dc5cabfbd005ba63455c70348aca60d836e333010acc9e2" dependencies = [ "aes", "async-trait", diff --git a/app/src/agentworld/assets.ts b/app/src/agentworld/assets.ts new file mode 100644 index 000000000..289f5f1bf --- /dev/null +++ b/app/src/agentworld/assets.ts @@ -0,0 +1,48 @@ +/** + * Asset resolution for Agent World / x402 surfaces. + * + * x402 payment challenges now return the asset as a **mint address** (e.g. the + * USDC SPL mint) rather than a symbol like `"USDC"`. This module maps the common + * mints back to a display symbol + decimals so the UI never shows a raw base58 + * address, and so amount scaling uses the right decimal count. + */ + +/** Known Solana SPL mints → display symbol (mainnet + devnet USDC, wrapped SOL). */ +const KNOWN_MINTS: Record = { + EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: 'USDC', // mainnet + '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU': 'USDC', // devnet + So11111111111111111111111111111111111111112: 'SOL', // wrapped SOL +}; + +/** Decimals per known symbol. USDC/CASH = 6, SOL/WSOL = 9, others = 0. */ +export function decimalsForSymbol(symbol: string | undefined): number { + const up = (symbol ?? '').toUpperCase(); + if (up === 'USDC' || up === 'CASH') return 6; + if (up === 'SOL' || up === 'WSOL') return 9; + return 0; +} + +/** True when `value` looks like a base58 Solana address rather than a symbol. */ +function looksLikeMint(value: string): boolean { + return value.length >= 32 && /^[1-9A-HJ-NP-Za-km-z]+$/.test(value); +} + +/** + * Resolve an x402 asset (symbol OR mint address) to a display symbol. + * + * Preference order: an explicit wallet-resolved symbol → known-mint lookup → + * the value itself when it already looks like a symbol → a truncated address. + */ +export function resolveAssetSymbol(asset: string | undefined, walletSymbol?: string): string { + if (walletSymbol && walletSymbol.trim()) return walletSymbol.trim(); + if (!asset) return ''; + if (KNOWN_MINTS[asset]) return KNOWN_MINTS[asset]; + if (!looksLikeMint(asset)) return asset; // already a symbol + return `${asset.slice(0, 4)}…${asset.slice(-4)}`; +} + +/** Decimals for an x402 asset that may be a symbol or a mint address. */ +export function decimalsForAsset(asset: string | undefined, walletDecimals?: number): number { + if (typeof walletDecimals === 'number') return walletDecimals; + return decimalsForSymbol(resolveAssetSymbol(asset)); +} diff --git a/app/src/agentworld/components/X402ConfirmDialog.test.tsx b/app/src/agentworld/components/X402ConfirmDialog.test.tsx index a1c0e90d8..cb6143ce6 100644 --- a/app/src/agentworld/components/X402ConfirmDialog.test.tsx +++ b/app/src/agentworld/components/X402ConfirmDialog.test.tsx @@ -10,12 +10,16 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, test, vi } from 'vitest'; +import { openUrl } from '../../utils/openUrl'; import X402ConfirmDialog, { formatUnits, + fundingUrl, isInsufficient, type X402WalletBalance, } from './X402ConfirmDialog'; +vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() })); + const BALANCE: X402WalletBalance = { raw: '50000000', formatted: '50', @@ -60,6 +64,20 @@ describe('isInsufficient', () => { }); }); +describe('fundingUrl', () => { + test('builds the tiny.place fund URL with address + asset params', () => { + expect(fundingUrl('GW1jU1ZoVpugX4Z164j7x7rpTsKQn1QvK4Techu7P6PH', 'USDC')).toBe( + 'https://tiny.place/fund?address=GW1jU1ZoVpugX4Z164j7x7rpTsKQn1QvK4Techu7P6PH&asset=USDC' + ); + }); + + test('url-encodes param values', () => { + expect(fundingUrl('addr+slash/value', 'US DC')).toBe( + 'https://tiny.place/fund?address=addr%2Bslash%2Fvalue&asset=US+DC' + ); + }); +}); + describe('X402ConfirmDialog', () => { test('renders amount, asset, network, balance and a truncated wallet', () => { render(); @@ -88,10 +106,16 @@ describe('X402ConfirmDialog', () => { expect(props.onCancel).toHaveBeenCalledTimes(1); }); - test('disables confirm and shows a notice when balance is insufficient', () => { + test('replaces Pay with an Add-funds redirect when balance is insufficient', async () => { render(); - expect(screen.getByTestId('x402-confirm')).toBeDisabled(); + // The dead disabled Pay button is gone; the notice + fund redirect appear. + expect(screen.queryByTestId('x402-confirm')).not.toBeInTheDocument(); expect(screen.getByTestId('x402-insufficient')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('x402-add-funds')); + expect(vi.mocked(openUrl)).toHaveBeenCalledWith( + 'https://tiny.place/fund?address=WaLLetdeadbeef0123456789&asset=USDC' + ); }); test('shows "Unknown" balance and still allows confirm when balance is null', () => { diff --git a/app/src/agentworld/components/X402ConfirmDialog.tsx b/app/src/agentworld/components/X402ConfirmDialog.tsx index 82e33a198..b3a6813f9 100644 --- a/app/src/agentworld/components/X402ConfirmDialog.tsx +++ b/app/src/agentworld/components/X402ConfirmDialog.tsx @@ -12,6 +12,22 @@ */ import Button from '../../components/ui/Button'; import { ModalShell } from '../../components/ui/ModalShell'; +import { openUrl } from '../../utils/openUrl'; +import { decimalsForAsset, resolveAssetSymbol } from '../assets'; + +/** tiny.place hosted funding page — handles deposits / on-ramp for the wallet. */ +const FUND_PAGE_URL = 'https://tiny.place/fund'; + +/** + * Build the tiny.place funding URL for a wallet + asset, e.g. + * `https://tiny.place/fund?address=&asset=USDC`. The fund page reads + * these params to pre-fill the deposit target, so the user lands ready to top + * up the exact wallet that came up short. + */ +export function fundingUrl(address: string, asset: string): string { + const params = new URLSearchParams({ address, asset }); + return `${FUND_PAGE_URL}?${params.toString()}`; +} export interface X402WalletBalance { /** Balance in raw base units (same scale as the challenge amount). */ @@ -100,7 +116,10 @@ export default function X402ConfirmDialog({ onConfirm, onCancel, }: X402ConfirmDialogProps) { - const decimals = balance?.decimals ?? (asset === 'USDC' ? 6 : 0); + // `asset` may arrive as a mint address; resolve to a display symbol + decimals + // (preferring the wallet's own resolution when present). + const assetSymbol = resolveAssetSymbol(asset, balance?.assetSymbol); + const decimals = decimalsForAsset(asset, balance?.decimals); const amountDisplay = formatUnits(amount, decimals); const insufficient = isInsufficient(balance, amount); const confirmDisabled = busy || insufficient; @@ -118,7 +137,7 @@ export default function X402ConfirmDialog({ - {amountDisplay} {asset} + {amountDisplay} {assetSymbol} @@ -144,7 +163,8 @@ export default function X402ConfirmDialog({ {insufficient ? (

- Insufficient {asset} balance to complete this payment. + Insufficient {assetSymbol} balance to complete this payment. Add funds to your wallet to + continue.

) : (

@@ -156,14 +176,28 @@ export default function X402ConfirmDialog({ - + {insufficient ? ( + // Not enough balance — send the user to the tiny.place fund page for + // the exact wallet + asset instead of a dead, disabled Pay button. + + ) : ( + + )} diff --git a/app/src/agentworld/pages/AgentWorld.tsx b/app/src/agentworld/pages/AgentWorld.tsx index b0896f748..a8ffc1da7 100644 --- a/app/src/agentworld/pages/AgentWorld.tsx +++ b/app/src/agentworld/pages/AgentWorld.tsx @@ -44,9 +44,9 @@ const navIcon = (d: string) => ( // Format: { slug: '', labelKey: 'agentWorld.', iconPath: '' } // Fan-out agents: add a row here AND a below AND an i18n key. // Sidebar order: Feed first, then Messages, then the rest; Profiles sits at the -// end. Marketplace and Jobs are intentionally OMITTED from the sidebar (their -// routes still exist below so buy/bid/offer and job-apply flows remain -// reachable) — hidden, not removed. +// end. Marketplace, Jobs and Explore are intentionally OMITTED from the sidebar +// (their routes still exist below so existing flows / deep links remain +// reachable) — hidden, not removed. Jobs is superseded by Bounties. const SECTIONS: AgentWorldSection[] = [ { slug: 'feed', @@ -72,11 +72,6 @@ const SECTIONS: AgentWorldSection[] = [ iconPath: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z', }, - { - slug: 'explore', - labelKey: 'agentWorld.explore', - iconPath: 'M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z', - }, { slug: 'directory', labelKey: 'agentWorld.directory', diff --git a/app/src/agentworld/pages/BountiesSection.test.tsx b/app/src/agentworld/pages/BountiesSection.test.tsx index f4671b17c..94a630f0b 100644 --- a/app/src/agentworld/pages/BountiesSection.test.tsx +++ b/app/src/agentworld/pages/BountiesSection.test.tsx @@ -23,7 +23,6 @@ vi.mock('../AgentWorldShell', () => ({ list: vi.fn(), get: vi.fn(), create: vi.fn(), - fund: vi.fn(), cancel: vi.fn(), submit: vi.fn(), listSubmissions: vi.fn(), @@ -489,62 +488,6 @@ describe('Comment flow', () => { }); }); -// ── Fund flow (x402) ────────────────────────────────────────────────────────── - -describe('Fund flow (x402)', () => { - test('Fund button visible to creator on draft bounty', async () => { - const user = userEvent.setup(); - vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty); - render(); - - await waitFor(() => { - expect(screen.getByText('My own draft bounty')).toBeInTheDocument(); - }); - - await user.click(screen.getByText('My own draft bounty')); - - await waitFor(() => { - expect(screen.getByText('Fund Bounty')).toBeInTheDocument(); - }); - }); - - test('Fund button triggers x402 challenge (confirmed:false)', async () => { - const user = userEvent.setup(); - vi.mocked(apiClient.bounties.list).mockResolvedValue(listWithOwnBounty); - vi.mocked(apiClient.bounties.fund).mockResolvedValue({ - challenge: { - amount: '5000000', - asset: 'USDC', - network: 'solana-devnet', - nonce: 'test-nonce', - payTo: 'pay-to-addr', - }, - walletBalance: { raw: '10000000', formatted: '10.00', decimals: 6, assetSymbol: 'USDC' }, - walletAddress: MY_AGENT_ID, - } as never); - render(); - - await waitFor(() => { - expect(screen.getByText('My own draft bounty')).toBeInTheDocument(); - }); - - await user.click(screen.getByText('My own draft bounty')); - - await waitFor(() => { - expect(screen.getByRole('button', { name: /fund bounty/i })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: /fund bounty/i })); - - // Verify fund was called with confirmed:false - await waitFor(() => { - expect(apiClient.bounties.fund).toHaveBeenCalledWith(sampleOwnBounty.bountyId, { - confirmed: false, - }); - }); - }); -}); - // ── Cancel flow ─────────────────────────────────────────────────────────────── describe('Cancel flow', () => { diff --git a/app/src/agentworld/pages/BountiesSection.tsx b/app/src/agentworld/pages/BountiesSection.tsx index 2f191f716..e7e1412a5 100644 --- a/app/src/agentworld/pages/BountiesSection.tsx +++ b/app/src/agentworld/pages/BountiesSection.tsx @@ -30,8 +30,8 @@ import { import { fetchWalletStatus } from '../../services/walletApi'; import type { ToastNotification } from '../../types/intelligence'; import { apiClient } from '../AgentWorldShell'; +import { decimalsForAsset, resolveAssetSymbol } from '../assets'; import X402ConfirmDialog, { formatUnits } from '../components/X402ConfirmDialog'; -import { useX402Buy } from '../hooks/useX402Buy'; // ── State types ─────────────────────────────────────────────────────────────── @@ -72,19 +72,12 @@ function abbrev(addr: string): string { return addr; } -/** Decimals for a given asset symbol. USDC = 6, SOL = 9, others = 0. */ -function decimalsForAsset(asset: string): number { - const up = asset.toUpperCase(); - if (up === 'USDC' || up === 'CASH') return 6; - if (up === 'SOL' || up === 'WSOL') return 9; - return 0; -} - -/** Format a base-unit reward amount to a human-readable string. */ +/** Format a base-unit reward amount to a human-readable string. `asset` may be + * a symbol or a mint address — {@link resolveAssetSymbol} normalises it. */ function formatReward(amount: string, asset: string): string { const decimals = decimalsForAsset(asset); const display = decimals > 0 ? formatUnits(amount, decimals) : amount; - return `${formatAmount(display)} ${asset}`; + return `${formatAmount(display)} ${resolveAssetSymbol(asset)}`; } /** Centered status message for loading / error / info states. */ @@ -142,7 +135,6 @@ interface BountyRowProps { expanded: boolean; onToggle: () => void; myAgentId: string | null; - onFund: (bountyId: string) => void; onSubmit: (bountyId: string) => void; onComment: (bountyId: string) => void; onCancel: (bountyId: string) => void; @@ -155,7 +147,6 @@ function BountyRow({ expanded, onToggle, myAgentId, - onFund, onSubmit, onComment, onCancel, @@ -183,8 +174,13 @@ function BountyRow({ }, [expanded, bounty.bountyId]); return ( -

- {/* Summary row */} +
+ {/* Summary (card header) */} - )} {/* Submit Work: non-creator + open status */} {!isCreator && bounty.status === 'open' && ( -
-
- - )} - - {fundingBountyId && fundX402.state.phase === 'error' && ( - { - fundX402.reset(); - setFundingBountyId(null); - }}> -
-

{fundX402.state.message}

-
- -
-
-
- )} - ); diff --git a/app/src/agentworld/pages/DirectorySection.test.tsx b/app/src/agentworld/pages/DirectorySection.test.tsx index b1cdc5d5f..a13ef99a1 100644 --- a/app/src/agentworld/pages/DirectorySection.test.tsx +++ b/app/src/agentworld/pages/DirectorySection.test.tsx @@ -22,7 +22,13 @@ import DirectorySection from './DirectorySection'; vi.mock('../AgentWorldShell', () => ({ apiClient: { directory: { listAgents: vi.fn() }, - follows: { stats: vi.fn(), followers: vi.fn(), follow: vi.fn(), unfollow: vi.fn() }, + follows: { + stats: vi.fn(), + followers: vi.fn(), + following: vi.fn(), + follow: vi.fn(), + unfollow: vi.fn(), + }, }, })); @@ -31,7 +37,7 @@ vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() })); const listAgents = vi.mocked(apiClient.directory.listAgents); const walletStatus = vi.mocked(fetchWalletStatus); const followStats = vi.mocked(apiClient.follows.stats); -const followFollowers = vi.mocked(apiClient.follows.followers); +const followFollowing = vi.mocked(apiClient.follows.following); const followFollow = vi.mocked(apiClient.follows.follow); const followUnfollow = vi.mocked(apiClient.follows.unfollow); @@ -41,9 +47,9 @@ beforeEach(() => { walletStatus.mockResolvedValue({ accounts: [{ chain: 'solana', address: 'MyWaLLetAddr123' }], } as unknown as Awaited>); - // Default: stats and followers return empty/zero. + // Default: stats return zero and we follow nobody (single batched lookup). followStats.mockResolvedValue({ agentId: '', followerCount: 0, followingCount: 0 }); - followFollowers.mockResolvedValue({ followers: [] }); + followFollowing.mockResolvedValue({ following: [] }); }); // ── Loading state ────────────────────────────────────────────────────────────── @@ -329,7 +335,7 @@ describe('follow button', () => { listAgents.mockResolvedValueOnce({ agents: [{ agentId: 'other-agent-001', username: 'alice', name: 'Alice' }], }); - followFollowers.mockResolvedValueOnce({ followers: [] }); + followFollowing.mockResolvedValueOnce({ following: [] }); followStats.mockResolvedValueOnce({ agentId: 'other-agent-001', followerCount: 5, @@ -347,8 +353,8 @@ describe('follow button', () => { listAgents.mockResolvedValueOnce({ agents: [{ agentId: 'other-agent-002', username: 'bob', name: 'Bob' }], }); - followFollowers.mockResolvedValueOnce({ - followers: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-002', createdAt: '' }], + followFollowing.mockResolvedValueOnce({ + following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-002', createdAt: '' }], }); followStats.mockResolvedValueOnce({ agentId: 'other-agent-002', @@ -375,7 +381,7 @@ describe('follow button', () => { listAgents.mockResolvedValueOnce({ agents: [{ agentId: 'other-agent-003', username: 'carol', name: 'Carol' }], }); - followFollowers.mockResolvedValueOnce({ followers: [] }); + followFollowing.mockResolvedValueOnce({ following: [] }); followStats.mockResolvedValueOnce({ agentId: 'other-agent-003', followerCount: 0, @@ -393,13 +399,36 @@ describe('follow button', () => { expect(await screen.findByText('Following')).toBeInTheDocument(); }); + test('fetches the following-set once for the whole directory (no per-card N+1)', async () => { + listAgents.mockResolvedValueOnce({ + agents: [ + { agentId: 'other-agent-a', username: 'a', name: 'A' }, + { agentId: 'other-agent-b', username: 'b', name: 'B' }, + { agentId: 'other-agent-c', username: 'c', name: 'C' }, + ], + }); + followFollowing.mockResolvedValueOnce({ + following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-b', createdAt: '' }], + }); + render(); + + // Card B resolves to Following from the single batched lookup. + expect(await screen.findByText('Following')).toBeInTheDocument(); + + // One batched following lookup regardless of card count; the old per-card + // followers lookup must not fire at all. + expect(followFollowing).toHaveBeenCalledTimes(1); + expect(followFollowing).toHaveBeenCalledWith('MyWaLLetAddr123', expect.anything()); + expect(apiClient.follows.followers).not.toHaveBeenCalled(); + }); + test('clicking Following calls follows.unfollow and reverts to Follow', async () => { const user = userEvent.setup(); listAgents.mockResolvedValueOnce({ agents: [{ agentId: 'other-agent-004', username: 'dave', name: 'Dave' }], }); - followFollowers.mockResolvedValueOnce({ - followers: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-004', createdAt: '' }], + followFollowing.mockResolvedValueOnce({ + following: [{ follower: 'MyWaLLetAddr123', followee: 'other-agent-004', createdAt: '' }], }); followStats.mockResolvedValueOnce({ agentId: 'other-agent-004', diff --git a/app/src/agentworld/pages/DirectorySection.tsx b/app/src/agentworld/pages/DirectorySection.tsx index ce83aa157..99be0f477 100644 --- a/app/src/agentworld/pages/DirectorySection.tsx +++ b/app/src/agentworld/pages/DirectorySection.tsx @@ -124,6 +124,48 @@ function useMyAgentId(): string | null { return agentId; } +// Max followees pulled in the single batch lookup below. The directory rarely +// exceeds this; anything beyond it falls back to "not following" (the user can +// still follow, which corrects the state optimistically). +const FOLLOWING_FETCH_LIMIT = 500; + +/** + * Fetch the current agent's *following* list ONCE and expose it as a Set of + * followee ids. This replaces the previous per-card `follows.followers()` call + * (one request per directory card → N+1 / rate-limit pressure) with a single + * request, letting each card derive its follow-state locally. + * + * Returns `null` until myAgentId is known and the fetch resolves, so cards can + * distinguish "still loading" from "not following". + */ +function useMyFollowing(myAgentId: string | null): Set | null { + const [following, setFollowing] = useState | null>(null); + useEffect(() => { + if (!myAgentId) return; + let cancelled = false; + debug('[tinyplace][ui] DirectorySection: fetching following-set for %s', myAgentId); + void apiClient.follows + .following(myAgentId, { limit: FOLLOWING_FETCH_LIMIT }) + .then(res => { + if (cancelled) return; + const set = new Set(res.following.map(f => f.followee)); + debug('[tinyplace][ui] DirectorySection: following-set size=%d', set.size); + setFollowing(set); + }) + .catch((err: unknown) => { + if (cancelled) return; + // Treat a failed lookup as "follow nobody" so the UI still renders + // Follow buttons instead of getting stuck in the loading state. + debug('[tinyplace][ui] DirectorySection: following-set failed: %s', String(err)); + setFollowing(new Set()); + }); + return () => { + cancelled = true; + }; + }, [myAgentId]); + return following; +} + // ── Sub-components ──────────────────────────────────────────────────────────── const CARD_CLASS = @@ -151,18 +193,42 @@ function LoadingSkeleton() { ); } -function AgentCardItem({ agent, myAgentId }: { agent: AgentCard; myAgentId: string | null }) { +function AgentCardItem({ + agent, + myAgentId, + followingSet, +}: { + agent: AgentCard; + myAgentId: string | null; + // Set of followee ids the current agent follows; null while still loading. + // Sourced once by the parent (see useMyFollowing) instead of per-card. + followingSet: Set | null; +}) { const [selected, setSelected] = useState(false); - const [followState, setFollowState] = useState<'unknown' | 'following' | 'not_following'>( - 'unknown' - ); + // Optimistic override applied after the user (un)follows from this card; null + // means "defer to followingSet". Avoids a re-fetch just to reflect our click. + const [localFollow, setLocalFollow] = useState<'following' | 'not_following' | null>(null); const [followerCount, setFollowerCount] = useState(null); const [actionLoading, setActionLoading] = useState(false); const handle = getHandle(agent); const skills = getSkills(agent); const isSelf = myAgentId != null && agent.agentId === myAgentId; + // Follow-state derived from the batched following-set (or our optimistic + // override). 'unknown' = still loading, which hides the Follow button. + const followState: 'unknown' | 'following' | 'not_following' = + localFollow ?? + (followingSet == null + ? 'unknown' + : followingSet.has(agent.agentId) + ? 'following' + : 'not_following'); + // Fetch follow stats on mount. + // NOTE: follower COUNT is still one request per card — `directory.listAgents` + // doesn't return counts. Eliminating these needs a backend/GraphQL directory + // query that embeds followerCount (tracked separately); the per-card follower + // *list* lookup has already been removed in favour of the batched set above. useEffect(() => { let cancelled = false; void apiClient.follows @@ -179,25 +245,6 @@ function AgentCardItem({ agent, myAgentId }: { agent: AgentCard; myAgentId: stri }; }, [agent.agentId]); - // Check if we are following this agent. - useEffect(() => { - if (!myAgentId || isSelf) return; - let cancelled = false; - void apiClient.follows - .followers(agent.agentId, { limit: 100 }) - .then(res => { - if (cancelled) return; - const isFollowing = res.followers.some(f => f.follower === myAgentId); - setFollowState(isFollowing ? 'following' : 'not_following'); - }) - .catch(() => { - if (!cancelled) setFollowState('not_following'); - }); - return () => { - cancelled = true; - }; - }, [agent.agentId, myAgentId, isSelf]); - const handleFollow = useCallback( async (e: React.MouseEvent) => { e.stopPropagation(); @@ -206,12 +253,12 @@ function AgentCardItem({ agent, myAgentId }: { agent: AgentCard; myAgentId: stri try { if (followState === 'following') { await apiClient.follows.unfollow(agent.agentId); - setFollowState('not_following'); - setFollowerCount(c => (c != null ? c - 1 : c)); + setLocalFollow('not_following'); + setFollowerCount(c => (c != null ? Math.max(0, c - 1) : c)); debug('unfollowed %s', agent.agentId); } else { await apiClient.follows.follow(agent.agentId); - setFollowState('following'); + setLocalFollow('following'); setFollowerCount(c => (c != null ? c + 1 : c)); debug('followed %s', agent.agentId); } @@ -309,6 +356,8 @@ function StatusBlock({ tone, title, body }: { tone: string; title: string; body? export default function DirectorySection() { const state = useDirectoryAgents(); const myAgentId = useMyAgentId(); + // One batched lookup of who we follow, shared by every card below. + const followingSet = useMyFollowing(myAgentId); let body: React.ReactNode; @@ -351,7 +400,12 @@ export default function DirectorySection() { ) : (
{agents.map(agent => ( - + ))}
); diff --git a/app/src/agentworld/pages/FeedSection.test.tsx b/app/src/agentworld/pages/FeedSection.test.tsx index 08b421e30..10f2d3124 100644 --- a/app/src/agentworld/pages/FeedSection.test.tsx +++ b/app/src/agentworld/pages/FeedSection.test.tsx @@ -226,92 +226,6 @@ describe('Feed list', () => { }); }); -// ── Post detail drill-down ──────────────────────────────────────────────────── - -describe('Post detail drill-down', () => { - test('clicking a post card loads the post detail', async () => { - const user = userEvent.setup(); - vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); - render(); - - await waitFor(() => { - expect(screen.getByText('Hello from the network')).toBeInTheDocument(); - }); - - await user.click(screen.getByText('Hello from the network')); - - expect(vi.mocked(apiClient.graphql.post)).toHaveBeenCalledWith( - samplePost.author.handle, - samplePost.postId, - expect.objectContaining({ commentLimit: 20, likerLimit: 10 }) - ); - - await waitFor(() => { - expect(screen.getByText('Great post!')).toBeInTheDocument(); - }); - expect(screen.getByText('Agent Beta')).toBeInTheDocument(); - }); - - test('back button returns to the feed list', async () => { - const user = userEvent.setup(); - vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); - render(); - - await waitFor(() => { - expect(screen.getByText('Hello from the network')).toBeInTheDocument(); - }); - - await user.click(screen.getByText('Hello from the network')); - await waitFor(() => { - expect(screen.getByText(/back to feed/i)).toBeInTheDocument(); - }); - - await user.click(screen.getByText(/back to feed/i)); - - await waitFor(() => { - expect(screen.getByText('Hello from the network')).toBeInTheDocument(); - }); - expect(screen.queryByText(/back to feed/i)).not.toBeInTheDocument(); - }); - - test('shows empty comments and likers messages when post has none', async () => { - const user = userEvent.setup(); - vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); - vi.mocked(apiClient.graphql.post).mockResolvedValue({ - ...samplePost, - comments: [], - likers: [], - }); - render(); - - await waitFor(() => { - expect(screen.getByText('Hello from the network')).toBeInTheDocument(); - }); - await user.click(screen.getByText('Hello from the network')); - - await waitFor(() => { - expect(screen.getByText(/no comments yet/i)).toBeInTheDocument(); - expect(screen.getByText(/no likes yet/i)).toBeInTheDocument(); - }); - }); - - test('shows error message when post detail fetch fails', async () => { - const user = userEvent.setup(); - vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); - vi.mocked(apiClient.graphql.post).mockRejectedValue(new Error('fetch failed')); - render(); - - await waitFor(() => { - expect(screen.getByText('Hello from the network')).toBeInTheDocument(); - }); - await user.click(screen.getByText('Hello from the network')); - - await waitFor(() => { - expect(screen.getByText(/failed to load post details/i)).toBeInTheDocument(); - }); - }); -}); - // ── Follow/Unfollow ─────────────────────────────────────────────────────────── describe('Follow/Unfollow', () => { @@ -500,14 +414,16 @@ describe('like toggle', () => { // ── Comment composer ────────────────────────────────────────────────────────── describe('comment composer', () => { - test('submitting comment calls feeds.addComment then refetches post detail', async () => { + // Comments expand inline under each post card (the comment-count toggle), then + // InlineComments fetches the thread via graphql.post. + test('submitting comment calls feeds.addComment then refetches comments', async () => { const user = userEvent.setup(); vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); render(); await waitFor(() => { expect(screen.getByText('Hello from the network')).toBeInTheDocument(); }); - await user.click(screen.getByText('Hello from the network')); + await user.click(screen.getByRole('button', { name: '3 comments' })); await waitFor(() => { expect(screen.getByPlaceholderText(/write a comment/i)).toBeInTheDocument(); }); @@ -520,7 +436,7 @@ describe('comment composer', () => { 'My test comment' ); }); - // Refetch post detail after comment + // graphql.post: once on expand + once on refetch after comment. expect(vi.mocked(apiClient.graphql.post)).toHaveBeenCalledTimes(2); }); @@ -531,11 +447,8 @@ describe('comment composer', () => { await waitFor(() => { expect(screen.getByText('Hello from the network')).toBeInTheDocument(); }); - await user.click(screen.getByText('Hello from the network')); - await waitFor(() => { - expect(screen.getByPlaceholderText(/write a comment/i)).toBeInTheDocument(); - }); - const input = screen.getByPlaceholderText(/write a comment/i); + await user.click(screen.getByRole('button', { name: '3 comments' })); + const input = await screen.findByPlaceholderText(/write a comment/i); await user.type(input, 'test'); await user.click(screen.getByRole('button', { name: /^comment$/i })); await waitFor(() => { @@ -544,15 +457,17 @@ describe('comment composer', () => { }); test('comment composer hidden when wallet locked', async () => { + const user = userEvent.setup(); vi.mocked(fetchWalletStatus).mockRejectedValue(new Error('wallet locked')); vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); render(); await waitFor(() => { expect(screen.getByText('Hello from the network')).toBeInTheDocument(); }); - await userEvent.setup().click(screen.getByText('Hello from the network')); + // Expand comments — the composer must stay hidden with no agent id. + await user.click(screen.getByRole('button', { name: '3 comments' })); await waitFor(() => { - expect(screen.getByText(/back to feed/i)).toBeInTheDocument(); + expect(vi.mocked(apiClient.graphql.post)).toHaveBeenCalled(); }); expect(screen.queryByPlaceholderText(/write a comment/i)).not.toBeInTheDocument(); }); @@ -561,27 +476,20 @@ describe('comment composer', () => { // ── Post composer ───────────────────────────────────────────────────────────── describe('post composer', () => { - test('New Post button appears when wallet unlocked and feed loaded', async () => { + test('inline composer appears when wallet unlocked and feed loaded', async () => { vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); render(); - await waitFor(() => { - expect(screen.getByRole('button', { name: /new post/i })).toBeInTheDocument(); - }); - }); - - test('new post button opens modal and submitting calls feeds.createPost', async () => { - const user = userEvent.setup(); - vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); - render(); - await waitFor(() => { - expect(screen.getByRole('button', { name: /new post/i })).toBeInTheDocument(); - }); - await user.click(screen.getByRole('button', { name: /new post/i })); - // Modal should appear await waitFor(() => { expect(screen.getByPlaceholderText(/what's on your mind/i)).toBeInTheDocument(); }); - await user.type(screen.getByPlaceholderText(/what's on your mind/i), 'My new post'); + }); + + test('typing and clicking Post calls feeds.createPost', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); + render(); + const textarea = await screen.findByPlaceholderText(/what's on your mind/i); + await user.type(textarea, 'My new post'); await user.click(screen.getByRole('button', { name: /^post$/i })); await waitFor(() => { expect(vi.mocked(apiClient.feeds.createPost)).toHaveBeenCalledWith('My new post'); @@ -592,14 +500,8 @@ describe('post composer', () => { const user = userEvent.setup(); vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); render(); - await waitFor(() => { - expect(screen.getByRole('button', { name: /new post/i })).toBeInTheDocument(); - }); - await user.click(screen.getByRole('button', { name: /new post/i })); - await waitFor(() => { - expect(screen.getByPlaceholderText(/what's on your mind/i)).toBeInTheDocument(); - }); - await user.type(screen.getByPlaceholderText(/what's on your mind/i), 'test post'); + const textarea = await screen.findByPlaceholderText(/what's on your mind/i); + await user.type(textarea, 'test post'); await user.click(screen.getByRole('button', { name: /^post$/i })); await waitFor(() => { // homeFeed called once on mount + once after create @@ -607,32 +509,24 @@ describe('post composer', () => { }); }); - test('cancel closes modal without posting', async () => { + test('Post button is disabled until a draft is entered', async () => { const user = userEvent.setup(); vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); render(); - await waitFor(() => { - expect(screen.getByRole('button', { name: /new post/i })).toBeInTheDocument(); - }); - await user.click(screen.getByRole('button', { name: /new post/i })); - await waitFor(() => { - expect(screen.getByPlaceholderText(/what's on your mind/i)).toBeInTheDocument(); - }); - await user.click(screen.getByRole('button', { name: /cancel/i })); - await waitFor(() => { - expect(screen.queryByPlaceholderText(/what's on your mind/i)).not.toBeInTheDocument(); - }); - expect(vi.mocked(apiClient.feeds.createPost)).not.toHaveBeenCalled(); + const textarea = await screen.findByPlaceholderText(/what's on your mind/i); + expect(screen.getByRole('button', { name: /^post$/i })).toBeDisabled(); + await user.type(textarea, 'hi'); + expect(screen.getByRole('button', { name: /^post$/i })).toBeEnabled(); }); - test('new post button hidden when wallet locked', async () => { + test('composer hidden when wallet locked', async () => { vi.mocked(fetchWalletStatus).mockRejectedValue(new Error('wallet locked')); vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 }); render(); await waitFor(() => { expect(screen.getByText('Hello from the network')).toBeInTheDocument(); }); - expect(screen.queryByRole('button', { name: /new post/i })).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText(/what's on your mind/i)).not.toBeInTheDocument(); }); }); @@ -709,11 +603,11 @@ describe('delete actions', () => { await waitFor(() => { expect(screen.getByText('Hello from the network')).toBeInTheDocument(); }); - await user.click(screen.getByText('Hello from the network')); + // Expand the inline comment thread, then delete own comment. + await user.click(screen.getByRole('button', { name: '3 comments' })); await waitFor(() => { expect(screen.getByText('Great post!')).toBeInTheDocument(); }); - // Delete button for own comment (use getByText to avoid matching parent wrappers) const deleteBtn = screen.getByText('Delete'); await user.click(deleteBtn); await waitFor(() => { diff --git a/app/src/agentworld/pages/FeedSection.tsx b/app/src/agentworld/pages/FeedSection.tsx index f8f5226cd..6f087e3fd 100644 --- a/app/src/agentworld/pages/FeedSection.tsx +++ b/app/src/agentworld/pages/FeedSection.tsx @@ -9,21 +9,19 @@ * Phase A interactive features (wallet-gated): * - Like / unlike toggle with optimistic update and server reconcile * - Comment composer (adds comment, refetches detail via GraphQL) - * - New Post composer (ModalShell, refetches feed on success) + * - Inline post composer at the top of the feed (refetches feed on success) * - Delete post / delete comment (own content only, with window.confirm) * * Pattern mirrors ExploreSection / MarketplaceSection: useState + useEffect * fetch, PanelScaffold wrapper, StatusBlock for loading/error/empty states. */ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import PanelScaffold from '../../components/layout/PanelScaffold'; -import { ModalShell } from '../../components/ui/ModalShell'; import { type GqlComment, type GqlHomeFeedItem, type GqlPost, - type GqlPostDetail, type LikeResult, PaymentRequiredError, } from '../../lib/agentworld/invokeApiClient'; @@ -38,11 +36,6 @@ type FeedState = | { status: 'error'; message: string } | { status: 'ok'; items: GqlHomeFeedItem[] }; -type DetailState = - | { status: 'loading' } - | { status: 'error'; message: string } - | { status: 'ok'; detail: GqlPostDetail }; - // ── Helpers ─────────────────────────────────────────────────────────────────── function relativeTime(iso: string): string { @@ -155,26 +148,48 @@ function CommentComposer({ ); } -// ── PostComposerModal ───────────────────────────────────────────────────────── +// ── FeedComposer ────────────────────────────────────────────────────────────── -function PostComposerModal({ - onClose, - onPostCreated, -}: { - onClose: () => void; +/** Max post length, mirrors the tiny.place website composer. */ +const MAX_FEED_BODY_LENGTH = 500; + +/** + * Always-visible inline composer at the top of the feed (replaces the old + * "New Post" modal) — matches the tiny.place website's home-feed composer: + * avatar + textarea + live character countdown + Post button. + */ +interface FeedComposerProps { + myAgentId: string; onPostCreated: () => void; -}) { - const [body, setBody] = useState(''); +} + +function FeedComposer({ myAgentId, onPostCreated }: FeedComposerProps) { + const [draft, setDraft] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const textareaRef = useRef(null); + const remaining = MAX_FEED_BODY_LENGTH - draft.length; + const canPost = draft.trim().length > 0 && !submitting; + const nearLimit = remaining <= 40; - const handleSubmit = async () => { - if (!body.trim() || submitting) return; + // Auto-grow the textarea with its content (capped), so the composer expands + // naturally instead of scrolling inside two fixed rows. + const autoSize = (el: HTMLTextAreaElement) => { + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, 200)}px`; + }; + + const submit = async () => { + const body = draft.trim().slice(0, MAX_FEED_BODY_LENGTH); + if (!body || submitting) return; setSubmitting(true); setError(null); try { - await apiClient.feeds.createPost(body.trim()); - onClose(); + await apiClient.feeds.createPost(body); + setDraft(''); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } onPostCreated(); } catch (err) { setError(String(err)); @@ -184,48 +199,129 @@ function PostComposerModal({ }; return ( - -
+
+
+