From 89495ce0bdca4f6502943d1546ed988a1fb1bb40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Comerci?= <45410089+ncomerci@users.noreply.github.com> Date: Wed, 20 Sep 2023 11:06:13 -0300 Subject: [PATCH 1/5] feat: Proposal voting timeline (#1255) * feat: voting timeline * fix: build error * Requested changes * minor style fixes * custom tooltip started * zoom in chart removed * useProfiles hook * tooltip styled * type removed * fix: tooltip x position * final touchups * requested changes --- package-lock.json | 96 +++++++-- package.json | 7 +- src/components/Charts/LineChart.tsx | 2 +- src/components/Charts/ProposalVPChart.css | 49 +++++ src/components/Charts/ProposalVPChart.tsx | 191 ++++++++++++++++++ .../Charts/ProposalVPChart.utils.ts | 164 +++++++++++++++ src/components/Common/Avatar.css | 2 +- .../View/ProposalGovernanceSection.css | 1 + .../View/ProposalGovernanceSection.tsx | 5 +- src/hooks/useProfiles.ts | 48 +++++ src/intl/en.json | 5 + src/pages/proposal.tsx | 11 + src/utils/Catalyst/index.ts | 31 ++- 13 files changed, 584 insertions(+), 28 deletions(-) create mode 100644 src/components/Charts/ProposalVPChart.css create mode 100644 src/components/Charts/ProposalVPChart.tsx create mode 100644 src/components/Charts/ProposalVPChart.utils.ts create mode 100644 src/hooks/useProfiles.ts 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 e227a53f1..44a4d0220 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, From b5cc9aa21ff0485615f010a478b7e23f741fb14c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Comerci?= <45410089+ncomerci@users.noreply.github.com> Date: Wed, 20 Sep 2023 11:46:27 -0300 Subject: [PATCH 2/5] feat: snapshot status feature flag (#1277) --- gatsby-browser.js | 3 ++- src/back/jobs/PingSnapshot.ts | 5 ++++- src/constants.ts | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/gatsby-browser.js b/gatsby-browser.js index 017d247dd..fe7f2fa07 100644 --- a/gatsby-browser.js +++ b/gatsby-browser.js @@ -32,6 +32,7 @@ import { SEGMENT_KEY, SSO_URL } from "./src/constants" import { flattenMessages } from "./src/utils/intl" import en from "./src/intl/en.json" import SnapshotStatus from "./src/components/Debug/SnapshotStatus" +import SNAPSHOT_STATUS_ENABLED from "./src/constants" const queryClient = new QueryClient() @@ -50,7 +51,7 @@ export const wrapPageElement = ({ element, props }) => { - + {SNAPSHOT_STATUS_ENABLED && } }> {element} diff --git a/src/back/jobs/PingSnapshot.ts b/src/back/jobs/PingSnapshot.ts index bc3d81d43..dccc065ea 100644 --- a/src/back/jobs/PingSnapshot.ts +++ b/src/back/jobs/PingSnapshot.ts @@ -1,5 +1,8 @@ +import { SNAPSHOT_STATUS_ENABLED } from '../../constants' import { SnapshotService } from '../../services/SnapshotService' export async function pingSnapshot() { - await SnapshotService.ping() + if (SNAPSHOT_STATUS_ENABLED) { + await SnapshotService.ping() + } } diff --git a/src/constants.ts b/src/constants.ts index f6d3bb263..8c094061f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -38,3 +38,4 @@ export const DEBUG_ADDRESSES = (process.env.DEBUG_ADDRESSES || '') .split(',') .filter(isEthereumAddress) .map((address) => address.toLowerCase()) +export const SNAPSHOT_STATUS_ENABLED = false From 856ac4327ed13d0f63fec798ce18192cd6a3de99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Comerci?= <45410089+ncomerci@users.noreply.github.com> Date: Thu, 21 Sep 2023 15:56:32 -0300 Subject: [PATCH 3/5] fix: show/hide proposal vp chart (#1283) * fix: show/hide proposal vp chart * requested changes * removed unused import --- .../View/ProposalGovernanceSection.tsx | 23 ++++++++++++------- src/pages/proposal.tsx | 5 +++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/components/Proposal/View/ProposalGovernanceSection.tsx b/src/components/Proposal/View/ProposalGovernanceSection.tsx index 2da5f7f19..1c229002b 100644 --- a/src/components/Proposal/View/ProposalGovernanceSection.tsx +++ b/src/components/Proposal/View/ProposalGovernanceSection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect } from 'react' import classNames from 'classnames' import useAuthContext from 'decentraland-gatsby/dist/context/Auth/useAuthContext' @@ -54,7 +54,6 @@ export default function ProposalGovernanceSection({ const now = Time.utc() const finishAt = Time.utc(proposal?.finish_at) const finished = finishAt.isBefore(now) - const [showResults, setShowResults] = useState(finished) const [userAddress] = useAuthContext() const hasVoted = !!(!!userAddress && votes?.[userAddress]) const showResultsButton = !hasVoted && !finished && proposal?.status !== ProposalStatus.Pending @@ -67,9 +66,18 @@ export default function ProposalGovernanceSection({ !hasTenderProcessStarted && Number(bidProposals?.total) === 0 + const { showResults } = proposalPageState + + const handleShowResults = useCallback( + (show: boolean) => { + updatePageState({ showResults: show }) + }, + [updatePageState] + ) + useEffect(() => { - setShowResults(hasVoted || finished || !userAddress) - }, [hasVoted, finished, userAddress]) + handleShowResults(hasVoted || finished || !userAddress) + }, [hasVoted, finished, userAddress, handleShowResults]) return ( diff --git a/src/pages/proposal.tsx b/src/pages/proposal.tsx index 949aae87e..bb5afc43c 100644 --- a/src/pages/proposal.tsx +++ b/src/pages/proposal.tsx @@ -89,6 +89,7 @@ export type ProposalPageState = { showBidVotingModal: boolean showVotingError: boolean showSnapshotRedirect: boolean + showResults: boolean retryTimer: number selectedChoice: SelectedVoteChoice } @@ -157,6 +158,7 @@ export default function ProposalPage() { showBidVotingModal: false, showVotingError: false, showSnapshotRedirect: false, + showResults: false, retryTimer: SECONDS_FOR_VOTING_RETRY, selectedChoice: EMPTY_VOTE_CHOICE_SELECTION, }) @@ -335,7 +337,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 + const showVotesChart = + proposalPageState.showResults && !isLoadingProposal && proposal?.type !== ProposalType.Poll && highQualityVotes return ( <> From 9cfe4555b2452d52ee9c0b8f368ec073bd0a3798 Mon Sep 17 00:00:00 2001 From: lemu Date: Thu, 21 Sep 2023 16:02:25 -0300 Subject: [PATCH 4/5] fix: handle legislator badges errors when accepting proposals (#1284) * fix: handle legislator badges errors when accepting proposals * chore: notify finish proposal job errors --- src/entities/Coauthor/model.ts | 2 + src/entities/Proposal/jobs.ts | 68 ++++++++++++++++++------------ src/services/BadgesService.test.ts | 13 +++--- src/services/BadgesService.ts | 3 ++ 4 files changed, 53 insertions(+), 33 deletions(-) diff --git a/src/entities/Coauthor/model.ts b/src/entities/Coauthor/model.ts index 95c0dab1d..0b052e8bb 100644 --- a/src/entities/Coauthor/model.ts +++ b/src/entities/Coauthor/model.ts @@ -45,6 +45,8 @@ export default class CoauthorModel extends Model { } static async findAllByProposals(proposals: ProposalAttributes[], status?: CoauthorStatus): Promise { + if (proposals.length === 0) return [] + const query = SQL` SELECT address FROM ${table(this)} diff --git a/src/entities/Proposal/jobs.ts b/src/entities/Proposal/jobs.ts index 5a492a6ef..ffaae710a 100644 --- a/src/entities/Proposal/jobs.ts +++ b/src/entities/Proposal/jobs.ts @@ -40,7 +40,15 @@ async function updateAcceptedProposals(acceptedProposals: ProposalWithOutcome[], ProposalStatus.Passed ) - await BadgesService.giveLegislatorBadges(acceptedProposals) + try { + await BadgesService.giveLegislatorBadges(acceptedProposals) + } catch (error) { + ErrorService.report('Error while attempting to give badges', { + error, + category: ErrorCategory.Badges, + acceptedProposals, + }) + } } } @@ -195,35 +203,39 @@ async function categorizeProposals( } export async function finishProposal(context: JobContext) { - const finishableProposals = await ProposalModel.getFinishableProposals() - if (finishableProposals.length === 0) { - return - } + try { + const finishableProposals = await ProposalModel.getFinishableProposals() + if (finishableProposals.length === 0) { + return + } - const currentBudgets = await BudgetService.getBudgetsForProposals(finishableProposals) - - context.log(`Updating ${finishableProposals.length} proposals...`) - const { finishedProposals, acceptedProposals, outOfBudgetProposals, rejectedProposals, updatedBudgets } = - await categorizeProposals(finishableProposals, currentBudgets, context) - - await updateFinishedProposals(finishedProposals, context) - await updateAcceptedProposals(acceptedProposals, context) - await updateOutOfBudgetProposals(outOfBudgetProposals, context) - await updateRejectedProposals(rejectedProposals, context) - await BudgetService.updateBudgets(updatedBudgets) - - const proposals = [...finishedProposals, ...acceptedProposals, ...rejectedProposals] - context.log(`Updating ${proposals.length} proposals in discourse... \n\n`) - for (const { id, title, winnerChoice, outcomeStatus } of proposals) { - ProposalService.commentProposalUpdateInDiscourse(id) - if (outcomeStatus) { - DiscordService.finishProposal( - id, - title, - outcomeStatus, - outcomeStatus === ProposalOutcome.FINISHED ? winnerChoice : undefined - ) + const currentBudgets = await BudgetService.getBudgetsForProposals(finishableProposals) + + context.log(`Updating ${finishableProposals.length} proposals...`) + const { finishedProposals, acceptedProposals, outOfBudgetProposals, rejectedProposals, updatedBudgets } = + await categorizeProposals(finishableProposals, currentBudgets, context) + + await updateFinishedProposals(finishedProposals, context) + await updateAcceptedProposals(acceptedProposals, context) + await updateOutOfBudgetProposals(outOfBudgetProposals, context) + await updateRejectedProposals(rejectedProposals, context) + await BudgetService.updateBudgets(updatedBudgets) + + const proposals = [...finishedProposals, ...acceptedProposals, ...rejectedProposals] + context.log(`Updating ${proposals.length} proposals in discourse... \n\n`) + for (const { id, title, winnerChoice, outcomeStatus } of proposals) { + ProposalService.commentProposalUpdateInDiscourse(id) + if (outcomeStatus) { + DiscordService.finishProposal( + id, + title, + outcomeStatus, + outcomeStatus === ProposalOutcome.FINISHED ? winnerChoice : undefined + ) + } } + } catch (error) { + ErrorService.report('Error finishing proposals', { error, category: ErrorCategory.Job }) } } diff --git a/src/services/BadgesService.test.ts b/src/services/BadgesService.test.ts index 212450810..f659b11d0 100644 --- a/src/services/BadgesService.test.ts +++ b/src/services/BadgesService.test.ts @@ -12,13 +12,9 @@ jest.mock('../constants', () => ({ const COAUTHORS = ['0x56d0b5ed3d525332f00c9bc938f93598ab16aaa7', '0x49e4dbff86a2e5da27c540c9a9e8d2c3726e278f'] describe('giveLegislatorBadges', () => { - beforeAll(() => { - // eslint-disable-next-line @typescript-eslint/no-empty-function + it('should call queueAirdropJob with correct arguments for governance proposals', async () => { jest.spyOn(AirdropJobModel, 'create').mockResolvedValue(async () => {}) jest.spyOn(CoauthorModel, 'findAllByProposals').mockResolvedValue(COAUTHORS) - }) - - it('should call queueAirdropJob with correct arguments for governance proposals', async () => { const proposal = createTestProposal(ProposalType.Governance, ProposalStatus.Passed) proposal.user = '0x4757ce43dc5429b8f1a132dc29ef970e55ae722b' const expectedAuthorsAndCoauthors = [proposal.user, ...COAUTHORS].map(getChecksumAddress) @@ -29,4 +25,11 @@ describe('giveLegislatorBadges', () => { recipients: expectedAuthorsAndCoauthors, }) }) + + it('does not try to airdrop any badge when there are no governance proposals', async () => { + jest.clearAllMocks() + const proposal = createTestProposal(ProposalType.Draft, ProposalStatus.Passed) + await BadgesService.giveLegislatorBadges([proposal]) + expect(AirdropJobModel.create).not.toHaveBeenCalled() + }) }) diff --git a/src/services/BadgesService.ts b/src/services/BadgesService.ts index 226aac21a..bbece0027 100644 --- a/src/services/BadgesService.ts +++ b/src/services/BadgesService.ts @@ -95,6 +95,9 @@ export class BadgesService { static async giveLegislatorBadges(acceptedProposals: ProposalAttributes[]) { const governanceProposals = acceptedProposals.filter((proposal) => proposal.type === ProposalType.Governance) + if (governanceProposals.length === 0) { + return + } const coauthors = await CoauthorModel.findAllByProposals(governanceProposals, CoauthorStatus.APPROVED) const authors = governanceProposals.map((proposal) => proposal.user) const authorsAndCoauthors = new Set([...authors.map(getChecksumAddress), ...coauthors.map(getChecksumAddress)]) From 914ca0053d4cad3fae664792209a729aaa49c48b Mon Sep 17 00:00:00 2001 From: lemu Date: Thu, 21 Sep 2023 16:03:04 -0300 Subject: [PATCH 5/5] feat: floating magic bar (#1269) * feat: floating bar WIP * feat: scroll to survey results or comments depending on availability * chore: internationalization * chore: replace figma colors with the theme ones * chore: remove forum button * refactor: useSurvey hook * chore: floating bar comments loader * chore: replace svg with png, add opacity animation to floating bar * fix: always render floating bar --- package-lock.json | 4 +- src/components/FloatingBar/FloatingBar.css | 62 +++++++++++ src/components/FloatingBar/FloatingBar.tsx | 85 +++++++++++++++ src/components/Home/OpenProposal.css | 2 +- src/components/Icon/Forum.tsx | 6 +- .../Projects/ProjectCard/CliffNotice.css | 2 +- .../Proposal/Comments/ProposalComments.tsx | 26 +++-- src/components/Proposal/ProposalSidebar.tsx | 2 - .../SentimentSurvey/SurveyResults.tsx | 37 +++---- .../Proposal/Update/ProposalUpdate.css | 2 +- src/components/Proposal/View/ForumButton.tsx | 27 ----- src/hooks/useSurvey.ts | 25 +++++ src/images/reactions.png | Bin 0 -> 2430 bytes src/intl/en.json | 6 +- src/pages/proposal.tsx | 97 ++++++++++++------ 15 files changed, 283 insertions(+), 100 deletions(-) create mode 100644 src/components/FloatingBar/FloatingBar.css create mode 100644 src/components/FloatingBar/FloatingBar.tsx delete mode 100644 src/components/Proposal/View/ForumButton.tsx create mode 100644 src/hooks/useSurvey.ts create mode 100644 src/images/reactions.png diff --git a/package-lock.json b/package-lock.json index 9d3315b7b..63cf754ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,8 +128,8 @@ "typescript": "4.7.2" }, "engines": { - "node": ">=16.0.0", - "npm": ">=7.0.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" } }, "node_modules/@0xsequence/abi": { diff --git a/src/components/FloatingBar/FloatingBar.css b/src/components/FloatingBar/FloatingBar.css new file mode 100644 index 000000000..90250f510 --- /dev/null +++ b/src/components/FloatingBar/FloatingBar.css @@ -0,0 +1,62 @@ +.FloatingBar { + position: sticky; + bottom: 30px; + width: 100%; + height: 48px; + border-radius: 6px; + border: 1px solid var(--black-300); + background: var(--white-900); + box-shadow: 0 0 25px 0 var(--alpha-black-400); + padding: 12px 18px 12px 16px; + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.5s ease 0s; + opacity: 1; + z-index: 200; +} + +.FloatingBar--hidden { + opacity: 0; + z-index: -1; +} + +.FloatingBar__ProposalSectionActions { + display: flex; + justify-content: space-between; + padding-right: 24px; + width: 100%; +} + +.FloatingBar__Action { + color: var(--black-600); + font-size: 13px; + line-height: 24px; + font-weight: var(--weight-semi-bold); + text-transform: uppercase; + gap: 5px; + display: flex; + align-items: center; +} + +.FloatingBar__ReactionsImg { + height: 24px; + width: 36px; +} + +.FloatingBar__JoinDiscussion { + min-width: 138px !important; + padding: 0 !important; + display: flex !important; + align-items: center; + gap: 8px; +} + +.FloatingBar__LoaderLeft { + left: 32px !important; +} + +.FloatingBar__LoaderRight { + margin-right: 84px !important; + position: relative !important; +} diff --git a/src/components/FloatingBar/FloatingBar.tsx b/src/components/FloatingBar/FloatingBar.tsx new file mode 100644 index 000000000..1357fa31f --- /dev/null +++ b/src/components/FloatingBar/FloatingBar.tsx @@ -0,0 +1,85 @@ +import React from 'react' + +import classNames from 'classnames' +import { Button } from 'decentraland-ui/dist/components/Button/Button' +import { Loader } from 'decentraland-ui/dist/components/Loader/Loader' + +import { forumUrl } from '../../entities/Proposal/utils' +import useFormatMessage from '../../hooks/useFormatMessage' +import useProposalComments from '../../hooks/useProposalComments' +import Link from '../Common/Typography/Link' +import Forum from '../Icon/Forum' +import Open from '../Icon/Open' + +import './FloatingBar.css' + +const reactions = require('../../images/reactions.png').default + +interface FloatingBarProps { + isVisible: boolean + showViewReactions: boolean + scrollToComments: () => void + scrollToReactions: () => void + proposalId?: string + discourseTopicId?: number + discourseTopicSlug?: string + isLoadingProposal: boolean +} + +const FloatingBar: React.FC = ({ + proposalId, + discourseTopicId, + discourseTopicSlug, + showViewReactions, + isVisible, + scrollToComments, + scrollToReactions, + isLoadingProposal, +}) => { + const t = useFormatMessage() + const { comments, isLoadingComments } = useProposalComments(proposalId) + const showTotalComments = !isLoadingComments && !isLoadingProposal + return ( +
+
+ {showViewReactions && ( + + {t('component.floating_bar.view_reactions_label')} + + + )} + + {!showTotalComments && ( + + )} + {showTotalComments && ( + <> + + {t('component.floating_bar.total_comments_label', { count: comments?.totalComments || 0 })} + + )} + +
+ +
+ ) +} + +export default FloatingBar diff --git a/src/components/Home/OpenProposal.css b/src/components/Home/OpenProposal.css index 8ef5e0a3f..e4a905a87 100644 --- a/src/components/Home/OpenProposal.css +++ b/src/components/Home/OpenProposal.css @@ -4,7 +4,7 @@ align-items: center; justify-content: space-between; padding: 16px; - border-bottom: 1px solid #dddce0; + border-bottom: 1px solid var(--black-300); } .OpenProposal__Section { diff --git a/src/components/Icon/Forum.tsx b/src/components/Icon/Forum.tsx index 8dfd4d1d3..5d2d0a3c6 100644 --- a/src/components/Icon/Forum.tsx +++ b/src/components/Icon/Forum.tsx @@ -1,16 +1,16 @@ import React from 'react' -function Forum({ size = 24 }: { size?: number }) { +function Forum({ size = 24, color = 'var(--black-800)' }: { size?: number; color?: string }) { return ( diff --git a/src/components/Projects/ProjectCard/CliffNotice.css b/src/components/Projects/ProjectCard/CliffNotice.css index 7ea8c5b67..953a91e57 100644 --- a/src/components/Projects/ProjectCard/CliffNotice.css +++ b/src/components/Projects/ProjectCard/CliffNotice.css @@ -3,7 +3,7 @@ justify-content: flex-start; align-items: center; flex-direction: row; - border: 1px solid #dddce0; + border: 1px solid var(--black-300); border-radius: 8px; padding: 0 16px; min-height: 44px; diff --git a/src/components/Proposal/Comments/ProposalComments.tsx b/src/components/Proposal/Comments/ProposalComments.tsx index 6dff437d4..a3041dcc1 100644 --- a/src/components/Proposal/Comments/ProposalComments.tsx +++ b/src/components/Proposal/Comments/ProposalComments.tsx @@ -1,23 +1,27 @@ -import React from 'react' +import React, { Ref, forwardRef } from 'react' import { ProposalAttributes } from '../../../entities/Proposal/types' import useProposalComments from '../../../hooks/useProposalComments' import Comments from '../../Comments/Comments' -type ProposalComments = { +type ProposalCommentsProps = { proposal: ProposalAttributes | null } -export default function ProposalComments({ proposal }: ProposalComments) { +const ProposalComments = forwardRef(({ proposal }: ProposalCommentsProps, ref: Ref) => { const { comments, isLoadingComments } = useProposalComments(proposal?.id) return ( - +
+ +
) -} +}) + +export default ProposalComments diff --git a/src/components/Proposal/ProposalSidebar.tsx b/src/components/Proposal/ProposalSidebar.tsx index 04c1bf3c9..c6837f99b 100644 --- a/src/components/Proposal/ProposalSidebar.tsx +++ b/src/components/Proposal/ProposalSidebar.tsx @@ -15,7 +15,6 @@ import { ProposalPageState } from '../../pages/proposal' import CalendarAlertModal from '../Modal/CalendarAlertModal' import CalendarAlertButton from './View/CalendarAlertButton' -import ForumButton from './View/ForumButton' import ProposalCoAuthorStatus from './View/ProposalCoAuthorStatus' import ProposalDetailSection from './View/ProposalDetailSection' import ProposalGovernanceSection from './View/ProposalGovernanceSection' @@ -152,7 +151,6 @@ export default function ProposalSidebar({ {showProposalThresholdsSummary && ( )} - | null - isLoadingVotes: boolean surveyTopics: Topic[] | null isLoadingSurveyTopics: boolean } @@ -51,30 +50,26 @@ function getResults(availableTopics: Topic[] | null, votes: Record return topicsResults } -const SurveyResults = ({ votes, isLoadingVotes, surveyTopics, isLoadingSurveyTopics }: Props) => { +const SurveyResults = forwardRef(({ votes, surveyTopics, isLoadingSurveyTopics }: Props, ref: Ref) => { const t = useFormatMessage() const topicResults = useMemo(() => getResults(surveyTopics, votes), [surveyTopics, votes]) const topicIds = Object.keys(topicResults) - const hasVotes = votes && Object.keys(votes).length > 0 && !isLoadingVotes - const hasSurveyTopics = surveyTopics && surveyTopics?.length > 0 && !isLoadingSurveyTopics - - if (!hasVotes || !hasSurveyTopics) { - return null - } return ( -
- {topicIds.map((topicId: string, index: number) => { - return ( - - ) - })} -
+
+
+ {topicIds.map((topicId: string, index: number) => { + return ( + + ) + })} +
+
) -} +}) export default SurveyResults diff --git a/src/components/Proposal/Update/ProposalUpdate.css b/src/components/Proposal/Update/ProposalUpdate.css index 160ed45ca..5ddae7caf 100644 --- a/src/components/Proposal/Update/ProposalUpdate.css +++ b/src/components/Proposal/Update/ProposalUpdate.css @@ -3,7 +3,7 @@ justify-content: space-between; align-items: center; flex-direction: row; - border: 1px solid #dddce0; + border: 1px solid var(--black-300); border-radius: 8px; padding: 0 16px; min-height: 44px; diff --git a/src/components/Proposal/View/ForumButton.tsx b/src/components/Proposal/View/ForumButton.tsx deleted file mode 100644 index 8e56e4e7b..000000000 --- a/src/components/Proposal/View/ForumButton.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react' - -import { ProposalAttributes } from '../../../entities/Proposal/types' -import { forumUrl } from '../../../entities/Proposal/utils' -import useFormatMessage from '../../../hooks/useFormatMessage' -import Forum from '../../Icon/Forum' - -import SidebarLinkButton from './SidebarLinkButton' - -type ForumButtonProps = { - loading?: boolean - proposal: Pick | null -} - -export default function ForumButton({ loading, proposal }: ForumButtonProps) { - const t = useFormatMessage() - return ( - } - > - {t('page.proposal_detail.forum_button')} - - ) -} diff --git a/src/hooks/useSurvey.ts b/src/hooks/useSurvey.ts new file mode 100644 index 000000000..eb1782dce --- /dev/null +++ b/src/hooks/useSurvey.ts @@ -0,0 +1,25 @@ +import { ProposalAttributes } from '../entities/Proposal/types' +import { Vote } from '../entities/Votes/types' + +import useSurveyTopics from './useSurveyTopics' + +const SURVEY_FEATURE_LAUNCH = new Date(2023, 3, 5, 0, 0) + +export default function useSurvey( + proposal?: ProposalAttributes | null, + votes?: Record | null, + isLoadingVotes?: boolean, + isMobile?: boolean +) { + const { surveyTopics, isLoadingSurveyTopics } = useSurveyTopics(proposal?.id) + const voteWithSurvey = + !isLoadingSurveyTopics && + !!surveyTopics && + surveyTopics.length > 0 && + !!proposal && + proposal.created_at > SURVEY_FEATURE_LAUNCH + const hasVotes = votes && Object.keys(votes).length > 0 && !isLoadingVotes + const hasSurveyTopics = surveyTopics && surveyTopics?.length > 0 && !isLoadingSurveyTopics + const showSurveyResults = proposal && voteWithSurvey && !isMobile && hasVotes && hasSurveyTopics + return { surveyTopics, isLoadingSurveyTopics, voteWithSurvey, showSurveyResults } +} diff --git a/src/images/reactions.png b/src/images/reactions.png new file mode 100644 index 0000000000000000000000000000000000000000..e3c7c53d2cabec2976e349f7c3672b3d7c7d6d78 GIT binary patch literal 2430 zcmV-^34!*BP)uH_?`~ypBlqv1@dzdB3QrYL<2ZOdTS*M>5DDGF9ObY3eRu@`L7u-W76e70^>db z*LxA%ehk)Sy6aE~92^`RN~zUn4f?fo=}vK`C79EK-PJl?_H!8PQ3zdaq2)gV#xPT} zbTaUtChVUm;?_0q<27qSvgm;Y_pwvMHoU=E(kE8C9!9ERDi(`fZHfQs zI{K*gT={jC@Bq7?tzG5??+{?2XOg$e)qr~k$||NFn`mk|H`(*O8> z{?~Z^({ujGX#T-p|HLo&q!sO)692q0|FRwCMFjuBIs1_<|GF#3ZYBS_@=8ofNd2Wj|IswJj3lmu9_5k`zGV$}EDQhZegEr7 z{F*g3H8ua#FPu{t;f)O1fCp}AZ~VDhL_|dGkO%+yo&WNYVq#(c<46A8J^#rqy1_YqSO3gKt#UKy#Vq~5EC0DB@S!I5bQkA` z3ej~2USH?`{K|!egnfN`?z1ufwJxk-AcagBp>i1htP1W}3fx8rhfN1XZ@yYcyhA>t zoSd3gR#o(>KB|T;$%!ZSts;Cv4e^ZyCsoB(UcJA)ys@#cCju=#ZCX%OVhbX+@T+jM;xwN2He}&fL_+*VbJ>L zz&>59ifX25aFVftf`m1F(xZF8q;%(KbdGj$^w3Y_%}eUwN>4W)lw=n8kq?`B?dg=^ zfI_0Zi-{r|RA@m;lW0lLY#r2#75Fjl;s5{uMs!k6Qven>2)Z}=O>0s7zLkX%{>n-$ z{=T@Ekbh(={QCO&=-$7&wy27SeRF6a9{l<6=HTDg%3n!INJP}p%fzp)tf-}&l!a1w zb&r(*00s6*L_t(Y$L*G7R8wIXfX8S7vAeswyYI#}=76EVN;W49MUXiIl{pX?H5fxe zB}8dK8YH9}?C$PvfBD`3LH(!a_=o4m_MQj6XW#wK-9009(#gMI%$V-2<3y9Le-T=I z^yHKk-L=utM81eTXD+$5)_oN=X_4p7A!~J9q@`UwZE(w3dwTO75Ljy&aGY zGwDGBh9B)mBAX185RObD(Cs^I>lUGK%hqXqb=!pooU1>2_rv>>Cwoe^JB3?WTU&)b zuaNYDRQIK`d$qdo@$XLe6MJV59vwppetz770)GoE4?k?=M#U6>GVW_IwP1-cSm4jwc!3->6j_=8KcUE2X$JI@owB%EcZ z*=~M*>-O#2XGQ?yIzPX}=&S2~cNMlBunYAR9x)_tfFy~>@#7xXZd^UK4&&I>8`n@H zuHL^16d?iBy|dfa4!cmfXMoFe{NfCvEfZdPQ>iu7f?XJuFj5O%CRoy6zkcO}JEvDp zKte)dv~OOM9YIr)s>L%vxb;kbEVQsN@%r1!8%~>?oHk)N!Fa7QHZc)l7f`)%cYylV z-#_yD^+bAHeoS5{)=7%VPpS%lJM)MU+UZd%uyl)zij4I4_ur2JLnH_{OULwLSu99U zv9jXi$fzhcOF^x?sYVqFBq@8sbwS7F$SExd8P*Coha9|UQES=}Me}mta2#u8vRK^5 z%3_-_DX1N!%2zeX? z72xFpnQS*L$l*{5Jzp`pYAl3Cg&~drPe!FuW6d{j4vK|AmWDbc zti?NdGe)v^vpIA)sYuvurBW3DsFcdXc<0P1V=TmVcV{wH#RcBp-mt_R-ekoxCez)W zn|{+A00#s?=THiSd?6qN4l9-4pwku_ogU3(Iyp;^ewJqoF~UpWzBJL0-_|i0BN~ z#ts|?id@kfNk4>#hM0~J0&c?2I3h>X^sKKh{j|H4loSq!FGkpLZ^|1rQboB`TCP!O zvNckT!qin4LfS)oqR|7%*$Vflj_wa7Z0@Iog)$;JXU^oe6~~? z5idR4aHlalt1P};YHEx>njZx-5V~Woy}c!nll0~Cljpgu+x8duR{WiSuC~0}}4o`DLfS{)G>sAz~k8$pqtFISf>C*e#O%3{NwASL4S`OWz?8}n}YqVRAB zh*HG@I>KPH^e!kA7cQKK9Z|9Z)f9Nq#FvdD5$IB2 zpgm^NYIrJj%U}iju-QBwo9z=ElA!}n81kt0j+LP^0se2~h7B8B31A!nj-cz*sk6{+ z_5Q9G7s3KU;`F}GAYshb`RR1@aiDEoBkHLU`{o4N6 w>a^M|=x;o7=FAbs{lWc#dD^5-I{DxH0Ipg=GdX}U9{>OV07*qoM6N<$g4y4=@&Et; literal 0 HcmV?d00001 diff --git a/src/intl/en.json b/src/intl/en.json index c69db71fd..afe413ed2 100644 --- a/src/intl/en.json +++ b/src/intl/en.json @@ -723,6 +723,11 @@ "duplicated_error": "It looks like this {addressAlias} has already been added.", "max_error": "The maximum number of {addressesAlias} was exceeded.", "logged_user_invalid": "Logged user address is not allowed" + }, + "floating_bar": { + "view_reactions_label": "View Reactions", + "total_comments_label": "{count} {count, plural, one {Comment} other {Comments}}", + "forum_label": "Join Discussion" } }, "page": { @@ -963,7 +968,6 @@ "proposal_detail": { "title": "Decentraland DAO", "description": "The governance hub for Decentraland. Create and vote on proposals that help shape the future of the metaverse.", - "forum_button": "Discuss in the forum", "subscribe_button": "Add to my Watchlist", "subscribed_button": "Remove from my Watchlist", "calendar_button": "Set calendar alert", diff --git a/src/pages/proposal.tsx b/src/pages/proposal.tsx index bb5afc43c..14a8c6a1f 100644 --- a/src/pages/proposal.tsx +++ b/src/pages/proposal.tsx @@ -9,7 +9,7 @@ import useAuthContext from 'decentraland-gatsby/dist/context/Auth/useAuthContext import usePatchState from 'decentraland-gatsby/dist/hooks/usePatchState' import { Header } from 'decentraland-ui/dist/components/Header/Header' import { Loader } from 'decentraland-ui/dist/components/Loader/Loader' -import { Desktop } from 'decentraland-ui/dist/components/Media/Media' +import { useMobileMediaQuery } from 'decentraland-ui/dist/components/Media/Media' import Grid from 'semantic-ui-react/dist/commonjs/collections/Grid/Grid' import { ErrorClient } from '../clients/ErrorClient' @@ -17,6 +17,7 @@ import { Governance } from '../clients/Governance' import { SnapshotApi } from '../clients/SnapshotApi' import CategoryPill from '../components/Category/CategoryPill' import ProposalVPChart from '../components/Charts/ProposalVPChart' +import FloatingBar from '../components/FloatingBar/FloatingBar' import ContentLayout, { ContentSection } from '../components/Layout/ContentLayout' import MaintenanceLayout from '../components/Layout/MaintenanceLayout' import BidSubmittedModal from '../components/Modal/BidSubmittedModal' @@ -62,7 +63,7 @@ import useIsProposalOwner from '../hooks/useIsProposalOwner' import useProposal from '../hooks/useProposal' import useProposalUpdates from '../hooks/useProposalUpdates' import useProposalVotes from '../hooks/useProposalVotes' -import useSurveyTopics from '../hooks/useSurveyTopics' +import useSurvey from '../hooks/useSurvey' import useURLSearchParams from '../hooks/useURLSearchParams' import { ErrorCategory } from '../utils/errorCategories' import locations, { navigate } from '../utils/locations' @@ -73,7 +74,6 @@ import './proposal.css' const EMPTY_VOTE_CHOICE_SELECTION: SelectedVoteChoice = { choice: undefined, choiceIndex: undefined } const MAX_ERRORS_BEFORE_SNAPSHOT_REDIRECT = 3 const SECONDS_FOR_VOTING_RETRY = 5 -const SURVEY_TOPICS_FEATURE_LAUNCH = new Date(2023, 3, 5, 0, 0) export type ProposalPageState = { changingVote: boolean @@ -144,6 +144,7 @@ function getProposalView(proposal: ProposalAttributes | null) { export default function ProposalPage() { const t = useFormatMessage() const params = useURLSearchParams() + const isMobile = useMobileMediaQuery() const [proposalPageState, updatePageState] = usePatchState({ changingVote: false, confirmSubscription: false, @@ -171,7 +172,7 @@ export default function ProposalPage() { const { isOwner } = useIsProposalOwner(proposal) const { votes, isLoadingVotes, reloadVotes } = useProposalVotes(proposal?.id) const { highQualityVotes, lowQualityVotes } = useMemo(() => getVoteSegmentation(votes), [votes]) - const { surveyTopics, isLoadingSurveyTopics } = useSurveyTopics(proposal?.id) + const subscriptionsQueryKey = `subscriptions#${proposal?.id || ''}` const { data: subscriptions, isLoading: isSubscriptionsLoading } = useQuery({ queryKey: [subscriptionsQueryKey], @@ -189,6 +190,45 @@ export default function ProposalPage() { const showProposalUpdates = publicUpdates && isProposalStatusWithUpdates(proposal?.status) && proposal?.type === ProposalType.Grant + const { surveyTopics, isLoadingSurveyTopics, voteWithSurvey, showSurveyResults } = useSurvey( + proposal, + votes, + isLoadingVotes, + isMobile + ) + + const [isBarVisible, setIsBarVisible] = useState(true) + const commentsSectionRef = useRef(null) + const reactionsSectionRef = useRef(null) + const scrollToReactions = () => { + if (reactionsSectionRef.current) { + reactionsSectionRef.current.scrollIntoView({ behavior: 'smooth' }) + } + } + const scrollToComments = () => { + if (commentsSectionRef.current) { + commentsSectionRef.current.scrollIntoView({ behavior: 'smooth' }) + } + } + + useEffect(() => { + setIsBarVisible(true) + if (!isLoadingProposal && typeof window !== 'undefined') { + const handleScroll = () => { + const hideBarSectionRef = reactionsSectionRef.current || commentsSectionRef.current + if (!!hideBarSectionRef && !!window) { + const hideBarSectionTop = hideBarSectionRef.getBoundingClientRect().top + setIsBarVisible(hideBarSectionTop > window.innerHeight) + } + } + + window.addEventListener('scroll', handleScroll) + return () => { + window.removeEventListener('scroll', handleScroll) + } + } + }, [isLoadingProposal]) + const [castingVote, castVote] = useAsyncTask( async (selectedChoice: SelectedVoteChoice, survey?: Survey) => { if (proposal && account && provider && votes && selectedChoice.choiceIndex) { @@ -263,15 +303,11 @@ export default function ProposalPage() { }, [proposal, account, isDAOCommittee]) useEffect(() => { - updatePageStateRef.current({ showProposalSuccessModal: params.get('new') === 'true' }) - }, [params]) - - useEffect(() => { - updatePageStateRef.current({ showTenderPublishedModal: params.get('pending') === 'true' }) - }, [params]) - - useEffect(() => { - updatePageStateRef.current({ showBidSubmittedModal: params.get('bid') === 'true' }) + updatePageStateRef.current({ + showProposalSuccessModal: params.get('new') === 'true', + showTenderPublishedModal: params.get('pending') === 'true', + showBidSubmittedModal: params.get('bid') === 'true', + }) }, [params]) useEffect(() => { @@ -305,13 +341,6 @@ export default function ProposalPage() { navigate(locations.proposal(proposal!.id), { replace: true }) } - const voteWithSurvey = - !isLoadingSurveyTopics && - !!surveyTopics && - surveyTopics.length > 0 && - !!proposal && - proposal.created_at > SURVEY_TOPICS_FEATURE_LAUNCH - if (isErrorOnProposal) { return ( @@ -382,15 +411,13 @@ export default function ProposalPage() { isCoauthor={isCoauthor} /> )} - {proposal && voteWithSurvey && ( - - - + {showSurveyResults && ( + )} {showVotesChart && ( )} - + +