Skip to content

Commit

Permalink
Adds snapshot votes to ENS
Browse files Browse the repository at this point in the history
* Add prisma model for Snapshot Votes table

* Add hello-world to query teh snapshot vote table

* Minor refactor

* Adds snapshot votes as a tab

* Recovers ens contract from bang

* Moves to dropdown based selector which is more compatible with existing UI

* Adds 'gimmicks' (animation lol)

* Adds tenant config

* Adds empty votes placeholder for snapshot

---------

Co-authored-by: Michael Gingras <mcg79@cornell.edu>
  • Loading branch information
jefag and mcgingras authored Jul 22, 2024
1 parent 3173be7 commit a536c19
Show file tree
Hide file tree
Showing 8 changed files with 388 additions and 25 deletions.
22 changes: 22 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,28 @@ model SnapshotProposal {
@@schema("snapshot")
}

model SnapshotVotes {
id String @id @unique
voter String? // enforced lower case at ingestion
created Int?
choice String?
metadata Json?
reason String?
app String?
vp Float?
vp_by_strategy Json?
vp_state String?
proposal_id String?
choice_labels Json?
dao_slug String?
@@index([id], name: "ix_snapshot_votes_id")
@@schema("snapshot")
@@map("votes")
}



model api_user {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
api_key String? @unique @default(dbgenerated("gen_random_uuid()"))
Expand Down
74 changes: 73 additions & 1 deletion src/app/api/common/votes/getVotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { PaginatedResult, paginateResult } from "@/app/lib/pagination";
import { parseProposalData } from "@/lib/proposalUtils";
import { parseVote } from "@/lib/voteUtils";
import { cache } from "react";
import { Vote, VotePayload, VotesSort } from "./vote";
import { SnapshotVote, Vote, VotePayload, VotesSort } from "./vote";
import prisma from "@/app/lib/prisma";
import { addressOrEnsNameWrap } from "../utils/ensName";
import Tenant from "@/lib/tenant/tenant";
Expand All @@ -18,6 +18,78 @@ const getVotesForDelegate = ({
page,
});

export const getSnapshotVotesForDelegate = async ({
addressOrENSName,
page,
}: {
addressOrENSName: string;
page: number;
}) => {
return await addressOrEnsNameWrap(
getSnapshotVotesForDelegateForAddress,
addressOrENSName,
{
page,
}
);
};

async function getSnapshotVotesForDelegateForAddress({
address,
page = 1,
}: {
address: string;
page?: number;
}) {
const { slug } = Tenant.current();
const pageSize = 10;

const queryFunction = (skip: number, take: number) => {
const query = `
SELECT "vote".id,
"vote".voter,
"vote".created,
"vote".choice,
"vote".metadata,
"vote".reason,
"vote".app,
"vote".vp,
"vote".vp_by_strategy,
"vote".vp_state,
"vote".proposal_id,
"vote".choice_labels,
"proposal".title
FROM "snapshot".votes as "vote"
INNER JOIN "snapshot".proposals AS "proposal" ON "vote".proposal_id = "proposal".id
WHERE "vote".dao_slug = '${slug}'
AND "vote".voter = '${address}'
ORDER BY "vote".created DESC
OFFSET ${skip}
LIMIT ${take};
`;
// console.log("Executing query:", query);
return prisma.$queryRawUnsafe<SnapshotVote[]>(query, skip, take);
};

const { meta, data: votes } = await paginateResult(
queryFunction,
page,
pageSize
);

if (!votes || votes.length === 0) {
return {
meta,
votes: [],
};
} else {
return {
meta,
votes,
};
}
}

async function getVotesForDelegateForAddress({
address,
page = 1,
Expand Down
16 changes: 16 additions & 0 deletions src/app/api/common/votes/vote.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,19 @@ export type Vote = {
proposalType: ProposalType;
timestamp: Date | null;
};

export type SnapshotVote = {
id: string;
voter?: string;
created?: number;
choice?: string;
metadata?: Record<string, any>;
reason?: string;
app?: string;
vp?: number;
vp_by_strategy?: Record<string, any>;
vp_state?: string;
proposal_id?: string;
choice_labels?: Record<string, any>;
dao_slug?: string;
};
99 changes: 75 additions & 24 deletions src/app/delegates/[addressOrENSName]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
fetchDelegate,
fetchVotesForDelegate,
} from "@/app/delegates/actions";
import { getSnapshotVotesForDelegate } from "@/app/api/common/votes/getVotes";
import { formatNumber } from "@/lib/tokenUtils";
import {
processAddressOrEnsName,
Expand All @@ -24,6 +25,8 @@ import {
} from "@/app/lib/ENSUtils";
import Tenant from "@/lib/tenant/tenant";
import TopStakeholders from "@/components/Delegates/DelegateStatement/TopStakeholders";
import SnapshotVotes from "@/components/Delegates/DelegateVotes/SnapshotVotes";
import VotesContainer from "@/components/Delegates/DelegateVotes/VotesContainer";

export async function generateMetadata(
{ params }: { params: { addressOrENSName: string } },
Expand Down Expand Up @@ -86,13 +89,20 @@ export default async function Page({
}: {
params: { addressOrENSName: string };
}) {
const { ui } = Tenant.current();
const tenantSupportsSnapshotVote = ui.toggle("snapshotVotes") || false;

const address = (await resolveENSName(addressOrENSName)) || addressOrENSName;
const [delegate, delegateVotes, delegates, delegators] = await Promise.all([
fetchDelegate(address),
fetchVotesForDelegate(address),
fetchCurrentDelegatees(address),
fetchCurrentDelegators(address),
]);
const [delegate, delegateVotes, delegates, delegators, snapshotVotes] =
await Promise.all([
fetchDelegate(address),
fetchVotesForDelegate(address),
fetchCurrentDelegatees(address),
fetchCurrentDelegators(address),
tenantSupportsSnapshotVote
? getSnapshotVotesForDelegate({ addressOrENSName: address, page: 1 })
: Promise.resolve({ meta: { total: 0 }, votes: [] }),
]);

const statement = delegate.statement;

Expand Down Expand Up @@ -122,25 +132,66 @@ export default async function Page({
)}

<DelegationsContainer delegatees={delegates} delegators={delegators} />
<DelegateVotesProvider initialVotes={delegateVotes}>
{delegateVotes && delegateVotes.votes.length > 0 ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col justify-between gap-2 sm:flex-row">
<h2 className="text-2xl font-bold">Past Votes</h2>
{tenantSupportsSnapshotVote ? (
<VotesContainer
onchainVotes={
<DelegateVotesProvider initialVotes={delegateVotes}>
{delegateVotes && delegateVotes.votes.length > 0 ? (
<div className="flex flex-col gap-4">
<DelegateVotes
fetchDelegateVotes={async (page: number) => {
"use server";
return fetchVotesForDelegate(addressOrENSName, page);
}}
/>
</div>
) : (
<div className="default-message-class">
<p>No past votes available.</p>
</div>
)}
</DelegateVotesProvider>
}
snapshotVotes={
<>
{snapshotVotes && snapshotVotes.votes.length > 0 ? (
<SnapshotVotes
meta={snapshotVotes.meta}
initialVotes={snapshotVotes.votes}
fetchSnapshotVotes={async (page: number) => {
"use server";
return await getSnapshotVotesForDelegate({
addressOrENSName: addressOrENSName,
page: page,
});
}}
/>
) : (
<div className="default-message-class">
<p>No past votes available.</p>
</div>
)}
</>
}
/>
) : (
<DelegateVotesProvider initialVotes={delegateVotes}>
{delegateVotes && delegateVotes.votes.length > 0 ? (
<div className="flex flex-col gap-4">
<DelegateVotes
fetchDelegateVotes={async (page: number) => {
"use server";
return fetchVotesForDelegate(addressOrENSName, page);
}}
/>
</div>
<DelegateVotes
fetchDelegateVotes={async (page: number) => {
"use server";
return fetchVotesForDelegate(addressOrENSName, page);
}}
/>
</div>
) : (
<div className="default-message-class">
<p>No past votes available.</p>
</div>
)}
</DelegateVotesProvider>
) : (
<div className="default-message-class">
<p>No past votes available.</p>
</div>
)}
</DelegateVotesProvider>
)}
</div>
</div>
);
Expand Down
110 changes: 110 additions & 0 deletions src/components/Delegates/DelegateVotes/SnapshotVotes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use client";

import { useRef, useState } from "react";
import { HStack, VStack } from "../../Layout/Stack";
import { formatDistanceToNow } from "date-fns";
import InfiniteScroll from "react-infinite-scroller";
import styles from "./delegateVotes.module.scss";
import { pluralizeSnapshotVote } from "@/lib/tokenUtils";

const propHeader = (vote: any) => {
let headerString = "Snapshot vote - ";
headerString += `${formatDistanceToNow(new Date(vote.created * 1000))} ago`;
return headerString;
};

const VoteDetails = ({ vote }: { vote: any }) => {
const choiceLabels = vote.choice_labels;
const isFor = choiceLabels[0] === "For";
const isAgainst = choiceLabels[0] === "Against";

return (
<div
className={`text-xs mt-1 font-medium space-x-[3px] ${isFor ? "text-green-700" : isAgainst ? "text-red-700" : "text-stone-500"}`}
>
{choiceLabels.map((label: string, idx: number) => {
return (
<span key={`{choice-${idx}}`}>
{label}
{idx < choiceLabels.length - 1 ? ", " : ""}
</span>
);
})}
<span>{pluralizeSnapshotVote(BigInt(Math.trunc(vote.vp)))}</span>
</div>
);
};

export default function SnapshotVotes({
meta,
initialVotes,
fetchSnapshotVotes,
}: {
meta: any;
initialVotes: any;
fetchSnapshotVotes: any;
}) {
const [snapshotVotes, setSnapshotVotes] = useState(initialVotes);
const [snapshotMeta, setSnapshotMeta] = useState(meta);

const fetching = useRef(false);

const loadMore = async () => {
if (!fetching.current && snapshotMeta?.hasNextPage) {
fetching.current = true;
const data = await fetchSnapshotVotes(snapshotMeta?.currentPage + 1);
setSnapshotMeta(data.snapshotMeta);
setSnapshotVotes((prev: any) => prev.concat(data.votes));
fetching.current = false;
}
};

return (
<InfiniteScroll
hasMore={snapshotMeta?.hasNextPage}
pageStart={0}
loadMore={loadMore}
useWindow={false}
loader={
<div key={0}>
<HStack
key="loader"
className="gl_loader justify-center py-6 text-sm text-stone-500"
>
Loading...
</HStack>
</div>
}
element="main"
className="divide-y divide-gray-300 overflow-hidden bg-white shadow-newDefault ring-1 ring-gray-300 rounded-xl"
>
{snapshotVotes.map(
(vote: any, idx: number) =>
vote && (
<div key={`vote-${idx}`}>
<div className={styles.details_container}>
<VStack className={styles.details_sub}>
<div className="flex flex-row justify-between">
<div className="flex flex-col flex-1 pr-4">
<span className="text-[#66676b] text-xs font-medium">
{`${propHeader(vote)}`}
</span>
<h2 className="px-0 pt-1 overflow-hidden text-base text-black text-ellipsis">
{vote.title}
</h2>
<VoteDetails vote={vote} />
</div>
<div className="flex-1 border-l border-stone-100 pl-4">
<span className="text-xs text-stone-500 leading-5 block">
{vote.reason}
</span>
</div>
</div>
</VStack>
</div>
</div>
)
)}
</InfiniteScroll>
);
}
Loading

0 comments on commit a536c19

Please sign in to comment.