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
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const GovernanceSection = () => {
return (
<div className="bg-background flex min-h-screen flex-col">
<TheSectionLayout
title="Governance"
title="Proposals"
icon={<Building2 className="section-layout-icon" />}
description="View and vote on executable proposals from this DAO."
>
Expand All @@ -77,23 +77,27 @@ export const GovernanceSection = () => {
return (
<div className="bg-background flex min-h-screen flex-col">
<TheSectionLayout
title="Governance"
title="Proposals"
icon={<Landmark className="section-layout-icon" />}
description="View and vote on executable proposals from this DAO."
className="lg:bg-transparent"
>
<div className="flex-1">
{loading && proposals.length === 0 ? (
{loading && proposals.length === 0 && (
<div className="flex flex-col gap-2">
{Array.from({ length: 10 }).map((_, index) => (
<ProposalItemSkeleton key={index} />
))}
</div>
) : proposals.length === 0 ? (
)}

{!loading && proposals.length === 0 && (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground mb-4">No proposals found</p>
</div>
) : (
)}

{proposals.length > 0 && (
<>
<div className="flex flex-col gap-2 space-y-0">
{proposals.map((proposal) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,36 @@ import { useState } from "react";

import { useDecodeCalldata } from "@/features/governance/hooks/useDecodeCalldata";
import { BlankSlate, Button } from "@/shared/components";
import { EnsAvatar } from "@/shared/components/design-system/avatars/ens-avatar/EnsAvatar";
import { DefaultLink } from "@/shared/components/design-system/links/default-link";
import daoConfigByDaoId from "@/shared/dao-config";
import type { DaoIdEnum } from "@/shared/types/daos";

const ETH_ADDRESS_REGEX = /(0x[0-9a-fA-F]{40})(?![0-9a-fA-F])/g;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add a left boundary when extracting calldata addresses

The new ETH_ADDRESS_REGEX only checks the right boundary, so it can match the last 40 hex chars inside longer values (for example a 64-hex bytes32 hash) and treat them as Ethereum addresses. In decoded calldata that includes hashes or packed bytes, this will render incorrect ENS names and mutate the displayed payload, which can mislead users reviewing proposal actions.

Useful? React with 👍 / 👎.


const isEthAddress = (segment: string) => /^0x[0-9a-fA-F]{40}$/.test(segment);

const CalldataWithEns = ({ text }: { text: string }) => {
const segments = text.split(ETH_ADDRESS_REGEX).filter(Boolean);

return (
<>
{segments.map((segment, i) =>
isEthAddress(segment) ? (
<EnsAvatar
key={`ens-${i}`}
address={segment as `0x${string}`}
showAvatar={false}
nameClassName="text-secondary font-mono text-sm font-normal not-italic leading-5"
Comment on lines +26 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve full calldata addresses in ENS-rendered output

Rendering decoded calldata addresses through EnsAvatar without showFullAddress causes unresolved addresses to be shortened (e.g. 0x1234…abcd) instead of showing the exact 42-char value, so proposal actions can display ambiguous or indistinguishable recipients when multiple addresses share the same prefix/suffix. This regresses the fidelity of the calldata viewer and can mislead users validating execution targets even though the copied text still contains full addresses.

Useful? React with 👍 / 👎.

/>
) : (
<span key={`text-${i}`}>{segment}</span>
),
)}
</>
);
};

export const ActionsTabContent = ({
proposal,
}: {
Expand Down Expand Up @@ -90,30 +116,40 @@ const ActionItem = ({
</div>
<div className="flex w-full flex-col gap-3 p-3">
<div className="flex w-full gap-2">
<p className="min-w-[88px] font-mono text-sm font-normal not-italic leading-5">
<p className="min-w-22 font-mono text-sm font-normal not-italic leading-5">
target:
</p>
<DefaultLink
href={`${blockExplorerUrl}/address/${target}`}
openInNewTab
className="text-secondary break-all font-mono text-sm font-normal not-italic leading-5"
className="font-mono text-sm font-normal not-italic leading-5"
>
{target}
{target && (
<EnsAvatar
address={target as `0x${string}`}
showAvatar={false}
nameClassName="text-secondary font-mono text-sm font-normal not-italic leading-5"
/>
)}
</DefaultLink>
</div>
<div className="flex w-full gap-2">
<p className="min-w-[88px] shrink-0 font-mono text-sm font-normal not-italic leading-5">
<p className="min-w-22 shrink-0 font-mono text-sm font-normal not-italic leading-5">
calldata:
</p>
<div className="border-border-contrast relative min-w-0 flex-1 border">
<div className="scrollbar-thin max-h-[248px] overflow-y-auto p-3">
<p
className={`text-secondary font-mono text-sm font-normal not-italic leading-5 ${
isDecoded && !isLoading ? "whitespace-pre-wrap" : "break-all"
} ${isLoading ? "animate-pulse" : ""}`}
>
{displayCalldata}
</p>
<div className="scrollbar-thin max-h-62 overflow-y-auto p-3">
{isDecoded && !isLoading && decodedCalldata ? (
<div className="text-secondary whitespace-pre-wrap font-mono text-sm font-normal not-italic leading-5">
<CalldataWithEns text={decodedCalldata} />
</div>
) : (
<p
className={`text-secondary break-all font-mono text-sm font-normal not-italic leading-5 ${isLoading ? "animate-pulse" : ""}`}
>
{displayCalldata}
</p>
)}
</div>
{calldata && (
<Button
Expand All @@ -129,7 +165,7 @@ const ActionItem = ({
</div>
</div>
<div className="flex w-full gap-2">
<p className="min-w-[88px] font-mono text-sm font-normal not-italic leading-5">
<p className="min-w-22 font-mono text-sm font-normal not-italic leading-5">
value:
</p>
<p className="text-secondary font-mono text-sm font-normal not-italic leading-5">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export const getStatusText = (status: ProposalStatus) => {
case ProposalStatus.PENDING_EXECUTION:
return "Pending Execution";
case ProposalStatus.SUCCEEDED:
return "Succeeded";
return "Pending Queue";
case ProposalStatus.EXPIRED:
return "Expired";
case ProposalStatus.NO_QUORUM:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import type { GetProposalQuery } from "@anticapture/graphql-client";
import { Loader } from "lucide-react";
import { useParams } from "next/navigation";

import { ProposalTimeline } from "@/features/governance/components/proposal-overview/ProposalTimeline";
import daoConfigByDaoId from "@/shared/dao-config";
import type { DaoIdEnum } from "@/shared/types/daos";

export const ProposalStatusSection = ({
proposal,
}: {
proposal: NonNullable<GetProposalQuery["proposal"]>;
}) => {
const { daoId } = useParams<{ daoId: string }>();
const daoIdKey = daoId?.toUpperCase() as DaoIdEnum;
const blockExplorerUrl =
daoConfigByDaoId[daoIdKey]?.daoOverview?.chain?.blockExplorers?.default
?.url ?? "https://etherscan.io";

return (
<div className="border-border-default flex w-full flex-col gap-3 border p-3">
<div className="flex items-center gap-2">
Expand All @@ -17,7 +26,10 @@ export const ProposalStatusSection = ({
</p>
</div>

<ProposalTimeline proposal={proposal} />
<ProposalTimeline
proposal={proposal}
blockExplorerUrl={blockExplorerUrl}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import type { GetProposalQuery } from "@anticapture/graphql-client";
import { ExternalLink } from "lucide-react";
import Link from "next/link";

interface ProposalTimelineProps {
proposal: NonNullable<GetProposalQuery["proposal"]>;
blockExplorerUrl?: string;
}

export const ProposalTimeline = ({
proposal,
}: {
proposal: NonNullable<GetProposalQuery["proposal"]>;
}) => {
blockExplorerUrl = "https://etherscan.io",
}: ProposalTimelineProps) => {
const now = Date.now() / 1000;
const createdTime = parseInt(proposal.timestamp);
const startTime = parseInt(proposal.startTimestamp);
Expand All @@ -26,25 +32,59 @@ export const ProposalTimeline = ({
return "pending";
};

const proposalStatus = proposal.status.toLowerCase();
const isQueued =
proposalStatus === "queued" ||
proposalStatus === "pending_execution" ||
proposalStatus === "executed";
Comment on lines +36 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include succeeded proposals in queued timeline state

The timeline logic omits succeeded from isQueued, so proposals that are now labeled as pending queue never render a queued milestone at all. In practice, when a proposal has passed but not yet been queued, the UI stops at Ended, which misrepresents the current workflow stage and makes it look like nothing remains. This should treat succeeded as a queued-stage proposal (with pending state) so the timeline matches the status shown elsewhere.

Useful? React with 👍 / 👎.

Comment thread
brunod-e marked this conversation as resolved.
const isExecuted = proposalStatus === "executed";

const timelineItems = [
{
label: "Created",
timestamp: createdTime,
date: formatTimestamp(createdTime),
status: getTimelineItemStatus(createdTime),
txLink: proposal.txHash
? `${blockExplorerUrl}/tx/${proposal.txHash}`
: undefined,
},
{
label: startTime <= now ? "Started" : "Starts",
timestamp: startTime,
date: formatTimestamp(startTime),
status: getTimelineItemStatus(startTime),
txLink: undefined,
},
{
label: endTime <= now ? "Ended" : "Ends",
timestamp: endTime,
date: formatTimestamp(endTime),
status: getTimelineItemStatus(endTime),
txLink: undefined,
},
...(isQueued
? [
{
label: "Queued",
timestamp: endTime + 1,
date: undefined as string | undefined,
status: "completed" as const,
txLink: undefined,
},
]
: []),
...(isExecuted
? [
{
label: "Executed",
timestamp: endTime + 2,
date: undefined as string | undefined,
status: "completed" as const,
txLink: undefined,
},
]
: []),
];

const getTimelineItemBgColor = (index: number) => {
Expand Down Expand Up @@ -89,11 +129,23 @@ export const ProposalTimeline = ({
</div>

{/* Timeline content */}
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<p className={`font-roboto-mono text-[13px]`}>
<span className="text-primary">{item.label}</span>{" "}
<span className="text-secondary">on {item.date}</span>
{item.date && (
<span className="text-secondary">on {item.date}</span>
)}
</p>
{item.txLink && (
<Link
href={item.txLink}
target="_blank"
rel="noopener noreferrer"
className="text-secondary hover:text-primary transition-colors"
>
<ExternalLink className="size-3" />
</Link>
)}
</div>
</div>
{index < timelineItems.length - 1 && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,25 @@ import { useVotes } from "@/features/governance/hooks/useVotes";
import { SkeletonRow, Button, BlankSlate } from "@/shared/components";
import { CopyAndPasteButton } from "@/shared/components/buttons/CopyAndPasteButton";
import { EnsAvatar } from "@/shared/components/design-system/avatars/ens-avatar/EnsAvatar";
import { AddressFilter } from "@/shared/components/design-system/table/filters/AddressFilter";
import {
CategoriesFilter,
type FilterOption,
} from "@/shared/components/design-system/table/filters/CategoriesFilter";
import { Tooltip } from "@/shared/components/design-system/tooltips/Tooltip";
import { ArrowUpDown, ArrowState } from "@/shared/components/icons";
import { PERCENTAGE_NO_BASELINE } from "@/shared/constants/api";
import daoConfigByDaoId from "@/shared/dao-config";
import type { DaoIdEnum } from "@/shared/types/daos";
import { cn, formatNumberUserReadable } from "@/shared/utils";

const choiceFilterOptions: FilterOption[] = [
{ value: "all", label: "All Votes" },
{ value: "1", label: "For" },
{ value: "0", label: "Against" },
{ value: "2", label: "Abstain" },
];

interface TabsVotedContentProps {
proposal: NonNullable<GetProposalQuery["proposal"]>;
onAddressClick?: (address: string) => void;
Expand All @@ -44,11 +56,16 @@ export const TabsVotedContent = ({
const [sortBy, setSortBy] = useState<string>("votingPower");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");

// State for filters
const [choiceFilter, setChoiceFilter] = useState<string>("all");
const [voterFilter, setVoterFilter] = useState<string | undefined>(undefined);

const supportValue = choiceFilter === "all" ? null : Number(choiceFilter);

// Handle sorting
const handleSort = useCallback(
(field: string) => {
if (sortBy === field) {
// Toggle direction if same field
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortBy(field);
Expand All @@ -63,12 +80,14 @@ export const TabsVotedContent = ({
useVotes({
proposalId: proposal.id,
daoId: (daoId as string)?.toUpperCase() as DaoIdEnum,
limit: 10, // Load 10 items at a time
limit: 10,
proposalStartTimestamp: proposal.startTimestamp
? Number(proposal.startTimestamp) * 1000
: undefined,
orderBy: sortBy,
orderDirection: sortDirection,
support: supportValue,
voterAddress: voterFilter ?? null,
});

// Intersection observer on the loading row
Expand Down Expand Up @@ -162,8 +181,12 @@ export const TabsVotedContent = ({
);
},
header: () => (
<div className="text-table-header flex h-8 w-full items-center justify-start px-2">
<div className="text-table-header flex h-8 w-full items-center justify-start gap-1 px-2">
<p>Voter</p>
<AddressFilter
onApply={(addr) => setVoterFilter(addr)}
currentFilter={voterFilter}
/>
</div>
),
},
Expand Down Expand Up @@ -241,8 +264,13 @@ export const TabsVotedContent = ({
);
},
header: () => (
<div className="text-table-header flex h-8 w-full items-center justify-start px-2">
<div className="text-table-header flex h-8 w-full items-center justify-start gap-1 px-2">
<p>Choice</p>
<CategoriesFilter
options={choiceFilterOptions}
selectedValue={choiceFilter}
onValueChange={setChoiceFilter}
/>
</div>
),
},
Expand Down Expand Up @@ -578,7 +606,16 @@ export const TabsVotedContent = ({
),
},
],
[proposal, handleSort, sortBy, sortDirection, daoId, onAddressClick],
[
proposal,
handleSort,
sortBy,
sortDirection,
daoId,
onAddressClick,
voterFilter,
choiceFilter,
],
);

// Prepare table data with description rows and loading row if needed
Expand Down
Loading
Loading