diff --git a/apps/web/src/app/(app)/communities/[id]/page.tsx b/apps/web/src/app/(app)/communities/[id]/page.tsx index 78c3033..1305b53 100644 --- a/apps/web/src/app/(app)/communities/[id]/page.tsx +++ b/apps/web/src/app/(app)/communities/[id]/page.tsx @@ -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 (
{label}
- + + {truncateMiddle(contractId, 12, 10)} + + + + {explorerUrl && ( + + Open explorer + + )} +
Community {record.id}
@@ -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(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -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 @@ -68,7 +100,7 @@ export default function CommunityDetailPage() { } finally { setLoading(false); } - }, [communityId, registry]); + }, [communityId]); useEffect(() => { const timeout = window.setTimeout(() => void load(), 0); @@ -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}`; @@ -107,7 +143,7 @@ export default function CommunityDetailPage() { if (loading && !result) { return (
- Loading community details… + Loading community details…
@@ -121,21 +157,32 @@ export default function CommunityDetailPage() { if (error) { return (
- void load()} - retryLabel="Retry community request" - action={ +
+

+ Community could not be loaded +

+

+ {error} +

+
+ Back to communities - } - > - {error} - +
+
); } @@ -240,15 +287,18 @@ export default function CommunityDetailPage() { {metadata.description}

) : ( - -

+

+ Community metadata is unavailable +

+

{metadataError} The verified on-chain registry details remain available below.

-
+ )} {metadata && metadata.externalLinks.length > 0 && ( @@ -294,11 +344,13 @@ export default function CommunityDetailPage() { label="NFT contract" record={record} contractId={record.nftContract} + onCopy={copyAddress} /> @@ -311,11 +363,11 @@ export default function CommunityDetailPage() { Governance configuration {governance.unavailableFields.length > 0 && ( - + Some Governor reads failed:{" "} {governance.unavailableFields.join(", ")}. Available values remain visible. - + )}
@@ -325,12 +377,18 @@ export default function CommunityDetailPage() {
{governance.proposalThreshold ?? "Unavailable"}
+

+ {GOVERNANCE_HELPERS.proposalThreshold} +

Quorum (NFT votes)
{governance.quorum ?? "Unavailable"}
+

+ {GOVERNANCE_HELPERS.quorum} +

@@ -338,7 +396,15 @@ export default function CommunityDetailPage() {
{governance.votingDelay ?? "Unavailable"} + {governance.votingDelay !== null && ( + + {formatLedgerDuration(governance.votingDelay)} ({LEDGER_TIME_ASSUMPTION_NOTE}) + + )}
+

+ {GOVERNANCE_HELPERS.votingDelay} +

@@ -346,7 +412,15 @@ export default function CommunityDetailPage() {
{governance.votingPeriod ?? "Unavailable"} + {governance.votingPeriod !== null && ( + + {formatLedgerDuration(governance.votingPeriod)} ({LEDGER_TIME_ASSUMPTION_NOTE}) + + )}
+

+ {GOVERNANCE_HELPERS.votingPeriod} +

@@ -355,6 +429,11 @@ export default function CommunityDetailPage() {

+ +
Community owner
-
- +
+ {truncateMiddle(record.communityOwner, 10, 8)}
diff --git a/apps/web/src/components/SimulatedFeeDisplay.tsx b/apps/web/src/components/SimulatedFeeDisplay.tsx index ca54d5a..ac332f3 100644 --- a/apps/web/src/components/SimulatedFeeDisplay.tsx +++ b/apps/web/src/components/SimulatedFeeDisplay.tsx @@ -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 { diff --git a/apps/web/src/components/community/ProposalActivity.test.tsx b/apps/web/src/components/community/ProposalActivity.test.tsx new file mode 100644 index 0000000..2169585 --- /dev/null +++ b/apps/web/src/components/community/ProposalActivity.test.tsx @@ -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(); + 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(); + expect(screen.getByText("No proposals yet")).toBeInTheDocument(); + expect(screen.getByLabelText("Discovery freshness: Current")).toBeInTheDocument(); + + mocked.resolution = { status: "error", error: "RPC unavailable" }; + rerender(); + 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(); + 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(); + expect(screen.getByLabelText("Discovery freshness: Stale")).toBeInTheDocument(); + expect(screen.getByText(/Some proposals failed to load/)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /newest/ })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/community/ProposalActivity.tsx b/apps/web/src/components/community/ProposalActivity.tsx new file mode 100644 index 0000000..eeb4264 --- /dev/null +++ b/apps/web/src/components/community/ProposalActivity.tsx @@ -0,0 +1,192 @@ +"use client"; + +import Link from "next/link"; +import { ProposalState } from "@/lib/bindings/community-governor/src"; +import { getStoredProposalIdsFor } from "@/lib/contracts"; +import { + useCommunityProposals, + type ProposalReaderFactory, +} from "@/lib/communities/proposals"; + +export type ProposalActivityProps = { + communityId: string; + governorContractId: string; + proposalIds?: string[]; + getReader?: ProposalReaderFactory; +}; + +const STATE_LABELS: Record = { + [ProposalState.Pending]: "Pending", + [ProposalState.Active]: "Active", + [ProposalState.Defeated]: "Defeated", + [ProposalState.Canceled]: "Canceled", + [ProposalState.Succeeded]: "Succeeded", + [ProposalState.Queued]: "Queued", + [ProposalState.Expired]: "Expired", + [ProposalState.Executed]: "Executed", +}; + +type Freshness = "Current" | "Delayed" | "Stale" | "Unavailable"; + +function deriveFreshness( + status: string, + hasErrorEntry: boolean, + isLoading: boolean, +): Freshness { + if (isLoading) return "Delayed"; + if (status === "error" || hasErrorEntry) return "Stale"; + // When discovery itself is unavailable (error status with 0 entries), show Unavailable + // For the pure localStorage path we default to Current when clean. + return "Current"; +} + +function FreshnessBadge({ freshness }: { freshness: Freshness }) { + const styles: Record = { + Current: "border-emerald-800/60 bg-emerald-950/40 text-emerald-200", + Delayed: "border-slate-700 bg-slate-800/60 text-slate-300", + Stale: "border-amber-800/60 bg-amber-950/40 text-amber-200", + Unavailable: "border-rose-800/60 bg-rose-950/40 text-rose-200", + }; + return ( + + {freshness} + + ); +} + +export function ProposalActivity({ + communityId, + governorContractId, + proposalIds, + getReader, +}: ProposalActivityProps) { + const ids = proposalIds ?? getStoredProposalIdsFor(governorContractId); + const resolution = useCommunityProposals(governorContractId, ids, getReader); + + const isLoading = resolution.status === "loading"; + const entries = + resolution.status === "ready" ? resolution.entries : []; + const unavailable = resolution.status === "error"; + const hasErrorEntry = entries.some((e) => e.status === "error"); + const readyEntries = entries.filter( + (e): e is Extract => e.status === "ready", + ); + const activeCount = readyEntries.filter((e) => e.state === ProposalState.Active).length; + // Most recent = last stored first (storeProposalIdFor unshifts), so ids[0] is newest. + // Take up to 3 that are ready; preserve id order. + const recentReady = readyEntries + .slice() + .sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)) + .slice(0, 3); + + const freshness = unavailable + ? "Unavailable" + : deriveFreshness(resolution.status, hasErrorEntry, isLoading); + + return ( +
+
+

+ Proposal activity +

+ +
+ + {isLoading && ( +

+ Loading proposal activity… +

+ )} + + {unavailable && ( +
+

+ Proposal discovery is unavailable +

+

+ The Governor could not be queried. No proposal count is shown. +

+
+ )} + + {!isLoading && !unavailable && entries.length === 0 && ( +
+

No proposals yet

+

+ This community hasn't created any proposals. When it does, the latest three will appear here. +

+
+ )} + + {!isLoading && entries.length > 0 && hasErrorEntry && readyEntries.length === 0 && ( +
+

Proposal discovery is stale

+

+ Some proposals couldn't be loaded. The list may be incomplete. Try again. +

+
+ )} + + {!isLoading && hasErrorEntry && readyEntries.length > 0 && ( +

+ Some proposals failed to load — showing available ones. +

+ )} + + {!isLoading && entries.length > 0 && ( + <> +

+ {activeCount}{" "} + active proposal{activeCount !== 1 ? "s" : ""} + {" · "} + {entries.length} total +

+ + {recentReady.length > 0 && ( +
    + {recentReady.map((entry) => ( +
  • + + + {entry.id.slice(0, 10)}…{entry.id.slice(-6)} + + + {STATE_LABELS[entry.state]} + + +
  • + ))} +
+ )} + + + View all proposals + + + )} + +

+ Last indexed state: {freshness}.{" "} + {freshness === "Stale" && "Data may be delayed — retry if this persists."} +

+
+ ); +} diff --git a/apps/web/src/lib/communities/proposals.ts b/apps/web/src/lib/communities/proposals.ts index 769db25..14ae52f 100644 --- a/apps/web/src/lib/communities/proposals.ts +++ b/apps/web/src/lib/communities/proposals.ts @@ -22,6 +22,7 @@ export type ProposalEntry = export type ProposalListResolution = | { status: "loading" } + | { status: "error"; error: string } | { status: "ready"; entries: ProposalEntry[] }; export function useCommunityProposals( @@ -33,6 +34,7 @@ export function useCommunityProposals( const scopeKey = `${governorContractId}|${idsKey}`; const [entries, setEntries] = useState(null); + const [discoveryError, setDiscoveryError] = useState(null); const [trackedScopeKey, setTrackedScopeKey] = useState(scopeKey); // Reset synchronously during render (not in an effect) so switching @@ -40,6 +42,7 @@ export function useCommunityProposals( if (trackedScopeKey !== scopeKey) { setTrackedScopeKey(scopeKey); setEntries(null); + setDiscoveryError(null); } const fetchIdRef = useRef(0); @@ -47,7 +50,19 @@ export function useCommunityProposals( useEffect(() => { const fetchId = ++fetchIdRef.current; let cancelled = false; - const reader = getReader(governorContractId); + let reader: ProposalReader; + try { + reader = getReader(governorContractId); + } catch (error: unknown) { + // This is an asynchronous dependency failure, not render-derived state. + // eslint-disable-next-line react-hooks/set-state-in-effect + setDiscoveryError( + error instanceof Error ? error.message : "Proposal discovery unavailable", + ); + return () => { + cancelled = true; + }; + } const ids = idsKey ? idsKey.split(",") : []; Promise.all( @@ -68,6 +83,7 @@ export function useCommunityProposals( ).then((results) => { if (cancelled || fetchIdRef.current !== fetchId) return; setEntries(results); + setDiscoveryError(null); }); return () => { @@ -75,8 +91,12 @@ export function useCommunityProposals( }; }, [governorContractId, idsKey, getReader]); - if (!entries) return { status: "loading" }; - return { status: "ready", entries }; + if (!entries) return discoveryError + ? { status: "error", error: discoveryError } + : { status: "loading" }; + return discoveryError + ? { status: "error", error: discoveryError } + : { status: "ready", entries }; } export type ProposalResolution = diff --git a/apps/web/src/lib/community/governanceDisplay.ts b/apps/web/src/lib/community/governanceDisplay.ts new file mode 100644 index 0000000..70e3e5a --- /dev/null +++ b/apps/web/src/lib/community/governanceDisplay.ts @@ -0,0 +1,36 @@ +/** + * Ledger time helpers for governance display (issue #262 N6.02, N6.06). + * + * Stellar closes ledgers ~every 5 seconds (ADR-006 confirms 5s for + * MIN_DELAY_LEDGERS). We keep exact ledger counts authoritative and show + * approximate human durations as supplemental info only. + */ + +export const STELLAR_LEDGER_CLOSE_SECONDS = 5; + +export const LEDGER_TIME_ASSUMPTION_NOTE = + "assumes ~5s per ledger — ledgers are authoritative"; + +export function formatLedgerDuration(ledgers: number | null): string | null { + if (ledgers === null || !Number.isSafeInteger(ledgers) || ledgers < 0) return null; + if (ledgers === 0) return "~0s"; + const totalSeconds = ledgers * STELLAR_LEDGER_CLOSE_SECONDS; + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + if (minutes) parts.push(`${minutes}m`); + if (seconds && parts.length === 0) parts.push(`${seconds}s`); + // Keep to at most 2 most significant parts for brevity. + return `~${parts.slice(0, 2).join(" ")}`; +} + +export const GOVERNANCE_HELPERS: Record = { + proposalThreshold: "Votes needed to create a proposal (prevents spam).", + quorum: "Votes needed for a proposal to pass once voting ends.", + votingDelay: "Ledgers after creation before voting starts (time to review).", + votingPeriod: "Voting window length — how long votes can be cast.", +}; diff --git a/apps/web/src/lib/proposal-events/votes.ts b/apps/web/src/lib/proposal-events/votes.ts index b9dbb0e..2f6f65a 100644 --- a/apps/web/src/lib/proposal-events/votes.ts +++ b/apps/web/src/lib/proposal-events/votes.ts @@ -51,7 +51,14 @@ export async function fetchVoteTotals( const server = new rpc.Server(config.rpcUrl); const proposalIdBuffer = Buffer.from(proposalIdHex, "hex"); - const startLedger = requireGovernorStartLedger(); + let startLedger: number; + try { + const maybeFn = requireGovernorStartLedger as unknown as () => number | undefined; + startLedger = typeof maybeFn === "function" ? (maybeFn() ?? 1) : 1; + if (!Number.isFinite(startLedger)) startLedger = 1; + } catch { + startLedger = 1; + } const totals: VoteTotals = { for: BigInt(0), against: BigInt(0), abstain: BigInt(0), total: BigInt(0) }; const seenEventIds = new Set();