diff --git a/package-lock.json b/package-lock.json index 5ef8156c2..9d3315b7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,11 +17,14 @@ "@tanstack/react-query": "^4.29.7", "autoprefixer": "^10.4.4", "chalk": "^4.1.2", - "chart.js": "^3.8.2", + "chart.js": "^4.4.0", + "chartjs-adapter-dayjs-4": "^1.0.4", + "chartjs-plugin-annotation": "^3.0.1", "classnames": "^2.3.2", "clipboard-copy": "^4.0.1", "core-js": "^3.21.1", "cssnano": "^6.0.1", + "dayjs": "^1.11.9", "dayjs-precise-range": "^1.0.1", "dcl-catalyst-client": "^21.5.0", "decentraland-gatsby": "^5.86.3", @@ -54,7 +57,7 @@ "pg-tsquery": "^8.3.0", "postcss": "^8.4.12", "react": "^17.0.2", - "react-chartjs-2": "^4.3.1", + "react-chartjs-2": "^5.2.0", "react-dom": "^17.0.2", "react-flickity-component": "^3.6.2", "react-hook-form": "^7.43.5", @@ -5480,6 +5483,11 @@ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", "dev": true }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@lezer/common": { "version": "0.15.12", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-0.15.12.tgz", @@ -16265,9 +16273,35 @@ } }, "node_modules/chart.js": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.8.2.tgz", - "integrity": "sha512-7rqSlHWMUKFyBDOJvmFGW2lxULtcwaPLegDjX/Nu5j6QybY+GCiQkEY+6cqHw62S5tcwXMD8Y+H5OBGoR7d+ZQ==" + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.0.tgz", + "integrity": "sha512-vQEj6d+z0dcsKLlQvbKIMYFHd3t8W/7L2vfJIbYcfyPcRx92CsHqECpueN8qVGNlKyDcr5wBrYAYKnfu/9Q1hQ==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=7" + } + }, + "node_modules/chartjs-adapter-dayjs-4": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz", + "integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "chart.js": ">=4.0.1", + "dayjs": "^1.9.7" + } + }, + "node_modules/chartjs-plugin-annotation": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.0.1.tgz", + "integrity": "sha512-hlIrXXKqSDgb+ZjVYHefmlZUXK8KbkCPiynSVrTb/HjTMkT62cOInaT1NTQCKtxKKOm9oHp958DY3RTAFKtkHg==", + "peerDependencies": { + "chart.js": ">=4.0.0" + } }, "node_modules/cheerio": { "version": "1.0.0-rc.10", @@ -18938,9 +18972,9 @@ "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" }, "node_modules/dayjs": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.0.tgz", - "integrity": "sha512-JLC809s6Y948/FuCZPm5IX8rRhQwOiyMb2TfVVQEixG7P8Lm/gt5S7yoQZmC8x1UehI9Pb7sksEt4xx14m+7Ug==" + "version": "1.11.9", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", + "integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==" }, "node_modules/dayjs-precise-range": { "version": "1.0.1", @@ -36403,11 +36437,11 @@ } }, "node_modules/react-chartjs-2": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-4.3.1.tgz", - "integrity": "sha512-5i3mjP6tU7QSn0jvb8I4hudTzHJqS8l00ORJnVwI2sYu0ihpj83Lv2YzfxunfxTZkscKvZu2F2w9LkwNBhj6xA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", "peerDependencies": { - "chart.js": "^3.5.0", + "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, @@ -49227,6 +49261,11 @@ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", "dev": true }, + "@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "@lezer/common": { "version": "0.15.12", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-0.15.12.tgz", @@ -57193,9 +57232,24 @@ "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==" }, "chart.js": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.8.2.tgz", - "integrity": "sha512-7rqSlHWMUKFyBDOJvmFGW2lxULtcwaPLegDjX/Nu5j6QybY+GCiQkEY+6cqHw62S5tcwXMD8Y+H5OBGoR7d+ZQ==" + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.0.tgz", + "integrity": "sha512-vQEj6d+z0dcsKLlQvbKIMYFHd3t8W/7L2vfJIbYcfyPcRx92CsHqECpueN8qVGNlKyDcr5wBrYAYKnfu/9Q1hQ==", + "requires": { + "@kurkle/color": "^0.3.0" + } + }, + "chartjs-adapter-dayjs-4": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz", + "integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==", + "requires": {} + }, + "chartjs-plugin-annotation": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.0.1.tgz", + "integrity": "sha512-hlIrXXKqSDgb+ZjVYHefmlZUXK8KbkCPiynSVrTb/HjTMkT62cOInaT1NTQCKtxKKOm9oHp958DY3RTAFKtkHg==", + "requires": {} }, "cheerio": { "version": "1.0.0-rc.10", @@ -59117,9 +59171,9 @@ "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" }, "dayjs": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.0.tgz", - "integrity": "sha512-JLC809s6Y948/FuCZPm5IX8rRhQwOiyMb2TfVVQEixG7P8Lm/gt5S7yoQZmC8x1UehI9Pb7sksEt4xx14m+7Ug==" + "version": "1.11.9", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", + "integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==" }, "dayjs-precise-range": { "version": "1.0.1", @@ -72392,9 +72446,9 @@ } }, "react-chartjs-2": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-4.3.1.tgz", - "integrity": "sha512-5i3mjP6tU7QSn0jvb8I4hudTzHJqS8l00ORJnVwI2sYu0ihpj83Lv2YzfxunfxTZkscKvZu2F2w9LkwNBhj6xA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", "requires": {} }, "react-colorful": { diff --git a/package.json b/package.json index 0911e3967..46a1fcf19 100644 --- a/package.json +++ b/package.json @@ -60,11 +60,14 @@ "@tanstack/react-query": "^4.29.7", "autoprefixer": "^10.4.4", "chalk": "^4.1.2", - "chart.js": "^3.8.2", + "chart.js": "^4.4.0", + "chartjs-adapter-dayjs-4": "^1.0.4", + "chartjs-plugin-annotation": "^3.0.1", "classnames": "^2.3.2", "clipboard-copy": "^4.0.1", "core-js": "^3.21.1", "cssnano": "^6.0.1", + "dayjs": "^1.11.9", "dayjs-precise-range": "^1.0.1", "dcl-catalyst-client": "^21.5.0", "decentraland-gatsby": "^5.86.3", @@ -97,7 +100,7 @@ "pg-tsquery": "^8.3.0", "postcss": "^8.4.12", "react": "^17.0.2", - "react-chartjs-2": "^4.3.1", + "react-chartjs-2": "^5.2.0", "react-dom": "^17.0.2", "react-flickity-component": "^3.6.2", "react-hook-form": "^7.43.5", diff --git a/src/components/Charts/LineChart.tsx b/src/components/Charts/LineChart.tsx index b14d41acf..17fd0be82 100644 --- a/src/components/Charts/LineChart.tsx +++ b/src/components/Charts/LineChart.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Chart } from 'react-chartjs-2' import { useIntl } from 'react-intl' -import { ChartArea, ChartData, Chart as ChartJS } from 'chart.js' +import type { ChartArea, ChartData, Chart as ChartJS } from 'chart.js' import 'chart.js/auto' import useAbbreviatedFormatter from '../../hooks/useAbbreviatedFormatter' diff --git a/src/components/Charts/ProposalVPChart.css b/src/components/Charts/ProposalVPChart.css new file mode 100644 index 000000000..025f79566 --- /dev/null +++ b/src/components/Charts/ProposalVPChart.css @@ -0,0 +1,49 @@ +#ProposalVPChartTooltip { + --padding-size: 9px; + display: flex; + flex-direction: row; + align-items: center; + background: rgba(22, 20, 26, 0.9); + border-radius: 5px; + color: var(--white-900); + opacity: 1; + pointer-events: none; + position: absolute; + transform: translate(-50%, 0); + transition: all 0.1s ease; + width: max-content; + padding: var(--padding-size); + z-index: 100; +} + +#ProposalVPChartTooltip .avatar { + --size: 40px; + min-width: var(--size) !important; + max-width: var(--size) !important; + min-height: var(--size) !important; + max-height: var(--size) !important; + border-radius: 100%; + background-color: var(--black-600); + vertical-align: middle; + margin: 0; +} + +#ProposalVPChartTooltip .container { + margin: 0; + margin-left: var(--padding-size); +} + +#ProposalVPChartTooltip .title { + font-size: 13px; + font-style: normal; + line-height: 18px; + max-width: 160px; +} + +#ProposalVPChartTooltip .details { + font-size: 10px; + font-style: normal; + font-weight: var(--weight-normal); + line-height: 18px; + opacity: 0.6; +} diff --git a/src/components/Charts/ProposalVPChart.tsx b/src/components/Charts/ProposalVPChart.tsx new file mode 100644 index 000000000..042d5c5cd --- /dev/null +++ b/src/components/Charts/ProposalVPChart.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { Chart } from 'react-chartjs-2' + +import { ChartData, Chart as ChartJS, ScriptableTooltipContext } from 'chart.js' +import 'chart.js/auto' +import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm' +import annotationPlugin from 'chartjs-plugin-annotation' + +import { Vote } from '../../entities/Votes/types' +import useAbbreviatedFormatter from '../../hooks/useAbbreviatedFormatter' +import useFormatMessage from '../../hooks/useFormatMessage' +import useProfiles from '../../hooks/useProfiles' +import { Avatar } from '../../utils/Catalyst/types' +import Section from '../Proposal/View/Section' + +import './ProposalVPChart.css' +import { + HOUR_IN_MS, + externalTooltipHandler, + getAbstainColor, + getDataset, + getNoColor, + getSegregatedVotes, + getSortedVotes, + getYesColor, +} from './ProposalVPChart.utils' + +ChartJS.register(annotationPlugin) + +interface Props { + requiredToPass?: number | null + voteMap: Record + isLoadingVotes?: boolean + startTimestamp?: number + endTimestamp?: number +} + +const COMMON_DATASET_OPTIONS = { + fill: false, + stepped: 'before' as const, + pointHoverRadius: 3, + pointHoverBorderWidth: 8, +} + +function ProposalVPChart({ requiredToPass, voteMap, isLoadingVotes, startTimestamp, endTimestamp }: Props) { + const t = useFormatMessage() + const YAxisFormat = useAbbreviatedFormatter() + const chartRef = useRef(null) + const sortedVotes = useMemo(() => getSortedVotes(voteMap), [voteMap]) + const { profiles, isLoadingProfiles } = useProfiles(sortedVotes.map((vote) => vote.address)) + const profileByAddress = useMemo( + () => + profiles.reduce((acc, { profile }) => { + acc.set(profile.ethAddress.toLowerCase(), profile) + return acc + }, new Map()), + [profiles] + ) + const votes = useMemo(() => getSegregatedVotes(sortedVotes, profileByAddress), [profileByAddress, sortedVotes]) + + const tooltipTitle = (choice: string, vp: number) => + t('page.proposal_view.votes_chart.tooltip_title', { choice, vp: vp.toLocaleString('en-US') }) + + const [chartData, setChartData] = useState>({ datasets: [] }) + useEffect(() => { + setChartData({ + datasets: [ + { + label: 'Yes', + data: getDataset(votes.yes, endTimestamp), + borderColor: getYesColor(), + backgroundColor: getYesColor(), + pointHoverBorderColor: getYesColor(0.4), + ...COMMON_DATASET_OPTIONS, + }, + { + label: 'No', + data: getDataset(votes.no, endTimestamp), + borderColor: getNoColor(), + backgroundColor: getNoColor(), + pointHoverBorderColor: getNoColor(0.4), + ...COMMON_DATASET_OPTIONS, + }, + { + label: 'Abstain', + data: getDataset(votes.abstain, endTimestamp), + borderColor: getAbstainColor(), + backgroundColor: getAbstainColor(), + pointHoverBorderColor: getAbstainColor(0.4), + ...COMMON_DATASET_OPTIONS, + }, + ], + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [votes.abstain.length, votes.no.length, votes.yes.length, endTimestamp]) + + const options = { + responsive: true, + plugins: { + legend: { + display: true, + align: 'end' as const, + labels: { + boxHeight: 1, + boxWidth: 10, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onHover: (e: any) => { + if (e.native && e.native.target) { + e.native.target.style.cursor = 'pointer' + } + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onLeave: (e: any) => { + if (e.native && e.native.target) { + e.native.target.style.cursor = 'default' + } + }, + }, + tooltip: { + enabled: false, + position: 'nearest' as const, + external: (context: ScriptableTooltipContext<'line'>) => + externalTooltipHandler({ context, votes, title: tooltipTitle }), + }, + annotation: { + annotations: { + line1: { + type: 'line' as const, + display: !!requiredToPass, + yMin: requiredToPass || 0, + yMax: requiredToPass || 0, + borderColor: 'rgb(115, 110, 125)', + borderWidth: 1, + borderDash: [5], + label: { + content: t('page.proposal_view.votes_chart.pass_threshold'), + display: true, + position: 'end' as const, + yAdjust: 10, + color: 'rgb(115, 110, 125)', + backgroundColor: 'transparent', + font: { + weight: 'normal' as const, + }, + }, + }, + }, + }, + }, + scales: { + x: { + type: 'time' as const, + time: { + displayFormats: { + day: 'DD/MM', + }, + }, + min: startTimestamp, + max: sortedVotes[sortedVotes.length - 1]?.timestamp + HOUR_IN_MS * 2, + ticks: { + autoSkip: true, + maxTicksLimit: 4, + }, + grid: { + drawOnChartArea: false, + }, + }, + y: { + ticks: { + callback: (value: number | string) => `${YAxisFormat(Number(value))} VP`, + autoSkip: true, + maxTicksLimit: 5, + }, + min: 0, + border: { + display: false, + }, + padding: 5, + }, + }, + } + + return ( +
+ +
+ ) +} + +export default ProposalVPChart diff --git a/src/components/Charts/ProposalVPChart.utils.ts b/src/components/Charts/ProposalVPChart.utils.ts new file mode 100644 index 000000000..86c9a0d9f --- /dev/null +++ b/src/components/Charts/ProposalVPChart.utils.ts @@ -0,0 +1,164 @@ +import type { Chart, ScriptableTooltipContext } from 'chart.js' + +import { Vote } from '../../entities/Votes/types' +import { DEFAULT_AVATAR_IMAGE } from '../../utils/Catalyst' +import { Avatar } from '../../utils/Catalyst/types' +import Time from '../../utils/date/Time' + +type VoteWithAddress = Vote & { address: string } +type VoteWithProfile = VoteWithAddress & { profile?: Avatar } + +const TOOLTIP_ID = 'ProposalVPChartTooltip' +export const HOUR_IN_MS = 60 * 60 * 1000 +const DAY_IN_MS = 24 * HOUR_IN_MS + +export function getSortedVotes(votesMap: Record) { + return Object.entries(votesMap) + .map(([address, vote]) => ({ address, ...vote, timestamp: vote.timestamp * 1000 })) + .sort((a, b) => a.timestamp - b.timestamp) +} + +export function getSegregatedVotes(votes: VoteWithAddress[], profileMap: Map) { + const yes: VoteWithProfile[] = [] + const no: VoteWithProfile[] = [] + const abstain: VoteWithProfile[] = [] + + for (const vote of votes) { + const profile = profileMap.get(vote.address.toLowerCase()) + const voteWithProfile = { ...vote, profile } + + if (voteWithProfile.choice === 1) { + yes.push(voteWithProfile) + } else if (voteWithProfile.choice === 2) { + no.push(voteWithProfile) + } else if (voteWithProfile.choice === 3) { + abstain.push(voteWithProfile) + } + } + + return { yes, no, abstain } +} + +export function getDataset(votes: VoteWithAddress[], endTimestamp?: number) { + type DataPoint = { x: number; y: number } + const dataset = votes.reduce( + (acc, vote) => { + const last = acc[acc.length - 1] + const x = vote.timestamp + const y = last ? last.y + vote.vp : vote.vp + acc.push({ x, y }) + return acc + }, + [{ x: 0, y: 0 }] + ) + + const last = dataset[dataset.length - 1] + const result = endTimestamp ? [...dataset, { x: endTimestamp + DAY_IN_MS, y: last.y }] : dataset + const hasNoVotes = result.length === 2 && result[0].y === result[1].y + return hasNoVotes ? [] : result +} + +function getColor(r: number, g: number, b: number, a = 1) { + return `rgba(${r},${g},${b},${a})` +} + +export function getYesColor(a?: number) { + return getColor(68, 182, 0, a) +} + +export function getNoColor(a?: number) { + return getColor(255, 69, 69, a) +} + +export function getAbstainColor(a?: number) { + return getColor(115, 110, 125, a) +} + +function getOrCreateTooltip(chart: Chart<'line'>) { + let customTooltip: HTMLDivElement | null | undefined = chart.canvas.parentNode?.querySelector(`#${TOOLTIP_ID}`) + + if (!customTooltip) { + customTooltip = document.createElement('div') + customTooltip.id = TOOLTIP_ID + + chart.canvas.parentNode?.appendChild(customTooltip) + } + + return customTooltip +} + +type TooltipHandlerProps = { + context: ScriptableTooltipContext<'line'> + votes: Record + title: (choice: string, vp: number) => string +} +export function externalTooltipHandler({ context, votes, title }: TooltipHandlerProps) { + // Tooltip Element + const { chart, tooltip } = context + const customTooltip = getOrCreateTooltip(chart) + const dataPoint = tooltip.dataPoints?.[0].raw as { x: number; y: number } | undefined + const dataIdx = tooltip.dataPoints?.[0].dataIndex + const datasetLabel = tooltip.dataPoints?.[0].dataset.label || '' + + const vote = votes[datasetLabel.toLowerCase()]?.[dataIdx - 1] + + const username = vote?.profile?.name || vote?.address.slice(0, 7) + const userVP = vote?.vp || 0 + const formattedTimestamp = Time(dataPoint?.x || 0).format('DD/MM/YY, HH:mm z') + + // Hide if no tooltip + if (tooltip.opacity === 0) { + customTooltip.style.opacity = '0' + return + } + + //Set Avatar + const avatar = document.createElement('img') + avatar.className = 'avatar' + avatar.src = vote?.profile?.avatar?.snapshots?.face256 || DEFAULT_AVATAR_IMAGE + + // Set Text + const textContainer = document.createElement('div') + textContainer.className = 'container' + + const titleElement = document.createElement('div') + const userElement = document.createElement('strong') + const userText = document.createTextNode(username) + userElement.appendChild(userText) + titleElement.appendChild(userElement) + const titleText = document.createTextNode(title(datasetLabel, userVP)) + titleElement.appendChild(titleText) + titleElement.className = 'title' + + const detailsElement = document.createElement('div') + const details = document.createTextNode(`${formattedTimestamp}. Acc. VP: ${dataPoint?.y.toLocaleString('en-US')}`) + detailsElement.appendChild(details) + detailsElement.className = 'details' + + textContainer.appendChild(titleElement) + textContainer.appendChild(detailsElement) + + // Remove old children + while (customTooltip.firstChild) { + customTooltip.firstChild.remove() + } + + // Add new children + customTooltip.appendChild(avatar) + customTooltip.appendChild(textContainer) + + const tooltipWidth = customTooltip.clientWidth + + const { offsetLeft: positionX, offsetTop: positionY, clientWidth: canvasWidth } = chart.canvas + + const maxX = canvasWidth - tooltipWidth / 2 + const minX = tooltipWidth / 2 + + const isLowerHalf = tooltip.caretX < canvasWidth / 2 + const xShift = isLowerHalf ? Math.max(minX, tooltip.caretX) : Math.min(maxX, tooltip.caretX) + + // Display and position + customTooltip.style.opacity = '1' + customTooltip.style.left = positionX + xShift + 'px' + customTooltip.style.top = positionY + tooltip.caretY + 'px' +} diff --git a/src/components/Common/Avatar.css b/src/components/Common/Avatar.css index f9ea434eb..6674a355c 100644 --- a/src/components/Common/Avatar.css +++ b/src/components/Common/Avatar.css @@ -10,7 +10,7 @@ min-height: var(--avatar-size) !important; max-height: var(--avatar-size) !important; border-radius: 100%; - background-color: #7a7a7a; + background-color: var(--black-600); vertical-align: middle; margin: 0; } diff --git a/src/components/Proposal/View/ProposalGovernanceSection.css b/src/components/Proposal/View/ProposalGovernanceSection.css index 79d3d8a93..3a7c98027 100644 --- a/src/components/Proposal/View/ProposalGovernanceSection.css +++ b/src/components/Proposal/View/ProposalGovernanceSection.css @@ -17,6 +17,7 @@ border: none; background: none; cursor: pointer; + text-align: center; } .ProposalGovernanceSection__Results { diff --git a/src/components/Proposal/View/ProposalGovernanceSection.tsx b/src/components/Proposal/View/ProposalGovernanceSection.tsx index 181a19781..2da5f7f19 100644 --- a/src/components/Proposal/View/ProposalGovernanceSection.tsx +++ b/src/components/Proposal/View/ProposalGovernanceSection.tsx @@ -88,12 +88,13 @@ export default function ProposalGovernanceSection({ {showResults ? t('page.proposal_detail.result_label') : t('page.proposal_detail.get_involved')} {showResultsButton && ( - + )} diff --git a/src/hooks/useProfiles.ts b/src/hooks/useProfiles.ts new file mode 100644 index 000000000..63ee7f1c9 --- /dev/null +++ b/src/hooks/useProfiles.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query' +import isEthereumAddress from 'validator/lib/isEthereumAddress' + +import { createDefaultAvatar, getProfiles } from '../utils/Catalyst' +import { Avatar } from '../utils/Catalyst/types' + +import { DEFAULT_QUERY_STALE_TIME } from './constants' + +type Profile = { + profile: Avatar + isDefaultProfile: boolean +} + +export default function useProfiles(addresses: (string | null | undefined)[]): { + profiles: Profile[] + isLoadingProfiles: boolean +} { + const fetchProfiles = async () => { + const validAddresses = addresses.filter((address) => isEthereumAddress(address || '')) as string[] + let validAddressesProfiles: Profile[] = [] + + try { + const profiles = await getProfiles(validAddresses) + validAddressesProfiles = profiles.map((profile, idx) => ({ + profile: profile || createDefaultAvatar(validAddresses[idx]), + isDefaultProfile: !profile, + })) + } catch (error) { + console.error(error) + validAddressesProfiles = validAddresses.map((address) => ({ + profile: createDefaultAvatar(address), + isDefaultProfile: true, + })) + } + + return { profiles: validAddressesProfiles } + } + + const { data, isLoading: isLoadingProfiles } = useQuery({ + queryKey: [`userProfiles#${addresses.join(',')}`], + queryFn: () => fetchProfiles(), + staleTime: DEFAULT_QUERY_STALE_TIME, + }) + + const { profiles } = data || {} + + return { profiles: profiles || [], isLoadingProfiles } +} diff --git a/src/intl/en.json b/src/intl/en.json index 4a1710e87..c69db71fd 100644 --- a/src/intl/en.json +++ b/src/intl/en.json @@ -869,6 +869,11 @@ "personnel_title": "Personnel", "roadmap_title": "Roadmap and milestones", "relevant_link": "Relevant link" + }, + "votes_chart": { + "title": "Voting Timeline", + "pass_threshold": "Acceptance threshold", + "tooltip_title": ", voted \"{choice}\" with {vp} VP" } }, "proposal_bidding_tendering": { diff --git a/src/pages/proposal.tsx b/src/pages/proposal.tsx index a164ff10d..949aae87e 100644 --- a/src/pages/proposal.tsx +++ b/src/pages/proposal.tsx @@ -16,6 +16,7 @@ import { ErrorClient } from '../clients/ErrorClient' import { Governance } from '../clients/Governance' import { SnapshotApi } from '../clients/SnapshotApi' import CategoryPill from '../components/Category/CategoryPill' +import ProposalVPChart from '../components/Charts/ProposalVPChart' import ContentLayout, { ContentSection } from '../components/Layout/ContentLayout' import MaintenanceLayout from '../components/Layout/MaintenanceLayout' import BidSubmittedModal from '../components/Modal/BidSubmittedModal' @@ -334,6 +335,8 @@ export default function ProposalPage() { proposal?.status === ProposalStatus.Active && (proposal?.type === ProposalType.Tender || proposal?.type === ProposalType.Bid) + const showVotesChart = !isLoadingProposal && proposal?.type !== ProposalType.Poll && highQualityVotes + return ( <> )} + {showVotesChart && ( + + )} diff --git a/src/utils/Catalyst/index.ts b/src/utils/Catalyst/index.ts index 3079221ca..6a2268407 100644 --- a/src/utils/Catalyst/index.ts +++ b/src/utils/Catalyst/index.ts @@ -1,10 +1,12 @@ import fetch from 'isomorphic-fetch' import isEthereumAddress from 'validator/lib/isEthereumAddress' +import { isSameAddress } from '../../entities/Snapshot/utils' + import { Avatar, ProfileResponse } from './types' const CATALYST_URL = 'https://peer.decentraland.org' -const DEFAULT_AVATAR_IMAGE = 'https://decentraland.org/images/male.png' +export const DEFAULT_AVATAR_IMAGE = 'https://decentraland.org/images/male.png' export async function getProfile(address: string): Promise { if (!isEthereumAddress(address)) { @@ -16,6 +18,33 @@ export async function getProfile(address: string): Promise { return response.avatars.length > 0 ? response.avatars[0] : null } +export async function getProfiles(addresses: string[]): Promise<(Avatar | null)[]> { + for (const address of addresses) { + if (!isEthereumAddress(address)) { + throw new Error(`Invalid address provided. Value: ${address}`) + } + } + + const response: ProfileResponse[] = await ( + await fetch(`${CATALYST_URL}/lambdas/profiles`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ids: addresses }), + }) + ).json() + + const result: (Avatar | null)[] = [] + + for (const address of addresses) { + const profile = response.find((profile) => isSameAddress(profile.avatars[0]?.ethAddress, address)) + result.push(profile?.avatars[0] || null) + } + + return result +} + export function createDefaultAvatar(address: string): Avatar { return { userId: address,