From 779c340a92e61f0a2ce318123fe42c619633323c Mon Sep 17 00:00:00 2001 From: jipstavenuiter Date: Mon, 22 Dec 2025 11:05:56 +0100 Subject: [PATCH 01/21] feat: add OrchestratorVotingList component for displaying voting data - Introduced a new component to present a list of orchestrators with their voting statistics, including proposals voted on, votes casted, recent votes, and voting turnout. --- components/OrchestratorVotingList/index.tsx | 318 ++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 components/OrchestratorVotingList/index.tsx diff --git a/components/OrchestratorVotingList/index.tsx b/components/OrchestratorVotingList/index.tsx new file mode 100644 index 00000000..a7401eca --- /dev/null +++ b/components/OrchestratorVotingList/index.tsx @@ -0,0 +1,318 @@ +import { ExplorerTooltip } from "@components/ExplorerTooltip"; +import Table from "@components/Table"; +import { textTruncate } from "@lib/utils"; +import { Badge, Box, Flex, Link as A, Text } from "@livepeer/design-system"; +import { CheckIcon, Cross2Icon, MinusIcon } from "@radix-ui/react-icons"; +import { useEnsData } from "hooks"; +import Link from "next/link"; +import numbro from "numbro"; +import QRCode from "qrcode.react"; +import { useMemo } from "react"; +import { Column } from "react-table"; + +type VoterSummary = { + id: string; + noOfProposalsVotedOn: number; + noOfVotesCasted: number; + mostRecentVotes: (string | null)[]; + votingTurnout: number; +}; + +const OrchestratorVotingList = ({ + initialVoterData, + pageSize = 10, +}: { + initialVoterData?: VoterSummary[]; + pageSize: number; +}) => { + const columns = useMemo( + () => [ + { + Header: ( + + The account which is actively coordinating transcoders and + receiving fees/rewards. + + } + > + + Orchestrator + + + ), + accessor: "id", + Cell: ({ row }) => { + const identity = useEnsData(row.values.id); + + return ( + + + + + {+row.id + 1} + + + + {identity?.avatar ? ( + + ) : ( + + )} + {identity?.name ? ( + + + {textTruncate(identity.name, 20, "…")} + + + {row.values.id.substring(0, 6)} + + + ) : ( + + {row.values.id.replace(row.values.id.slice(7, 37), "…")} + + )} + + + + + ); + }, + }, + { + Header: ( + + The total number of governance proposals this orchestrator has + participated in by casting a vote. + + } + > + Number of Proposals Voted On + + ), + accessor: "noOfProposalsVotedOn", + Cell: ({ row }) => ( + + + {numbro(row.values.noOfProposalsVotedOn).format({ + mantissa: 0, + thousandSeparated: true, + })} + + + ), + sortType: "number", + }, + { + Header: ( + + The total count of individual votes submitted by this + orchestrator across all proposals. + + } + > + Number of Votes Casted + + ), + accessor: "noOfVotesCasted", + Cell: ({ row }) => ( + + + {numbro(row.values.noOfVotesCasted).format({ + mantissa: 0, + thousandSeparated: true, + })} + + + ), + sortType: "number", + }, + { + Header: ( + + A list of up to 5 of the orchestrator’s most recent votes, + marked as [✓] for For, [✗] for Against, and [–] for Abstain. + + } + > + Most Recent Votes + + ), + accessor: "mostRecentVotes", + Cell: ({ row }) => ( + + + {row.values.mostRecentVotes?.map((mostRecentVote, index) => { + const icon = + mostRecentVote == "for" ? ( + + ) : mostRecentVote == "against" ? ( + + ) : mostRecentVote == "abstain" ? ( + + ) : null; + + return ( + + {icon} + + ); + })} + + + ), + }, + { + Header: ( + + The percentage of total governance proposals this orchestrator + voted on, showing how actively they participate in protocol + decisions. + + } + > + Voting Turnout + + ), + accessor: "votingTurnout", + Cell: ({ row }) => ( + + + {numbro(row.values.votingTurnout).format({ + output: "percent", + mantissa: 2, + })} + + + ), + sortType: "number", + }, + ], + [] + ); + if (initialVoterData) { + return ( + []} + initialState={{ + pageSize, + }} + /> + ); + } else { + return null; + } +}; + +export default OrchestratorVotingList; From 77689dd35472b184eaa4fa3cfbe7857a013e5206 Mon Sep 17 00:00:00 2001 From: jipstavenuiter Date: Mon, 22 Dec 2025 11:33:36 +0100 Subject: [PATCH 02/21] feat: integrate Cube.js client and add VotingHistoryView component - Added Cube.js client for data fetching with new utility functions for querying voting history. - Introduced VotingHistoryView component to display voting statistics, including proposals voted on and voting turnout. - Updated package.json and pnpm-lock.yaml to include Cube.js client dependencies. --- components/VotingHistoryView/index.tsx | 306 +++++++++++++++++++++++++ cube/cube-client.ts | 46 ++++ cube/query-generator.ts | 53 +++++ package.json | 2 + pnpm-lock.yaml | 51 +++++ 5 files changed, 458 insertions(+) create mode 100644 components/VotingHistoryView/index.tsx create mode 100644 cube/cube-client.ts create mode 100644 cube/query-generator.ts diff --git a/components/VotingHistoryView/index.tsx b/components/VotingHistoryView/index.tsx new file mode 100644 index 00000000..ad344258 --- /dev/null +++ b/components/VotingHistoryView/index.tsx @@ -0,0 +1,306 @@ +import { ExplorerTooltip } from "@components/ExplorerTooltip"; +import Spinner from "@components/Spinner"; +import { Box, Flex } from "@livepeer/design-system"; +import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; +import { CUBE_TYPE, getCubeData } from "cube/cube-client"; +import { getAccountVotingHistory } from "cube/query-generator"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +const Index = () => { + const router = useRouter(); + const query = router.query; + const account = query.account as string; + + const [proposalVotedOn, setProposalVotedOn] = useState(); + const [votingTurnOut, setVotingTurnOut] = useState(); + const [votingData, setVotingData] = useState(); + const [isLoading, setIsLoading] = useState(false); + + const getBackgroundColorByStatus = (status: string) => { + let bgColor = "#212322"; + switch (status) { + case "Active": + bgColor = "#16271F"; + break; + case "Defeated": + bgColor = "#321C1D"; + break; + case "Executed": + bgColor = "#212322"; + break; + default: + break; + } + return bgColor; + }; + + const getTextStyleByStatus = (status: string) => { + const stylesMap: Record = { + Active: { + color: "#51A7FD", + backgroundColor: "#11233E", + maxWidth: 80, + justifyContent: "center", + display: "flex", + borderRadius: 8, + }, + Defeated: { + color: "#FF6468", + backgroundColor: "#3C181A", + maxWidth: 80, + justifyContent: "center", + display: "flex", + borderRadius: 8, + }, + Executed: { + color: "#4ABF87", + backgroundColor: "#10291E", + maxWidth: 80, + justifyContent: "center", + display: "flex", + borderRadius: 8, + }, + }; + + return stylesMap[status] || {}; // Returns styles if status is found, otherwise returns an empty object + }; + + function shortenAddress(address: string) { + if (address.length < 10) return address; // Handle short addresses + + const first = address.slice(0, 6); // Get the '0x' + first 4 characters + const last = address.slice(-4); // Get last 4 characters + + return `${first}...${last}`; // Return formatted string + } + + useEffect(() => { + const fetchingData = async () => { + setIsLoading(true); + try { + const query = getAccountVotingHistory(account); + const response = await getCubeData(query, { type: CUBE_TYPE.SERVER }); + const data = response?.[0]?.data; + if (data.length > 0) { + setVotingTurnOut(data[0]["LivepeerProposalStatus.votingTurnout"]); + setProposalVotedOn(data[0]["LivepeerProposalStatus.proposalVotedOn"]); + setVotingData(data); + } + setIsLoading(false); + } catch (error) { + console.error(error); + setIsLoading(false); + } + }; + fetchingData(); + }, [account]); + + const getDateTimeAndRound = (date: string, round: string): string => { + // Parse the date string to a Date object + const dateObj = new Date(date); + + // Function to format the date to "MM/DD/YYYY h:mm:ss a" + const formatDate = (date: Date): string => { + const months = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = date.getSeconds(); + const ampm = hours >= 12 ? "pm" : "am"; + + const day = date.getDate(); + const month = months[date.getMonth()]; + const year = date.getFullYear(); + const formattedTime = `${month} ${day}, ${year} ${hours % 12 || 12}:${ + minutes < 10 ? "0" + minutes : minutes + }:${seconds < 10 ? "0" + seconds : seconds} ${ampm}`; + + return formattedTime; + }; + + // Round logic (In case the round value needs transformation, it's done here) + const roundNumber = round.split("-")[0]; // Assuming round is in the format "3466-01-01T00:00:00.000", just using the first part + + // Format date + const formattedDate = formatDate(dateObj); + + // Return the final output in the required format + return `${formattedDate} - Round #${roundNumber}`; + }; + + if (isLoading) { + return ( + + + + ); + } + return ( +
+
+
+
+
+ PROPOSALS VOTED ON +
+ + The total number of governance proposals this orchestrator has + participated in by casting a vote. + + } + > + + + + +
+
{proposalVotedOn}
+
+ +
+
+
VOTING TURNOUT
+ + The percentage of total governance proposals this orchestrator + voted on, showing how actively they participate in protocol + decisions. + + } + > + + + + +
+
{votingTurnOut}%
+
+
+
+ {votingData && + // @ts-expect-error - votingData is an array of objects + votingData.map((el, index) => { + return ( +
+
+ {el["LivepeerProposalStatus.nameOfProposal"]} +
+
+ {getDateTimeAndRound( + el["LivepeerProposalStatus.date"], + el["LivepeerProposalStatus.round"] + )} +
+
+ Proposed by{" "} + + livepeer.eth + +
+
+ {el["LivepeerProposalStatus.status"]} +
+ +
+ ); + })} +
+
+ ); +}; + +export default Index; diff --git a/cube/cube-client.ts b/cube/cube-client.ts new file mode 100644 index 00000000..343c93a7 --- /dev/null +++ b/cube/cube-client.ts @@ -0,0 +1,46 @@ +import cubejs, { Query } from "@cubejs-client/core"; + +export enum CUBE_TYPE { + SERVER = "SERVER", + CLIENT = "CLIENT", + PUBLIC = "PUBLIC", +} + +const CUBE_BASE_URL = "https://cube.dev.analytics.pyor.xyz"; +const cubePublicAuthToken = process.env.CUBE_PUBLIC_TOKEN || ""; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const cubejsApiClient = cubejs("CUBEJS-API-TOKEN", { + apiUrl: `/api/services/cube/cubejs-api/v1`, +}); + +const cubejsApiPublic = cubejs(cubePublicAuthToken, { + apiUrl: `${CUBE_BASE_URL}/cubejs-api/v1`, +}); + +export async function getCubeData( + query: Query, + options: { + type: CUBE_TYPE; + headerData?: { token: string }; + } = { + type: CUBE_TYPE.CLIENT, + } +) { + const cubejsApi = + options.type === CUBE_TYPE.CLIENT + ? cubejsApiPublic + : options.type === CUBE_TYPE.SERVER + ? cubejs(options.headerData?.token || "", { + apiUrl: `${CUBE_BASE_URL}/cubejs-api/v1`, + }) + : cubejsApiPublic; + + try { + const resultSet = await cubejsApi.load(query); + const response = resultSet.rawData; + return response; + } catch (error) { + console.error(error); + } +} diff --git a/cube/query-generator.ts b/cube/query-generator.ts new file mode 100644 index 00000000..98b9e855 --- /dev/null +++ b/cube/query-generator.ts @@ -0,0 +1,53 @@ +import { Query } from "@cubejs-client/core"; + +export const getAccountVotingHistory = (id: string): Query => { + // @ts-expect-error - this is a string query + return `{ + "measures": [ + "LivepeerProposalStatus.count", + "LivepeerProposalStatus.votingTurnout", + "LivepeerProposalStatus.proposalVotedOn" + ], + "order": { + "LivepeerProposalStatus.count": "desc" + }, + "dimensions": [ + "LivepeerProposalStatus.date", + "LivepeerProposalStatus.round", + "LivepeerProposalStatus.eventTxnHash", + "LivepeerProposalStatus.nameOfProposal", + "LivepeerProposalStatus.voteType", + "LivepeerProposalStatus.status", + "LivepeerProposalStatus.proposedBy", + "LivepeerProposalStatus.voter" + ], + "filters": [ + { + "member": "LivepeerProposalStatus.voter", + "operator": "equals", + "values": [ + "${id}" + ] + } + ] + }`; +}; + +export const getOrchestratorsVotingHistory = () => { + return `{ + "measures": [ + "LivepeerVoteProposals.count", + "LivepeerVoteProposals.numOfProposals", + "LivepeerVoteProposals.numOfVoteCasted" + ], + "order": { + "LivepeerVoteProposals.count": "desc" + }, + "dimensions": [ + "LivepeerVoteProposals.date", + "LivepeerVoteProposals.voter", + "LivepeerVoteProposals.eventTxnsHash", + "LivepeerVoteProposals.voteType" + ] + }`; +}; diff --git a/package.json b/package.json index f7823590..906a0f4b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ }, "dependencies": { "@apollo/client": "^3.13.1", + "@cubejs-client/core": "^1.6.1", + "@cubejs-client/react": "^1.6.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@graphql-tools/delegate": "8.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97f96d23..e985581b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,12 @@ importers: '@apollo/client': specifier: ^3.13.1 version: 3.14.0(@types/react@19.2.2)(graphql-ws@5.12.1(graphql@16.12.0))(graphql@16.12.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@cubejs-client/core': + specifier: ^1.6.1 + version: 1.6.1(encoding@0.1.13) + '@cubejs-client/react': + specifier: ^1.6.1 + version: 1.6.1(encoding@0.1.13)(react@19.2.1) '@emotion/react': specifier: ^11.14.0 version: 11.14.0(@types/react@19.2.2)(react@19.2.1) @@ -1167,6 +1173,14 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@cubejs-client/core@1.6.1': + resolution: {integrity: sha512-OcVpJ7vLyKB6sGuHAcZgJRih5EP2GU+hBbbuSUzW2zvPAvrhmswdNFef/DjFJAlHeiNXoSeX5dXxvITiZbJOdg==} + + '@cubejs-client/react@1.6.1': + resolution: {integrity: sha512-MfIWcCACjgVescy44t/FpXjo6+oP9TKGr6dtszDR3OvAk9EBWYvZfRm1NO54HaUnZRop7OFWO1Ywf6Vtod/F8g==} + peerDependencies: + react: '>=16.10.2' + '@ecies/ciphers@0.2.5': resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -8419,6 +8433,9 @@ packages: radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + ramda@0.27.2: + resolution: {integrity: sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -9672,6 +9689,9 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + url-search-params-polyfill@7.0.1: + resolution: {integrity: sha512-bAw7L2E+jn9XHG5P9zrPnHdO0yJub4U+yXJOdpcpkr7OBd9T8oll4lUos0iSGRcDvfZoLUKfx9a6aNmIhJ4+mQ==} + urlpattern-polyfill@6.0.2: resolution: {integrity: sha512-5vZjFlH9ofROmuWmXM9yj2wljYKgWstGwe8YTyiqM7hVum/g9LyCizPZtb3UqsuppVwety9QJmfc42VggLpTgg==} @@ -9723,6 +9743,10 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} hasBin: true @@ -11272,6 +11296,27 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@cubejs-client/core@1.6.1(encoding@0.1.13)': + dependencies: + core-js: 3.46.0 + cross-fetch: 3.2.0(encoding@0.1.13) + dayjs: 1.11.19 + ramda: 0.27.2 + url-search-params-polyfill: 7.0.1 + uuid: 11.1.0 + transitivePeerDependencies: + - encoding + + '@cubejs-client/react@1.6.1(encoding@0.1.13)(react@19.2.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@cubejs-client/core': 1.6.1(encoding@0.1.13) + core-js: 3.46.0 + ramda: 0.27.2 + react: 19.2.1 + transitivePeerDependencies: + - encoding + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': dependencies: '@noble/ciphers': 1.3.0 @@ -21464,6 +21509,8 @@ snapshots: radix3@1.1.2: {} + ramda@0.27.2: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -22878,6 +22925,8 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + url-search-params-polyfill@7.0.1: {} + urlpattern-polyfill@6.0.2: dependencies: braces: 3.0.3 @@ -22925,6 +22974,8 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.19 + uuid@11.1.0: {} + uuid@7.0.3: {} uuid@8.3.2: {} From b2499a36d1114860e98e4f438c0fe753e39eec8a Mon Sep 17 00:00:00 2001 From: jipstavenuiter Date: Mon, 22 Dec 2025 11:53:54 +0100 Subject: [PATCH 03/21] feat: enhance voting history functionality and UI - Exported VoterSummary type for better type management in voting components. - Integrated VotingHistoryView into the account layout for displaying voting statistics. - Added new VotingHistory page to handle routing and data fetching for user voting history. - Updated account layout to include a new tab for voting history, improving user navigation. - Implemented utility functions for processing and summarizing voting data from Cube.js. --- components/OrchestratorVotingList/index.tsx | 2 +- layouts/account.tsx | 20 +- lib/orchestrator.ts | 4 + pages/accounts/[account]/voting_history.tsx | 68 +++++++ pages/index.tsx | 193 +++++++++++++++++++- 5 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 lib/orchestrator.ts create mode 100644 pages/accounts/[account]/voting_history.tsx diff --git a/components/OrchestratorVotingList/index.tsx b/components/OrchestratorVotingList/index.tsx index a7401eca..970393a5 100644 --- a/components/OrchestratorVotingList/index.tsx +++ b/components/OrchestratorVotingList/index.tsx @@ -10,7 +10,7 @@ import QRCode from "qrcode.react"; import { useMemo } from "react"; import { Column } from "react-table"; -type VoterSummary = { +export type VoterSummary = { id: string; noOfProposalsVotedOn: number; noOfVotesCasted: number; diff --git a/layouts/account.tsx b/layouts/account.tsx index 9750293b..fcdd9a97 100644 --- a/layouts/account.tsx +++ b/layouts/account.tsx @@ -5,6 +5,7 @@ import HistoryView from "@components/HistoryView"; import OrchestratingView from "@components/OrchestratingView"; import Profile from "@components/Profile"; import Spinner from "@components/Spinner"; +import VotingHistoryView from "@components/VotingHistoryView"; import { getLayout, LAYOUT_MAX_WIDTH } from "@layouts/main"; import { bondingManager } from "@lib/api/abis/main/BondingManager"; import { getAccount, getSortedOrchestrators } from "@lib/api/ssr"; @@ -42,9 +43,18 @@ export interface TabType { isActive?: boolean; } -type TabTypeEnum = "delegating" | "orchestrating" | "history"; +type TabTypeEnum = + | "delegating" + | "orchestrating" + | "history" + | "voting-history"; -const ACCOUNT_VIEWS: TabTypeEnum[] = ["delegating", "orchestrating", "history"]; +const ACCOUNT_VIEWS: TabTypeEnum[] = [ + "delegating", + "orchestrating", + "history", + "voting-history", +]; const AccountLayout = () => { /* PART OF https://github.com/livepeer/explorer/pull/427 - TODO: REMOVE ONCE SERVER-SIDE ISSUE IS FIXED */ @@ -373,6 +383,7 @@ const AccountLayout = () => { /> )} {view === "history" && } + {view === "voting-history" && } {(isOrchestrator || isMyDelegate || isDelegatingAndIsMyAccountView) && (width > 1020 ? ( @@ -446,6 +457,11 @@ function getTabs( href: `/accounts/${account}/history`, isActive: view === "history", }, + { + name: "Voting History", + href: `/accounts/${account}/voting_history`, + isActive: view === "voting-history", + }, ]; if (isOrchestrator || isMyDelegate) { tabs.unshift({ diff --git a/lib/orchestrator.ts b/lib/orchestrator.ts new file mode 100644 index 00000000..5ad32d8e --- /dev/null +++ b/lib/orchestrator.ts @@ -0,0 +1,4 @@ +export enum OrchestratorTabs { + YIELD_OVERVIEW = "Yield Overview", + VOTING_HISTORY = "Voting History", +} diff --git a/pages/accounts/[account]/voting_history.tsx b/pages/accounts/[account]/voting_history.tsx new file mode 100644 index 00000000..b12a0a47 --- /dev/null +++ b/pages/accounts/[account]/voting_history.tsx @@ -0,0 +1,68 @@ +import AccountLayout from "@layouts/account"; +import { getLayout } from "@layouts/main"; +import { getAccount, getSortedOrchestrators } from "@lib/api/ssr"; +import { EnsIdentity } from "@lib/api/types/get-ens"; +import { + AccountQueryResult, + getApollo, + OrchestratorsSortedQueryResult, +} from "apollo"; + +type PageProps = { + account: AccountQueryResult["data"]; + sortedOrchestrators: OrchestratorsSortedQueryResult["data"]; + fallback: { [key: string]: EnsIdentity }; +}; + +const VotingHistory = () => ; + +VotingHistory.getLayout = getLayout; + +export const getStaticPaths = async () => { + const { sortedOrchestrators } = await getSortedOrchestrators(); + + return { + paths: + sortedOrchestrators?.data?.transcoders?.map((t) => ({ + params: { account: t.id }, + })) ?? [], + fallback: "blocking", + }; +}; + +export const getStaticProps = async (context) => { + try { + const client = getApollo(); + const { account, fallback } = await getAccount( + client, + context.params?.account?.toString().toLowerCase() + ); + + const { sortedOrchestrators, fallback: sortedOrchestratorsFallback } = + await getSortedOrchestrators(client); + + if (!account.data || !sortedOrchestrators.data) { + return null; + } + + const props: PageProps = { + account: account.data, + sortedOrchestrators: sortedOrchestrators.data, + fallback: { + ...sortedOrchestratorsFallback, + ...fallback, + }, + }; + + return { + props, + revalidate: 600, + }; + } catch (e) { + console.error(e); + } + + return null; +}; + +export default VotingHistory; diff --git a/pages/index.tsx b/pages/index.tsx index d31861ce..fd3d34bc 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -3,6 +3,9 @@ import "react-circular-progressbar/dist/styles.css"; import ErrorComponent from "@components/Error"; import ExplorerChart from "@components/ExplorerChart"; import OrchestratorList from "@components/OrchestratorList"; +import OrchestratorVotingList, { + VoterSummary, +} from "@components/OrchestratorVotingList"; import RoundStatus from "@components/RoundStatus"; import Spinner from "@components/Spinner"; import TransactionsList, { @@ -11,6 +14,7 @@ import TransactionsList, { import { getLayout, LAYOUT_MAX_WIDTH } from "@layouts/main"; import { HomeChartData } from "@lib/api/types/get-chart-data"; import { EnsIdentity } from "@lib/api/types/get-ens"; +import { OrchestratorTabs } from "@lib/orchestrator"; import { Box, Button, @@ -20,6 +24,9 @@ import { Link as A, } from "@livepeer/design-system"; import { ArrowRightIcon } from "@modulz/radix-icons"; +import { Tab, TabList, TabPanel, TabPanels, Tabs } from "@reach/tabs"; +import { CUBE_TYPE, getCubeData } from "cube/cube-client"; +import { getOrchestratorsVotingHistory } from "cube/query-generator"; import { useChartData } from "hooks"; import Link from "next/link"; import { useMemo, useState } from "react"; @@ -229,9 +236,16 @@ type PageProps = { events: EventsQueryResult["data"] | null; protocol: ProtocolQueryResult["data"] | null; fallback: { [key: string]: EnsIdentity }; + initialVoterData?: VoterSummary[]; }; -const Home = ({ hadError, orchestrators, events, protocol }: PageProps) => { +const Home = ({ + hadError, + orchestrators, + events, + protocol, + initialVoterData, +}: PageProps) => { const allEvents = useMemo( () => events?.transactions @@ -374,6 +388,95 @@ const Home = ({ hadError, orchestrators, events, protocol }: PageProps) => { + + {({ selectedIndex, focusedIndex }) => { + const getTabStyle = (index) => ({ + borderBottom: `4px solid ${ + selectedIndex === index + ? "#6ec08d" + : focusedIndex === index + ? "#141716" + : "#141716" + }`, + backgroundColor: "#141716", + borderWidth: 0, + borderBottomWidth: 1, + paddingBottom: 12, + }); + return ( + <> + + Yield Overview + Voting History + + + + + + + + + + + + + + + ); + }} + + + {/* + + + Yield Overview + + + Voting History + + + + {!orchestrators?.transcoders || !protocol?.protocol ? ( + + + + ) : ( + + + + )} + + + + + + + */} + {!orchestrators?.transcoders || !protocol?.protocol ? ( @@ -455,6 +558,24 @@ export const getStaticProps = async () => { const { events } = await getEvents(client); const protocol = await getProtocol(client); + const query = getOrchestratorsVotingHistory(); + // @ts-expect-error - query is a string + const response = await getCubeData(query, { type: CUBE_TYPE.SERVER }); + + // Log the response to check the structure of the data + + if (!response || !response[0] || !response[0].data) { + return { + props: { + initialVoterData: [], + }, + }; + } + + const data = response[0].data; + + const voterSummaries = getVoterSummaries(data); + if (!orchestrators.data || !events.data || !protocol.data) { return { props: errorProps, @@ -467,6 +588,7 @@ export const getStaticProps = async () => { orchestrators: orchestrators.data, events: events.data, protocol: protocol.data, + initialVoterData: voterSummaries, fallback: {}, }; @@ -483,6 +605,75 @@ export const getStaticProps = async () => { } }; +type VoteProposal = { + "LivepeerVoteProposals.date": string; + "LivepeerVoteProposals.voter": string; + "LivepeerVoteProposals.eventTxnsHash": string; + "LivepeerVoteProposals.voteType": string; + "LivepeerVoteProposals.count": string; + "LivepeerVoteProposals.numOfProposals": string; + "LivepeerVoteProposals.numOfVoteCasted": string; +}; + +// Function to get unique voter IDs +const getUniqueVoters = (data: VoteProposal[]): string[] => { + const voterSet = new Set( + data.map((proposal) => proposal["LivepeerVoteProposals.voter"]) + ); + return Array.from(voterSet); +}; + +// Function to group data by voter +const groupByVoter = ( + data: VoteProposal[], + voterId: string +): VoteProposal[] => { + return data.filter( + (proposal) => proposal["LivepeerVoteProposals.voter"] === voterId + ); +}; + +// Function to process vote proposals and generate voter summary +const processVoteProposals = (proposals: VoteProposal[]): VoterSummary => { + const sortedVotes = proposals.sort( + (a, b) => + new Date(b["LivepeerVoteProposals.date"]).getTime() - + new Date(a["LivepeerVoteProposals.date"]).getTime() + ); + + const mostRecentVotes = sortedVotes + .slice(0, 5) + .map((vote) => vote["LivepeerVoteProposals.voteType"] || null); + + const noOfProposalsVotedOn = Number( + proposals[0]["LivepeerVoteProposals.numOfProposals"] || 0 + ); + const noOfVotesCasted = Number( + proposals[0]["LivepeerVoteProposals.numOfVoteCasted"] || 0 + ); + + const votingTurnout = noOfProposalsVotedOn + ? noOfVotesCasted / noOfProposalsVotedOn + : 0; + + return { + id: proposals[0]["LivepeerVoteProposals.voter"], + noOfProposalsVotedOn, + noOfVotesCasted, + mostRecentVotes, + votingTurnout, + }; +}; + +// Function to get voter summaries for all unique voters +const getVoterSummaries = (data: VoteProposal[]): VoterSummary[] => { + const uniqueVoters = getUniqueVoters(data); + return uniqueVoters.map((voterId) => { + const groupedProposals = groupByVoter(data, voterId); + return processVoteProposals(groupedProposals); + }); +}; + Home.getLayout = getLayout; export default Home; From 7a772b08169f9f57398d183bae94fbe941e059a8 Mon Sep 17 00:00:00 2001 From: jipstavenuiter Date: Mon, 22 Dec 2025 11:58:31 +0100 Subject: [PATCH 04/21] feat: enhance orchestrators page with voting history and summaries - Added OrchestratorVotingList component to display voting data for orchestrators. - Implemented tabbed interface for Yield Overview and Voting History sections. - Integrated voter summary processing functions to aggregate voting data. - Updated getStaticProps to fetch and pass initial voter data to the page. --- pages/orchestrators.tsx | 146 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 139 insertions(+), 7 deletions(-) diff --git a/pages/orchestrators.tsx b/pages/orchestrators.tsx index dd468252..c0ec270e 100644 --- a/pages/orchestrators.tsx +++ b/pages/orchestrators.tsx @@ -1,8 +1,11 @@ import ErrorComponent from "@components/Error"; import OrchestratorList from "@components/OrchestratorList"; +import OrchestratorVotingList from "@components/OrchestratorVotingList"; +import { VoterSummary } from "@components/OrchestratorVotingList"; import { getLayout, LAYOUT_MAX_WIDTH } from "@layouts/main"; import { getOrchestrators, getProtocol } from "@lib/api/ssr"; import { EnsIdentity } from "@lib/api/types/get-ens"; +import { OrchestratorTabs } from "@lib/orchestrator"; import { Box, Button, @@ -12,6 +15,9 @@ import { Link as A, } from "@livepeer/design-system"; import { ArrowRightIcon } from "@modulz/radix-icons"; +import { Tab, TabList, TabPanel, TabPanels, Tabs } from "@reach/tabs"; +import { CUBE_TYPE, getCubeData } from "cube/cube-client"; +import { getOrchestratorsVotingHistory } from "cube/query-generator"; import Head from "next/head"; import Link from "next/link"; @@ -26,12 +32,14 @@ type PageProps = { orchestrators: OrchestratorsQueryResult["data"] | null; protocol: ProtocolQueryResult["data"] | null; fallback: { [key: string]: EnsIdentity }; + initialVoterData?: VoterSummary[]; }; const OrchestratorsPage = ({ hadError, orchestrators, protocol, + initialVoterData, }: PageProps) => { if (hadError) { return ; @@ -74,19 +82,124 @@ const OrchestratorsPage = ({ )} - - - + + {({ selectedIndex, focusedIndex }) => { + const getTabStyle = (index) => ({ + borderBottom: `4px solid ${ + selectedIndex === index + ? "#6ec08d" + : focusedIndex === index + ? "#141716" + : "#141716" + }`, + backgroundColor: "#141716", + borderWidth: 0, + borderBottomWidth: 1, + paddingBottom: 12, + }); + return ( + <> + + Yield Overview + Voting History + + + + + + + + + + + + + + + ); + }} + ); }; +type VoteProposal = { + "LivepeerVoteProposals.date": string; + "LivepeerVoteProposals.voter": string; + "LivepeerVoteProposals.eventTxnsHash": string; + "LivepeerVoteProposals.voteType": string; + "LivepeerVoteProposals.count": string; + "LivepeerVoteProposals.numOfProposals": string; + "LivepeerVoteProposals.numOfVoteCasted": string; +}; + +// Function to get unique voter IDs +const getUniqueVoters = (data: VoteProposal[]): string[] => { + const voterSet = new Set( + data.map((proposal) => proposal["LivepeerVoteProposals.voter"]) + ); + return Array.from(voterSet); +}; + +// Function to group data by voter +const groupByVoter = ( + data: VoteProposal[], + voterId: string +): VoteProposal[] => { + return data.filter( + (proposal) => proposal["LivepeerVoteProposals.voter"] === voterId + ); +}; + +// Function to process vote proposals and generate voter summary +const processVoteProposals = (proposals: VoteProposal[]): VoterSummary => { + const sortedVotes = proposals.sort( + (a, b) => + new Date(b["LivepeerVoteProposals.date"]).getTime() - + new Date(a["LivepeerVoteProposals.date"]).getTime() + ); + + const mostRecentVotes = sortedVotes + .slice(0, 5) + .map((vote) => vote["LivepeerVoteProposals.voteType"] || null); + + const noOfProposalsVotedOn = Number( + proposals[0]["LivepeerVoteProposals.numOfProposals"] || 0 + ); + const noOfVotesCasted = Number( + proposals[0]["LivepeerVoteProposals.numOfVoteCasted"] || 0 + ); + const votingTurnout = noOfProposalsVotedOn + ? noOfVotesCasted / noOfProposalsVotedOn + : 0; + + return { + id: proposals[0]["LivepeerVoteProposals.voter"], + noOfProposalsVotedOn, + noOfVotesCasted, + mostRecentVotes, + votingTurnout, + }; +}; + +// Function to get voter summaries for all unique voters +const getVoterSummaries = (data: VoteProposal[]): VoterSummary[] => { + const uniqueVoters = getUniqueVoters(data); + return uniqueVoters.map((voterId) => { + const groupedProposals = groupByVoter(data, voterId); + return processVoteProposals(groupedProposals); + }); +}; + export const getStaticProps = async () => { const errorProps: PageProps = { hadError: true, @@ -100,6 +213,24 @@ export const getStaticProps = async () => { const { orchestrators, fallback } = await getOrchestrators(client); const protocol = await getProtocol(client); + const query = getOrchestratorsVotingHistory(); + // @ts-expect-error - query is a string + const response = await getCubeData(query, { type: CUBE_TYPE.SERVER }); + + // Log the response to check the structure of the data + + if (!response || !response[0] || !response[0].data) { + return { + props: { + initialVoterData: [], + }, + }; + } + + const data = response[0].data; + + const voterSummaries = getVoterSummaries(data); + if (!orchestrators.data || !protocol.data) { return { props: errorProps, @@ -112,6 +243,7 @@ export const getStaticProps = async () => { orchestrators: orchestrators.data, protocol: protocol.data, fallback, + initialVoterData: voterSummaries, }; return { From d4d97c028d7dbfe7ab65fefd59e9947c43eb30ea Mon Sep 17 00:00:00 2001 From: jipstavenuiter Date: Mon, 22 Dec 2025 11:59:42 +0100 Subject: [PATCH 05/21] feat: add Vector.png asset for UI enhancements --- public/img/Vector.png | Bin 0 -> 380 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/img/Vector.png diff --git a/public/img/Vector.png b/public/img/Vector.png new file mode 100644 index 0000000000000000000000000000000000000000..639d88d5905dd06817195a8e51529ac4046d3da2 GIT binary patch literal 380 zcmeAS@N?(olHy`uVBq!ia0vp^iXhCv1|-9u9Lfh$oCO|{#XvC&5N33pW|#mJWGoJH zcVbv~PUa<$!;&U>cv7h@-A}df%(9^{+q=ND7bZ@>U0|AG0DgFnnqV|Tz=9&xm zEqJ4%Ch(!5h$*|-y?Tkhx6*C!cDRIc1fAs0uA05Bx3pky7_*dr|iDZ91 zx}fpQ;Z3V{Y-8TSs*=oqQ8eJY(SsUhv5teCzBwFcR?S)6{kW?A!NmC7B!P~N-&HzX z;uqZ4doSe7v^+zeeewmt4_XVgANU?}Indp*l3`~{XTvY2Cxwi{93HnKgw Date: Mon, 22 Dec 2025 13:21:47 +0100 Subject: [PATCH 06/21] Cherry pick PR https://github.com/livepeer/explorer/pull/300 - Adds treasury proposals voting overview --- apollo/subgraph.ts | 45 ++++ components/TreasuryVotingWidget/index.tsx | 2 +- components/{ => Votes}/VoteButton/index.tsx | 0 components/Votes/VoteDetail/index.tsx | 112 ++++++++ components/Votes/VoteModal/index.tsx | 81 ++++++ components/Votes/VotePopover/index.tsx | 46 ++++ .../VoteTable/Views/DesktopVoteTable.tsx | 102 +++++++ .../Votes/VoteTable/Views/MobileVoteTable.tsx | 72 +++++ components/Votes/VoteTable/Views/VoteItem.tsx | 250 ++++++++++++++++++ components/Votes/VoteTable/index.tsx | 88 ++++++ components/{ => Votes}/VotingWidget/index.tsx | 2 +- hooks/TreasuryVotes/useFetchVotes.ts | 95 +++++++ hooks/TreasuryVotes/useInfuraVoterVotes.ts | 93 +++++++ lib/api/ens.ts | 12 + lib/api/ssr.ts | 17 ++ lib/api/types/votes.ts | 20 ++ lib/chains.ts | 14 + lib/utils.tsx | 9 + pages/treasury/[proposal].tsx | 89 ++++++- pages/voting/[poll].tsx | 2 +- queries/treasuryProposalsByIds.graphql | 8 + utils/formatAddress.ts | 10 + utils/voting.ts | 49 ++++ 23 files changed, 1213 insertions(+), 5 deletions(-) rename components/{ => Votes}/VoteButton/index.tsx (100%) create mode 100644 components/Votes/VoteDetail/index.tsx create mode 100644 components/Votes/VoteModal/index.tsx create mode 100644 components/Votes/VotePopover/index.tsx create mode 100644 components/Votes/VoteTable/Views/DesktopVoteTable.tsx create mode 100644 components/Votes/VoteTable/Views/MobileVoteTable.tsx create mode 100644 components/Votes/VoteTable/Views/VoteItem.tsx create mode 100644 components/Votes/VoteTable/index.tsx rename components/{ => Votes}/VotingWidget/index.tsx (99%) create mode 100644 hooks/TreasuryVotes/useFetchVotes.ts create mode 100644 hooks/TreasuryVotes/useInfuraVoterVotes.ts create mode 100644 lib/api/types/votes.ts create mode 100644 queries/treasuryProposalsByIds.graphql create mode 100644 utils/formatAddress.ts create mode 100644 utils/voting.ts diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts index 96a56a01..359eeb31 100644 --- a/apollo/subgraph.ts +++ b/apollo/subgraph.ts @@ -8963,6 +8963,13 @@ export type TreasuryProposalsQueryVariables = Exact<{ [key: string]: never; }>; export type TreasuryProposalsQuery = { __typename: 'Query', treasuryProposals: Array<{ __typename: 'TreasuryProposal', id: string, description: string, calldatas: Array, targets: Array, values: Array, voteEnd: string, voteStart: string, proposer: { __typename: 'LivepeerAccount', id: string } }> }; +export type GetProposalsByIdsQueryVariables = Exact<{ + ids: Array | Scalars['ID']; +}>; + + +export type GetProposalsByIdsQuery = { __typename: 'Query', treasuryProposals: Array<{ __typename: 'TreasuryProposal', id: string, description: string, voteStart: string, voteEnd: string }> }; + export type VoteQueryVariables = Exact<{ id: Scalars['ID']; }>; @@ -9906,6 +9913,44 @@ export function useTreasuryProposalsLazyQuery(baseOptions?: Apollo.LazyQueryHook export type TreasuryProposalsQueryHookResult = ReturnType; export type TreasuryProposalsLazyQueryHookResult = ReturnType; export type TreasuryProposalsQueryResult = Apollo.QueryResult; +export const GetProposalsByIdsDocument = gql` + query getProposalsByIds($ids: [ID!]!) { + treasuryProposals(where: {id_in: $ids}) { + id + description + voteStart + voteEnd + } +} + `; + +/** + * __useGetProposalsByIdsQuery__ + * + * To run a query within a React component, call `useGetProposalsByIdsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetProposalsByIdsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetProposalsByIdsQuery({ + * variables: { + * ids: // value for 'ids' + * }, + * }); + */ +export function useGetProposalsByIdsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetProposalsByIdsDocument, options); + } +export function useGetProposalsByIdsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetProposalsByIdsDocument, options); + } +export type GetProposalsByIdsQueryHookResult = ReturnType; +export type GetProposalsByIdsLazyQueryHookResult = ReturnType; +export type GetProposalsByIdsQueryResult = Apollo.QueryResult; export const VoteDocument = gql` query vote($id: ID!) { vote(id: $id) { diff --git a/components/TreasuryVotingWidget/index.tsx b/components/TreasuryVotingWidget/index.tsx index 167c1194..145d72e2 100644 --- a/components/TreasuryVotingWidget/index.tsx +++ b/components/TreasuryVotingWidget/index.tsx @@ -11,7 +11,7 @@ import numbro from "numbro"; import { useMemo, useState } from "react"; import { zeroAddress } from "viem"; -import VoteButton from "../VoteButton"; +import VoteButton from "../Votes/VoteButton"; type Props = { proposal: ProposalExtended; diff --git a/components/VoteButton/index.tsx b/components/Votes/VoteButton/index.tsx similarity index 100% rename from components/VoteButton/index.tsx rename to components/Votes/VoteButton/index.tsx diff --git a/components/Votes/VoteDetail/index.tsx b/components/Votes/VoteDetail/index.tsx new file mode 100644 index 00000000..578e6529 --- /dev/null +++ b/components/Votes/VoteDetail/index.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { Vote, VOTING_SUPPORT } from "@lib/api/types/votes"; +import { formatLpt } from "@lib/utils"; +import { Badge, Box, Heading, Link, Text } from "@livepeer/design-system"; +import { ArrowTopRightIcon } from "@modulz/radix-icons"; +import React from "react"; +import { formatAddress } from "utils/formatAddress"; + +interface VoteDetailItemProps { + vote: Vote; +} + +const Index: React.FC = ({ vote }) => { + const support = VOTING_SUPPORT[vote.choiceID] || VOTING_SUPPORT["2"]; + return ( + + + + {vote.proposalTitle} + + + + + + Proposal ID: + {" "} + + {formatAddress(vote.proposalId)} + + + + + + Support: + + + {support.text} + + + + + + Weight: + {" "} + + {formatLpt(vote.weight)} + + + + + + Reason: + {" "} + + {vote.reason || "No reason provided"} + + + + + {vote.transactionHash ? ( + e.stopPropagation()} + css={{ + display: "inline-block", + transition: "transform 0.2s ease", + "&:hover": { transform: "scale(1.1)" }, + }} + > + + {formatAddress(vote.transactionHash)} + + + + ) : ( + N/A + )} + + + ); +}; + +export default Index; diff --git a/components/Votes/VoteModal/index.tsx b/components/Votes/VoteModal/index.tsx new file mode 100644 index 00000000..46ab4ebf --- /dev/null +++ b/components/Votes/VoteModal/index.tsx @@ -0,0 +1,81 @@ +import { Box, Button } from "@livepeer/design-system"; +import React from "react"; +import { createPortal } from "react-dom"; + +interface VoteModalProps { + onClose: () => void; + children: React.ReactNode; +} + +const Index: React.FC = ({ onClose, children }) => + createPortal( + e.stopPropagation()} + > + e.stopPropagation()} + > + + + + + + {children} + + + , + document.body + ); + +export default Index; diff --git a/components/Votes/VotePopover/index.tsx b/components/Votes/VotePopover/index.tsx new file mode 100644 index 00000000..30da7228 --- /dev/null +++ b/components/Votes/VotePopover/index.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Spinner from "@components/Spinner"; +import { Flex, Text } from "@livepeer/design-system"; +import React from "react"; + +import { useInfuraVoterVotes } from "../../../hooks/TreasuryVotes/useInfuraVoterVotes"; +import VoteDetail from "../VoteDetail"; +import VoteModal from "../VoteModal"; + +interface VoterPopoverProps { + voter: string; + onClose: () => void; +} + +const Index: React.FC = ({ voter, onClose }) => { + const { votes, isLoading } = useInfuraVoterVotes(voter); + + return ( + + {isLoading ? ( + + + + ) : votes.length > 0 ? ( + votes.map((vote, idx) => ( + + )) + ) : ( + + No votes found for this voter. + + )} + + ); +}; + +export default Index; diff --git a/components/Votes/VoteTable/Views/DesktopVoteTable.tsx b/components/Votes/VoteTable/Views/DesktopVoteTable.tsx new file mode 100644 index 00000000..4a97212d --- /dev/null +++ b/components/Votes/VoteTable/Views/DesktopVoteTable.tsx @@ -0,0 +1,102 @@ +import { Box, Flex, Text } from "@livepeer/design-system"; +import React from "react"; + +import { Vote } from "../../../../lib/api/types/votes"; +import { VoteView } from "./VoteItem"; + +export interface VoteTableProps { + votes: Vote[]; + counts: { yes: number; no: number; abstain: number }; + formatWeight: (weight: string) => string; + onSelect: (voter: string) => void; +} + +export const DesktopVoteTable: React.FC = ({ + votes, + counts, + formatWeight, + onSelect, +}) => ( + + + Vote Results + + + + Yes ({counts.yes}) + + | + + No ({counts.no}) + + | + + Abstain ({counts.abstain}) + + + + + Click on a vote to view a voters proposal voting history. + + + + + {["Voter", "Support", "Weight", "Reason", "Vote Txn"].map((label) => ( + + {label} + + ))} + + + + {votes.map((vote) => { + return ( + + ); + })} + + + +); diff --git a/components/Votes/VoteTable/Views/MobileVoteTable.tsx b/components/Votes/VoteTable/Views/MobileVoteTable.tsx new file mode 100644 index 00000000..58cb9ab3 --- /dev/null +++ b/components/Votes/VoteTable/Views/MobileVoteTable.tsx @@ -0,0 +1,72 @@ +import { Box, Flex, Text } from "@livepeer/design-system"; +import React from "react"; + +import { VoteTableProps } from "./DesktopVoteTable"; +import { VoteView } from "./VoteItem"; + +export const MobileVoteCards: React.FC = ({ + votes, + counts, + formatWeight, + onSelect, +}) => ( + + + Vote Results + + + + Yes ({counts.yes}) + + | + + No ({counts.no}) + + | + + Abstain ({counts.abstain}) + + + + + Click on a vote to view a voters proposal voting history. + + + {votes.map((vote) => { + return ( + + ); + })} + +); diff --git a/components/Votes/VoteTable/Views/VoteItem.tsx b/components/Votes/VoteTable/Views/VoteItem.tsx new file mode 100644 index 00000000..1c17bc99 --- /dev/null +++ b/components/Votes/VoteTable/Views/VoteItem.tsx @@ -0,0 +1,250 @@ +import { Badge, Box, Card, Heading, Link, Text } from "@livepeer/design-system"; +import { ArrowTopRightIcon } from "@modulz/radix-icons"; +import { formatAddress } from "utils/formatAddress"; + +import { Vote, VOTING_SUPPORT } from "../../../../lib/api/types/votes"; + +interface VoteViewProps { + vote: Vote; + onSelect: (voter: string) => void; + formatWeight: (weight: string) => string; + isMobile?: boolean; +} + +export function VoteView({ + vote, + onSelect, + formatWeight, + isMobile, +}: VoteViewProps) { + return isMobile ? ( + + ) : ( + + ); +} + +function MobileVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { + const support = VOTING_SUPPORT[vote.choiceID] || VOTING_SUPPORT["2"]; + return ( + { + if ((e.target as HTMLElement).closest("a")) return; + e.stopPropagation(); + onSelect(vote.voter); + }} + > + + e.stopPropagation()} + > + {vote.ensName} + + + + + Support: + + + {support.text} + + + + + Weight: + {" "} + {formatWeight(vote.weight)} + + + + Reason: + {" "} + {vote.reason} + + + {vote.transactionHash ? ( + e.stopPropagation()} + css={{ + display: "inline-block", + transition: "transform 0.2s ease", + "&:hover": { transform: "scale(1.1)" }, + }} + > + + {formatAddress(vote.transactionHash)} + + + + ) : ( + N/A + )} + + + ); +} + +function DesktopVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { + const support = VOTING_SUPPORT[vote.choiceID] || VOTING_SUPPORT["2"]; + return ( + td": { padding: "$2 $3" }, + }} + onClickCapture={(e) => { + if ((e.target as HTMLElement).closest("a")) return; + e.stopPropagation(); + onSelect(vote.voter); + }} + > + + e.stopPropagation()} + > + + {vote.ensName} + + + + + + {support.text} + + + + + {formatWeight(vote.weight)} + + + + {vote.reason} + + + {vote.transactionHash ? ( + e.stopPropagation()} + css={{ + display: "inline-block", + transition: "transform 0.2s ease", + "&:hover": { transform: "scale(1.1)" }, + }} + > + + {formatAddress(vote.transactionHash)} + + + + ) : ( + N/A + )} + + + ); +} diff --git a/components/Votes/VoteTable/index.tsx b/components/Votes/VoteTable/index.tsx new file mode 100644 index 00000000..1f264f3d --- /dev/null +++ b/components/Votes/VoteTable/index.tsx @@ -0,0 +1,88 @@ +"use client"; + +import Spinner from "@components/Spinner"; +import { Vote } from "@lib/api/types/votes"; +import { lptFormatter } from "@lib/utils"; +import { Flex, Text } from "@livepeer/design-system"; +import React, { useState } from "react"; +import { useWindowSize } from "react-use"; + +import { useFetchVotes } from "../../../hooks/TreasuryVotes/useFetchVotes"; +import VoterPopover from "../VotePopover"; +import { DesktopVoteTable } from "./Views/DesktopVoteTable"; +import { MobileVoteCards } from "./Views/MobileVoteTable"; + +interface VoteTableProps { + proposalId: string; +} + +const countVotes = (votes: Vote[]) => { + return { + yes: votes.filter((v) => v.choiceID === "1").length || 0, + no: votes.filter((v) => v.choiceID === "0").length || 0, + abstain: votes.filter((v) => v.choiceID === "2").length || 0, + }; +}; + +const Index: React.FC = ({ proposalId }) => { + const { votes, loading, error } = useFetchVotes(proposalId); + const { width } = useWindowSize(); + const isDesktop = width >= 768; + + const [selectedVoter, setSelectedVoter] = useState(null); + const counts = countVotes(votes); + const totalWeight = votes.reduce((sum, v) => sum + parseFloat(v.weight), 0); + + const formatWeight = (w: string) => + `${lptFormatter.format(parseFloat(w) / 1e18)} LPT (${ + totalWeight > 0 ? ((parseFloat(w) / totalWeight) * 100).toFixed(2) : "0" + }%)`; + + if (loading) { + return ( + + + + ); + } + if (error) + return ( + + Error loading votes: {error} + + ); + + if (!votes.length) + return ( + + No votes found for this proposal. + + ); + + const VoteListView = isDesktop ? DesktopVoteTable : MobileVoteCards; + + return ( + <> + + {selectedVoter && ( + setSelectedVoter(null)} + /> + )} + + ); +}; + +export default Index; diff --git a/components/VotingWidget/index.tsx b/components/Votes/VotingWidget/index.tsx similarity index 99% rename from components/VotingWidget/index.tsx rename to components/Votes/VotingWidget/index.tsx index bb02a577..d585e18b 100644 --- a/components/VotingWidget/index.tsx +++ b/components/Votes/VotingWidget/index.tsx @@ -1,5 +1,6 @@ import { PollExtended } from "@lib/api/polls"; import dayjs from "@lib/dayjs"; +import { abbreviateNumber, fromWei } from "@lib/utils"; import { Box, Button, @@ -19,7 +20,6 @@ import numbro from "numbro"; import { useEffect, useMemo, useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; -import { abbreviateNumber, fromWei } from "../../lib/utils"; import Check from "../../public/img/check.svg"; import Copy from "../../public/img/copy.svg"; import VoteButton from "../VoteButton"; diff --git a/hooks/TreasuryVotes/useFetchVotes.ts b/hooks/TreasuryVotes/useFetchVotes.ts new file mode 100644 index 00000000..0b6f5b5b --- /dev/null +++ b/hooks/TreasuryVotes/useFetchVotes.ts @@ -0,0 +1,95 @@ +import { getEnsForVotes } from "@lib/api/ens"; +import { + CONTRACT_ADDRESS, + contractInterface, + provider, + VOTECAST_TOPIC0, +} from "@lib/chains"; +import { useEffect, useState } from "react"; + +import { Vote } from "../../lib/api/types/votes"; +import { formatAddress } from "../../utils/formatAddress"; + +export const useFetchVotes = (proposalId: string) => { + const [votes, setVotes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!proposalId) { + setVotes([]); + setLoading(false); + return; + } + + const fetchVotes = async () => { + try { + const logs = await provider.getLogs({ + address: CONTRACT_ADDRESS, + fromBlock: "earliest", + toBlock: "latest", + topics: [VOTECAST_TOPIC0], + }); + + const decodedVotes = logs + .map((log) => { + const decoded = contractInterface.parseLog(log); + return { + transactionHash: log.transactionHash, + voter: decoded?.args.voter.toLowerCase() || "", + choiceID: decoded?.args.support.toString() || "", + proposalId: decoded?.args.proposalId.toString() || "", + weight: decoded?.args.weight.toString() || "0", + reason: decoded?.args.reason || "No reason provided", + }; + }) + .filter((vote) => vote.proposalId === proposalId) + + .sort((a, b) => parseFloat(b.weight) - parseFloat(a.weight)); + + const uniqueVoters = Array.from( + new Set(decodedVotes.map((v) => v.voter)) + ); + const localEnsCache: { [address: string]: string } = {}; + + await Promise.all( + uniqueVoters.map(async (address) => { + try { + if (localEnsCache[address]) { + return; + } + const ensAddress = await getEnsForVotes(address); + + if (ensAddress && ensAddress.name) { + localEnsCache[address] = ensAddress.name; + } else { + localEnsCache[address] = formatAddress(address); + } + } catch (e) { + console.warn(`Failed to fetch ENS for ${address}`, e); + } + }) + ); + + setVotes( + decodedVotes.map((vote) => ({ + ...vote, + ensName: localEnsCache[vote.voter], + })) + ); + } catch (error) { + console.error("Error fetching logs from Infura:", error); + setError("Failed to fetch votes"); + } finally { + setLoading(false); + } + }; + fetchVotes(); + }, [proposalId]); + + return { + votes, + loading, + error, + }; +}; diff --git a/hooks/TreasuryVotes/useInfuraVoterVotes.ts b/hooks/TreasuryVotes/useInfuraVoterVotes.ts new file mode 100644 index 00000000..8224d377 --- /dev/null +++ b/hooks/TreasuryVotes/useInfuraVoterVotes.ts @@ -0,0 +1,93 @@ +import { getProposalsByIds } from "@lib/api"; +import { + CONTRACT_ADDRESS, + contractInterface, + provider, + VOTECAST_TOPIC0, +} from "@lib/chains"; +import { GetProposalsByIdsQueryResult } from "apollo"; +import { ethers } from "ethers"; +import { useEffect, useMemo, useState } from "react"; +import useSWR from "swr"; + +import { Vote } from "../../lib/api/types/votes"; + +export function useInfuraVoterVotes(voter: string) { + const [logsLoading, setLogsLoading] = useState(true); + const [rawVotes, setRawVotes] = useState([]); + const proposalIds = useMemo( + () => Array.from(new Set(rawVotes.map((v) => v.proposalId))), + [rawVotes] + ); + + useEffect(() => { + let cancelled = false; + async function fetch() { + setLogsLoading(true); + try { + const topic = ethers.utils.zeroPad(voter, 32); + const logs = await provider.getLogs({ + address: CONTRACT_ADDRESS, + fromBlock: "earliest", + toBlock: "latest", + topics: [VOTECAST_TOPIC0, ethers.utils.hexlify(topic)], + }); + if (cancelled) return; + const transactions = logs.map((log) => { + const args = contractInterface.parseLog(log).args; + return { + transactionHash: log.transactionHash, + voter: args.voter, + choiceID: args.support.toString(), + proposalId: args.proposalId.toString(), + weight: args.weight.toString(), + reason: args.reason ?? "", + }; + }); + setRawVotes(transactions); + } catch (e) { + console.error(e); + } finally { + if (!cancelled) setLogsLoading(false); + } + } + fetch(); + return () => { + cancelled = true; + }; + }, [voter]); + + const { data, isLoading: proposalsLoading } = useSWR< + GetProposalsByIdsQueryResult["data"] + >(`/api/ssr/sorted-orchestrators`, async () => { + const { proposals } = await getProposalsByIds(proposalIds); + return proposals.data as GetProposalsByIdsQueryResult["data"]; + }); + + const votes: Vote[] = useMemo(() => { + if (logsLoading || proposalsLoading) return []; + const map = new Map(); + data?.treasuryProposals?.forEach((p) => { + map.set(p.id, { + description: p.description || "", + voteEnd: Number(p.voteEnd) || 0, + }); + }); + return rawVotes + .map((r) => { + const meta = map.get(r.proposalId) ?? { description: "", voteEnd: 0 }; + const title = + (meta.description.split("\n")[0] || "").replace(/^#\s*/, "") || + "Unknown Proposal"; + return { + ...r, + endVote: meta.voteEnd, + description: meta.description, + proposalTitle: title, + }; + }) + .sort((a, b) => b.endVote - a.endVote); + }, [rawVotes, data, logsLoading, proposalsLoading]); + + return { votes, isLoading: logsLoading || proposalsLoading }; +} diff --git a/lib/api/ens.ts b/lib/api/ens.ts index 293a4e1d..b41eced1 100644 --- a/lib/api/ens.ts +++ b/lib/api/ens.ts @@ -100,3 +100,15 @@ export const nl2br = (str, is_xhtml = true) => { "$1" + breakTag + "$2" ); }; + +export const getEnsForVotes = async (address: string | null | undefined) => { + const idShort = address?.replace(address?.slice(6, 38), "…"); + + const name = address ? await l1Provider.lookupAddress(address) : null; + + return { + id: address ?? "", + idShort: idShort ?? "", + name, + }; +}; diff --git a/lib/api/ssr.ts b/lib/api/ssr.ts index 1ee96be2..7b5c43e5 100644 --- a/lib/api/ssr.ts +++ b/lib/api/ssr.ts @@ -9,6 +9,9 @@ import { EventsQuery, EventsQueryVariables, getApollo, + GetProposalsByIdsDocument, + GetProposalsByIdsQuery, + GetProposalsByIdsQueryVariables, OrchestratorsDocument, OrchestratorsQuery, OrchestratorsQueryVariables, @@ -103,6 +106,20 @@ export async function getEvents(client = getApollo(), first = 100) { }; } +export async function getProposalsByIds(ids: string[], client = getApollo()) { + const proposals = await client.query< + GetProposalsByIdsQuery, + GetProposalsByIdsQueryVariables + >({ + query: GetProposalsByIdsDocument, + variables: { ids }, + }); + return { + fallback: {}, + proposals, + }; +} + export const server = process.env.NODE_ENV !== "production" ? "http://localhost:3000" diff --git a/lib/api/types/votes.ts b/lib/api/types/votes.ts new file mode 100644 index 00000000..12f619d8 --- /dev/null +++ b/lib/api/types/votes.ts @@ -0,0 +1,20 @@ +export interface Vote { + transactionHash: string; + voter: string; + choiceID: string; + proposalId: string; + weight: string; + reason?: string; + ensName?: string; + endVote?: number; + description?: string; + proposalTitle?: string; +} + +export const VOTING_SUPPORT = { + "0": { text: "No", style: { color: "$red9", fontWeight: 600 } }, + "1": { text: "Yes", style: { color: "$green9", fontWeight: 600 } }, + "2": { text: "Abstain", style: { color: "$yellow9", fontWeight: 600 } }, +} as const; + +export type SupportKey = keyof typeof VOTING_SUPPORT; diff --git a/lib/chains.ts b/lib/chains.ts index 91918167..85d87078 100644 --- a/lib/chains.ts +++ b/lib/chains.ts @@ -246,3 +246,17 @@ export const l2Provider = new ethers.providers.JsonRpcProvider( export function isL2ChainId(chainId: number | undefined): boolean { return L2_CHAIN_IDS.some((e) => e.id === chainId); } + +// Votecast Constants +export const INFURA_RPC_URL = `https://arbitrum-mainnet.infura.io/v3/${process.env.NEXT_PUBLIC_INFURA_KEY}`; +export const CONTRACT_ADDRESS = "0xcfe4e2879b786c3aa075813f0e364bb5accb6aa0"; + +export const VOTECAST_TOPIC0 = ethers.utils.id( + "VoteCast(address,uint256,uint8,uint256,string)" +); + +export const provider = new ethers.providers.JsonRpcProvider(INFURA_RPC_URL); + +export const contractInterface = new ethers.utils.Interface([ + "event VoteCast(address indexed voter, uint256 proposalId, uint8 support, uint256 weight, string reason)", +]); diff --git a/lib/utils.tsx b/lib/utils.tsx index ff0a35c3..61a76045 100644 --- a/lib/utils.tsx +++ b/lib/utils.tsx @@ -278,3 +278,12 @@ export const isImageUrl = (url: string): boolean => { */ export const shortenAddress = (address: string) => address?.replace(address.slice(5, 39), "…") ?? ""; + +export const lptFormatter = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +export const formatLpt = (w: string) => { + return `${lptFormatter.format(parseFloat(w) / 1e18)} LPT`; +}; diff --git a/pages/treasury/[proposal].tsx b/pages/treasury/[proposal].tsx index 018f2348..e675ec2b 100644 --- a/pages/treasury/[proposal].tsx +++ b/pages/treasury/[proposal].tsx @@ -4,6 +4,7 @@ import Spinner from "@components/Spinner"; import Stat from "@components/Stat"; import { BadgeVariantByState } from "@components/TreasuryProposalRow"; import TreasuryVotingWidget from "@components/TreasuryVotingWidget"; +import VoteTable from "@components/Votes/VoteTable"; import { getLayout, LAYOUT_MAX_WIDTH } from "@layouts/main"; import { livepeerToken } from "@lib/api/abis/main/LivepeerToken"; import { getProposalExtended } from "@lib/api/treasury"; @@ -27,7 +28,7 @@ import { BigNumber } from "ethers"; import Head from "next/head"; import { useRouter } from "next/router"; import numbro from "numbro"; -import { useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useWindowSize } from "react-use"; import { decodeFunctionData } from "viem"; @@ -40,6 +41,7 @@ import { useProposalVotingPowerData, useTreasuryProposalState, } from "../../hooks"; +import { useFetchVotes } from "../../hooks/TreasuryVotes/useFetchVotes"; import FourZeroFour from "../404"; const formatPercent = (percent: number) => @@ -60,6 +62,7 @@ const formatDateTime = (date: dayjs.Dayjs) => { const Proposal = () => { const router = useRouter(); const { width } = useWindowSize(); + const [isDesktop, setIsDesktop] = useState(false); const { setBottomDrawerOpen } = useExplorerStore(); const { query } = router; @@ -79,6 +82,13 @@ const Proposal = () => { const { data: protocolQuery } = useProtocolQuery(); const currentRound = useCurrentRoundData(); + const { votes, loading: votesLoading } = useFetchVotes(proposalId ?? ""); + const [votesOpen, setVotesOpen] = useState(false); + + useEffect(() => { + setIsDesktop(width >= 768); + }, [width]); + const proposal = useMemo(() => { if (!proposalQuery || !state || !protocolQuery || !currentRound) { return null; @@ -93,6 +103,25 @@ const Proposal = () => { const proposerId = useEnsData(proposal?.proposer.id); + const votesContent = useCallback(() => { + if (votesLoading) { + return ( + + + + ); + } + if (votes.length === 0) return No votes yet.; + return ; + }, [votesLoading, votes.length, proposal]); + const actions = useMemo(() => { if (!proposal || !contractAddresses) { return null; @@ -613,10 +642,66 @@ const Proposal = () => { {proposal.description} + + setVotesOpen(!votesOpen)} + > + + + + + {votesLoading + ? "Loading votes…" + : `View Votes (${votes.length})`} + + + + + {votesOpen ? "–" : "+"} + + + + {votesOpen && ( + {votesContent()} + )} + - {width > 1200 ? ( + {isDesktop ? ( { + if (!addr) return ""; + if (addr.endsWith(".xyz")) { + return addr.length > 21 ? `${addr.slice(0, 6)}...${addr.slice(-6)}` : addr; + } + if (addr.endsWith(".eth") && addr.length < 21) { + return addr; + } + return addr.length > 21 ? `${addr.slice(0, 6)}...${addr.slice(-6)}` : addr; +}; diff --git a/utils/voting.ts b/utils/voting.ts new file mode 100644 index 00000000..f7db15e9 --- /dev/null +++ b/utils/voting.ts @@ -0,0 +1,49 @@ +import { PollExtended } from "@lib/api/polls"; +import { fromWei } from "@lib/utils"; +import { AccountQuery, PollChoice } from "apollo"; +import numbro from "numbro"; +export type VotingResponse = { + poll: PollExtended; + delegateVote: + | { + __typename: "Vote"; + choiceID?: PollChoice; + voteStake: string; + nonVoteStake: string; + } + | undefined + | null; + vote: + | { + __typename: "Vote"; + choiceID?: PollChoice; + voteStake: string; + nonVoteStake: string; + } + | undefined + | null; + myAccount: AccountQuery; +}; + +export const formatPercent = (percent: number) => + numbro(percent).format({ output: "percent", mantissa: 4 }); + +export function getVotingPower( + accountAddress: string, + myAccount: VotingResponse["myAccount"], + vote: VotingResponse["vote"], + pendingStake?: string +) { + // if account is a delegate its voting power is its total stake minus its delegators' vote stake (nonVoteStake) + if (accountAddress === myAccount?.delegator?.delegate?.id) { + if (vote?.voteStake) { + return +vote.voteStake - +vote?.nonVoteStake; + } + return ( + +myAccount?.delegator?.delegate?.totalStake - + (vote?.nonVoteStake ? +vote?.nonVoteStake : 0) + ); + } + + return fromWei(pendingStake ? pendingStake : "0"); +} From 0a56114b2402db5ac9ea16b369e6e0dd47f4df03 Mon Sep 17 00:00:00 2001 From: jipstavenuiter Date: Tue, 23 Dec 2025 13:52:04 +0100 Subject: [PATCH 07/21] fix: update Cube.js data fetching and response handling - Changed the method of accessing raw data from Cube.js API to use a function call. - Simplified response validation in getStaticProps by removing unnecessary checks and directly using the response for voter summaries. --- cube/cube-client.ts | 2 +- pages/index.tsx | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/cube/cube-client.ts b/cube/cube-client.ts index 343c93a7..6bcc2026 100644 --- a/cube/cube-client.ts +++ b/cube/cube-client.ts @@ -38,7 +38,7 @@ export async function getCubeData( try { const resultSet = await cubejsApi.load(query); - const response = resultSet.rawData; + const response = resultSet.rawData(); return response; } catch (error) { console.error(error); diff --git a/pages/index.tsx b/pages/index.tsx index 079d03d5..b1fe05d4 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -604,7 +604,7 @@ export const getStaticProps = async () => { // Log the response to check the structure of the data - if (!response || !response[0] || !response[0].data) { + if (!response) { return { props: { initialVoterData: [], @@ -612,9 +612,7 @@ export const getStaticProps = async () => { }; } - const data = response[0].data; - - const voterSummaries = getVoterSummaries(data); + const voterSummaries = getVoterSummaries(response); if (!orchestrators.data || !events.data || !protocol.data) { return { From 91d8581adf04f14a258437dc79b33ccdfe3fef45 Mon Sep 17 00:00:00 2001 From: jipstavenuiter Date: Tue, 23 Dec 2025 16:58:01 +0100 Subject: [PATCH 08/21] fix: standardize address and transaction hash formatting --- components/Account/index.tsx | 7 +- components/AccountCell/index.tsx | 4 +- components/Claim/index.tsx | 6 +- components/DelegatingView/index.tsx | 7 +- components/DelegatingWidget/Header.tsx | 3 +- components/HistoryView/index.tsx | 68 ++++--------------- components/OrchestratorList/index.tsx | 4 +- components/OrchestratorVotingList/index.tsx | 4 +- components/PerformanceList/index.tsx | 4 +- components/Profile/index.tsx | 5 +- components/Search/index.tsx | 11 ++- components/StakeTransactions/index.tsx | 15 +--- components/TransactionsList/index.tsx | 3 +- components/TreasuryVotingWidget/index.tsx | 4 +- components/TxConfirmedDialog/index.tsx | 12 +--- components/TxStartedDialog/index.tsx | 10 ++- components/Votes/VoteDetail/index.tsx | 5 +- components/Votes/VoteTable/Views/VoteItem.tsx | 6 +- components/Votes/VotingWidget/index.tsx | 11 +-- components/VotingHistoryView/index.tsx | 12 +--- hooks/TreasuryVotes/useFetchVotes.ts | 2 +- hooks/useSwr.tsx | 3 +- layouts/main.tsx | 9 +-- lib/api/ens.ts | 3 +- lib/utils.test.ts | 11 ++- lib/utils.tsx | 30 +++++--- pages/index.tsx | 1 - pages/migrate/broadcaster.tsx | 10 +-- pages/migrate/delegator/index.tsx | 10 +-- pages/migrate/orchestrator.tsx | 10 +-- pages/treasury/[proposal].tsx | 8 +-- utils/formatAddress.ts | 10 --- 32 files changed, 109 insertions(+), 199 deletions(-) delete mode 100644 utils/formatAddress.ts diff --git a/components/Account/index.tsx b/components/Account/index.tsx index 9f74de7a..e295376b 100644 --- a/components/Account/index.tsx +++ b/components/Account/index.tsx @@ -1,3 +1,4 @@ +import { formatAddress } from "@lib/utils"; import { Box, Flex, Link as A } from "@livepeer/design-system"; import { useAccountAddress, useEnsData } from "hooks"; import Link from "next/link"; @@ -52,11 +53,7 @@ const Account = () => { > - - {ens?.name - ? ens.name - : accountAddress.replace(accountAddress.slice(6, 38), "…")} - + {ens?.name ? ens.name : formatAddress(accountAddress)} diff --git a/components/AccountCell/index.tsx b/components/AccountCell/index.tsx index f9dc8d59..43f86c15 100644 --- a/components/AccountCell/index.tsx +++ b/components/AccountCell/index.tsx @@ -1,4 +1,4 @@ -import { textTruncate } from "@lib/utils"; +import { formatAddress, textTruncate } from "@lib/utils"; import { Box, Flex } from "@livepeer/design-system"; import { useEnsData } from "hooks"; import { QRCodeCanvas } from "qrcode.react"; @@ -88,7 +88,7 @@ const Index = ({ active, address }) => { {identity?.name ? textTruncate(identity.name, 12, "…") - : address.replace(address.slice(5, 36), "…")} + : formatAddress(address)} diff --git a/components/Claim/index.tsx b/components/Claim/index.tsx index b64985d2..d0abfa8e 100644 --- a/components/Claim/index.tsx +++ b/components/Claim/index.tsx @@ -1,6 +1,7 @@ import { LAYOUT_MAX_WIDTH } from "@layouts/constants"; import { l2Migrator } from "@lib/api/abis/bridge/L2Migrator"; import { getL2MigratorAddress } from "@lib/api/contracts"; +import { formatAddress } from "@lib/utils"; import { Box, Button, Container, Flex, Text } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@modulz/radix-icons"; import { constants, ethers } from "ethers"; @@ -202,10 +203,7 @@ const Claim = () => { borderBottom: "1px solid rgba(255,255,255, .2)", }} > - {migrationParams.delegate.replace( - migrationParams.delegate.slice(6, 38), - "…" - )} + {formatAddress(migrationParams.delegate)} diff --git a/components/DelegatingView/index.tsx b/components/DelegatingView/index.tsx index 83279309..0f32a1b9 100644 --- a/components/DelegatingView/index.tsx +++ b/components/DelegatingView/index.tsx @@ -1,7 +1,7 @@ import { ExplorerTooltip } from "@components/ExplorerTooltip"; import Stat from "@components/Stat"; import { bondingManager } from "@lib/api/abis/main/BondingManager"; -import { checkAddressEquality } from "@lib/utils"; +import { checkAddressEquality, formatAddress } from "@lib/utils"; import { Box, Button, Flex, Link as A, Text } from "@livepeer/design-system"; import { QuestionMarkCircledIcon } from "@modulz/radix-icons"; import { AccountQueryResult, OrchestratorsSortedQueryResult } from "apollo"; @@ -167,10 +167,7 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => { {delegateIdentity?.name ? delegateIdentity?.name - : delegator?.delegate?.id.replace( - delegator?.delegate?.id.slice(7, 37), - "…" - )} + : formatAddress(delegator?.delegate?.id)} } />{" "} diff --git a/components/DelegatingWidget/Header.tsx b/components/DelegatingWidget/Header.tsx index 60cda370..b808c216 100644 --- a/components/DelegatingWidget/Header.tsx +++ b/components/DelegatingWidget/Header.tsx @@ -1,4 +1,5 @@ import { EnsIdentity } from "@lib/api/types/get-ens"; +import { formatAddress } from "@lib/utils"; import { Box, Flex, Heading } from "@livepeer/design-system"; import { QRCodeCanvas } from "qrcode.react"; @@ -58,7 +59,7 @@ const Header = ({ {delegateProfile?.name ? delegateProfile.name - : transcoder?.id.replace(transcoder.id.slice(7, 37), "…")} + : formatAddress(transcoder?.id)} - Delegated with{" "} - {event.newDelegate.id.replace( - event.newDelegate.id.slice(7, 37), - "…" - )} + Delegated with {formatAddress(event.newDelegate.id)} - {event.transaction.id.replace( - event.transaction.id.slice(6, 62), - "…" - )} + {formatTransactionHash(event.transaction.id)} @@ -271,10 +265,7 @@ function renderSwitch(event, i: number) { }} > - {event.transaction.id.replace( - event.transaction.id.slice(6, 62), - "…" - )} + {formatTransactionHash(event.transaction.id)} @@ -312,8 +303,7 @@ function renderSwitch(event, i: number) { > - Redelegated with{" "} - {event.delegate.id.replace(event.delegate.id.slice(7, 37), "…")} + Redelegated with {formatAddress(event.delegate.id)} - {event.transaction.id.replace( - event.transaction.id.slice(6, 62), - "…" - )} + {formatTransactionHash(event.transaction.id)} @@ -378,8 +365,7 @@ function renderSwitch(event, i: number) { > - Undelegated from{" "} - {event.delegate.id.replace(event.delegate.id.slice(7, 37), "…")} + Undelegated from {formatAddress(event.delegate.id)} - {event.transaction.id.replace( - event.transaction.id.slice(6, 62), - "…" - )} + {formatTransactionHash(event.transaction.id)} @@ -463,10 +446,7 @@ function renderSwitch(event, i: number) { }} > - {event.transaction.id.replace( - event.transaction.id.slice(6, 62), - "…" - )} + {formatTransactionHash(event.transaction.id)} @@ -526,10 +506,7 @@ function renderSwitch(event, i: number) { }} > - {event.transaction.id.replace( - event.transaction.id.slice(6, 62), - "…" - )} + {formatTransactionHash(event.transaction.id)} @@ -593,10 +570,7 @@ function renderSwitch(event, i: number) { }} > - {event.transaction.id.replace( - event.transaction.id.slice(6, 62), - "…" - )} + {formatTransactionHash(event.transaction.id)} @@ -655,10 +629,7 @@ function renderSwitch(event, i: number) { }} > - {event.transaction.id.replace( - event.transaction.id.slice(6, 62), - "…" - )} + {formatTransactionHash(event.transaction.id)} @@ -717,10 +688,7 @@ function renderSwitch(event, i: number) { }} > - {event.transaction.id.replace( - event.transaction.id.slice(6, 62), - "…" - )} + {formatTransactionHash(event.transaction.id)} @@ -780,10 +748,7 @@ function renderSwitch(event, i: number) { }} > - {event.transaction.id.replace( - event.transaction.id.slice(6, 62), - "…" - )} + {formatTransactionHash(event.transaction.id)} @@ -848,10 +813,7 @@ function renderSwitch(event, i: number) { }} > - {event.transaction.id.replace( - event.transaction.id.slice(6, 62), - "…" - )} + {formatTransactionHash(event.transaction.id)} diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 1370086e..dade2dec 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -11,7 +11,7 @@ import { ROIInflationChange, ROITimeHorizon, } from "@lib/roi"; -import { textTruncate } from "@lib/utils"; +import { formatAddress, textTruncate } from "@lib/utils"; import { Badge, Box, @@ -291,7 +291,7 @@ const OrchestratorList = ({ ) : ( - {row.values.id.replace(row.values.id.slice(7, 37), "…")} + {formatAddress(row.values.id)} )} {/* {(row?.original?.daysSinceChangeParams ?? diff --git a/components/OrchestratorVotingList/index.tsx b/components/OrchestratorVotingList/index.tsx index 970393a5..b57d06de 100644 --- a/components/OrchestratorVotingList/index.tsx +++ b/components/OrchestratorVotingList/index.tsx @@ -1,6 +1,6 @@ import { ExplorerTooltip } from "@components/ExplorerTooltip"; import Table from "@components/Table"; -import { textTruncate } from "@lib/utils"; +import { formatAddress, textTruncate } from "@lib/utils"; import { Badge, Box, Flex, Link as A, Text } from "@livepeer/design-system"; import { CheckIcon, Cross2Icon, MinusIcon } from "@radix-ui/react-icons"; import { useEnsData } from "hooks"; @@ -129,7 +129,7 @@ const OrchestratorVotingList = ({ ) : ( - {row.values.id.replace(row.values.id.slice(7, 37), "…")} + {formatAddress(row.values.id)} )} diff --git a/components/PerformanceList/index.tsx b/components/PerformanceList/index.tsx index fede2324..3bb703ad 100644 --- a/components/PerformanceList/index.tsx +++ b/components/PerformanceList/index.tsx @@ -3,7 +3,7 @@ import IdentityAvatar from "@components/IdentityAvatar"; import Table from "@components/Table"; import { Pipeline } from "@lib/api/types/get-available-pipelines"; import { Region } from "@lib/api/types/get-regions"; -import { textTruncate } from "@lib/utils"; +import { formatAddress, textTruncate } from "@lib/utils"; import { Badge, Box, Flex, Link as A, Skeleton } from "@livepeer/design-system"; import { QuestionMarkCircledIcon } from "@modulz/radix-icons"; import { OrchestratorsQueryResult } from "apollo"; @@ -149,7 +149,7 @@ const PerformanceList = ({ ) : ( - {row.values.id.replace(row.values.id.slice(7, 37), "…")} + {formatAddress(row.values.id)} )} {typeof row.values.scores != "undefined" && diff --git a/components/Profile/index.tsx b/components/Profile/index.tsx index 2f5f1522..233226d9 100644 --- a/components/Profile/index.tsx +++ b/components/Profile/index.tsx @@ -1,6 +1,7 @@ import { ExplorerTooltip } from "@components/ExplorerTooltip"; import ShowMoreRichText from "@components/ShowMoreRichText"; import { EnsIdentity } from "@lib/api/types/get-ens"; +import { formatAddress } from "@lib/utils"; import { Box, Flex, Heading, Link as A, Text } from "@livepeer/design-system"; import { CheckIcon, @@ -107,9 +108,7 @@ const Index = ({ account, isMyAccount = false, identity }: Props) => { fontWeight: 700, }} > - {identity?.name - ? identity.name - : account.replace(account.slice(5, 39), "…")} + {identity?.name ? identity.name : formatAddress(account)} diff --git a/components/Search/index.tsx b/components/Search/index.tsx index 28853e7e..ad2f8d96 100644 --- a/components/Search/index.tsx +++ b/components/Search/index.tsx @@ -1,4 +1,5 @@ import Spinner from "@components/Spinner"; +import { formatAddress } from "@lib/utils"; import { Box, Dialog, @@ -182,14 +183,10 @@ const Index = ({ css = {}, ...props }) => { {result.item.name - ? `${result.item.name} (${result.item.id.replace( - result.item.id.slice(5, 39), - "…" + ? `${result.item.name} (${formatAddress( + result.item.id )})` - : result.item.id.replace( - result.item.id.slice(7, 37), - "…" - )} + : formatAddress(result.item.id, 8, 6)} diff --git a/components/StakeTransactions/index.tsx b/components/StakeTransactions/index.tsx index d4511cee..c2f244ad 100644 --- a/components/StakeTransactions/index.tsx +++ b/components/StakeTransactions/index.tsx @@ -4,6 +4,7 @@ import { parseEther } from "viem"; import { abbreviateNumber, + formatAddress, getHint, simulateNewActiveSetOrder, } from "../../lib/utils"; @@ -62,11 +63,7 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => { > - Undelegating from{" "} - {lock.delegate.id.replace( - lock.delegate.id.slice(7, 37), - "…" - )} + Undelegating from {formatAddress(lock.delegate.id)} Tokens will be available for withdrawal in approximately{" "} @@ -136,13 +133,7 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => { justifyContent: "space-between", }} > - - Undelegated from{" "} - {lock.delegate.id.replace( - lock.delegate.id.slice(7, 37), - "…" - )} - + Undelegated from {formatAddress(lock.delegate.id)} {isMyAccount && ( diff --git a/components/TransactionsList/index.tsx b/components/TransactionsList/index.tsx index 7fdb1e05..f887da8a 100644 --- a/components/TransactionsList/index.tsx +++ b/components/TransactionsList/index.tsx @@ -1,5 +1,6 @@ import Table from "@components/Table"; import dayjs from "@lib/dayjs"; +import { formatTransactionHash } from "@lib/utils"; import { Badge, Box, Flex, Link as A, Text } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@modulz/radix-icons"; import { EventsQueryResult } from "apollo"; @@ -79,7 +80,7 @@ const Transaction = (props: { id: string | undefined }) => { } > - {props.id ? props.id.replace(props.id.slice(6, 62), "…") : "N/A"} + {props.id ? formatTransactionHash(props.id) : "N/A"} { }} > - Delegate vote ({shortenAddress(vote.delegate!.address)}) + Delegate vote ({formatAddress(vote.delegate!.address)}) {vote.delegate!.hasVoted ? "Voted" : "Not voted"} diff --git a/components/TxConfirmedDialog/index.tsx b/components/TxConfirmedDialog/index.tsx index 6f108a17..984676fa 100644 --- a/components/TxConfirmedDialog/index.tsx +++ b/components/TxConfirmedDialog/index.tsx @@ -19,7 +19,7 @@ import { useRouter } from "next/router"; import { useCallback } from "react"; import { MdReceipt } from "react-icons/md"; -import { fromWei, txMessages } from "../../lib/utils"; +import { formatAddress, fromWei, txMessages } from "../../lib/utils"; const Index = () => { const router = useRouter(); @@ -219,10 +219,7 @@ function renderSwitch(tx: TransactionStatus, onDismiss: () => void) { }} > You've successfully redelegated to orchestrator{" "} - {tx.inputData.delegate.replace( - tx.inputData.delegate.slice(7, 37), - "…" - )} + {formatAddress(tx.inputData.delegate)}
@@ -249,10 +246,7 @@ function renderSwitch(tx: TransactionStatus, onDismiss: () => void) { You've successfully checkpointed{" "} {!isOrchestrator ? "your stake" - : `your orchestrator (${targetAddress?.replace( - targetAddress?.slice(7, 37) ?? "", - "…" - )}) stake!`} + : `your orchestrator (${formatAddress(targetAddress)}) stake!`} diff --git a/components/TxStartedDialog/index.tsx b/components/TxStartedDialog/index.tsx index 3c80c857..6b8df896 100644 --- a/components/TxStartedDialog/index.tsx +++ b/components/TxStartedDialog/index.tsx @@ -1,4 +1,4 @@ -import { fromWei, txMessages } from "@lib/utils"; +import { formatAddress, fromWei, txMessages } from "@lib/utils"; import { Badge, Box, @@ -90,7 +90,7 @@ function Table({ tx, account }: { tx: TransactionStatus; account: string }) { }} > - Your account {account?.replace(account?.slice(7, 37), "…")} + Your account {formatAddress(account)} @@ -107,8 +107,7 @@ function Inputs({ tx }: { tx: TransactionStatus }) { return ( <> - Delegate{" "} - {inputData.to.replace(inputData.to.slice(7, 37), "…")} + Delegate {formatAddress(inputData.to)} {Number(inputData.amount) > 0 ? ( @@ -134,8 +133,7 @@ function Inputs({ tx }: { tx: TransactionStatus }) { return ( <> - Delegate{" "} - {inputData.delegate.replace(inputData.delegate.slice(7, 37), "…")} + Delegate {formatAddress(inputData.delegate)} ); diff --git a/components/Votes/VoteDetail/index.tsx b/components/Votes/VoteDetail/index.tsx index 578e6529..21e0f7dc 100644 --- a/components/Votes/VoteDetail/index.tsx +++ b/components/Votes/VoteDetail/index.tsx @@ -1,11 +1,10 @@ "use client"; import { Vote, VOTING_SUPPORT } from "@lib/api/types/votes"; -import { formatLpt } from "@lib/utils"; +import { formatAddress, formatLpt, formatTransactionHash } from "@lib/utils"; import { Badge, Box, Heading, Link, Text } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@modulz/radix-icons"; import React from "react"; -import { formatAddress } from "utils/formatAddress"; interface VoteDetailItemProps { vote: Vote; @@ -94,7 +93,7 @@ const Index: React.FC = ({ vote }) => { }} > - {formatAddress(vote.transactionHash)} + {formatTransactionHash(vote.transactionHash)} - {formatAddress(vote.transactionHash)} + {formatTransactionHash(vote.transactionHash)} - {formatAddress(vote.transactionHash)} + {formatTransactionHash(vote.transactionHash)} { > My Delegate Vote{" "} - {delegate && - `(${delegate?.id?.replace( - delegate?.id?.slice(5, 39), - "…" - )})`} + {delegate && `(${formatAddress(delegate?.id)})`} {data?.delegateVote?.choiceID @@ -258,8 +254,7 @@ const Index = ({ data }: { data: Props }) => { }} > - My Vote ( - {accountAddress.replace(accountAddress.slice(5, 39), "…")}) + My Vote ({formatAddress(accountAddress)}) { return stylesMap[status] || {}; // Returns styles if status is found, otherwise returns an empty object }; - function shortenAddress(address: string) { - if (address.length < 10) return address; // Handle short addresses - - const first = address.slice(0, 6); // Get the '0x' + first 4 characters - const last = address.slice(-4); // Get last 4 characters - - return `${first}...${last}`; // Return formatted string - } - useEffect(() => { const fetchingData = async () => { setIsLoading(true); @@ -284,7 +276,7 @@ const Index = () => { href={`https://explorer.livepeer.org/accounts/${el["LivepeerProposalStatus.voter"]}/delegating`} >
- {shortenAddress(el["LivepeerProposalStatus.voter"])} + {formatAddress(el["LivepeerProposalStatus.voter"])}
{ const [votes, setVotes] = useState([]); diff --git a/hooks/useSwr.tsx b/hooks/useSwr.tsx index 6da995cc..57492d30 100644 --- a/hooks/useSwr.tsx +++ b/hooks/useSwr.tsx @@ -21,6 +21,7 @@ import { RegisteredToVote, VotingPower, } from "@lib/api/types/get-treasury-proposal"; +import { formatAddress } from "@lib/utils"; import useSWR from "swr"; import { Address } from "viem"; @@ -39,7 +40,7 @@ export const useEnsData = (address: string | undefined | null): EnsIdentity => { const fallbackIdentity: EnsIdentity = { id: address ?? "", - idShort: address?.replace(address?.slice(6, 38), "…") ?? "", + idShort: formatAddress(address), name: null, }; diff --git a/layouts/main.tsx b/layouts/main.tsx index 24ed02b0..094c73cf 100644 --- a/layouts/main.tsx +++ b/layouts/main.tsx @@ -15,7 +15,7 @@ import TxSummaryDialog from "@components/TxSummaryDialog"; import URLVerificationBanner from "@components/URLVerificationBanner"; import { IS_L2 } from "@lib/chains"; import { globalStyles } from "@lib/globalStyles"; -import { EMPTY_ADDRESS } from "@lib/utils"; +import { EMPTY_ADDRESS, formatAddress } from "@lib/utils"; import { Badge, Box, @@ -856,13 +856,10 @@ const ContractAddressesPopover = ({ activeChain }: { activeChain?: Chain }) => { }} size="2" > - {contractAddresses?.[ - key as keyof typeof contractAddresses - ]?.address?.replace( + {formatAddress( contractAddresses?.[ key as keyof typeof contractAddresses - ]?.address?.slice(7, 37) ?? "", - "…" + ]?.address )} diff --git a/lib/api/ens.ts b/lib/api/ens.ts index b41eced1..777fd7a8 100644 --- a/lib/api/ens.ts +++ b/lib/api/ens.ts @@ -1,4 +1,5 @@ import { l1Provider } from "@lib/chains"; +import { formatAddress } from "@lib/utils"; import sanitizeHtml from "sanitize-html"; import { EnsIdentity } from "./types/get-ens"; @@ -102,7 +103,7 @@ export const nl2br = (str, is_xhtml = true) => { }; export const getEnsForVotes = async (address: string | null | undefined) => { - const idShort = address?.replace(address?.slice(6, 38), "…"); + const idShort = formatAddress(address); const name = address ? await l1Provider.lookupAddress(address) : null; diff --git a/lib/utils.test.ts b/lib/utils.test.ts index e125fbbd..02312d45 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -6,12 +6,12 @@ import { avg, checkAddressEquality, EMPTY_ADDRESS, + formatAddress, fromWei, getDelegatorStatus, getHint, getPercentChange, isImageUrl, - shortenAddress, simulateNewActiveSetOrder, textTruncate, toWei, @@ -336,19 +336,18 @@ describe("isImageUrl", () => { }); }); -describe("shortenAddress", () => { +describe("formatAddress", () => { it("shortens a normal ethereum address", () => { const addr = "0x1234567890abcdef1234567890abcdef12345678"; - const shortened = shortenAddress(addr); + const shortened = formatAddress(addr); // Implementation: replace address.slice(5, 39) with "…" - const expected = addr.slice(0, 5) + "…" + addr.slice(39); + const expected = addr.slice(0, 6) + "…" + addr.slice(-4); expect(shortened).toBe(expected); }); it("returns empty string for falsy address", () => { - // @ts-expect-error testing runtime behavior - expect(shortenAddress(null)).toBe(""); + expect(formatAddress(null)).toBe(""); }); }); diff --git a/lib/utils.tsx b/lib/utils.tsx index a2397602..ab154555 100644 --- a/lib/utils.tsx +++ b/lib/utils.tsx @@ -272,14 +272,6 @@ export const isImageUrl = (url: string): boolean => { return /\.(jpg|jpeg|png|gif|webp)$/i.test(url); }; -/** - * Shorten an Ethereum address for display. - * @param address - The address to shorten. - * @returns The shortened address. - */ -export const shortenAddress = (address: string) => - address?.replace(address.slice(5, 39), "…") ?? ""; - export const lptFormatter = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, @@ -288,3 +280,25 @@ export const lptFormatter = new Intl.NumberFormat("en-US", { export const formatLpt = (w: string) => { return `${lptFormatter.format(parseFloat(w) / 1e18)} LPT`; }; + +export const formatAddress = ( + addr: string | null | undefined, + startLength = 6, + endLength = 4 +): string => { + if (!addr) return ""; + if (addr.endsWith(".xyz")) { + return addr.length > 21 ? `${addr.slice(0, 6)}...${addr.slice(-6)}` : addr; + } + if (addr.endsWith(".eth") && addr.length < 21) { + return addr; + } + return addr.length > 21 + ? `${addr.slice(0, startLength)}…${addr.slice(-endLength)}` + : addr; +}; + +export const formatTransactionHash = (id: string | null | undefined) => { + if (!id) return ""; + return id.replace(id.slice(6, 62), "…"); +}; diff --git a/pages/index.tsx b/pages/index.tsx index b1fe05d4..e927d6d1 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -603,7 +603,6 @@ export const getStaticProps = async () => { const response = await getCubeData(query, { type: CUBE_TYPE.SERVER }); // Log the response to check the structure of the data - if (!response) { return { props: { diff --git a/pages/migrate/broadcaster.tsx b/pages/migrate/broadcaster.tsx index 906ae453..73b829f5 100644 --- a/pages/migrate/broadcaster.tsx +++ b/pages/migrate/broadcaster.tsx @@ -12,6 +12,7 @@ import { l2Provider, l2PublicClient, } from "@lib/chains"; +import { formatAddress, formatTransactionHash } from "@lib/utils"; import { Box, Button, @@ -874,12 +875,7 @@ function MigrationFields({ migrationParams, css = {} }) { Address - - {migrationParams.l1Addr.replace( - migrationParams.l1Addr.slice(6, 38), - "…" - )} - + {formatAddress(migrationParams.l1Addr)} Deposit @@ -914,7 +910,7 @@ function ReceiptLink({ label, hash, chainId }) { rel="noopener noreferrer" href={`${CHAIN_INFO[chainId].explorer}tx/${hash}`} > - {hash.replace(hash.slice(6, 62), "…")} + {formatTransactionHash(hash)} diff --git a/pages/migrate/delegator/index.tsx b/pages/migrate/delegator/index.tsx index 29f10669..5e52513a 100644 --- a/pages/migrate/delegator/index.tsx +++ b/pages/migrate/delegator/index.tsx @@ -11,6 +11,7 @@ import { l2Provider, l2PublicClient, } from "@lib/chains"; +import { formatAddress, formatTransactionHash } from "@lib/utils"; import { Box, Button, @@ -877,12 +878,7 @@ function MigrationFields({ migrationParams, css = {} }) { Address - - {migrationParams.l1Addr.replace( - migrationParams.l1Addr.slice(6, 38), - "…" - )} - + {formatAddress(migrationParams.l1Addr)} @@ -915,7 +911,7 @@ function ReceiptLink({ label, hash, chainId }) { rel="noopener noreferrer" href={`${CHAIN_INFO[chainId].explorer}tx/${hash}`} > - {hash.replace(hash.slice(6, 62), "…")} + {formatTransactionHash(hash)} diff --git a/pages/migrate/orchestrator.tsx b/pages/migrate/orchestrator.tsx index 41610969..2ae59910 100644 --- a/pages/migrate/orchestrator.tsx +++ b/pages/migrate/orchestrator.tsx @@ -6,6 +6,7 @@ import { l1Migrator } from "@lib/api/abis/bridge/L1Migrator"; import { nodeInterface } from "@lib/api/abis/bridge/NodeInterface"; import { getL1MigratorAddress } from "@lib/api/contracts"; import { isL2ChainId, l1PublicClient, l2PublicClient } from "@lib/chains"; +import { formatAddress, formatTransactionHash } from "@lib/utils"; import { Box, Button, @@ -866,12 +867,7 @@ function MigrationFields({ migrationParams, css = {} }) { Address - - {migrationParams.delegate.replace( - migrationParams.delegate.slice(6, 38), - "…" - )} - + {formatAddress(migrationParams.delegate)} Self stake @@ -914,7 +910,7 @@ function ReceiptLink({ label, hash, chainId }) { rel="noopener noreferrer" href={`${CHAIN_INFO[chainId].explorer}tx/${hash}`} > - {hash.replace(hash.slice(6, 62), "…")} + {formatTransactionHash(hash)} diff --git a/pages/treasury/[proposal].tsx b/pages/treasury/[proposal].tsx index 49fd127b..21aa6dfe 100644 --- a/pages/treasury/[proposal].tsx +++ b/pages/treasury/[proposal].tsx @@ -11,7 +11,7 @@ import { livepeerToken } from "@lib/api/abis/main/LivepeerToken"; import { getProposalExtended } from "@lib/api/treasury"; import { CHAIN_INFO, DEFAULT_CHAIN, DEFAULT_CHAIN_ID } from "@lib/chains"; import dayjs from "@lib/dayjs"; -import { abbreviateNumber, fromWei, shortenAddress } from "@lib/utils"; +import { abbreviateNumber, formatAddress, fromWei } from "@lib/utils"; import { Badge, Box, @@ -227,7 +227,7 @@ const Proposal = () => { Proposed by{" "} - {proposerId?.name ?? shortenAddress(proposal.proposer.id)} + {proposerId?.name ?? formatAddress(proposal.proposer.id)} @@ -487,7 +487,7 @@ const Proposal = () => { size="2" > {width <= 640 - ? shortenAddress(action.lptTransfer.receiver) + ? formatAddress(action.lptTransfer.receiver) : action.lptTransfer.receiver} @@ -545,7 +545,7 @@ const Proposal = () => { size="2" > {action.contract - ? `${action.contract?.name} (${shortenAddress( + ? `${action.contract?.name} (${formatAddress( action.target )})` : action.target} diff --git a/utils/formatAddress.ts b/utils/formatAddress.ts deleted file mode 100644 index 014029c5..00000000 --- a/utils/formatAddress.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const formatAddress = (addr: string): string => { - if (!addr) return ""; - if (addr.endsWith(".xyz")) { - return addr.length > 21 ? `${addr.slice(0, 6)}...${addr.slice(-6)}` : addr; - } - if (addr.endsWith(".eth") && addr.length < 21) { - return addr; - } - return addr.length > 21 ? `${addr.slice(0, 6)}...${addr.slice(-6)}` : addr; -}; From a443e2b551e2e3e61e075c472c7cdb3eb2e70eca Mon Sep 17 00:00:00 2001 From: jipstavenuiter Date: Tue, 23 Dec 2025 18:13:57 +0100 Subject: [PATCH 09/21] fix: orchestrators page early return --- pages/orchestrators.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pages/orchestrators.tsx b/pages/orchestrators.tsx index 93b6d18a..68e3e252 100644 --- a/pages/orchestrators.tsx +++ b/pages/orchestrators.tsx @@ -218,9 +218,7 @@ export const getStaticProps = async () => { // @ts-expect-error - query is a string const response = await getCubeData(query, { type: CUBE_TYPE.SERVER }); - // Log the response to check the structure of the data - - if (!response || !response[0] || !response[0].data) { + if (!response) { return { props: { initialVoterData: [], @@ -228,9 +226,7 @@ export const getStaticProps = async () => { }; } - const data = response[0].data; - - const voterSummaries = getVoterSummaries(data); + const voterSummaries = getVoterSummaries(response); if (!orchestrators.data || !protocol.data) { return { From 6e55877ad1b1bd1a17db84fb0a5b424d6b7cf723 Mon Sep 17 00:00:00 2001 From: thebeyondr <19380973+thebeyondr@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:11:24 -0500 Subject: [PATCH 10/21] feat: add governance participation stat card to orchestrator overview - Fetches voting metrics (voted/eligible) from Cube.js using LivepeerVoteProposals - Adds an interactive Stat card to OrchestratingView with a visual progress bar - Displays participation rate percentage and integrated link to account history - Adds utility to the Cube query generator --- components/OrchestratingView/index.tsx | 154 ++++++++++++++++++++++++- cube/query-generator.ts | 22 ++++ 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/components/OrchestratingView/index.tsx b/components/OrchestratingView/index.tsx index df2cf494..e89fc8de 100644 --- a/components/OrchestratingView/index.tsx +++ b/components/OrchestratingView/index.tsx @@ -1,12 +1,15 @@ import Stat from "@components/Stat"; import dayjs from "@lib/dayjs"; -import { Box, Flex } from "@livepeer/design-system"; -import { CheckIcon, Cross1Icon } from "@modulz/radix-icons"; +import { Box, Flex, Link as A, Text } from "@livepeer/design-system"; +import { ArrowTopRightIcon, CheckIcon, Cross1Icon } from "@modulz/radix-icons"; import { AccountQueryResult } from "apollo"; +import { CUBE_TYPE, getCubeData } from "cube/cube-client"; +import { getAccountVoterSummary } from "cube/query-generator"; import { useScoreData } from "hooks"; import { useRegionsData } from "hooks/useSwr"; +import Link from "next/link"; import numbro from "numbro"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import Masonry from "react-masonry-css"; const breakpointColumnsObj = { @@ -24,6 +27,11 @@ interface Props { isActive: boolean; } +interface GovStats { + voted: number; + eligible: number; +} + const Index = ({ currentRound, transcoder, isActive }: Props) => { const callsMade = useMemo( () => transcoder?.pools?.filter((r) => r.rewardTokens != null)?.length ?? 0, @@ -33,6 +41,41 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { const scores = useScoreData(transcoder?.id); const knownRegions = useRegionsData(); + const [govStats, setGovStats] = useState(null); + + useEffect(() => { + const fetchGovStats = async () => { + if (!transcoder?.id) return; + + try { + const cubeQuery = getAccountVoterSummary(transcoder.id); + const response = await getCubeData(cubeQuery, { + type: CUBE_TYPE.SERVER, + }); + + // Some cube responses are wrapped in [0].data, others are direct arrays + const data = Array.isArray(response) + ? response[0]?.data || response + : null; + + if (data && data.length > 0) { + const votedCount = Number( + data[0]["LivepeerVoteProposals.numOfVoteCasted"] || 0 + ); + const eligibleCount = Number( + data[0]["LivepeerVoteProposals.numOfProposals"] || 0 + ); + + setGovStats({ voted: votedCount, eligible: eligibleCount }); + } + } catch (error) { + console.error("Error fetching governance stats:", error); + } + }; + + fetchGovStats(); + }, [transcoder?.id]); + const maxScore = useMemo(() => { const topTransData = Object.keys(scores?.scores ?? {}).reduce( (prev, curr) => { @@ -279,6 +322,111 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { } /> )} + + + Number of proposals voted on relative to the number of proposals + the orchestrator was eligible for while active. + + } + value={ + govStats ? ( + + {govStats.voted} + + / {govStats.eligible} Proposals + + + ) : ( + "N/A" + ) + } + meta={ + + {govStats && ( + + + + )} + + {govStats && ( + + {numbro(govStats.voted / govStats.eligible).format({ + output: "percent", + mantissa: 0, + })}{" "} + Participation + + )} + + See history + + + + + } + /> + ); diff --git a/cube/query-generator.ts b/cube/query-generator.ts index 98b9e855..e4a22c1c 100644 --- a/cube/query-generator.ts +++ b/cube/query-generator.ts @@ -51,3 +51,25 @@ export const getOrchestratorsVotingHistory = () => { ] }`; }; + +export const getAccountVoterSummary = (id: string): Query => { + return `{ + "measures": [ + "LivepeerVoteProposals.count", + "LivepeerVoteProposals.numOfProposals", + "LivepeerVoteProposals.numOfVoteCasted" + ], + "dimensions": [ + "LivepeerVoteProposals.voter" + ], + "filters": [ + { + "member": "LivepeerVoteProposals.voter", + "operator": "equals", + "values": [ + "${id}" + ] + } + ] + }`; +}; From 504f60436b9a21dfca8a83d357fb2c872fc57564 Mon Sep 17 00:00:00 2001 From: thebeyondr <19380973+thebeyondr@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:14:13 -0500 Subject: [PATCH 11/21] fix: add TypeScript error handling for string query in getAccountVoterSummary --- cube/query-generator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cube/query-generator.ts b/cube/query-generator.ts index e4a22c1c..6f819d9e 100644 --- a/cube/query-generator.ts +++ b/cube/query-generator.ts @@ -53,6 +53,7 @@ export const getOrchestratorsVotingHistory = () => { }; export const getAccountVoterSummary = (id: string): Query => { + // @ts-expect-error - this is a string query return `{ "measures": [ "LivepeerVoteProposals.count", From 278a93ad8a365bbf4273380f6cceeb779fbb0a0f Mon Sep 17 00:00:00 2001 From: thebeyondr <19380973+thebeyondr@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:43:40 -0500 Subject: [PATCH 12/21] refactor: refresh the treasury voters list experience and unify terminology - Moved to a friendlier "Sky & Tomato" color scheme to keep voting separate from our main green brand accent. - Standardized labels to "For / Against / Abstain" everywhere, including the global transaction feed. - Rebuilt the vote table using our core data table component for a consistent look. - Made it easier to explore voter history with new dedicated buttons, better tooltips, and subtle animations. - Cleaned up the layout by stripping out redundant titles and shrinking fonts where they were getting too loud. - Polished the details: added specific styling for "No reason provided" and refined how voter profiles are we showing voter names and txn links. --- components/Table/index.tsx | 1 - components/TreasuryVotingWidget/index.tsx | 43 ++- .../VoteTable/Views/DesktopVoteTable.tsx | 289 +++++++++++++----- .../Votes/VoteTable/Views/MobileVoteTable.tsx | 143 +++++---- components/Votes/VoteTable/Views/VoteItem.tsx | 190 ++++++++---- components/Votes/VoteTable/index.tsx | 89 ++++-- lib/api/types/votes.ts | 6 +- pages/treasury/[proposal].tsx | 66 +--- 8 files changed, 531 insertions(+), 296 deletions(-) diff --git a/components/Table/index.tsx b/components/Table/index.tsx index 6e9832fa..239e427f 100644 --- a/components/Table/index.tsx +++ b/components/Table/index.tsx @@ -98,7 +98,6 @@ function DataTable({ css={{ borderCollapse: "collapse", tableLayout: "auto", - minWidth: 980, width: "100%", "@bp4": { width: "100%", diff --git a/components/TreasuryVotingWidget/index.tsx b/components/TreasuryVotingWidget/index.tsx index 68680353..a9f5e0e1 100644 --- a/components/TreasuryVotingWidget/index.tsx +++ b/components/TreasuryVotingWidget/index.tsx @@ -111,7 +111,7 @@ const TreasuryVotingWidget = ({ proposal, vote, ...props }: Props) => { css={{ height: "100%", borderRadius: 3, - backgroundColor: "$neutral9", + backgroundColor: "$tomato9", width: `${proposal.votes.percent.against * 100}%`, }} /> @@ -162,7 +162,7 @@ const TreasuryVotingWidget = ({ proposal, vote, ...props }: Props) => { css={{ height: "100%", borderRadius: 3, - backgroundColor: "$green9", + backgroundColor: "$sky9", width: `${proposal.votes.percent.for * 100}%`, }} /> @@ -233,13 +233,26 @@ const TreasuryVotingWidget = ({ proposal, vote, ...props }: Props) => { {/* Summary line */} - - {abbreviateNumber(proposal.votes.total.voters, 4)} LPT voted ·{" "} - {proposal.state !== "Pending" && proposal.state !== "Active" - ? "Final Results" - : dayjs.duration(proposal.votes.voteEndTime.diff()).humanize() + - " left"} - + + + {abbreviateNumber(proposal.votes.total.voters, 4)} LPT voted ·{" "} + {proposal.state !== "Pending" && proposal.state !== "Active" + ? "Final Results" + : dayjs.duration(proposal.votes.voteEndTime.diff()).humanize() + + " left"} + + + View votes + + {/* ========== YOUR VOTE SECTION ========== */} @@ -321,20 +334,28 @@ const TreasuryVotingWidget = ({ proposal, vote, ...props }: Props) => { }} > Against For diff --git a/components/Votes/VoteTable/Views/DesktopVoteTable.tsx b/components/Votes/VoteTable/Views/DesktopVoteTable.tsx index 4a97212d..88474e6b 100644 --- a/components/Votes/VoteTable/Views/DesktopVoteTable.tsx +++ b/components/Votes/VoteTable/Views/DesktopVoteTable.tsx @@ -1,102 +1,225 @@ -import { Box, Flex, Text } from "@livepeer/design-system"; -import React from "react"; +import DataTable from "@components/Table"; +import { formatTransactionHash } from "@lib/utils"; +import { Badge, Box, Link, Text, Tooltip } from "@livepeer/design-system"; +import { + ArrowTopRightIcon, + CounterClockwiseClockIcon, +} from "@radix-ui/react-icons"; +import React, { useMemo } from "react"; +import { Column } from "react-table"; -import { Vote } from "../../../../lib/api/types/votes"; -import { VoteView } from "./VoteItem"; +import { Vote, VOTING_SUPPORT } from "../../../../lib/api/types/votes"; export interface VoteTableProps { votes: Vote[]; - counts: { yes: number; no: number; abstain: number }; formatWeight: (weight: string) => string; onSelect: (voter: string) => void; + pageSize?: number; + totalPages?: number; + currentPage?: number; + onPageChange?: (page: number) => void; } export const DesktopVoteTable: React.FC = ({ votes, - counts, formatWeight, onSelect, -}) => ( - - - Vote Results - - - - Yes ({counts.yes}) - - | - - No ({counts.no}) - - | - - Abstain ({counts.abstain}) - - - - - Click on a vote to view a voters proposal voting history. - - - - - {["Voter", "Support", "Weight", "Reason", "Vote Txn"].map((label) => ( - { + const columns = useMemo[]>( + () => [ + { + Header: "Voter", + accessor: "ensName", + id: "voter", + Cell: ({ row }) => ( + + e.stopPropagation()} > - {label} + + {row.original.ensName} + + + + ), + }, + { + Header: "Support", + accessor: "choiceID", + id: "support", + Cell: ({ row }) => { + const support = + VOTING_SUPPORT[ + row.original.choiceID as keyof typeof VOTING_SUPPORT + ] || VOTING_SUPPORT["2"]; + return ( + + + {support.text} + - ))} - - - - {votes.map((vote) => { + ); + }, + }, + { + Header: "Weight", + accessor: "weight", + id: "weight", + Cell: ({ row }) => ( + + + {formatWeight(row.original.weight)} + + + ), + sortType: (rowA, rowB) => { return ( - + parseFloat(rowA.original.weight) - parseFloat(rowB.original.weight) ); - })} - + }, + }, + { + Header: "Reason", + accessor: "reason", + id: "reason", + Cell: ({ row }) => ( + + + {row.original.reason} + + + ), + }, + { + Header: "Vote Txn", + accessor: "transactionHash", + id: "transaction", + Cell: ({ row }) => ( + + {row.original.transactionHash ? ( + e.stopPropagation()} + css={{ + display: "inline-block", + transition: "transform 0.2s ease", + "&:hover": { transform: "scale(1.1)" }, + }} + > + + {formatTransactionHash(row.original.transactionHash)} + + + + ) : ( + + N/A + + )} + + ), + }, + { + Header: "", + id: "history", + Cell: ({ row }) => ( + + + { + e.stopPropagation(); + onSelect(row.original.voter); + }} + css={{ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 32, + height: 32, + borderRadius: "50%", + cursor: "pointer", + border: "none", + backgroundColor: "transparent", + color: "$neutral10", + "&:hover": { + color: "$primary11", + backgroundColor: "$primary3", + transform: "rotate(-15deg)", + }, + transition: "color .2s, background-color .2s, transform .2s", + }} + > + + + + + ), + disableSortBy: true, + }, + ], + [formatWeight, onSelect] + ); + + return ( + + - -); + ); +}; diff --git a/components/Votes/VoteTable/Views/MobileVoteTable.tsx b/components/Votes/VoteTable/Views/MobileVoteTable.tsx index 58cb9ab3..1e185735 100644 --- a/components/Votes/VoteTable/Views/MobileVoteTable.tsx +++ b/components/Votes/VoteTable/Views/MobileVoteTable.tsx @@ -1,72 +1,87 @@ -import { Box, Flex, Text } from "@livepeer/design-system"; +import { Box, Button, Flex, Text } from "@livepeer/design-system"; +import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"; import React from "react"; import { VoteTableProps } from "./DesktopVoteTable"; import { VoteView } from "./VoteItem"; -export const MobileVoteCards: React.FC = ({ - votes, - counts, - formatWeight, - onSelect, -}) => ( - - - Vote Results - - - - Yes ({counts.yes}) - - | - - No ({counts.no}) - - | - - Abstain ({counts.abstain}) +export const MobileVoteCards: React.FC = (props) => { + const { + votes, + formatWeight, + onSelect, + totalPages = 0, + currentPage = 1, + onPageChange, + } = props; + + return ( + + + Click on a vote to view a voter's proposal voting history. - - - Click on a vote to view a voters proposal voting history. - + {votes.map((vote) => { + return ( + + ); + })} - {votes.map((vote) => { - return ( - - ); - })} - -); + {/* Pagination */} + {totalPages > 1 && ( + + + + Page {currentPage} of {totalPages} + + + + )} + + ); +}; diff --git a/components/Votes/VoteTable/Views/VoteItem.tsx b/components/Votes/VoteTable/Views/VoteItem.tsx index f956b615..8f1df235 100644 --- a/components/Votes/VoteTable/Views/VoteItem.tsx +++ b/components/Votes/VoteTable/Views/VoteItem.tsx @@ -1,6 +1,18 @@ import { formatTransactionHash } from "@lib/utils"; -import { Badge, Box, Card, Heading, Link, Text } from "@livepeer/design-system"; -import { ArrowTopRightIcon } from "@modulz/radix-icons"; +import { + Badge, + Box, + Card, + Flex, + Heading, + Link, + Text, + Tooltip, +} from "@livepeer/design-system"; +import { + ArrowTopRightIcon, + CounterClockwiseClockIcon, +} from "@radix-ui/react-icons"; import { Vote, VOTING_SUPPORT } from "../../../../lib/api/types/votes"; @@ -39,27 +51,20 @@ function MobileVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { css={{ padding: "$4", marginBottom: "$3", - cursor: "pointer", position: "relative", zIndex: 2, backgroundColor: "$neutral3", - "&:hover": { bg: "$neutral4" }, - }} - onClickCapture={(e) => { - if ((e.target as HTMLElement).closest("a")) return; - e.stopPropagation(); - onSelect(vote.voter); }} > - + Support: - + {support.text} - - + + Weight: {" "} - {formatWeight(vote.weight)} + + {formatWeight(vote.weight)} + - - + + Reason: {" "} - {vote.reason} + + {vote.reason} + - - {vote.transactionHash ? ( - e.stopPropagation()} - css={{ - display: "inline-block", - transition: "transform 0.2s ease", - "&:hover": { transform: "scale(1.1)" }, - }} - > - - {formatTransactionHash(vote.transactionHash)} - - - - ) : ( - N/A - )} - + + + {vote.transactionHash ? ( + e.stopPropagation()} + css={{ + display: "inline-block", + transition: "transform 0.2s ease", + "&:hover": { transform: "scale(1.1)" }, + }} + > + + {formatTransactionHash(vote.transactionHash)} + + + + ) : ( + N/A + )} + + onSelect(vote.voter)} + > + + History + + + + ); } @@ -124,23 +154,16 @@ function DesktopVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { as="tr" css={{ backgroundColor: "$neutral3", - cursor: "pointer", position: "relative", zIndex: 2, - "&:hover": { backgroundColor: "$neutral4" }, "& > td": { padding: "$2 $3" }, }} - onClickCapture={(e) => { - if ((e.target as HTMLElement).closest("a")) return; - e.stopPropagation(); - onSelect(vote.voter); - }} > @@ -148,7 +171,7 @@ function DesktopVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { href={`https://explorer.livepeer.org/accounts/${vote.voter}/delegating`} target="_blank" css={{ - color: "$green11", + color: "$primary11", textDecoration: "none", "&:hover": { textDecoration: "underline" }, }} @@ -157,7 +180,7 @@ function DesktopVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { - {vote.reason} + + {vote.reason} + {formatTransactionHash(vote.transactionHash)}
@@ -245,6 +278,47 @@ function DesktopVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { N/A )}
+ + + { + e.stopPropagation(); + onSelect(vote.voter); + }} + css={{ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 32, + height: 32, + borderRadius: "50%", + cursor: "pointer", + border: "none", + backgroundColor: "transparent", + color: "$neutral10", + "&:hover": { + color: "$primary11", + backgroundColor: "$primary3", + transform: "rotate(-15deg)", + }, + transition: "color .2s, background-color .2s, transform .2s", + }} + > + + + +
); } diff --git a/components/Votes/VoteTable/index.tsx b/components/Votes/VoteTable/index.tsx index 1f264f3d..ea4c90e9 100644 --- a/components/Votes/VoteTable/index.tsx +++ b/components/Votes/VoteTable/index.tsx @@ -1,10 +1,7 @@ -"use client"; - import Spinner from "@components/Spinner"; -import { Vote } from "@lib/api/types/votes"; import { lptFormatter } from "@lib/utils"; import { Flex, Text } from "@livepeer/design-system"; -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useWindowSize } from "react-use"; import { useFetchVotes } from "../../../hooks/TreasuryVotes/useFetchVotes"; @@ -16,27 +13,58 @@ interface VoteTableProps { proposalId: string; } -const countVotes = (votes: Vote[]) => { - return { - yes: votes.filter((v) => v.choiceID === "1").length || 0, - no: votes.filter((v) => v.choiceID === "0").length || 0, - abstain: votes.filter((v) => v.choiceID === "2").length || 0, - }; -}; - const Index: React.FC = ({ proposalId }) => { const { votes, loading, error } = useFetchVotes(proposalId); const { width } = useWindowSize(); const isDesktop = width >= 768; const [selectedVoter, setSelectedVoter] = useState(null); - const counts = countVotes(votes); - const totalWeight = votes.reduce((sum, v) => sum + parseFloat(v.weight), 0); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const totalWeight = useMemo( + () => votes.reduce((sum, v) => sum + parseFloat(v.weight), 0), + [votes] + ); + + const formatWeight = useMemo( + () => (w: string) => + `${lptFormatter.format(parseFloat(w) / 1e18)} LPT (${ + totalWeight > 0 ? ((parseFloat(w) / totalWeight) * 100).toFixed(2) : "0" + }%)`, + [totalWeight] + ); - const formatWeight = (w: string) => - `${lptFormatter.format(parseFloat(w) / 1e18)} LPT (${ - totalWeight > 0 ? ((parseFloat(w) / totalWeight) * 100).toFixed(2) : "0" - }%)`; + const paginatedVotesForMobile = useMemo(() => { + const sorted = [...votes].sort( + (a, b) => parseFloat(b.weight) - parseFloat(a.weight) + ); + const startIndex = (currentPage - 1) * pageSize; + return sorted.slice(startIndex, startIndex + pageSize); + }, [votes, currentPage, pageSize]); + + const totalPages = Math.ceil(votes.length / pageSize); + + // #region agent log + useEffect(() => { + fetch("http://127.0.0.1:7242/ingest/8cfdffd9-8818-4982-b018-96c8efb80746", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + location: "VoteTable/index.tsx:50", + message: "Passing props to MobileVoteCards", + data: { + paginatedVotesCount: paginatedVotesForMobile.length, + currentPage, + totalPages, + }, + timestamp: Date.now(), + sessionId: "debug-mobile-table", + hypothesisId: "B", + }), + }).catch(() => {}); + }, [paginatedVotesForMobile.length, currentPage, totalPages]); + // #endregion if (loading) { return ( @@ -65,16 +93,25 @@ const Index: React.FC = ({ proposalId }) => { ); - const VoteListView = isDesktop ? DesktopVoteTable : MobileVoteCards; - return ( <> - + {isDesktop ? ( + + ) : ( + + )} {selectedVoter && ( { const currentRound = useCurrentRoundData(); const { votes, loading: votesLoading } = useFetchVotes(proposalId ?? ""); - const [votesOpen, setVotesOpen] = useState(false); useEffect(() => { setIsDesktop(width >= 768); @@ -184,7 +183,9 @@ const Proposal = () => { return ( <> - Livepeer Explorer - Treasury + + {proposal.attributes.title} | Proposal | Livepeer Explorer + { }} > - + For ({formatPercent(proposal.votes.percent.for)}) @@ -326,7 +327,7 @@ const Proposal = () => { }} > - + Against ( {formatPercent(proposal.votes.percent.against)}) @@ -344,7 +345,7 @@ const Proposal = () => { }} > - + Abstain ( {formatPercent(proposal.votes.percent.abstain)}) @@ -645,59 +646,24 @@ const Proposal = () => { setVotesOpen(!votesOpen)} > - - - - - {votesLoading - ? "Loading votes…" - : `View Votes (${votes.length})`} - - - - - {votesOpen ? "–" : "+"} - - - - {votesOpen && ( - {votesContent()} - )} + {votesLoading ? "Loading votes…" : `Votes (${votes.length})`} + + {votesContent()} From 5c758d81e406c7a5f075e297aac2c5c960ec74f1 Mon Sep 17 00:00:00 2001 From: thebeyondr <19380973+thebeyondr@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:44:05 -0500 Subject: [PATCH 13/21] refactor: remove unused agent log and clean up VoteTable component - Eliminated the agent log useEffect that was posting debug information. - Streamlined imports by removing unnecessary useEffect import. --- components/Votes/VoteTable/index.tsx | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/components/Votes/VoteTable/index.tsx b/components/Votes/VoteTable/index.tsx index ea4c90e9..b75342a3 100644 --- a/components/Votes/VoteTable/index.tsx +++ b/components/Votes/VoteTable/index.tsx @@ -1,7 +1,7 @@ import Spinner from "@components/Spinner"; import { lptFormatter } from "@lib/utils"; import { Flex, Text } from "@livepeer/design-system"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo, useState } from "react"; import { useWindowSize } from "react-use"; import { useFetchVotes } from "../../../hooks/TreasuryVotes/useFetchVotes"; @@ -45,27 +45,6 @@ const Index: React.FC = ({ proposalId }) => { const totalPages = Math.ceil(votes.length / pageSize); - // #region agent log - useEffect(() => { - fetch("http://127.0.0.1:7242/ingest/8cfdffd9-8818-4982-b018-96c8efb80746", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - location: "VoteTable/index.tsx:50", - message: "Passing props to MobileVoteCards", - data: { - paginatedVotesCount: paginatedVotesForMobile.length, - currentPage, - totalPages, - }, - timestamp: Date.now(), - sessionId: "debug-mobile-table", - hypothesisId: "B", - }), - }).catch(() => {}); - }, [paginatedVotesForMobile.length, currentPage, totalPages]); - // #endregion - if (loading) { return ( Date: Tue, 23 Dec 2025 19:45:28 -0500 Subject: [PATCH 14/21] refactor: update voting badge terminology and styling in TransactionsList - Changed badge colors to use a "Sky & Tomato" color scheme for better visual distinction. - Updated badge labels from "Yes/No" to "For/Against" to standardize terminology across the application. --- components/TransactionsList/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/components/TransactionsList/index.tsx b/components/TransactionsList/index.tsx index f887da8a..8b9fe98c 100644 --- a/components/TransactionsList/index.tsx +++ b/components/TransactionsList/index.tsx @@ -379,10 +379,13 @@ const TransactionsList = ({ {`Voted `} - {+event?.choiceID === 0 ? '"Yes"' : '"No"'} + {+event?.choiceID === 0 ? '"Against"' : '"For"'} {` on a proposal`} {renderEmoji("👩‍⚖️")} From debdee45e055bcd5dfae48a0bf57597d52a8fa2d Mon Sep 17 00:00:00 2001 From: thebeyondr <19380973+thebeyondr@users.noreply.github.com> Date: Tue, 23 Dec 2025 20:37:48 -0500 Subject: [PATCH 15/21] fix(votes): correct SWR key to resolve missing proposal titles --- hooks/TreasuryVotes/useInfuraVoterVotes.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/hooks/TreasuryVotes/useInfuraVoterVotes.ts b/hooks/TreasuryVotes/useInfuraVoterVotes.ts index 8224d377..87283a28 100644 --- a/hooks/TreasuryVotes/useInfuraVoterVotes.ts +++ b/hooks/TreasuryVotes/useInfuraVoterVotes.ts @@ -59,10 +59,13 @@ export function useInfuraVoterVotes(voter: string) { const { data, isLoading: proposalsLoading } = useSWR< GetProposalsByIdsQueryResult["data"] - >(`/api/ssr/sorted-orchestrators`, async () => { - const { proposals } = await getProposalsByIds(proposalIds); - return proposals.data as GetProposalsByIdsQueryResult["data"]; - }); + >( + proposalIds.length > 0 ? [`/api/treasury/proposals`, proposalIds] : null, + async () => { + const { proposals } = await getProposalsByIds(proposalIds); + return proposals.data as GetProposalsByIdsQueryResult["data"]; + } + ); const votes: Vote[] = useMemo(() => { if (logsLoading || proposalsLoading) return []; From a9684e13d604d6be4db7d9e265c65cedf148be46 Mon Sep 17 00:00:00 2001 From: thebeyondr <19380973+thebeyondr@users.noreply.github.com> Date: Tue, 23 Dec 2025 20:38:53 -0500 Subject: [PATCH 16/21] feat(ux): add focus trap, keyboard shortcuts, and ARIA support to modal --- components/Votes/VoteModal/index.tsx | 170 +++++++++++++++++++++++---- 1 file changed, 150 insertions(+), 20 deletions(-) diff --git a/components/Votes/VoteModal/index.tsx b/components/Votes/VoteModal/index.tsx index 46ab4ebf..1b448985 100644 --- a/components/Votes/VoteModal/index.tsx +++ b/components/Votes/VoteModal/index.tsx @@ -1,14 +1,80 @@ -import { Box, Button } from "@livepeer/design-system"; -import React from "react"; +import { Box, Text } from "@livepeer/design-system"; +import { Cross1Icon } from "@radix-ui/react-icons"; +import React, { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; interface VoteModalProps { onClose: () => void; children: React.ReactNode; + title?: string; + header?: React.ReactNode; } -const Index: React.FC = ({ onClose, children }) => - createPortal( +const Index: React.FC = ({ + onClose, + children, + title, + header, +}) => { + const modalRef = useRef(null); + + useEffect(() => { + // Disable scroll on mount + const originalStyle = window.getComputedStyle(document.body).overflow; + document.body.style.overflow = "hidden"; + + // Focus management + const focusableElementsSelector = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const firstFocusableElement = modalRef.current?.querySelectorAll( + focusableElementsSelector + )[0] as HTMLElement; + + if (firstFocusableElement) { + firstFocusableElement.focus(); + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + + if (e.key === "Tab") { + const focusableContent = modalRef.current?.querySelectorAll( + focusableElementsSelector + ); + if (!focusableContent) return; + + const focusableArray = Array.from(focusableContent) as HTMLElement[]; + const firstElement = focusableArray[0]; + const lastElement = focusableArray[focusableArray.length - 1]; + + if (e.shiftKey) { + // if shift key pressed for shift + tab combination + if (document.activeElement === firstElement) { + lastElement.focus(); // add focus for the last focusable element + e.preventDefault(); + } + } else { + // if tab key is pressed + if (document.activeElement === lastElement) { + // if focused has reached to last element then focus again first element + firstElement.focus(); // add focus for the first focusable element + e.preventDefault(); + } + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + document.body.style.overflow = originalStyle; + window.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + return createPortal( = ({ onClose, children }) => display: "flex", alignItems: "center", justifyContent: "center", - backgroundColor: "rgba(0, 0, 0, 0.5)", + backgroundColor: "rgba(0, 0, 0, 0.7)", backdropFilter: "blur(4px)", zIndex: 9999, }} - onClick={(e) => e.stopPropagation()} + onClick={onClose} > e.stopPropagation()} > @@ -39,26 +111,83 @@ const Index: React.FC = ({ onClose, children }) => css={{ position: "sticky", top: 0, - right: 0, zIndex: 10, backgroundColor: "$neutral3", paddingLeft: "$4", paddingRight: "$4", - paddingTop: "$2", + paddingTop: "$4", paddingBottom: "$2", - display: "flex", - justifyContent: "flex-end", + borderBottom: "1px solid $neutral5", + borderTopLeftRadius: "$2", + borderTopRightRadius: "$2", }} > - + {title && ( + + {title} + + )} + + + ESC TO CLOSE + + + + + + + {header && {header}} = ({ onClose, children }) => overflowY: "auto", paddingLeft: "$4", paddingRight: "$4", - paddingTop: "$2", - paddingBottom: "$2", - maxHeight: "calc(90vh - 56px)", + paddingTop: "$4", + paddingBottom: "$4", + maxHeight: "calc(90vh - 100px)", }} > {children} @@ -77,5 +206,6 @@ const Index: React.FC = ({ onClose, children }) => , document.body ); +}; export default Index; From 4a2c314de7eee71f2597038e77dc27e076499da2 Mon Sep 17 00:00:00 2001 From: thebeyondr <19380973+thebeyondr@users.noreply.github.com> Date: Tue, 23 Dec 2025 20:40:46 -0500 Subject: [PATCH 17/21] feat(votes): add voting summary stats and ENS propagation to voter history modal --- components/Votes/VotePopover/index.tsx | 121 +++++++++++++++++- .../VoteTable/Views/DesktopVoteTable.tsx | 7 +- components/Votes/VoteTable/index.tsx | 8 +- 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/components/Votes/VotePopover/index.tsx b/components/Votes/VotePopover/index.tsx index 30da7228..fb09be0d 100644 --- a/components/Votes/VotePopover/index.tsx +++ b/components/Votes/VotePopover/index.tsx @@ -1,7 +1,8 @@ "use client"; import Spinner from "@components/Spinner"; -import { Flex, Text } from "@livepeer/design-system"; +import { Box, Flex, Link, Text } from "@livepeer/design-system"; +import { ArrowTopRightIcon } from "@radix-ui/react-icons"; import React from "react"; import { useInfuraVoterVotes } from "../../../hooks/TreasuryVotes/useInfuraVoterVotes"; @@ -10,14 +11,101 @@ import VoteModal from "../VoteModal"; interface VoterPopoverProps { voter: string; + ensName?: string; onClose: () => void; } -const Index: React.FC = ({ voter, onClose }) => { +const Index: React.FC = ({ voter, ensName, onClose }) => { const { votes, isLoading } = useInfuraVoterVotes(voter); + const stats = React.useMemo(() => { + if (!votes.length) return null; + return { + total: votes.length, + for: votes.filter((v) => v.choiceID === "1").length, + against: votes.filter((v) => v.choiceID === "0").length, + abstain: votes.filter((v) => v.choiceID === "2").length, + }; + }, [votes]); + + const summaryHeader = React.useMemo(() => { + return ( + + + + {ensName || voter} + + + + {stats && ( + + + + Total: + + + {stats.total} + + + + + + For: {stats.for} + + + + + + Against: {stats.against} + + + + + + Abstain: {stats.abstain} + + + + )} + + ); + }, [stats, voter, ensName]); + return ( - + {isLoading ? ( = ({ voter, onClose }) => { ) : votes.length > 0 ? ( - votes.map((vote, idx) => ( - - )) + + {votes.map((vote, idx) => ( + + + + ))} + ) : ( string; - onSelect: (voter: string) => void; + onSelect: (voter: { address: string; ensName?: string }) => void; pageSize?: number; totalPages?: number; currentPage?: number; @@ -170,7 +170,10 @@ export const DesktopVoteTable: React.FC = ({ as="button" onClick={(e) => { e.stopPropagation(); - onSelect(row.original.voter); + onSelect({ + address: row.original.voter, + ensName: row.original.ensName, + }); }} css={{ display: "inline-flex", diff --git a/components/Votes/VoteTable/index.tsx b/components/Votes/VoteTable/index.tsx index b75342a3..823bc571 100644 --- a/components/Votes/VoteTable/index.tsx +++ b/components/Votes/VoteTable/index.tsx @@ -18,7 +18,10 @@ const Index: React.FC = ({ proposalId }) => { const { width } = useWindowSize(); const isDesktop = width >= 768; - const [selectedVoter, setSelectedVoter] = useState(null); + const [selectedVoter, setSelectedVoter] = useState<{ + address: string; + ensName?: string; + } | null>(null); const [currentPage, setCurrentPage] = useState(1); const pageSize = 10; @@ -93,7 +96,8 @@ const Index: React.FC = ({ proposalId }) => { )} {selectedVoter && ( setSelectedVoter(null)} /> )} From 08039ebeb82e8e6f1853ebce938751f8beb7c57f Mon Sep 17 00:00:00 2001 From: thebeyondr <19380973+thebeyondr@users.noreply.github.com> Date: Tue, 23 Dec 2025 20:41:23 -0500 Subject: [PATCH 18/21] style(ui): refactor vote cards with timeline layout and high-contrast focus states --- components/Votes/VoteDetail/index.tsx | 227 ++++++++++++------ components/Votes/VoteTable/Views/VoteItem.tsx | 163 +++++++++---- components/VotingHistoryView/index.tsx | 186 +++++++++----- 3 files changed, 379 insertions(+), 197 deletions(-) diff --git a/components/Votes/VoteDetail/index.tsx b/components/Votes/VoteDetail/index.tsx index 21e0f7dc..7c8aa461 100644 --- a/components/Votes/VoteDetail/index.tsx +++ b/components/Votes/VoteDetail/index.tsx @@ -2,7 +2,7 @@ import { Vote, VOTING_SUPPORT } from "@lib/api/types/votes"; import { formatAddress, formatLpt, formatTransactionHash } from "@lib/utils"; -import { Badge, Box, Heading, Link, Text } from "@livepeer/design-system"; +import { Badge, Box, Flex, Heading, Link, Text } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@modulz/radix-icons"; import React from "react"; @@ -12,98 +12,165 @@ interface VoteDetailItemProps { const Index: React.FC = ({ vote }) => { const support = VOTING_SUPPORT[vote.choiceID] || VOTING_SUPPORT["2"]; + const hasReason = + vote.reason && vote.reason.toLowerCase() !== "no reason provided"; + return ( - - - {vote.proposalTitle} - - - - - - Proposal ID: - {" "} - - {formatAddress(vote.proposalId)} - - - - - - Support: - - - {support.text} - - - - - - Weight: - {" "} - - {formatLpt(vote.weight)} - - - - - - Reason: - {" "} - - {vote.reason || "No reason provided"} - - + {/* Timeline Dot */} + - - {vote.transactionHash ? ( - e.stopPropagation()} + + - - {formatTransactionHash(vote.transactionHash)} - - - - ) : ( - N/A - )} - + + + + {vote.proposalTitle} + + + + ID: {formatAddress(vote.proposalId)} + + + {hasReason && ( + + + “{vote.reason}” + + + )} + + + + + {support.text.toUpperCase()} + + + + {formatLpt(vote.weight)} + + + {vote.transactionHash && ( + + {formatTransactionHash(vote.transactionHash)} + + + )} + + + + ); }; diff --git a/components/Votes/VoteTable/Views/VoteItem.tsx b/components/Votes/VoteTable/Views/VoteItem.tsx index 8f1df235..0419e528 100644 --- a/components/Votes/VoteTable/Views/VoteItem.tsx +++ b/components/Votes/VoteTable/Views/VoteItem.tsx @@ -18,7 +18,7 @@ import { Vote, VOTING_SUPPORT } from "../../../../lib/api/types/votes"; interface VoteViewProps { vote: Vote; - onSelect: (voter: string) => void; + onSelect: (voter: { address: string; ensName?: string }) => void; formatWeight: (weight: string) => string; isMobile?: boolean; } @@ -46,6 +46,9 @@ export function VoteView({ function MobileVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { const support = VOTING_SUPPORT[vote.choiceID] || VOTING_SUPPORT["2"]; + const hasReason = + vote.reason && vote.reason.toLowerCase() !== "no reason provided"; + return ( - - + + + e.stopPropagation()} + > + {vote.ensName} + + + + {formatWeight(vote.weight)} + + + e.stopPropagation()} - > - {vote.ensName} - - - - - Support: - - {support.text} - - - - - Weight: - {" "} - - {formatWeight(vote.weight)} - - - - - Reason: - {" "} - - {vote.reason} - - +
+ + + {hasReason && ( + + + “{vote.reason}” + + + )} + {vote.transactionHash ? ( @@ -112,35 +133,57 @@ function MobileVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { display: "inline-block", transition: "transform 0.2s ease", "&:hover": { transform: "scale(1.1)" }, + "&:focus-visible": { + outline: "2px solid $primary11", + outlineOffset: "2px", + borderRadius: "2px", + }, }} > {formatTransactionHash(vote.transactionHash)} ) : ( - N/A + + N/A + )} - { + e.stopPropagation(); + onSelect({ address: vote.voter, ensName: vote.ensName }); }} - onClick={() => onSelect(vote.voter)} > History - - + + ); @@ -174,6 +217,11 @@ function DesktopVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { color: "$primary11", textDecoration: "none", "&:hover": { textDecoration: "underline" }, + "&:focus-visible": { + outline: "2px solid $primary11", + outlineOffset: "2px", + borderRadius: "2px", + }, }} onClick={(e) => e.stopPropagation()} > @@ -264,6 +312,11 @@ function DesktopVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { display: "inline-block", transition: "transform 0.2s ease", "&:hover": { transform: "scale(1.1)" }, + "&:focus-visible": { + outline: "2px solid $primary11", + outlineOffset: "2px", + borderRadius: "2px", + }, }} > @@ -291,7 +344,7 @@ function DesktopVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { as="button" onClick={(e) => { e.stopPropagation(); - onSelect(vote.voter); + onSelect({ address: vote.voter, ensName: vote.ensName }); }} css={{ display: "inline-flex", @@ -309,7 +362,13 @@ function DesktopVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { backgroundColor: "$primary3", transform: "rotate(-15deg)", }, - transition: "color .2s, background-color .2s, transform .2s", + "&:focus-visible": { + outline: "2px solid $primary11", + outlineOffset: "2px", + color: "$primary11", + backgroundColor: "$primary3", + }, + transition: "all 0.2s", }} > {
{votingTurnOut}%
-
+ {votingData && // @ts-expect-error - votingData is an array of objects votingData.map((el, index) => { + const status = el["LivepeerProposalStatus.status"]; + const statusStyle = getTextStyleByStatus(status); return ( -
-
- {el["LivepeerProposalStatus.nameOfProposal"]} -
-
- {getDateTimeAndRound( - el["LivepeerProposalStatus.date"], - el["LivepeerProposalStatus.round"] - )} -
-
- Proposed by{" "} - - livepeer.eth - -
-
- {el["LivepeerProposalStatus.status"]} -
- -
+ + + + + ); })} -
+
); }; From fc7293a0963bc7706bdbdb01de8df869fc5281f7 Mon Sep 17 00:00:00 2001 From: thebeyondr <19380973+thebeyondr@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:02:05 -0500 Subject: [PATCH 19/21] style(table): change overflow property from scroll to auto for better horizontal scrolling --- components/Table/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Table/index.tsx b/components/Table/index.tsx index 239e427f..84884998 100644 --- a/components/Table/index.tsx +++ b/components/Table/index.tsx @@ -80,7 +80,7 @@ function DataTable({ <> {input && ( From a0e5a1e2620b2faecfe2f99d7530227e05188acc Mon Sep 17 00:00:00 2001 From: thebeyondr <19380973+thebeyondr@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:16:29 -0500 Subject: [PATCH 20/21] style: refine treasury voting widget UI and accessibility - Enhance typography hierarchy and spacing in the 'Your vote' section. - Implement translucent button styles with high-contrast labels and subtle borders. - Improve progress bar visibility and color contrast for better scannability. - Adjust voting reason input for improved visual balance and focus states. Fixes livepeer/explorer#464 --- components/TreasuryVotingReason/index.tsx | 34 ++++++---- components/TreasuryVotingWidget/index.tsx | 75 +++++++++++++++-------- 2 files changed, 71 insertions(+), 38 deletions(-) diff --git a/components/TreasuryVotingReason/index.tsx b/components/TreasuryVotingReason/index.tsx index 52724984..67b77998 100644 --- a/components/TreasuryVotingReason/index.tsx +++ b/components/TreasuryVotingReason/index.tsx @@ -1,4 +1,4 @@ -import { Text, TextArea } from "@livepeer/design-system"; +import { Box, Text, TextArea } from "@livepeer/design-system"; const MAX_INPUT_LENGTH = 256; const MIN_INPUT_LENGTH = 3; @@ -22,17 +22,30 @@ const Index = ({ const charsLeft = MAX_INPUT_LENGTH - reason.length; return ( - <> - + + Reason (optional)