From 26ec5b8fde8d924e1281efad4e5a8a497b761781 Mon Sep 17 00:00:00 2001 From: Hollujay Date: Wed, 26 Aug 2026 15:29:57 +0100 Subject: [PATCH] feat(web): add shared OnChainIdentifier component (#253) Introduce one OnChainIdentifier component to standardize how wallet addresses, contract IDs, transaction hashes, and proposal IDs are truncated, copied, and linked to the explorer, then migrate the Community detail page, CommunityDeploymentPanel, ProposalSummaryCard, proposal detail page, and CommunityCard onto it. - Add buildStellarExplorerAccountUrl to stellarExplorer.ts so G-address owners/proposers can link out like contracts and transactions already do. - Copy feedback now goes through a stable aria-live region so a successful/failed copy never changes the button's accessible name (the previous proposer "Copy"/"Copied!" toggle did). --- .../__tests__/CommunityDetailPage.test.tsx | 7 +- .../src/app/(app)/communities/[id]/page.tsx | 65 +++------- .../(app)/communities/[id]/proposals/page.tsx | 3 - .../web/src/app/(app)/proposals/[id]/page.tsx | 62 +-------- apps/web/src/app/(app)/proposals/page.tsx | 1 - apps/web/src/components/CommunityCard.tsx | 16 ++- .../components/CommunityDeploymentPanel.tsx | 35 ++--- .../src/components/ProposalSummaryCard.tsx | 15 +-- .../__tests__/ProposalSummaryCard.test.tsx | 17 ++- .../components/ui/OnChainIdentifier.test.tsx | 94 ++++++++++++++ .../src/components/ui/OnChainIdentifier.tsx | 120 ++++++++++++++++++ apps/web/src/lib/stellarExplorer.test.ts | 21 +++ apps/web/src/lib/stellarExplorer.ts | 14 ++ 13 files changed, 314 insertions(+), 156 deletions(-) create mode 100644 apps/web/src/components/ui/OnChainIdentifier.test.tsx create mode 100644 apps/web/src/components/ui/OnChainIdentifier.tsx diff --git a/apps/web/src/__tests__/CommunityDetailPage.test.tsx b/apps/web/src/__tests__/CommunityDetailPage.test.tsx index a605931..26aa1bd 100644 --- a/apps/web/src/__tests__/CommunityDetailPage.test.tsx +++ b/apps/web/src/__tests__/CommunityDetailPage.test.tsx @@ -103,11 +103,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 8a8899f..52dc9b2 100644 --- a/apps/web/src/app/(app)/communities/[id]/page.tsx +++ b/apps/web/src/app/(app)/communities/[id]/page.tsx @@ -5,67 +5,37 @@ import { useParams } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { CommunityAvatar } from "@/components/CommunityAvatar"; import { LiveStatus } from "@/components/ui/LiveStatus"; +import { OnChainIdentifier } from "@/components/ui/OnChainIdentifier"; import { Skeleton } from "@/components/ui/Skeleton"; import { getCommunity } from "@/lib/community/registry"; 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}
@@ -111,10 +81,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}`; @@ -338,13 +304,11 @@ export default function CommunityDetailPage() { label="NFT contract" record={record} contractId={record.nftContract} - onCopy={copyAddress} /> @@ -419,11 +383,14 @@ export default function CommunityDetailPage() {
Community owner
-
- {truncateMiddle(record.communityOwner, 10, 8)} +
+
diff --git a/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx b/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx index d9efea5..b32866e 100644 --- a/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx +++ b/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx @@ -225,9 +225,6 @@ function ScopedProposalHistory({ community }: { community: CommunityView }) { : undefined } isRetryingState={retrying.includes(proposal.id)} - onCopyId={() => - void navigator.clipboard.writeText(proposal.id) - } /> ); diff --git a/apps/web/src/app/(app)/proposals/[id]/page.tsx b/apps/web/src/app/(app)/proposals/[id]/page.tsx index 6535fab..e4a9805 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 { CommunityView } 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 3ace97a..ce2573e 100644 --- a/apps/web/src/app/(app)/proposals/page.tsx +++ b/apps/web/src/app/(app)/proposals/page.tsx @@ -493,7 +493,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 accdb1b..9a6ba8b 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 { CommunityView } from "@/lib/community/types"; import { truncateMiddle } from "@/lib/truncate"; @@ -19,12 +20,15 @@ export function CommunityCard({ community }: { community: CommunityView }) {

{name}

-

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

+
+ +
diff --git a/apps/web/src/components/CommunityDeploymentPanel.tsx b/apps/web/src/components/CommunityDeploymentPanel.tsx index d13857a..b7d118f 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 = { @@ -468,28 +466,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}`; +}