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
53 changes: 53 additions & 0 deletions src/lib/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,59 @@ export async function apiLegitimacyObjectSet(input: {
);
}

export async function apiDelegationSet(input: {
chamberId: string;
delegateeAddress: string;
idempotencyKey?: string;
}): Promise<{
ok: true;
type: "delegation.set";
chamberId: string;
delegatorAddress: string;
delegateeAddress: string;
updatedAt: string;
}> {
return await apiPost(
"/api/command",
{
type: "delegation.set",
payload: {
chamberId: input.chamberId,
delegateeAddress: input.delegateeAddress,
},
idempotencyKey: input.idempotencyKey,
},
input.idempotencyKey
? { headers: { "idempotency-key": input.idempotencyKey } }
: undefined,
);
}

export async function apiDelegationClear(input: {
chamberId: string;
idempotencyKey?: string;
}): Promise<{
ok: true;
type: "delegation.clear";
chamberId: string;
delegatorAddress: string;
cleared: boolean;
}> {
return await apiPost(
"/api/command",
{
type: "delegation.clear",
payload: {
chamberId: input.chamberId,
},
idempotencyKey: input.idempotencyKey,
},
input.idempotencyKey
? { headers: { "idempotency-key": input.idempotencyKey } }
: undefined,
);
}

export async function apiCmMe(): Promise<CmSummaryDto> {
return await apiGet<CmSummaryDto>("/api/cm/me");
}
Expand Down
222 changes: 220 additions & 2 deletions src/pages/MyGovernance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { AppCard } from "@/components/AppCard";
import { HintLabel } from "@/components/Hint";
import { Badge } from "@/components/primitives/badge";
import { Button } from "@/components/primitives/button";
import { Input } from "@/components/primitives/input";
import { AddressInline } from "@/components/AddressInline";
import { PipelineList } from "@/components/PipelineList";
import { StatGrid, makeChamberStats } from "@/components/StatGrid";
import { Surface } from "@/components/Surface";
Expand All @@ -20,6 +22,8 @@ import { Kicker } from "@/components/Kicker";
import {
apiChambers,
apiClock,
apiDelegationClear,
apiDelegationSet,
apiLegitimacyObjectSet,
apiCmMe,
apiMyGovernance,
Expand Down Expand Up @@ -152,6 +156,15 @@ const formatDayHourMinute = (targetMs: number, nowMs: number): string => {
return `${days}d:${String(hours).padStart(2, "0")}h:${String(minutes).padStart(2, "0")}m`;
};

const chamberLabel = (
chamberId: string,
chambers: ChamberDto[] | null,
): string => {
if (chamberId === "general") return "General chamber";
const match = chambers?.find((item) => item.id === chamberId);
return match?.name ?? chamberId;
};

const MyGovernance: React.FC = () => {
const [gov, setGov] = useState<GetMyGovernanceResponse | null>(null);
const [chambers, setChambers] = useState<ChamberDto[] | null>(null);
Expand All @@ -160,6 +173,15 @@ const MyGovernance: React.FC = () => {
const [loadError, setLoadError] = useState<string | null>(null);
const [legitimacyPending, setLegitimacyPending] = useState(false);
const [legitimacyError, setLegitimacyError] = useState<string | null>(null);
const [delegationDrafts, setDelegationDrafts] = useState<
Record<string, string>
>({});
const [delegationPendingByChamber, setDelegationPendingByChamber] = useState<
Record<string, boolean>
>({});
const [delegationErrorByChamber, setDelegationErrorByChamber] = useState<
Record<string, string | null>
>({});
const [nowMs, setNowMs] = useState<number>(() => Date.now());

useEffect(() => {
Expand All @@ -182,6 +204,15 @@ const MyGovernance: React.FC = () => {
setChambers(chambersRes.items);
setClock(clockRes);
setCmSummary(cmRes);
setDelegationDrafts((current) => {
const next = { ...current };
for (const item of govRes.delegation.chambers) {
if (next[item.chamberId] === undefined) {
next[item.chamberId] = item.delegateeAddress ?? "";
}
}
return next;
});
setLoadError(null);
} catch (error) {
if (!active) return;
Expand Down Expand Up @@ -256,6 +287,90 @@ const MyGovernance: React.FC = () => {
triggerThresholdPercent: 33.3,
};

const updateDelegationDraft = (chamberId: string, value: string) => {
setDelegationDrafts((current) => ({
...current,
[chamberId]: value,
}));
};

const refreshGovernance = async () => {
const fresh = await apiMyGovernance();
setGov(fresh);
setDelegationDrafts((current) => {
const next = { ...current };
for (const item of fresh.delegation.chambers) {
next[item.chamberId] = item.delegateeAddress ?? "";
}
return next;
});
};

const handleDelegationSet = async (chamberId: string) => {
const delegateeAddress = delegationDrafts[chamberId]?.trim() ?? "";
if (!delegateeAddress) {
setDelegationErrorByChamber((current) => ({
...current,
[chamberId]: "Enter an address to delegate to.",
}));
return;
}
try {
setDelegationPendingByChamber((current) => ({
...current,
[chamberId]: true,
}));
setDelegationErrorByChamber((current) => ({
...current,
[chamberId]: null,
}));
await apiDelegationSet({
chamberId,
delegateeAddress,
idempotencyKey: crypto.randomUUID(),
});
await refreshGovernance();
} catch (error) {
setDelegationErrorByChamber((current) => ({
...current,
[chamberId]: formatLoadError((error as Error).message),
}));
} finally {
setDelegationPendingByChamber((current) => ({
...current,
[chamberId]: false,
}));
}
};

const handleDelegationClear = async (chamberId: string) => {
try {
setDelegationPendingByChamber((current) => ({
...current,
[chamberId]: true,
}));
setDelegationErrorByChamber((current) => ({
...current,
[chamberId]: null,
}));
await apiDelegationClear({
chamberId,
idempotencyKey: crypto.randomUUID(),
});
await refreshGovernance();
} catch (error) {
setDelegationErrorByChamber((current) => ({
...current,
[chamberId]: formatLoadError((error as Error).message),
}));
} finally {
setDelegationPendingByChamber((current) => ({
...current,
[chamberId]: false,
}));
}
};

const handleLegitimacyToggle = async () => {
try {
setLegitimacyPending(true);
Expand All @@ -264,8 +379,7 @@ const MyGovernance: React.FC = () => {
active: !legitimacy.objecting,
idempotencyKey: crypto.randomUUID(),
});
const fresh = await apiMyGovernance();
setGov(fresh);
await refreshGovernance();
} catch (error) {
setLegitimacyError(formatLoadError((error as Error).message));
} finally {
Expand Down Expand Up @@ -677,6 +791,110 @@ const MyGovernance: React.FC = () => {
</CardContent>
</Card>

<Card>
<CardHeader className="pb-2">
<CardTitle>Delegation</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{gov?.delegation.chambers.length ? (
<div className="grid gap-3 md:grid-cols-2">
{gov.delegation.chambers.map((item) => {
const pending =
delegationPendingByChamber[item.chamberId] ?? false;
const currentValue =
delegationDrafts[item.chamberId] ??
item.delegateeAddress ??
"";
return (
<Surface
key={item.chamberId}
variant="panelAlt"
radius="2xl"
shadow="tile"
className="space-y-3 p-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<Kicker>
{chamberLabel(item.chamberId, chambers)}
</Kicker>
<p className="mt-1 text-sm font-semibold text-text">
Current delegate
</p>
{item.delegateeAddress ? (
<AddressInline
address={item.delegateeAddress}
textClassName="text-sm text-muted"
/>
) : (
<p className="text-sm text-muted">No delegate set</p>
)}
</div>
<Badge variant="outline">
Inbound weight {item.inboundWeight}
</Badge>
</div>

<div className="space-y-2">
<Input
value={currentValue}
onChange={(event) =>
updateDelegationDraft(
item.chamberId,
event.target.value,
)
}
placeholder="Delegate address"
disabled={pending}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={() =>
void handleDelegationSet(item.chamberId)
}
disabled={pending || currentValue.trim().length === 0}
>
{item.delegateeAddress
? "Update delegate"
: "Set delegate"}
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
void handleDelegationClear(item.chamberId)
}
disabled={pending || item.delegateeAddress === null}
>
Clear
</Button>
</div>
</div>

{delegationErrorByChamber[item.chamberId] ? (
<p className="text-sm text-danger">
{delegationErrorByChamber[item.chamberId]}
</p>
) : null}
</Surface>
);
})}
</div>
) : (
<Surface
variant="panelAlt"
radius="2xl"
shadow="tile"
className="px-4 py-3 text-sm text-muted"
>
Delegation becomes available once you are participating in chamber
governance.
</Surface>
)}
</CardContent>
</Card>

<Card>
<CardHeader className="pb-2">
<CardTitle>System legitimacy</CardTitle>
Expand Down
37 changes: 33 additions & 4 deletions src/pages/chambers/Chamber.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,12 @@ const Chamber: React.FC = () => {
String(gov.acm).includes(term) ||
String(gov.lcm).includes(term) ||
String(gov.mcm).includes(term) ||
String(gov.delegatedWeight).includes(term),
String(gov.delegatedWeight).includes(term) ||
String(gov.effectiveVotingPower).includes(term) ||
gov.delegateeAddress?.toLowerCase().includes(term) ||
gov.inboundDelegators.some((address) =>
address.toLowerCase().includes(term),
),
);
}, [data, governorSearch]);

Expand Down Expand Up @@ -969,14 +974,38 @@ const Chamber: React.FC = () => {
<p className="text-xs text-muted">
<TierLabel tier={gov.tier} /> · {gov.focus}
</p>
<p className="text-xs text-muted">
Vote power {gov.effectiveVotingPower}
{gov.delegatedWeight > 0
? ` · Carries +${gov.delegatedWeight} delegated`
: ""}
</p>
<p className="text-xs text-muted">
ACM {gov.acm.toLocaleString()} · LCM{" "}
{gov.lcm.toLocaleString()} · MCM{" "}
{gov.mcm.toLocaleString()}
{gov.delegatedWeight > 0
? ` · Delegated +${gov.delegatedWeight}`
: ""}
</p>
{gov.delegateeAddress ? (
<div className="mt-1 flex items-center gap-1 text-xs text-muted">
<span>Delegates to</span>
<AddressInline
address={gov.delegateeAddress}
showCopy={false}
/>
</div>
) : null}
{gov.inboundDelegators.length > 0 ? (
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs text-muted">
<span>Backed by</span>
{gov.inboundDelegators.map((address) => (
<AddressInline
key={`${gov.id}-${address}`}
address={address}
showCopy={false}
/>
))}
</div>
) : null}
</div>
<Button asChild size="sm" variant="ghost">
<Link to={`/app/human-nodes/${gov.id}`}>Profile</Link>
Expand Down
Loading
Loading