diff --git a/garage/src/hooks/useOwnerVotes.ts b/garage/src/hooks/useOwnerVotes.ts new file mode 100644 index 00000000..3c9fc311 --- /dev/null +++ b/garage/src/hooks/useOwnerVotes.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import { selectOwnerVotes } from "../utils/queries"; +import { useTablelandConnection } from "./useTablelandConnection"; +import { ProposalWithOptions } from "../types"; + +export interface Vote { + proposal: Pick; + ft: number; + choices: { optionId: string; weight: number; comment?: string }[]; +} + +export const useOwnerVotes = (owner?: string) => { + const { db } = useTablelandConnection(); + + const [votes, setVotes] = useState(); + + useEffect(() => { + if (!owner) return; + + let isCancelled = false; + + db.prepare(selectOwnerVotes(owner)) + .all() + .then(({ results }) => { + if (isCancelled) return; + + setVotes(results); + }); + + return () => { + isCancelled = true; + }; + }, [owner, setVotes]); + + return { votes }; +}; diff --git a/garage/src/hooks/useRigImageUrls.ts b/garage/src/hooks/useRigImageUrls.ts index 6e15be3d..f4b990b0 100644 --- a/garage/src/hooks/useRigImageUrls.ts +++ b/garage/src/hooks/useRigImageUrls.ts @@ -1,10 +1,11 @@ import { Rig } from "../types"; -const ipfsGatewayBaseUrl = "https://nftstorage.link"; - const ipfsUriToGatewayUrl = (ipfsUri: string): string => { - const cidAndPath = ipfsUri.match(/^ipfs:\/\/(.*)$/)![1]; - return `${ipfsGatewayBaseUrl}/ipfs/${cidAndPath}`; + const match = ipfsUri.match(/^ipfs:\/\/([a-zA-Z0-9]*)\/(.*)$/); + if (!match) return ""; + + const [, cid, path] = match; + return `https://${cid}.ipfs.nftstorage.link/${path}`; }; interface RigImageUrls { diff --git a/garage/src/pages/OwnerDetails/index.tsx b/garage/src/pages/OwnerDetails/index.tsx index 2dd190fa..d30066f2 100644 --- a/garage/src/pages/OwnerDetails/index.tsx +++ b/garage/src/pages/OwnerDetails/index.tsx @@ -6,12 +6,14 @@ import { useOwnedRigs } from "../../hooks/useOwnedRigs"; import { useOwnerPilots } from "../../hooks/useOwnerPilots"; import { useOwnerActivity } from "../../hooks/useOwnerActivity"; import { useOwnerFTRewards } from "../../hooks/useOwnerFTRewards"; +import { useOwnerVotes } from "../../hooks/useOwnerVotes"; import { useNFTsCached } from "../../components/NFTsContext"; import { TOPBAR_HEIGHT } from "../../Topbar"; import { RigsGrid } from "./modules/RigsInventory"; import { ActivityLog } from "./modules/Activity"; import { Pilots } from "./modules/Pilots"; import { FTRewards } from "./modules/FTRewards"; +import { Votes } from "./modules/Votes"; import { prettyNumber } from "../../utils/fmt"; import { isValidAddress } from "../../utils/types"; @@ -45,6 +47,7 @@ export const OwnerDetails = () => { const { events } = useOwnerActivity(owner); const { nfts } = useNFTsCached(pilots); const { rewards } = useOwnerFTRewards(owner); + const { votes } = useOwnerVotes(owner); const { data: ens } = useEnsName({ address: isValidAddress(owner) ? owner : undefined, @@ -91,7 +94,7 @@ export const OwnerDetails = () => { width="100%" align={{ base: "stretch", lg: "start" }} > - + { gap={GRID_GAP} flexGrow="1" /> - + + - + diff --git a/garage/src/pages/OwnerDetails/modules/RigsInventory.tsx b/garage/src/pages/OwnerDetails/modules/RigsInventory.tsx index ec7cf4a7..b1e32502 100644 --- a/garage/src/pages/OwnerDetails/modules/RigsInventory.tsx +++ b/garage/src/pages/OwnerDetails/modules/RigsInventory.tsx @@ -24,7 +24,7 @@ interface RigsGridProps extends React.ComponentProps { export const RigsGrid = ({ rigs, nfts, gap, p, ...props }: RigsGridProps) => { return ( - Rigs {rigs?.length && ` (${rigs.length})`} + Rigs ({rigs?.length ?? 0}) { })} {rigs?.length === 0 && ( - + This wallet doesn't own any Rigs. )} diff --git a/garage/src/pages/OwnerDetails/modules/Votes.tsx b/garage/src/pages/OwnerDetails/modules/Votes.tsx new file mode 100644 index 00000000..be9b35c0 --- /dev/null +++ b/garage/src/pages/OwnerDetails/modules/Votes.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { + Box, + Flex, + Heading, + Spinner, + Table, + Tbody, + Thead, + Th, + Text, + Tr, + Td, + VStack, + Show, + useBreakpointValue, +} from "@chakra-ui/react"; +import { prettyNumber } from "../../../utils/fmt"; +import { Vote } from "../../../hooks/useOwnerVotes"; +import { Link } from "react-router-dom"; + +interface VotesProps extends React.ComponentProps { + votes?: Vote[]; +} + +const truncateChoiceString = (s: string, l: number = 80) => + s.slice(0, l) + (s.length > l ? "..." : ""); + +const noBorderBottom = { borderBottom: "none" }; + +export const Votes = ({ votes, p, ...props }: VotesProps) => { + const isMobile = useBreakpointValue({ + base: true, + sm: false, + }); + + const mainRowColAttrs = isMobile ? noBorderBottom : {}; + + return ( + + Votes + + + + + + + + + + + + {votes && + votes.map(({ choices, proposal, ft }, index) => { + const { options } = proposal; + + const optionLookupMap = Object.fromEntries( + options.map(({ id, description }) => [id, description]) + ); + const choiceString = choices + .map( + ({ optionId, weight }) => + `${weight}% for ${optionLookupMap[optionId]}` + ) + .join(", "); + return ( + + + + + + + + + + + + + + + ); + })} + +
+ Proposal + Choices + Voting Power +
+ + {proposal.name} + + + {truncateChoiceString(choiceString)} + + {prettyNumber(ft)} +
+ {truncateChoiceString(choiceString)} +
+ {!votes && ( + + + + )} + {votes?.length === 0 && ( + + This wallet has not voted in any proposals yet. + + )} +
+ ); +}; diff --git a/garage/src/utils/queries.ts b/garage/src/utils/queries.ts index 58b0db19..be6ba1da 100644 --- a/garage/src/utils/queries.ts +++ b/garage/src/utils/queries.ts @@ -7,6 +7,10 @@ const { ftRewardsTable, lookupsTable, pilotSessionsTable, + proposalsTable, + optionsTable, + ftSnapshotTable, + votesTable, } = deployment; const IMAGE_IPFS_URI_SELECT = `'ipfs://'||renders_cid||'/'||(SELECT value from ${lookupsTable} WHERE label = 'image_full_name')`; @@ -446,3 +450,30 @@ export const selectFilteredRigs = ( export const selectOwnerFTRewards = (owner: string) => { return `SELECT block_num as "blockNum", recipient, reason, amount FROM ${ftRewardsTable} WHERE recipient = '${owner}' ORDER BY block_num DESC`; }; + +export const selectOwnerVotes = (owner: string) => { + return ` + SELECT + (SELECT json_object( + 'name', name, + 'id', id, + 'options', ( + SELECT json_group_array(json_object('id', id, 'description', description)) + FROM ${optionsTable} WHERE proposal_id = proposal_id + ) + ) FROM ${proposalsTable} WHERE id = proposal_id + ) as "proposal", + (SELECT ft + FROM ${ftSnapshotTable} + WHERE proposal_id = proposal_id AND lower(address) = lower(votes.address) + ) as "ft", + json_group_array(json_object( + 'optionId', option_id, 'weight', weight, 'comment', comment + )) as "choices" + FROM + ${votesTable} as "votes" + WHERE + lower(votes.address) = lower('${owner}') AND votes.weight > 0 + GROUP BY + proposal_id`; +};