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
200 changes: 105 additions & 95 deletions apps/web/src/app/(app)/communities/[id]/proposals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@ import { Buffer } from "buffer";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ProposalSummaryCard } from "@/components/ProposalSummaryCard";
import { AsyncState } from "@/components/ui/AsyncState";
import { EmptyState } from "@/components/ui/EmptyState";
import { ErrorState } from "@/components/ui/ErrorState";
import { FreshnessNotice } from "@/components/ui/FreshnessNotice";
import { DiscoveryFreshnessBanner } from "@/components/DiscoveryFreshnessBanner";
import { LiveStatus } from "@/components/ui/LiveStatus";
import { Skeleton } from "@/components/ui/Skeleton";
import { useProposalDiscovery } from "@/hooks/useProposalDiscovery";
import { useCommunityRegistry } from "@/lib/community/CommunityRegistryProvider";
import type { Community } from "@/lib/community/types";
import { getCommunity } from "@/lib/community/registry";
import type { CommunityView } from "@/lib/community/types";
import { createReadOnlyGovernorClient } from "@/lib/contracts";
import {
ProposalState,
Expand All @@ -23,10 +21,16 @@ import {
const PAGE_SIZE = 10;
const ALL_STATES = "all";

function ScopedProposalHistory({ community }: { community: Community }) {
function ScopedProposalHistory({ community }: { community: CommunityView }) {
const governorContract = community.record.governorContract;
const { proposals: discovered, loading, error, empty, refresh } =
useProposalDiscovery(governorContract);
const {
proposals: discovered,
loading,
error,
empty,
freshness,
refresh,
} = useProposalDiscovery(governorContract);
const proposals = useMemo(
() =>
Array.from(
Expand All @@ -48,7 +52,7 @@ function ScopedProposalHistory({ community }: { community: Community }) {
try {
const client = createReadOnlyGovernorClient(governorContract);
const transaction = await client.proposal_state({
proposal_id: Buffer.from(proposalId, "hex"),
proposal_id: Uint8Array.from(Buffer.from(proposalId, "hex")),
});
setStates((current) => ({
...current,
Expand Down Expand Up @@ -153,9 +157,9 @@ function ScopedProposalHistory({ community }: { community: Community }) {

{loading && proposals.length === 0 && (
<>
<AsyncState className="sr-only">
<LiveStatus className="sr-only">
Loading community proposal history…
</AsyncState>
</LiveStatus>
<div className="mt-3 space-y-2" aria-hidden="true">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
Expand All @@ -164,24 +168,37 @@ function ScopedProposalHistory({ community }: { community: Community }) {
)}

{error && (
<ErrorState
className="mt-3"
title={
proposals.length
? "Proposal history is incomplete"
: "Proposal history is unavailable"
}
onRetry={() => void refresh()}
retryLabel="Retry proposal history"
<div
role="alert"
className="mt-3 rounded-lg border border-rose-800/70 bg-rose-950/40 p-4 text-sm text-rose-200"
>
{error}
</ErrorState>
<p>
{proposals.length
? "More proposal history could not be loaded."
: "Proposal history is temporarily unavailable."}
</p>
<p className="mt-1 text-rose-300">{error}</p>
<button
type="button"
onClick={() => void refresh()}
className="mt-3 min-h-11 rounded-lg border border-rose-700 px-3 py-2"
>
Retry proposal history
</button>
</div>
)}

{!loading && proposals.length > 0 && (
<DiscoveryFreshnessBanner
freshness={freshness}
onRetry={() => void refresh()}
/>
)}

{!loading && !error && empty && (
<EmptyState className="mt-3">
<LiveStatus className="mt-3 rounded-lg border border-dashed border-slate-700 p-5 text-sm text-slate-400">
This community has no public proposals yet.
</EmptyState>
</LiveStatus>
)}

{!loading && proposals.length > 0 && filtered.length === 0 && (
Expand All @@ -191,53 +208,45 @@ function ScopedProposalHistory({ community }: { community: Community }) {
)}

{visible.length > 0 && (
<>
{Object.values(states).includes("unavailable") && (
<FreshnessNotice className="mt-3">
Some proposal states could not be refreshed. Available
proposals remain visible below.
</FreshnessNotice>
)}
<ul className="mt-3 space-y-2">
{visible.map((proposal) => {
const state = states[proposal.id];
const unavailable = state === "unavailable";
return (
<li key={proposal.id}>
<ProposalSummaryCard
summary={{
proposalId: proposal.id,
description: proposal.description,
}}
showDescription
href={`/communities/${community.record.id}/proposals/${proposal.id}`}
stateStatus={
unavailable
? "unavailable"
: state === undefined
? "loading"
: "ready"
}
stateLabel={
typeof state === "number"
? PROPOSAL_STATE_LABELS[state]
: undefined
}
onRetryState={
unavailable
? () => void retryState(proposal.id)
: undefined
}
isRetryingState={retrying.includes(proposal.id)}
onCopyId={() =>
void navigator.clipboard.writeText(proposal.id)
}
/>
</li>
);
})}
</ul>
</>
<ul className="mt-3 space-y-2">
{visible.map((proposal) => {
const state = states[proposal.id];
const unavailable = state === "unavailable";
return (
<li key={proposal.id}>
<ProposalSummaryCard
summary={{
proposalId: proposal.id,
description: proposal.description,
}}
showDescription
href={`/communities/${community.record.id}/proposals/${proposal.id}`}
stateStatus={
unavailable
? "unavailable"
: state === undefined
? "loading"
: "ready"
}
stateLabel={
typeof state === "number"
? PROPOSAL_STATE_LABELS[state]
: undefined
}
onRetryState={
unavailable
? () => void retryState(proposal.id)
: undefined
}
isRetryingState={retrying.includes(proposal.id)}
onCopyId={() =>
void navigator.clipboard.writeText(proposal.id)
}
/>
</li>
);
})}
</ul>
)}

{visibleCount < filtered.length && (
Expand All @@ -256,8 +265,7 @@ function ScopedProposalHistory({ community }: { community: Community }) {

export default function CommunityProposalHistoryPage() {
const { id = "" } = useParams<{ id: string }>();
const registry = useCommunityRegistry();
const [community, setCommunity] = useState<Community | null>(null);
const [community, setCommunity] = useState<CommunityView | null>(null);
const [status, setStatus] = useState<"loading" | "not-found" | "error">(
"loading",
);
Expand All @@ -267,7 +275,7 @@ export default function CommunityProposalHistoryPage() {
const timeout = window.setTimeout(() => {
setStatus("loading");
setCommunity(null);
void registry.get(id)
void getCommunity(id)
.then((result) => {
if (!active) return;
if (result.status !== "found") {
Expand All @@ -284,34 +292,36 @@ export default function CommunityProposalHistoryPage() {
active = false;
window.clearTimeout(timeout);
};
}, [id, registry]);
}, [id]);

if (community) return <ScopedProposalHistory community={community} />;

return (
<div className="mx-auto w-full max-w-3xl px-4 py-10">
{status === "loading" ? (
<AsyncState>Loading community proposal history…</AsyncState>
<LiveStatus>Loading community proposal history…</LiveStatus>
) : (
<ErrorState
title={
status === "not-found"
? "Community not found"
: "Community proposal history unavailable"
}
action={
<Link
href="/communities"
className="inline-flex min-h-11 items-center rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white"
>
Browse communities
</Link>
}
<section
role={status === "error" ? "alert" : undefined}
className="rounded-xl border border-slate-800 bg-[#151b2b] p-6"
>
{status === "not-found"
? "The route community is malformed or is not registered."
: "The canonical community record could not be loaded."}
</ErrorState>
<h1 className="text-xl font-semibold text-slate-100">
{status === "not-found"
? "Community not found"
: "Community proposal history unavailable"}
</h1>
<p className="mt-2 text-sm text-slate-400">
{status === "not-found"
? "The route community is malformed or is not registered."
: "The canonical community record could not be loaded."}
</p>
<Link
href="/communities"
className="mt-4 inline-flex min-h-11 items-center rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white"
>
Browse communities
</Link>
</section>
)}
</div>
);
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/app/(app)/proposals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { contractIds } from "@/lib/stellar";
import { Skeleton } from "@/components/ui/Skeleton";
import { ProposalSummaryCard } from "@/components/ProposalSummaryCard";
import { DiscoveryFreshnessBanner } from "@/components/DiscoveryFreshnessBanner";
import { truncateEnd } from "@/lib/truncate";
import { LiveStatus } from "@/components/ui/LiveStatus";
import { TransactionLifecycleStatus } from "@/components/TransactionLifecycleStatus";
Expand Down Expand Up @@ -46,6 +47,7 @@ export default function ProposalsPage() {
loading,
error,
empty,
freshness,
refresh,
} =
useProposalDiscovery();
Expand Down Expand Up @@ -479,6 +481,13 @@ export default function ProposalsPage() {
</div>
)}

{!loading && uniqueProposalIds.length > 0 && (
<DiscoveryFreshnessBanner
freshness={freshness}
onRetry={() => void refresh()}
/>
)}

{!loading && !error && empty && (
<LiveStatus className="mt-3 rounded-lg border border-dashed border-slate-700 bg-slate-900/40 p-4 text-sm text-slate-400">
No public proposals have been discovered yet.
Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/components/DiscoveryFreshnessBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"use client";

import type { FreshnessResult } from "@/lib/proposal/freshness";

const STATE_STYLES: Record<
FreshnessResult["state"],
{ className: string; icon: string }
> = {
current: {
className: "border-emerald-800/60 bg-emerald-950/40 text-emerald-200",
icon: "\u2713",
},
delayed: {
className: "border-amber-800/60 bg-amber-950/40 text-amber-200",
icon: "\u25B2",
},
stale: {
className: "border-amber-800/70 bg-amber-950/50 text-amber-200",
icon: "\u25B2",
},
unavailable: {
className: "border-rose-800/70 bg-rose-950/40 text-rose-200",
icon: "\u2717",
},
};

const EXPLANATION_URL =
"https://github.com/stolla-labs/stolla/blob/main/docs/community-proposal-indexing.md#finality-freshness-and-caching";

export type DiscoveryFreshnessBannerProps = {
freshness: FreshnessResult;
/** When provided, render a retry button that calls this callback. */
onRetry?: () => void;
isRetrying?: boolean;
};

/**
* Banner indicating the freshness of proposal discovery results.
*
* Renders nothing for the `current` state — users don't need to know
* that everything is fine. All other states show a banner with an
* explanation, a link to documentation, and an optional retry action.
*/
export function DiscoveryFreshnessBanner({
freshness,
onRetry,
isRetrying = false,
}: DiscoveryFreshnessBannerProps) {
if (!freshness || freshness.state === "current") return null;

const { className, icon } = STATE_STYLES[freshness.state];

return (
<div
role="status"
className={`mt-3 rounded-lg border p-4 text-sm ${className}`}
>
<p>
<span aria-hidden="true">{icon} </span>
{freshness.explanation}
</p>
<div className="mt-2 flex flex-wrap items-center gap-3">
<a
href={EXPLANATION_URL}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:no-underline"
>
Learn about proposal history limits
</a>
{onRetry && (
<button
type="button"
onClick={onRetry}
disabled={isRetrying}
className="min-h-9 rounded-lg border border-current px-3 py-1.5 text-xs font-medium transition hover:bg-white/10 disabled:opacity-50"
>
{isRetrying ? "Retrying\u2026" : "Retry discovery"}
</button>
)}
</div>
</div>
);
}
Loading