diff --git a/src/screens/SorobanScreen.test.tsx b/src/screens/SorobanScreen.test.tsx index 3e1728c..c1bfe8c 100644 --- a/src/screens/SorobanScreen.test.tsx +++ b/src/screens/SorobanScreen.test.tsx @@ -105,12 +105,12 @@ describe("SorobanScreen", () => { expect(input.value).toBe(CONTRACT_A); }); - it("does not render the saved-contracts dropdown when there is only one entry", () => { + it("renders the saved-contracts dropdown with a single entry", () => { localStorage.setItem("sorokit-soroban-contract-history", JSON.stringify([CONTRACT_A])); render(); - expect(screen.queryByText("Saved Contracts")).not.toBeInTheDocument(); + expect(screen.getByText("Saved Contracts")).toBeInTheDocument(); }); it("lists saved contracts in a dropdown and applies one on click", () => { @@ -143,6 +143,65 @@ describe("SorobanScreen", () => { expect(input.value).toBe(""); expect(screen.queryByText("Saved Contracts")).not.toBeInTheDocument(); }); + + it("does not pre-fill an invalid contract id that is first in history", () => { + localStorage.setItem( + "sorokit-soroban-contract-history", + JSON.stringify(["not-a-valid-contract", CONTRACT_A]), + ); + + render(); + + const input = screen.getByPlaceholderText(/C\.\.\./i) as HTMLInputElement; + expect(input.value).toBe(""); + }); + + it("removes a single saved contract from the dropdown and storage", () => { + localStorage.setItem( + "sorokit-soroban-contract-history", + JSON.stringify([CONTRACT_A, CONTRACT_B]), + ); + + render(); + + fireEvent.click( + screen.getByRole("button", { + name: `Remove ${CONTRACT_A.slice(0, 6)}…${CONTRACT_A.slice(-4)} from saved contracts`, + }), + ); + + expect(screen.queryByText(`${CONTRACT_A.slice(0, 6)}…${CONTRACT_A.slice(-4)}`)).not.toBeInTheDocument(); + expect( + JSON.parse(localStorage.getItem("sorokit-soroban-contract-history") ?? "[]"), + ).toEqual([CONTRACT_B]); + }); + + it("clears all saved contracts", () => { + localStorage.setItem( + "sorokit-soroban-contract-history", + JSON.stringify([CONTRACT_A, CONTRACT_B]), + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Clear all" })); + + expect(screen.queryByText("Saved Contracts")).not.toBeInTheDocument(); + expect( + JSON.parse(localStorage.getItem("sorokit-soroban-contract-history") ?? "[]"), + ).toEqual([]); + }); + + it("shows the full contract id as a title on each saved-contract button", () => { + localStorage.setItem( + "sorokit-soroban-contract-history", + JSON.stringify([CONTRACT_A]), + ); + + render(); + + expect(screen.getByTitle(CONTRACT_A)).toBeInTheDocument(); + }); }); describe("Stellar Expert link (#350)", () => { @@ -213,5 +272,19 @@ describe("SorobanScreen", () => { expect(screen.queryByRole("link", { name: /Stellar Expert/i })).not.toBeInTheDocument(); }); + + it("renders no Stellar Expert link for a non-empty but invalid contract id", () => { + vi.mocked(useSorokit).mockReturnValue({ + isConnected: true, + address: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA", + network: { name: "testnet" }, + } as unknown as ReturnType); + + render(); + const input = screen.getByPlaceholderText(/C\.\.\./i); + fireEvent.change(input, { target: { value: "not-a-valid-contract" } }); + + expect(screen.queryByRole("link", { name: /Stellar Expert/i })).not.toBeInTheDocument(); + }); }); }); diff --git a/src/screens/SorobanScreen.tsx b/src/screens/SorobanScreen.tsx index d9f14d6..e91b861 100644 --- a/src/screens/SorobanScreen.tsx +++ b/src/screens/SorobanScreen.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { ContractEventFeed } from "@/components/ContractEventFeed"; import { ErrorBoundary } from "@/components/ErrorBoundary"; @@ -7,13 +7,20 @@ import { useSorokit } from "@/context/useSorokit"; import { SCREEN_LABELS } from "@/lib/nav-labels"; const CONTRACT_HISTORY_KEY = "sorokit-soroban-contract-history"; +// A Soroban contract ID is a C-prefixed strkey (56 chars, base32-ish payload). +const CONTRACT_ID_PATTERN = /^C[A-Z0-9]{55}$/; + +function isValidContractId(id: string): boolean { + return CONTRACT_ID_PATTERN.test(id.trim()); +} function readRecentContract(): string { try { const raw = localStorage.getItem(CONTRACT_HISTORY_KEY); if (!raw) return ""; const parsed = JSON.parse(raw); - return Array.isArray(parsed) && typeof parsed[0] === "string" ? parsed[0] : ""; + const first = Array.isArray(parsed) && typeof parsed[0] === "string" ? parsed[0] : ""; + return isValidContractId(first) ? first : ""; } catch { return ""; } @@ -32,6 +39,14 @@ function readAllRecent(): string[] { } } +function writeRecent(ids: string[]): void { + try { + localStorage.setItem(CONTRACT_HISTORY_KEY, JSON.stringify(ids)); + } catch { + // localStorage unavailable (e.g. private browsing) — history is best-effort + } +} + function stellarExpertUrl(networkName: string | undefined, contractId: string): string | null { const segment = networkName === "mainnet" ? "public" : networkName === "testnet" ? "testnet" : null; @@ -42,16 +57,29 @@ function stellarExpertUrl(networkName: string | undefined, contractId: string): export function SorobanScreen() { const { network } = useSorokit(); const [contractId, setContractId] = useState(() => readRecentContract()); - const [savedContracts, setSavedContracts] = useState(() => readAllRecent()); + // IDs removed from the saved list in this session; base list is re-read fresh + // from localStorage on each render so external additions are reflected too. + const [removed, setRemoved] = useState>(() => new Set()); const { title, sub } = SCREEN_LABELS.soroban; - // Refresh saved contracts when contractId changes (new entry added by SorobanPanel) - useEffect(() => { - setSavedContracts(readAllRecent()); - }, [contractId]); + const savedContracts = readAllRecent().filter((id) => !removed.has(id)); - const expertUrl = - contractId.trim() !== "" ? stellarExpertUrl(network?.name, contractId.trim()) : null; + const validContractId = isValidContractId(contractId); + const expertUrl = validContractId ? stellarExpertUrl(network?.name, contractId.trim()) : null; + + function handleSelectContract(id: string) { + setContractId(id); + } + + function handleRemoveContract(id: string) { + writeRecent(readAllRecent().filter((entry) => entry !== id)); + setRemoved((prev) => new Set(prev).add(id)); + } + + function handleClearContracts() { + writeRecent([]); + setRemoved(new Set(readAllRecent())); + } return ( @@ -73,24 +101,48 @@ export function SorobanScreen() { )} - {savedContracts.length > 1 && ( + {savedContracts.length > 0 && ( - - Saved Contracts - + + + Saved Contracts + + + Clear all + + {savedContracts.map((id) => ( - setContractId(id)} - className={`text-[11px] font-mono px-2 py-1 rounded border transition-colors ${ + className={`inline-flex items-center gap-1 text-[11px] font-mono border rounded transition-colors ${ id === contractId ? "border-brand bg-brand-dim text-brand" : "border-line bg-surface-2 text-ink-2 hover:border-line-2" }`} > - {id.slice(0, 6)}…{id.slice(-4)} - + handleSelectContract(id)} + title={id} + className="pl-2 py-1" + > + {id.slice(0, 6)}…{id.slice(-4)} + + handleRemoveContract(id)} + aria-label={`Remove ${id.slice(0, 6)}…${id.slice(-4)} from saved contracts`} + title={`Remove ${id}`} + className="pr-2 pl-0 text-[11px] leading-none text-ink-4 hover:text-red" + > + × + + ))}
- Saved Contracts -
+ Saved Contracts +