diff --git a/apps/web/src/__tests__/CommunityDetailPage.test.tsx b/apps/web/src/__tests__/CommunityDetailPage.test.tsx index ddac9e9..1c068f5 100644 --- a/apps/web/src/__tests__/CommunityDetailPage.test.tsx +++ b/apps/web/src/__tests__/CommunityDetailPage.test.tsx @@ -109,11 +109,14 @@ describe("CommunityDetailPage", () => { `/communities/${COMMUNITY_ID}/proposals`, ); expect( - screen.getAllByRole("button", { name: /Copy full .* address/ }), + screen.getAllByRole("button", { name: /^Copy .* contract$/ }), ).toHaveLength(2); expect( - screen.getAllByRole("link", { name: /on Stellar Expert/ }), + screen.getAllByRole("link", { name: /Open .* contract in explorer/ }), ).toHaveLength(2); + expect( + screen.getByRole("button", { name: "Copy Community owner" }), + ).toBeInTheDocument(); }); it("renders malformed and unknown IDs as clear not-found states", async () => { diff --git a/apps/web/src/app/(app)/communities/[id]/page.tsx b/apps/web/src/app/(app)/communities/[id]/page.tsx index 36014d1..78c3033 100644 --- a/apps/web/src/app/(app)/communities/[id]/page.tsx +++ b/apps/web/src/app/(app)/communities/[id]/page.tsx @@ -8,67 +8,37 @@ import { AsyncState } from "@/components/ui/AsyncState"; import { ErrorState } from "@/components/ui/ErrorState"; import { FreshnessNotice } from "@/components/ui/FreshnessNotice"; import { LiveStatus } from "@/components/ui/LiveStatus"; +import { OnChainIdentifier } from "@/components/ui/OnChainIdentifier"; import { Skeleton } from "@/components/ui/Skeleton"; import { useCommunityRegistry } from "@/lib/community/CommunityRegistryProvider"; import type { CommunityDetailResult, CommunityRegistryRecord, } from "@/lib/community/types"; -import { - buildStellarExplorerContractUrl, - resolveStellarNetworkId, -} from "@/lib/stellarExplorer"; import { truncateMiddle } from "@/lib/truncate"; function ContractAddress({ label, record, contractId, - onCopy, }: { label: string; record: CommunityRegistryRecord; contractId: string; - onCopy: (label: string, contractId: string) => void; }) { - const explorerUrl = buildStellarExplorerContractUrl( - contractId, - resolveStellarNetworkId(), - ); - return (
{label}
- - {truncateMiddle(contractId, 12, 10)} - - - - {explorerUrl && ( - - Open explorer - - )} - +
Community {record.id}
@@ -115,10 +85,6 @@ export default function CommunityDetailPage() { } } - function copyAddress(label: string, address: string) { - void copyValue(`${label} address`, address); - } - async function shareCommunity(name: string, id: string) { setCopyStatus(""); const canonicalUrl = `${window.location.origin}/communities/${id}`; @@ -328,13 +294,11 @@ export default function CommunityDetailPage() { label="NFT contract" record={record} contractId={record.nftContract} - onCopy={copyAddress} /> @@ -409,11 +373,14 @@ export default function CommunityDetailPage() {
Community owner
-
- {truncateMiddle(record.communityOwner, 10, 8)} +
+
diff --git a/apps/web/src/app/(app)/proposals/[id]/page.tsx b/apps/web/src/app/(app)/proposals/[id]/page.tsx index 2bacdef..93a7e34 100644 --- a/apps/web/src/app/(app)/proposals/[id]/page.tsx +++ b/apps/web/src/app/(app)/proposals/[id]/page.tsx @@ -20,12 +20,8 @@ import { useTransactionLifecycle } from "@/hooks/useTransactionLifecycle"; import { TransactionLifecycleDisplay } from "@/components/TransactionLifecycleDisplay"; import { truncateMiddle } from "@/lib/truncate"; import { LiveStatus } from "@/components/ui/LiveStatus"; +import { OnChainIdentifier } from "@/components/ui/OnChainIdentifier"; import type { Community } from "@/lib/community/types"; - -function shortenAddress(addr: string): string { - if (addr.length <= 12) return addr; - return `${addr.slice(0, 6)}...${addr.slice(-4)}`; -} import { fetchVoteTotals, type VoteTotals } from "@/lib/voteAggregation"; import { fmt, pct } from "@/lib/voteDisplay"; @@ -64,7 +60,6 @@ export default function ProposalDetailPage({ const [reason, setReason] = useState("Support"); const [status, setStatus] = useState(null); const [proposer, setProposer] = useState(null); - const [copied, setCopied] = useState(false); const [totals, setTotals] = useState(null); const [quorum, setQuorum] = useState(null); const [totalsError, setTotalsError] = useState(null); @@ -340,13 +335,8 @@ export default function ProposalDetailPage({
Loading proposal…

Proposal

-
-

- {truncateMiddle(proposalIdHex)} -

+
+
@@ -385,22 +375,8 @@ export default function ProposalDetailPage({ )}

Proposal

-
-

- {truncateMiddle(proposalIdHex)} -

- +
+
@@ -467,39 +443,13 @@ export default function ProposalDetailPage({
Proposer
{proposer ? ( - - - {shortenAddress(proposer)} - - - + ) : ( Unknown )}
-
- {copied ? "Proposer address copied to clipboard" : null} -
diff --git a/apps/web/src/app/(app)/proposals/page.tsx b/apps/web/src/app/(app)/proposals/page.tsx index 8a09dae..efa33cc 100644 --- a/apps/web/src/app/(app)/proposals/page.tsx +++ b/apps/web/src/app/(app)/proposals/page.tsx @@ -490,7 +490,6 @@ export default function ProposalsPage() { : undefined } isRetryingState={isRetrying} - onCopyId={() => void navigator.clipboard.writeText(id)} /> ); diff --git a/apps/web/src/components/CommunityCard.tsx b/apps/web/src/components/CommunityCard.tsx index 13c5bc7..5619fed 100644 --- a/apps/web/src/components/CommunityCard.tsx +++ b/apps/web/src/components/CommunityCard.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { CommunityAvatar } from "@/components/CommunityAvatar"; +import { OnChainIdentifier } from "@/components/ui/OnChainIdentifier"; import type { Community } from "@/lib/community/types"; import { truncateMiddle } from "@/lib/truncate"; import { FreshnessNotice } from "@/components/ui/FreshnessNotice"; @@ -20,12 +21,15 @@ export function CommunityCard({ community }: { community: Community }) {

{name}

-

- {truncateMiddle(record.id, 10, 8)} -

+
+ +
diff --git a/apps/web/src/components/CommunityDeploymentPanel.tsx b/apps/web/src/components/CommunityDeploymentPanel.tsx index 22395d6..f86edf7 100644 --- a/apps/web/src/components/CommunityDeploymentPanel.tsx +++ b/apps/web/src/components/CommunityDeploymentPanel.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { TransactionLifecycleStatus } from "@/components/TransactionLifecycleStatus"; import { LiveStatus } from "@/components/ui/LiveStatus"; +import { OnChainIdentifier } from "@/components/ui/OnChainIdentifier"; import { useWallet } from "@/context/WalletProvider"; import { communityDeploymentRecoveryKey, @@ -21,10 +22,7 @@ import type { } from "@/lib/community/schema"; import { getE2EBridge } from "@/lib/e2eMock"; import { config } from "@/lib/stellar"; -import { - buildStellarExplorerContractUrl, - buildStellarExplorerTxUrl, -} from "@/lib/stellarExplorer"; +import { buildStellarExplorerTxUrl } from "@/lib/stellarExplorer"; import type { TransactionLifecycleStage } from "@/lib/transactionLifecycle"; type Props = { @@ -481,28 +479,15 @@ export function CommunityDeploymentPanel({ ["Governor", expected.governorContract], ] as const ).map(([label, contractId]) => ( -
+

{label} contract

-

- {contractId} -

-
- - - Open explorer - +
+
))} diff --git a/apps/web/src/components/ProposalSummaryCard.tsx b/apps/web/src/components/ProposalSummaryCard.tsx index ffa804e..4ce83ad 100644 --- a/apps/web/src/components/ProposalSummaryCard.tsx +++ b/apps/web/src/components/ProposalSummaryCard.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import type { ProposalSummary } from "@/lib/proposal/types"; +import { OnChainIdentifier } from "@/components/ui/OnChainIdentifier"; import { truncateMiddle } from "@/lib/truncate"; export type ProposalSummaryCardStateStatus = @@ -31,7 +32,6 @@ export type ProposalSummaryCardProps = { showDescription?: boolean; onRetryState?: () => void; isRetryingState?: boolean; - onCopyId?: () => void; href?: string; }; @@ -89,7 +89,6 @@ export function ProposalSummaryCard({ showDescription = false, onRetryState, isRetryingState = false, - onCopyId, href, }: ProposalSummaryCardProps) { const { proposalId } = summary; @@ -140,17 +139,7 @@ export function ProposalSummaryCard({ {isRetryingState ? "Retrying…" : "Retry state"} )} - {onCopyId && ( - - )} +
); diff --git a/apps/web/src/components/__tests__/ProposalSummaryCard.test.tsx b/apps/web/src/components/__tests__/ProposalSummaryCard.test.tsx index ccb1883..c92069a 100644 --- a/apps/web/src/components/__tests__/ProposalSummaryCard.test.tsx +++ b/apps/web/src/components/__tests__/ProposalSummaryCard.test.tsx @@ -30,7 +30,6 @@ describe("ProposalSummaryCard", () => { showDescription stateStatus="ready" stateLabel="Active" - onCopyId={() => undefined} />, ); @@ -126,6 +125,22 @@ describe("ProposalSummaryCard", () => { expect(onRetryState).toHaveBeenCalledTimes(1); }); + it("copies the proposal id via the shared identifier control", async () => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Copy Proposal ID" })); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(FULL_ID); + }); + it("keeps long proposal IDs from breaking the layout", () => { const { container } = render( { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_STELLAR_NETWORK", "testnet"); + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("truncates the value deterministically and exposes the full value via title", () => { + render(); + + const text = screen.getByTitle(CONTRACT_ID); + expect(text).toHaveTextContent(`${CONTRACT_ID.slice(0, 8)}…${CONTRACT_ID.slice(-6)}`); + }); + + it("does not change the copy button's accessible name after a successful copy", async () => { + render(); + + const button = screen.getByRole("button", { name: "Copy Governor contract" }); + fireEvent.click(button); + + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith(CONTRACT_ID)); + expect(screen.getByRole("button", { name: "Copy Governor contract" })).toBe(button); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("Governor contract copied to clipboard"), + ); + }); + + it("announces a failure without changing the accessible name", async () => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) }, + }); + + render(); + const button = screen.getByRole("button", { name: "Copy Proposer" }); + fireEvent.click(button); + + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Failed to copy Proposer")); + expect(screen.getByRole("button", { name: "Copy Proposer" })).toBe(button); + }); + + it("links contract ids to the contract explorer entity for the active network", () => { + render(); + + expect(screen.getByRole("link", { name: "Open Governor contract in explorer" })).toHaveAttribute( + "href", + `https://stellar.expert/explorer/public/contract/${CONTRACT_ID}`, + ); + }); + + it("links account addresses to the account explorer entity", () => { + render(); + + expect(screen.getByRole("link", { name: "Open Proposer in explorer" })).toHaveAttribute( + "href", + `https://stellar.expert/explorer/testnet/account/${ACCOUNT_ID}`, + ); + }); + + it("links transaction hashes to the tx explorer entity", () => { + render(); + + expect(screen.getByRole("link", { name: "Open Transaction in explorer" })).toHaveAttribute( + "href", + `https://stellar.expert/explorer/testnet/tx/${TX_HASH}`, + ); + }); + + it("never renders an explorer link for opaque values like proposal ids", () => { + render(); + + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("never renders an explorer link when the value fails validation for its kind", () => { + render(); + + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/ui/OnChainIdentifier.tsx b/apps/web/src/components/ui/OnChainIdentifier.tsx new file mode 100644 index 0000000..7f1789c --- /dev/null +++ b/apps/web/src/components/ui/OnChainIdentifier.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { truncateMiddle } from "@/lib/truncate"; +import { + buildStellarExplorerAccountUrl, + buildStellarExplorerContractUrl, + buildStellarExplorerTxUrl, + resolveStellarNetworkId, + type StellarNetworkId, +} from "@/lib/stellarExplorer"; +import { LiveStatus } from "@/components/ui/LiveStatus"; + +export type OnChainIdentifierKind = "contract" | "tx" | "account" | "opaque"; + +export type OnChainIdentifierProps = { + /** Human-readable label used in aria-labels and copy/explorer feedback, e.g. "Governor contract". */ + label: string; + /** Full, untruncated identifier value. */ + value: string; + /** Which explorer lookup (if any) applies to this value. "opaque" never links out. */ + kind: OnChainIdentifierKind; + /** Defaults to the app's configured network. */ + network?: StellarNetworkId; + truncateStart?: number; + truncateEnd?: number; + className?: string; + /** Render only the copy/explorer controls, e.g. when the value is already shown elsewhere. */ + hideValue?: boolean; +}; + +const EXPLORER_BUILDERS: Record< + Exclude, + (value: string, network: StellarNetworkId) => string | null +> = { + contract: buildStellarExplorerContractUrl, + tx: buildStellarExplorerTxUrl, + account: buildStellarExplorerAccountUrl, +}; + +const COPY_FEEDBACK_MS = 2000; + +/** + * Deterministic display for a wallet address, contract id, transaction hash, + * or proposal id: truncated text with the full value in `title`, a copy + * button with keyboard access and a live-region announcement, and an + * explorer link when the kind/network combination resolves to one. + */ +export function OnChainIdentifier({ + label, + value, + kind, + network, + truncateStart = 8, + truncateEnd = 6, + className, + hideValue = false, +}: OnChainIdentifierProps) { + const [copyStatus, setCopyStatus] = useState<"idle" | "success" | "error">("idle"); + const resetTimer = useRef | null>(null); + + useEffect(() => { + return () => { + if (resetTimer.current) clearTimeout(resetTimer.current); + }; + }, []); + + const resolvedNetwork = network ?? resolveStellarNetworkId(); + const explorerUrl = kind === "opaque" ? null : EXPLORER_BUILDERS[kind](value, resolvedNetwork); + const displayValue = truncateMiddle(value, truncateStart, truncateEnd); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopyStatus("success"); + } catch { + setCopyStatus("error"); + } + if (resetTimer.current) clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setCopyStatus("idle"), COPY_FEEDBACK_MS); + }; + + return ( + + {!hideValue && ( + + {displayValue} + + )} + + {explorerUrl && ( + + Explorer + + )} + + {copyStatus === "success" + ? `${label} copied to clipboard` + : copyStatus === "error" + ? `Failed to copy ${label}` + : ""} + + + ); +} diff --git a/apps/web/src/lib/stellarExplorer.test.ts b/apps/web/src/lib/stellarExplorer.test.ts index d222433..00e5284 100644 --- a/apps/web/src/lib/stellarExplorer.test.ts +++ b/apps/web/src/lib/stellarExplorer.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildStellarExplorerAccountUrl, buildStellarExplorerContractUrl, buildStellarExplorerTxUrl, resolveStellarNetworkId, @@ -56,6 +57,26 @@ describe("buildStellarExplorerContractUrl", () => { }); }); +describe("buildStellarExplorerAccountUrl", () => { + const accountId = `G${"A".repeat(55)}`; + + it("builds network-specific account links", () => { + expect(buildStellarExplorerAccountUrl(accountId, "testnet")).toBe( + `https://stellar.expert/explorer/testnet/account/${accountId}`, + ); + expect(buildStellarExplorerAccountUrl(accountId, "mainnet")).toBe( + `https://stellar.expert/explorer/public/account/${accountId}`, + ); + }); + + it("returns null for missing or malformed account addresses", () => { + expect(buildStellarExplorerAccountUrl(null, "testnet")).toBeNull(); + expect(buildStellarExplorerAccountUrl(undefined, "testnet")).toBeNull(); + expect(buildStellarExplorerAccountUrl("", "testnet")).toBeNull(); + expect(buildStellarExplorerAccountUrl("CNFT", "testnet")).toBeNull(); + }); +}); + describe("resolveStellarNetworkId", () => { it("maps configured network values", () => { expect(resolveStellarNetworkId("mainnet")).toBe("mainnet"); diff --git a/apps/web/src/lib/stellarExplorer.ts b/apps/web/src/lib/stellarExplorer.ts index 7d9a7b8..e4c3736 100644 --- a/apps/web/src/lib/stellarExplorer.ts +++ b/apps/web/src/lib/stellarExplorer.ts @@ -2,6 +2,7 @@ export type StellarNetworkId = "testnet" | "mainnet"; const TX_HASH_PATTERN = /^[0-9a-fA-F]{64}$/; const CONTRACT_ADDRESS_PATTERN = /^C[A-Z2-7]{55}$/; +const ACCOUNT_ADDRESS_PATTERN = /^G[A-Z2-7]{55}$/; /** * Build a Stellar Expert explorer URL for a confirmed transaction hash. @@ -35,3 +36,16 @@ export function buildStellarExplorerContractUrl( const explorerNetwork = network === "mainnet" ? "public" : "testnet"; return `https://stellar.expert/explorer/${explorerNetwork}/contract/${contractId}`; } + +/** + * Build a Stellar Expert explorer URL for a G... account address. + * Returns null when the address is missing or invalid so callers can skip the link. + */ +export function buildStellarExplorerAccountUrl( + address: string | null | undefined, + network: StellarNetworkId = "testnet", +): string | null { + if (!address || !ACCOUNT_ADDRESS_PATTERN.test(address)) return null; + const explorerNetwork = network === "mainnet" ? "public" : "testnet"; + return `https://stellar.expert/explorer/${explorerNetwork}/account/${address}`; +}