Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 116 additions & 40 deletions apps/web/src/app/(app)/communities/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,74 @@ import Link from "next/link";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { CommunityAvatar } from "@/components/CommunityAvatar";
import { AsyncState } from "@/components/ui/AsyncState";
import { ErrorState } from "@/components/ui/ErrorState";
import { FreshnessNotice } from "@/components/ui/FreshnessNotice";
import { ProposalActivity } from "@/components/community/ProposalActivity";
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 { getCommunity } from "@/lib/community/registry";
import {
formatLedgerDuration,
GOVERNANCE_HELPERS,
LEDGER_TIME_ASSUMPTION_NOTE,
} from "@/lib/community/governanceDisplay";
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 (
<div className="min-w-0 rounded-lg border border-slate-800 bg-[#0b0f19] p-4">
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
{label}
</dt>
<dd className="mt-2 min-w-0">
<OnChainIdentifier
label={label}
value={contractId}
kind="contract"
truncateStart={12}
truncateEnd={10}
/>
<span
className="block break-all font-mono text-sm text-slate-200"
title={contractId}
>
{truncateMiddle(contractId, 12, 10)}
</span>
<span className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => onCopy(label, contractId)}
className="min-h-11 rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-200 hover:bg-slate-800"
aria-label={`Copy full ${label.toLowerCase()} address`}
>
Copy address
</button>
{explorerUrl && (
<a
href={explorerUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-200 hover:bg-slate-800"
aria-label={`View ${label.toLowerCase()} on Stellar Expert`}
>
Open explorer
</a>
)}
</span>
</dd>
<span className="sr-only">Community {record.id}</span>
</div>
Expand All @@ -48,7 +81,6 @@ function ContractAddress({
export default function CommunityDetailPage() {
const params = useParams<{ id: string }>();
const communityId = params.id;
const registry = useCommunityRegistry();
const [result, setResult] = useState<CommunityDetailResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
Expand All @@ -58,7 +90,7 @@ export default function CommunityDetailPage() {
setLoading(true);
setError(null);
try {
setResult(await registry.get(communityId));
setResult(await getCommunity(communityId));
} catch (cause) {
setError(
cause instanceof Error
Expand All @@ -68,7 +100,7 @@ export default function CommunityDetailPage() {
} finally {
setLoading(false);
}
}, [communityId, registry]);
}, [communityId]);

useEffect(() => {
const timeout = window.setTimeout(() => void load(), 0);
Expand All @@ -85,6 +117,10 @@ 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}`;
Expand All @@ -107,7 +143,7 @@ export default function CommunityDetailPage() {
if (loading && !result) {
return (
<div className="mx-auto w-full min-w-0 max-w-4xl px-4 py-10">
<AsyncState className="sr-only">Loading community details…</AsyncState>
<LiveStatus className="sr-only">Loading community details…</LiveStatus>
<Skeleton className="h-8 w-56" />
<Skeleton className="mt-3 h-5 w-full max-w-xl" />
<div className="mt-6 grid gap-4 md:grid-cols-2">
Expand All @@ -121,21 +157,32 @@ export default function CommunityDetailPage() {
if (error) {
return (
<div className="mx-auto w-full min-w-0 max-w-4xl px-4 py-10">
<ErrorState
title="Community could not be loaded"
onRetry={() => void load()}
retryLabel="Retry community request"
action={
<section
role="alert"
className="rounded-xl border border-rose-800/70 bg-rose-950/40 p-5"
>
<h1 className="text-xl font-semibold text-rose-100">
Community could not be loaded
</h1>
<p className="mt-2 break-words text-sm text-rose-200 [overflow-wrap:anywhere]">
{error}
</p>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
onClick={() => void load()}
className="min-h-11 rounded-lg border border-rose-700 px-4 py-2 text-sm font-medium text-rose-100 hover:bg-rose-900/60"
>
Retry community request
</button>
<Link
href="/communities"
className="inline-flex min-h-11 items-center rounded-lg px-4 py-2 text-sm text-slate-300 hover:bg-slate-800"
>
Back to communities
</Link>
}
>
{error}
</ErrorState>
</div>
</section>
</div>
);
}
Expand Down Expand Up @@ -240,15 +287,18 @@ export default function CommunityDetailPage() {
{metadata.description}
</p>
) : (
<FreshnessNotice
title="Community metadata is unavailable"
className="mt-6"
<section
role="status"
className="mt-6 rounded-lg border border-amber-800/70 bg-amber-950/40 p-4"
>
<p className="break-words [overflow-wrap:anywhere]">
<h2 className="font-semibold text-amber-100">
Community metadata is unavailable
</h2>
<p className="mt-1 break-words text-sm text-amber-200 [overflow-wrap:anywhere]">
{metadataError} The verified on-chain registry details remain
available below.
</p>
</FreshnessNotice>
</section>
)}

{metadata && metadata.externalLinks.length > 0 && (
Expand Down Expand Up @@ -294,11 +344,13 @@ export default function CommunityDetailPage() {
label="NFT contract"
record={record}
contractId={record.nftContract}
onCopy={copyAddress}
/>
<ContractAddress
label="Governor contract"
record={record}
contractId={record.governorContract}
onCopy={copyAddress}
/>
</dl>
</section>
Expand All @@ -311,11 +363,11 @@ export default function CommunityDetailPage() {
Governance configuration
</h2>
{governance.unavailableFields.length > 0 && (
<FreshnessNotice className="mt-3 p-3">
<LiveStatus className="mt-3 rounded-lg border border-amber-800/70 bg-amber-950/40 p-3 text-sm text-amber-200">
Some Governor reads failed:{" "}
{governance.unavailableFields.join(", ")}. Available values remain
visible.
</FreshnessNotice>
</LiveStatus>
)}
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
Expand All @@ -325,28 +377,50 @@ export default function CommunityDetailPage() {
<dd className="mt-1 text-slate-100">
{governance.proposalThreshold ?? "Unavailable"}
</dd>
<p className="mt-1 text-xs text-slate-500">
{GOVERNANCE_HELPERS.proposalThreshold}
</p>
</div>
<div>
<dt className="text-sm text-slate-500">Quorum (NFT votes)</dt>
<dd className="mt-1 text-slate-100">
{governance.quorum ?? "Unavailable"}
</dd>
<p className="mt-1 text-xs text-slate-500">
{GOVERNANCE_HELPERS.quorum}
</p>
</div>
<div>
<dt className="text-sm text-slate-500">
Voting delay (Stellar ledgers)
</dt>
<dd className="mt-1 text-slate-100">
{governance.votingDelay ?? "Unavailable"}
{governance.votingDelay !== null && (
<span className="ml-2 text-xs text-slate-400">
{formatLedgerDuration(governance.votingDelay)} ({LEDGER_TIME_ASSUMPTION_NOTE})
</span>
)}
</dd>
<p className="mt-1 text-xs text-slate-500">
{GOVERNANCE_HELPERS.votingDelay}
</p>
</div>
<div>
<dt className="text-sm text-slate-500">
Voting period (Stellar ledgers)
</dt>
<dd className="mt-1 text-slate-100">
{governance.votingPeriod ?? "Unavailable"}
{governance.votingPeriod !== null && (
<span className="ml-2 text-xs text-slate-400">
{formatLedgerDuration(governance.votingPeriod)} ({LEDGER_TIME_ASSUMPTION_NOTE})
</span>
)}
</dd>
<p className="mt-1 text-xs text-slate-500">
{GOVERNANCE_HELPERS.votingPeriod}
</p>
</div>
</dl>
<p className="mt-4 text-xs text-slate-500">
Expand All @@ -355,6 +429,11 @@ export default function CommunityDetailPage() {
</p>
</section>

<ProposalActivity
communityId={record.id}
governorContractId={record.governorContract}
/>

<section
aria-labelledby="registry-title"
className="mt-6 rounded-xl border border-slate-800 bg-[#151b2b] p-4 sm:p-6"
Expand All @@ -373,14 +452,11 @@ export default function CommunityDetailPage() {
</div>
<div className="min-w-0">
<dt className="text-slate-500">Community owner</dt>
<dd className="mt-1 min-w-0 text-slate-200">
<OnChainIdentifier
label="Community owner"
value={record.communityOwner}
kind="account"
truncateStart={10}
truncateEnd={8}
/>
<dd
title={record.communityOwner}
className="mt-1 break-all font-mono text-slate-200"
>
{truncateMiddle(record.communityOwner, 10, 8)}
</dd>
</div>
<div className="min-w-0">
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/SimulatedFeeDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type SimulationResult,
type SimulationStatus,
formatFeeBreakdown,
stroopsToXlm // eslint-disable-line @typescript-eslint/no-unused-vars,
} from "@/lib/fee-utils";

export interface SimulatedFeeDisplayProps {
Expand Down
75 changes: 75 additions & 0 deletions apps/web/src/components/community/ProposalActivity.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { ProposalState } from "@/lib/bindings/community-governor/src";
import { ProposalActivity } from "./ProposalActivity";
import type { ProposalListResolution } from "@/lib/communities/proposals";

const mocked = vi.hoisted(() => ({
resolution: { status: "loading" } as ProposalListResolution,
}));

vi.mock("@/lib/communities/proposals", () => ({
useCommunityProposals: vi.fn(() => mocked.resolution),
}));

describe("ProposalActivity", () => {
const ids = ["newest", "middle", "oldest", "fourth"];
const props = { communityId: "community-1", governorContractId: "governor-1", proposalIds: ids };

it("shows loading as Delayed", () => {
mocked.resolution = { status: "loading" };
render(<ProposalActivity {...props} />);
expect(screen.getByLabelText("Discovery freshness: Delayed")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Loading proposal activity");
});

it("distinguishes a successful empty result from unavailable discovery", () => {
mocked.resolution = { status: "ready", entries: [] };
const { rerender } = render(<ProposalActivity {...props} />);
expect(screen.getByText("No proposals yet")).toBeInTheDocument();
expect(screen.getByLabelText("Discovery freshness: Current")).toBeInTheDocument();

mocked.resolution = { status: "error", error: "RPC unavailable" };
rerender(<ProposalActivity {...props} />);
expect(screen.getByText("Proposal discovery is unavailable")).toBeInTheDocument();
expect(screen.getByLabelText("Discovery freshness: Unavailable")).toBeInTheDocument();
expect(screen.queryByText("No proposals yet")).not.toBeInTheDocument();
});

it("counts active proposals and links the three newest ready proposals", () => {
mocked.resolution = {
status: "ready",
entries: [
{ id: "newest", status: "ready", state: ProposalState.Active },
{ id: "middle", status: "ready", state: ProposalState.Succeeded },
{ id: "oldest", status: "ready", state: ProposalState.Active },
{ id: "fourth", status: "ready", state: ProposalState.Defeated },
],
};
render(<ProposalActivity {...props} />);
expect(screen.getByRole("region")).toHaveTextContent(/2\s+active proposals?/);
const recent = screen.getByRole("list", { name: "Recent proposals" });
const links = within(recent).getAllByRole("link");
expect(links).toHaveLength(3);
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/communities/community-1/proposals/newest",
"/communities/community-1/proposals/middle",
"/communities/community-1/proposals/oldest",
]);
expect(screen.getByLabelText("Discovery freshness: Current")).toBeInTheDocument();
});

it("shows Stale for partial failures while preserving ready entries", () => {
mocked.resolution = {
status: "ready",
entries: [
{ id: "newest", status: "ready", state: ProposalState.Active },
{ id: "middle", status: "error", error: "timeout" },
],
};
render(<ProposalActivity {...props} />);
expect(screen.getByLabelText("Discovery freshness: Stale")).toBeInTheDocument();
expect(screen.getByText(/Some proposals failed to load/)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /newest/ })).toBeInTheDocument();
});
});
Loading