diff --git a/packages/next-common/hooks/collectives/useRankedCollectiveMinRank.js b/packages/next-common/hooks/collectives/useRankedCollectiveMinRank.js new file mode 100644 index 0000000000..d337e58e17 --- /dev/null +++ b/packages/next-common/hooks/collectives/useRankedCollectiveMinRank.js @@ -0,0 +1,10 @@ +// used on ranked collective referendum detail page +import { useTrack } from "next-common/context/post/gov2/track"; +import { useRankedCollectivePallet } from "next-common/context/collectives/collectives"; +import { getMinRankOfClass } from "next-common/context/post/fellowship/useMaxVoters"; + +export default function useRankedCollectiveMinRank() { + const { id: trackId } = useTrack(); + const collectivePallet = useRankedCollectivePallet(); + return getMinRankOfClass(trackId, collectivePallet); +} diff --git a/packages/next-common/hooks/fellowship/useFellowshipMemberRank.js b/packages/next-common/hooks/fellowship/useFellowshipMemberRank.js index d11194cfaf..91835be1d6 100644 --- a/packages/next-common/hooks/fellowship/useFellowshipMemberRank.js +++ b/packages/next-common/hooks/fellowship/useFellowshipMemberRank.js @@ -1,25 +1,13 @@ -import { useContextApi } from "next-common/context/api"; -import { useEffect, useState } from "react"; +import useSubStorage from "next-common/hooks/common/useSubStorage"; export function useFellowshipMemberRank( address, pallet = "fellowshipCollective", ) { - const [rank, setRank] = useState(null); - const api = useContextApi(); - - useEffect(() => { - if (!api || !api.query[pallet]) { - return; - } - - api.query[pallet].members(address).then((resp) => { - if (!resp.isNone) { - const json = resp.value.toJSON(); - setRank(json.rank); - } - }); - }, [api, address, pallet]); - - return rank; + const { result } = useSubStorage(pallet, "members", [address]); + if (result && result.isSome) { + return result.unwrap().rank.toNumber(); + } else { + return null; + } } diff --git a/packages/next-common/utils/hooks/collectives/useCollectiveEligibleVoters.js b/packages/next-common/utils/hooks/collectives/useCollectiveEligibleVoters.js new file mode 100644 index 0000000000..31b32da301 --- /dev/null +++ b/packages/next-common/utils/hooks/collectives/useCollectiveEligibleVoters.js @@ -0,0 +1,98 @@ +import { useContextApi } from "next-common/context/api"; +import { useReferendumVotingFinishIndexer } from "next-common/context/post/referenda/useReferendumVotingFinishHeight"; +import { useEffect, useState } from "react"; +import { groupBy, orderBy } from "lodash-es"; +import { normalizeRankedCollectiveEntries } from "next-common/utils/rankedCollective/normalize"; +import { useSelector } from "react-redux"; +import { + fellowshipVotesSelector, + isLoadingFellowshipVotesSelector, +} from "next-common/store/reducers/fellowship/votes"; +import useRankedCollectiveMinRank from "next-common/hooks/collectives/useRankedCollectiveMinRank"; + +async function queryFellowshipCollectiveMembers(api, blockHash) { + let blockApi = api; + if (blockHash) { + blockApi = await api.at(blockHash); + } + return await blockApi.query.fellowshipCollective.members.entries(); +} + +function getMemberVotes(rank, minRank) { + if (rank < minRank) { + throw new Error(`Rank ${rank} is too low, and minimum rank is ${minRank}`); + } + const excess = rank - minRank; + const v = excess + 1; + return Math.floor((v * (v + 1)) / 2); +} + +export default function useCollectiveEligibleVoters() { + const api = useContextApi(); + + const [voters, setVoters] = useState({ + votedMembers: [], + unVotedMembers: [], + }); + const [loading, setLoading] = useState(true); + + const votingFinishIndexer = useReferendumVotingFinishIndexer(); + const minRank = useRankedCollectiveMinRank(); + + const { allAye, allNay } = useSelector(fellowshipVotesSelector); + const isLoadingVotes = useSelector(isLoadingFellowshipVotesSelector); + + useEffect(() => { + if (!api || isLoadingVotes) { + return; + } + + (async () => { + setLoading(true); + try { + const memberEntries = await queryFellowshipCollectiveMembers( + api, + votingFinishIndexer?.blockHash, + ); + const normalizedMembers = + normalizeRankedCollectiveEntries(memberEntries); + const voters = normalizedMembers.filter( + (member) => member?.rank >= minRank, + ); + const sortedVoters = orderBy(voters, ["rank"], ["desc"]); + const votersWithPower = sortedVoters.map((m) => ({ + ...m, + votes: getMemberVotes(m.rank, minRank), + })); + + const allVotes = [...allAye, ...allNay]; + const votedSet = new Set(allVotes.map((i) => i.address)); + const { true: votedMembers, false: unVotedMembers } = groupBy( + votersWithPower, + (member) => votedSet.has(member.address), + ); + + setVoters({ + votedMembers: votedMembers.map((m) => { + const vote = allVotes.find((i) => i.address === m.address); + return { + ...m, + votes: vote.votes, + isAye: vote.isAye, + }; + }), + unVotedMembers, + }); + } catch (error) { + console.error("Failed to fetch fellowship voters:", error); + } finally { + setLoading(false); + } + })(); + }, [api, votingFinishIndexer, isLoadingVotes, allAye, allNay, minRank]); + + return { + ...voters, + isLoading: loading, + }; +} diff --git a/packages/next-common/utils/hooks/fellowship/useFellowshipVotes.js b/packages/next-common/utils/hooks/fellowship/useFellowshipVotes.js index 28744c17e4..75915d6736 100644 --- a/packages/next-common/utils/hooks/fellowship/useFellowshipVotes.js +++ b/packages/next-common/utils/hooks/fellowship/useFellowshipVotes.js @@ -45,10 +45,9 @@ function normalizeVotingRecord(optionalRecord) { }; } -async function query(api, targetPollIndex, blockHeight) { +async function query(api, targetPollIndex, blockHash) { let blockApi = api; - if (blockHeight) { - const blockHash = await api.rpc.chain.getBlockHash(blockHeight); + if (blockHash) { blockApi = await api.at(blockHash); } @@ -74,7 +73,7 @@ async function query(api, targetPollIndex, blockHeight) { return normalized; } -export default function useFellowshipVotes(pollIndex, blockHeight) { +export default function useFellowshipVotes(pollIndex, indexer) { const api = useContextApi(); const dispatch = useDispatch(); const votesTrigger = useSelector(fellowshipVotesTriggerSelector); @@ -88,7 +87,7 @@ export default function useFellowshipVotes(pollIndex, blockHeight) { dispatch(setIsLoadingFellowshipVotes(true)); } - query(api, pollIndex, blockHeight) + query(api, pollIndex, indexer?.blockHash) .then((votes) => { const [allAye = [], allNay = []] = partition(votes, (v) => v.isAye); dispatch(setFellowshipVotes({ allAye, allNay })); @@ -99,5 +98,5 @@ export default function useFellowshipVotes(pollIndex, blockHeight) { dispatch(clearFellowshipVotes()); dispatch(clearFellowshipVotesTrigger()); }; - }, [api, pollIndex, blockHeight, votesTrigger, dispatch]); + }, [api, pollIndex, indexer, votesTrigger, dispatch]); } diff --git a/packages/next/components/fellowship/referendum/sidebar/index.js b/packages/next/components/fellowship/referendum/sidebar/index.js index b5a76cb863..4ffba9b0e2 100644 --- a/packages/next/components/fellowship/referendum/sidebar/index.js +++ b/packages/next/components/fellowship/referendum/sidebar/index.js @@ -15,11 +15,10 @@ import { isNil, isUndefined, noop } from "lodash-es"; import useRealAddress from "next-common/utils/hooks/useRealAddress"; import useSubCollectiveRank from "next-common/hooks/collectives/useSubCollectiveRank"; import { useRankedCollectivePallet } from "next-common/context/collectives/collectives"; -import { getMinRankOfClass } from "next-common/context/post/fellowship/useMaxVoters"; -import { useTrack } from "next-common/context/post/gov2/track"; import Tooltip from "next-common/components/tooltip"; import dynamic from "next/dynamic"; import AllSpendsRequest from "./request/allSpendsRequest"; +import useRankedCollectiveMinRank from "next-common/hooks/collectives/useRankedCollectiveMinRank"; const MyCollectiveVote = dynamic( () => import("next-common/components/collectives/referenda/myCollectiveVote"), @@ -28,17 +27,11 @@ const MyCollectiveVote = dynamic( }, ); -function useMinRank() { - const { id: trackId } = useTrack(); - const collectivePallet = useRankedCollectivePallet(); - return getMinRankOfClass(trackId, collectivePallet); -} - function CollectiveVote({ onClick = noop }) { const address = useRealAddress(); const collectivePallet = useRankedCollectivePallet(); const { rank, loading } = useSubCollectiveRank(address, collectivePallet); - const minRank = useMinRank(); + const minRank = useRankedCollectiveMinRank(); const disabled = !address || loading || isNil(rank) || rank < minRank; const text = loading ? "Checking permissions" : "Vote"; diff --git a/packages/next/components/fellowship/referendum/sidebar/tally/allVotes.js b/packages/next/components/fellowship/referendum/sidebar/tally/allVotes.js index c495b84c76..bc2151292d 100644 --- a/packages/next/components/fellowship/referendum/sidebar/tally/allVotes.js +++ b/packages/next/components/fellowship/referendum/sidebar/tally/allVotes.js @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, memo } from "react"; import { Button } from "components/gov2/sidebar/tally/styled"; import { useSelector } from "react-redux"; import { @@ -6,10 +6,11 @@ import { isLoadingFellowshipVotesSelector, } from "next-common/store/reducers/fellowship/votes"; import dynamicPopup from "next-common/lib/dynamic/popup"; +import { orderBy } from "lodash-es"; const AllVotesPopup = dynamicPopup(() => import("./allVotesPopup")); -export default function AllVotes() { +function AllVotes() { const [showAllVotes, setShowAllVotes] = useState(false); const { allAye, allNay } = useSelector(fellowshipVotesSelector); const isLoadingVotes = useSelector(isLoadingFellowshipVotesSelector); @@ -20,11 +21,13 @@ export default function AllVotes() { {showAllVotes && ( )} ); } + +export default memo(AllVotes); diff --git a/packages/next/components/fellowship/referendum/sidebar/tally/allVotesPopup/index.js b/packages/next/components/fellowship/referendum/sidebar/tally/allVotesPopup/index.js index fd140966aa..f89243787f 100644 --- a/packages/next/components/fellowship/referendum/sidebar/tally/allVotesPopup/index.js +++ b/packages/next/components/fellowship/referendum/sidebar/tally/allVotesPopup/index.js @@ -7,6 +7,7 @@ import Pagination from "next-common/components/pagination"; import PopupListWrapper from "next-common/components/styled/popupListWrapper"; import AddressUser from "next-common/components/user/addressUser"; import DataList from "next-common/components/dataList"; +import { FellowshipRankInfo } from "../eligibleVoters/columns"; export default function VotesPopup({ setShowVoteList, @@ -59,6 +60,7 @@ export default function VotesPopup({ function VotesList({ items = [], loading }) { const columns = [ + { name: "", style: { width: 40, textAlign: "center" } }, { name: "ACCOUNT", style: { minWidth: 176, textAlign: "left" }, @@ -70,7 +72,12 @@ function VotesList({ items = [], loading }) { ]; const rows = items.map((item) => { - const row = [ + return [ + , , item.votes, ]; - - return row; }); return ( diff --git a/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/columns.jsx b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/columns.jsx new file mode 100644 index 0000000000..cea348791a --- /dev/null +++ b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/columns.jsx @@ -0,0 +1,54 @@ +import AddressUser from "next-common/components/user/addressUser"; +import FellowshipRank from "next-common/components/fellowship/rank"; +import { isNil } from "lodash-es"; +import { useFellowshipMemberRank } from "next-common/hooks/fellowship/useFellowshipMemberRank"; +import { useRankedCollectivePallet } from "next-common/context/collectives/collectives"; + +export function FellowshipRankInfo({ address }) { + const collectivePallet = useRankedCollectivePallet(); + const rank = useFellowshipMemberRank(address, collectivePallet); + + return ; +} + +const rankColumn = { + key: "rank", + className: "w-5", + render: (_, row) => ( +
+ +
+ ), +}; + +const addressColumn = { + key: "address", + className: "text-left min-w-[140px]", + render: (address) => , +}; + +const ayeColumn = { + key: "isAye", + className: "text-left w-[140px]", + render: (isAye) => { + if (isNil(isAye)) { + return -; + } + + return isAye ? ( + Aye + ) : ( + Nay + ); + }, +}; + +const votesColumn = { + key: "votes", + className: "text-right w-[140px] align-right flex-end", + render: (votes) => {votes}, +}; + +const columns = [rankColumn, addressColumn, ayeColumn, votesColumn]; + +export default columns; diff --git a/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/index.jsx b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/index.jsx new file mode 100644 index 0000000000..870fd5d1b0 --- /dev/null +++ b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/index.jsx @@ -0,0 +1,22 @@ +import { memo, useState } from "react"; +import { Button } from "components/gov2/sidebar/tally/styled"; +import dynamicPopup from "next-common/lib/dynamic/popup"; + +const EligibleVotersPopup = dynamicPopup(() => import("./popup")); + +function EligibleVoters() { + const [showEligibleVoters, setShowEligibleVoters] = useState(false); + + return ( + <> + + {showEligibleVoters && ( + setShowEligibleVoters(false)} /> + )} + + ); +} + +export default memo(EligibleVoters); diff --git a/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/listTable.jsx b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/listTable.jsx new file mode 100644 index 0000000000..7004518151 --- /dev/null +++ b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/listTable.jsx @@ -0,0 +1,54 @@ +import React from "react"; +import { cn } from "next-common/utils"; +import { SystemLoading } from "@osn/icons/subsquare"; + +export default function ListTable({ + rows = [], + columns = [], + className = "", + loading = false, + noDataText = "No data", +}) { + if (loading) { + return ( + + ); + } + + if (rows.length === 0) { + return ( +
+ {noDataText} +
+ ); + } + + return ( +
+ {rows.map((row, rowIdx) => ( +
+ {columns.map((column, colIdx) => ( +
+ {column?.render + ? column.render(row[column.key], row) + : row[column.key]} +
+ ))} +
+ ))} +
+ ); +} diff --git a/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/popup.jsx b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/popup.jsx new file mode 100644 index 0000000000..f9a26de5d2 --- /dev/null +++ b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/popup.jsx @@ -0,0 +1,35 @@ +import Popup from "next-common/components/popup/wrapper/Popup"; +import { GreyPanel } from "next-common/components/styled/containers/greyPanel"; +import UnVoted from "./unVoted"; +import Voted from "./voted"; +import useCollectiveEligibleVoters from "next-common/utils/hooks/collectives/useCollectiveEligibleVoters"; +import { noop } from "lodash-es"; +import useRankedCollectiveMinRank from "next-common/hooks/collectives/useRankedCollectiveMinRank"; +import { useReferendumVotingFinishIndexer } from "next-common/context/post/referenda/useReferendumVotingFinishHeight"; + +function HeaderPrompt() { + const minRank = useRankedCollectiveMinRank(); + const votingFinishIndexer = useReferendumVotingFinishIndexer(); + + return ( + + Only members{votingFinishIndexer ? "(in the voting time scope)" : ""}{" "} + whose rank >= {minRank} can vote. + + ); +} + +export default function EligibleVotersPopup({ onClose = noop }) { + const { votedMembers, unVotedMembers, isLoading } = + useCollectiveEligibleVoters(); + + return ( + + +
+ + +
+
+ ); +} diff --git a/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/unVoted.jsx b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/unVoted.jsx new file mode 100644 index 0000000000..10f71b9388 --- /dev/null +++ b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/unVoted.jsx @@ -0,0 +1,35 @@ +import { TitleContainer } from "next-common/components/styled/containers/titleContainer"; +import ListTable from "./listTable"; +import columns from "./columns"; + +export default function UnVoted({ unVotedMembers, isLoading }) { + const votedRows = unVotedMembers?.map((item) => { + return { + address: item?.address, + votes: item?.votes, + className: "bg-neutral200", + }; + }); + + const total = votedRows.length; + + return ( +
+ + + Un-voted + + {!isLoading && total} + + + + + +
+ ); +} diff --git a/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/voted.jsx b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/voted.jsx new file mode 100644 index 0000000000..ad532835ec --- /dev/null +++ b/packages/next/components/fellowship/referendum/sidebar/tally/eligibleVoters/voted.jsx @@ -0,0 +1,39 @@ +import { TitleContainer } from "next-common/components/styled/containers/titleContainer"; +import Divider from "next-common/components/styled/layout/divider"; +import ListTable from "./listTable"; +import columns from "./columns"; + +export default function Voted({ votedMembers, isLoading }) { + const votedRows = votedMembers?.map((item) => { + return { + address: item?.address, + isAye: item?.isAye, + votes: item?.votes, + className: item?.isAye ? "bg-green100" : "bg-red100", + }; + }); + + const total = votedRows.length; + + return ( +
+ + + Voted + + {!isLoading && total} + + + + + + + +
+ ); +} diff --git a/packages/next/components/fellowship/referendum/sidebar/tally/index.js b/packages/next/components/fellowship/referendum/sidebar/tally/index.js index 9aca27ed0a..34f34507da 100644 --- a/packages/next/components/fellowship/referendum/sidebar/tally/index.js +++ b/packages/next/components/fellowship/referendum/sidebar/tally/index.js @@ -9,16 +9,21 @@ import SupportBar from "../../../../gov2/sidebar/tally/supportBar"; import { useApprovalThreshold } from "next-common/context/post/gov2/threshold"; import VoteBar from "next-common/components/referenda/voteBar"; import useFellowshipVotes from "next-common/utils/hooks/fellowship/useFellowshipVotes"; -import useReferendumVotingFinishHeight from "next-common/context/post/referenda/useReferendumVotingFinishHeight"; +import { useReferendumVotingFinishIndexer } from "next-common/context/post/referenda/useReferendumVotingFinishHeight"; import { useOnchainData } from "next-common/context/post"; import AllVotes from "./allVotes"; import useFellowshipPerbill from "next-common/utils/hooks/fellowship/useFellowshipPerbill"; import CurvePopupOpener from "next-common/components/gov2/referendum/curvePopup"; import Calls from "./voteCalls"; -import { useChainSettings } from "next-common/context/chain"; +import { useChain, useChainSettings } from "next-common/context/chain"; import { useFellowshipReferendumTally } from "next-common/hooks/fellowship/useFellowshipReferendumInfo"; -import { useApprovalPercentage, useSupportPercentage } from "next-common/context/post/gov2/percentage"; +import { + useApprovalPercentage, + useSupportPercentage, +} from "next-common/context/post/gov2/percentage"; import ConfirmationEstimation from "next-common/components/tally/confirmationEstimation"; +import EligibleVoters from "./eligibleVoters"; +import { isCollectivesChain } from "next-common/utils/chain"; const Title = styled(TitleContainer)` margin-bottom: 16px; @@ -37,14 +42,16 @@ export default function FellowshipTally() { const approvalThreshold = useApprovalThreshold(); const { useVoteCall } = useChainSettings(); - const votingFinishHeight = useReferendumVotingFinishHeight(); + const votingFinishIndexer = useReferendumVotingFinishIndexer(); const { referendumIndex } = useOnchainData(); - useFellowshipVotes(referendumIndex, votingFinishHeight); + useFellowshipVotes(referendumIndex, votingFinishIndexer); const supportPerbill = useFellowshipPerbill(); const approvalPercentage = useApprovalPercentage(tally); const supportPercentage = useSupportPercentage(supportPerbill); + const chain = useChain(); + return ( @@ -72,8 +79,9 @@ export default function FellowshipTally() { supportPercentage={supportPercentage} /> - <Footer> + <Footer className="justify-end"> <AllVotes /> + {isCollectivesChain(chain) && <EligibleVoters />} {useVoteCall && <Calls />} </Footer> </SecondaryCardDetail>