From 244203d3c7be64f7b998729ea8bc0c88a260566a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Comerci?= <45410089+ncomerci@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:45:08 +0100 Subject: [PATCH] feat: Vote rationale for whales (#1703) * vote reason setup * added vote reason friction * rationale proposal section * check vp threshold * threshold moved to env var * requested changes * added reason on vote modal * requested changes * fix: rationale extra info * fix: modal * added tooltip to comment timestamp * label rename --- src/back/services/discord.ts | 18 ++-- src/clients/SnapshotApi.ts | 25 +++-- src/clients/SnapshotGraphql.ts | 1 + src/clients/SnapshotTypes.ts | 2 + src/components/Comments/Comment.css | 13 +++ src/components/Comments/Comment.tsx | 49 ++++++++-- src/components/Comments/Comments.css | 11 +++ src/components/Comments/Comments.tsx | 1 + .../Common/ProposalPreviewCard/VoteModule.tsx | 3 +- src/components/Common/Typography/Text.tsx | 4 +- src/components/Icon/ReadReason.tsx | 21 +++++ src/components/Icon/UnreadReason.tsx | 19 ++++ src/components/Modal/Votes/VoteListItem.tsx | 65 +++++++++---- src/components/Modal/Votes/VotesListModal.css | 33 +++++-- src/components/Modal/Votes/VotesListModal.tsx | 7 +- .../Votes/VotingModal/ReasonVoteArea.css | 5 + .../Votes/VotingModal/ReasonVoteArea.tsx | 49 ++++++++++ .../Modal/Votes/VotingModal/VotingModal.css | 13 ++- .../Modal/Votes/VotingModal/VotingModal.tsx | 11 ++- .../Votes/VotingModal/VotingModalSurvey.tsx | 92 ++++++++++++------- src/components/Proposal/ProposalSidebar.tsx | 9 +- .../Rationale/VotingRationaleSection.tsx | 57 ++++++++++++ .../SentimentSurvey/SentimentSurvey.css | 4 - .../ProposalVoting/ProposalVotingSection.tsx | 6 +- src/config/env/dev.json | 3 +- src/config/env/local.json | 3 +- src/config/env/prd.json | 3 +- src/constants.ts | 1 + src/entities/Votes/types.ts | 10 +- src/entities/Votes/utils.ts | 1 + src/helpers/index.ts | 7 ++ src/hooks/useDelegationOnProposal.ts | 6 ++ src/hooks/useProposalChoices.ts | 9 ++ src/hooks/useVoteReason.ts | 27 ++++++ src/hooks/useWinningChoice.ts | 8 +- src/intl/en.json | 14 ++- src/pages/proposal.tsx | 31 +++++-- 37 files changed, 515 insertions(+), 126 deletions(-) create mode 100644 src/components/Icon/ReadReason.tsx create mode 100644 src/components/Icon/UnreadReason.tsx create mode 100644 src/components/Modal/Votes/VotingModal/ReasonVoteArea.css create mode 100644 src/components/Modal/Votes/VotingModal/ReasonVoteArea.tsx create mode 100644 src/components/Proposal/Rationale/VotingRationaleSection.tsx create mode 100644 src/hooks/useProposalChoices.ts create mode 100644 src/hooks/useVoteReason.ts diff --git a/src/back/services/discord.ts b/src/back/services/discord.ts index 2a6693267..9d25cfaad 100644 --- a/src/back/services/discord.ts +++ b/src/back/services/discord.ts @@ -8,7 +8,7 @@ import { isGovernanceProcessProposal, proposalUrl } from '../../entities/Proposa import UpdateModel from '../../entities/Updates/model' import { UpdateAttributes } from '../../entities/Updates/types' import { getPublicUpdates, getUpdateNumber, getUpdateUrl } from '../../entities/Updates/utils' -import { capitalizeFirstLetter, getEnumDisplayName, inBackground } from '../../helpers' +import { capitalizeFirstLetter, getEnumDisplayName, inBackground, shortenText } from '../../helpers' import { ErrorService } from '../../services/ErrorService' import { getProfile } from '../../utils/Catalyst' import { ErrorCategory } from '../../utils/errorCategories' @@ -55,10 +55,6 @@ function getChoices(choices: string[]): Field[] { })) } -function getPreviewText(text: string) { - return text.length > PREVIEW_MAX_LENGTH ? text.slice(0, PREVIEW_MAX_LENGTH) + '...' : text -} - export class DiscordService { private static client: Client static init() { @@ -124,7 +120,7 @@ export class DiscordService { if (!!proposalType && !!description) { const embedDescription = !isGovernanceProcessProposal(proposalType) ? description.split('\n')[0] - : getPreviewText(description) + : shortenText(description, PREVIEW_MAX_LENGTH) fields.push({ name: getEnumDisplayName(proposalType), @@ -231,11 +227,11 @@ export class DiscordService { url: getUpdateUrl(updateId, proposalId), title, fields: [ - { name: 'Project Health', value: getPreviewText(health) }, - { name: 'Introduction', value: getPreviewText(introduction) }, - { name: 'Highlights', value: getPreviewText(highlights) }, - { name: 'Blockers', value: getPreviewText(blockers) }, - { name: 'Next Steps', value: getPreviewText(next_steps) }, + { name: 'Project Health', value: shortenText(health, PREVIEW_MAX_LENGTH) }, + { name: 'Introduction', value: shortenText(introduction, PREVIEW_MAX_LENGTH) }, + { name: 'Highlights', value: shortenText(highlights, PREVIEW_MAX_LENGTH) }, + { name: 'Blockers', value: shortenText(blockers, PREVIEW_MAX_LENGTH) }, + { name: 'Next Steps', value: shortenText(next_steps, PREVIEW_MAX_LENGTH) }, ], user, action, diff --git a/src/clients/SnapshotApi.ts b/src/clients/SnapshotApi.ts index bc476a011..af583ee11 100644 --- a/src/clients/SnapshotApi.ts +++ b/src/clients/SnapshotApi.ts @@ -32,6 +32,15 @@ export type SnapshotReceipt = { } } +type CastVote = { + account: Web3Provider | Wallet + address: string + proposalSnapshotId: string + choiceNumber: number + metadata?: string + reason?: string +} + export class SnapshotApi { static Url = process.env.GATSBY_SNAPSHOT_API || 'https://hub.snapshot.org' @@ -136,19 +145,21 @@ export class SnapshotApi { )) as SnapshotReceipt } - async castVote( - account: Web3Provider | Wallet, - address: string, - proposalSnapshotId: string, - choiceNumber: number, - metadata?: string - ): Promise { + async castVote({ + account, + address, + proposalSnapshotId, + choiceNumber, + metadata, + reason, + }: CastVote): Promise { const voteMessage: Vote = { space: SnapshotApi.getSpaceName(), proposal: proposalSnapshotId, type: SNAPSHOT_PROPOSAL_TYPE, choice: choiceNumber, metadata, + reason, app: SNAPSHOT_APP_NAME, } return (await this.client.vote(account, address, voteMessage)) as SnapshotReceipt diff --git a/src/clients/SnapshotGraphql.ts b/src/clients/SnapshotGraphql.ts index eb7631652..899ed1088 100644 --- a/src/clients/SnapshotGraphql.ts +++ b/src/clients/SnapshotGraphql.ts @@ -112,6 +112,7 @@ export class SnapshotGraphql extends API { metadata vp vp_by_strategy + reason } } ` diff --git a/src/clients/SnapshotTypes.ts b/src/clients/SnapshotTypes.ts index c16e6b998..2767564cb 100644 --- a/src/clients/SnapshotTypes.ts +++ b/src/clients/SnapshotTypes.ts @@ -28,6 +28,7 @@ export type SnapshotVote = { vp_by_strategy?: number[] choice: number metadata?: Record + reason?: string proposal?: { id: string title?: string @@ -81,6 +82,7 @@ export type SnapshotProposal = { space: SnapshotSpace strategies?: SnapshotStrategy[] discussion: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any plugins: any } diff --git a/src/components/Comments/Comment.css b/src/components/Comments/Comment.css index 98fcc1670..0c95436d6 100644 --- a/src/components/Comments/Comment.css +++ b/src/components/Comments/Comment.css @@ -74,3 +74,16 @@ .Comment .Comment__Content { width: 100%; } + +.Comment__AuthorText { + margin: 0 !important; +} + +.Comment__ExtraInfo { + margin: 0 !important; + flex: none !important; + color: var(--secondary-text) !important; +} +.Comment__ExtraInfo.Comment__ExtraInfo--choice { + margin: 0 7px !important; +} diff --git a/src/components/Comments/Comment.tsx b/src/components/Comments/Comment.tsx index 9a9bb49f6..9f5769cf3 100644 --- a/src/components/Comments/Comment.tsx +++ b/src/components/Comments/Comment.tsx @@ -1,10 +1,14 @@ import DOMPurify from 'dompurify' import { FORUM_URL } from '../../constants' +import { shortenText } from '../../helpers' +import useAbbreviatedFormatter from '../../hooks/useAbbreviatedFormatter' import useDclProfile from '../../hooks/useDclProfile' +import useFormatMessage from '../../hooks/useFormatMessage' import Time from '../../utils/date/Time' import locations from '../../utils/locations' import Avatar from '../Common/Avatar' +import DateTooltip from '../Common/DateTooltip' import Link from '../Common/Typography/Link' import Text from '../Common/Typography/Text' import ValidatedProfile from '../Icon/ValidatedProfile' @@ -21,10 +25,22 @@ type Props = { createdAt: string cooked?: string address?: string + isValidated?: boolean + extraInfo?: { choice: string; vp: number } } +const CHOICE_MAX_LENGTH = 14 + /* eslint-disable @typescript-eslint/no-explicit-any */ -export default function Comment({ forumUsername, avatarUrl, createdAt, cooked, address }: Props) { +export default function Comment({ + forumUsername, + avatarUrl, + createdAt, + cooked, + address, + isValidated, + extraInfo, +}: Props) { const createMarkup = (html: any) => { DOMPurify.addHook('afterSanitizeAttributes', function (node) { if (node.nodeName && node.nodeName === 'IMG' && node.getAttribute('alt') === 'image') { @@ -48,6 +64,10 @@ export default function Comment({ forumUsername, avatarUrl, createdAt, cooked, a const { profile, isLoadingDclProfile } = useDclProfile(address) const { username, hasCustomAvatar, avatarUrl: profileAvatarUrl } = profile + const t = useFormatMessage() + const formatter = useAbbreviatedFormatter() + const isChoiceShortened = (extraInfo?.choice.length || 0) > CHOICE_MAX_LENGTH + return (
@@ -63,14 +83,31 @@ export default function Comment({ forumUsername, avatarUrl, createdAt, cooked, a
- + {username || forumUsername} - {address && } + {address && isValidated && } - - {Time.from(createdAt).fromNow()} - + {extraInfo && ( + <> + + {t('page.rationale.vote_info', { choice: shortenText(extraInfo.choice, CHOICE_MAX_LENGTH) })} + + + {formatter(extraInfo.vp)} VP + + + )} + , + + + {Time.from(createdAt).fromNow()} + +
diff --git a/src/components/Comments/Comments.css b/src/components/Comments/Comments.css index 177fb5ed2..2ff7ca53e 100644 --- a/src/components/Comments/Comments.css +++ b/src/components/Comments/Comments.css @@ -37,3 +37,14 @@ font-size: 17px; } } + +.Comment__ExtraInfoText { + margin-left: 7px !important; + color: var(--secondary-text) !important; + margin-bottom: 0 !important; +} + +.Comment__ExtraInfoStrong { + margin: 0 !important; + color: var(--secondary-text) !important; +} diff --git a/src/components/Comments/Comments.tsx b/src/components/Comments/Comments.tsx index 0dc37e7b0..df68b1e39 100644 --- a/src/components/Comments/Comments.tsx +++ b/src/components/Comments/Comments.tsx @@ -73,6 +73,7 @@ export default function Comments({ comments, topicId, topicSlug, isLoading, topi createdAt={comment.created_at} cooked={comment.cooked} address={comment.address} + isValidated={!!comment.address} /> ))}
diff --git a/src/components/Common/ProposalPreviewCard/VoteModule.tsx b/src/components/Common/ProposalPreviewCard/VoteModule.tsx index 300ffab16..5f9267f7f 100644 --- a/src/components/Common/ProposalPreviewCard/VoteModule.tsx +++ b/src/components/Common/ProposalPreviewCard/VoteModule.tsx @@ -7,6 +7,7 @@ import { ProposalAttributes } from '../../../entities/Proposal/types' import { VoteByAddress } from '../../../entities/Votes/types' import { calculateResult } from '../../../entities/Votes/utils' import useFormatMessage from '../../../hooks/useFormatMessage' +import useProposalChoices from '../../../hooks/useProposalChoices' import CategoryPill from '../../Category/CategoryPill' import ChevronRight from '../../Icon/ChevronRight' import Text from '../Typography/Text' @@ -23,7 +24,7 @@ function VoteModule({ proposal, votes }: Props) { const t = useFormatMessage() const [account] = useAuthContext() const hasVote = !!account && !isEmpty(votes?.[account]) - const choices = useMemo((): string[] => proposal?.snapshot_proposal?.choices || [], [proposal]) + const choices = useProposalChoices(proposal) const vote = hasVote && !!votes?.[account].choice ? choices[votes?.[account].choice - 1] : undefined const results = useMemo( () => calculateResult(proposal?.snapshot_proposal?.choices || [], votes || {}), diff --git a/src/components/Common/Typography/Text.tsx b/src/components/Common/Typography/Text.tsx index ec3df57d5..2a8674d04 100644 --- a/src/components/Common/Typography/Text.tsx +++ b/src/components/Common/Typography/Text.tsx @@ -21,6 +21,7 @@ interface Props { color?: TextColor style?: FontStyle as?: 'span' + title?: string } const Text = React.forwardRef( @@ -33,6 +34,7 @@ const Text = React.forwardRef( style = DEFAULT_FONT_STYLE, className, as, + title, }, ref ) => { @@ -46,7 +48,7 @@ const Text = React.forwardRef( ) const Component = as ?? 'p' return ( - + {children} ) diff --git a/src/components/Icon/ReadReason.tsx b/src/components/Icon/ReadReason.tsx new file mode 100644 index 000000000..b131cff39 --- /dev/null +++ b/src/components/Icon/ReadReason.tsx @@ -0,0 +1,21 @@ +function ReadReason() { + return ( + + + + + + + + + + + ) +} + +export default ReadReason diff --git a/src/components/Icon/UnreadReason.tsx b/src/components/Icon/UnreadReason.tsx new file mode 100644 index 000000000..397eaa2ed --- /dev/null +++ b/src/components/Icon/UnreadReason.tsx @@ -0,0 +1,19 @@ +function UnreadReason() { + return ( + + + + + + + + + + + ) +} + +export default UnreadReason diff --git a/src/components/Modal/Votes/VoteListItem.tsx b/src/components/Modal/Votes/VoteListItem.tsx index 9194bde73..bf872d606 100644 --- a/src/components/Modal/Votes/VoteListItem.tsx +++ b/src/components/Modal/Votes/VoteListItem.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react' + import classNames from 'classnames' import Grid from 'semantic-ui-react/dist/commonjs/collections/Grid/Grid' @@ -8,6 +10,8 @@ import locations from '../../../utils/locations' import { formatChoice } from '../../../utils/votes/utils' import Link from '../../Common/Typography/Link' import Username from '../../Common/Username' +import ReadReason from '../../Icon/ReadReason' +import UnreadReason from '../../Icon/UnreadReason' export type VoteListItemModalProps = { address: string @@ -19,26 +23,47 @@ export type VoteListItemModalProps = { export function VoteListItem({ address, vote, choices, isLowQuality, active }: VoteListItemModalProps) { const t = useFormatMessage() + const [showReason, setshowReason] = useState(false) return ( - - - - - -

{formatChoice(choices[vote.choice - 1])}

-
- -

{`${abbreviateNumber(vote.vp)} ${t('modal.votes_list.vp')}`}

-
-
+ <> + + + + + + + +

{formatChoice(choices[vote.choice - 1])}

+
+ +

{`${abbreviateNumber(vote.vp)} ${t('modal.votes_list.vp')}`}

+
+ { + e.stopPropagation() + e.preventDefault() + setshowReason((prev) => !prev) + }} + > + + +
+ + +

{vote.reason}

+
+
+ ) } diff --git a/src/components/Modal/Votes/VotesListModal.css b/src/components/Modal/Votes/VotesListModal.css index 7beb4dcbc..070e80da7 100644 --- a/src/components/Modal/Votes/VotesListModal.css +++ b/src/components/Modal/Votes/VotesListModal.css @@ -31,10 +31,6 @@ } } -.VotesList__ItemsContainer .VoteList__Item:hover { - cursor: pointer; -} - .VotesList__HeaderContainer .VotesList__Header { color: var(--black-600); } @@ -51,7 +47,8 @@ border-bottom: 1px solid var(--black-300); } -.VotesList__Divider .VotesList__DividerLine:before { +.VotesList__Divider .VotesList__DividerLine:before, +.VoteList__ItemReason.VoteList__ItemReason--active:before { content: ''; position: absolute; bottom: 0; @@ -60,8 +57,9 @@ } .VoteList__Item:has(+ .VoteList__ShowButtonContainer)::before, -.VoteList__Item:has(+ .VoteList__LowQualityDividerContainer)::before { - border-bottom: none; +.VoteList__Item:has(+ .VoteList__LowQualityDividerContainer)::before, +.VotesList__DividerLine--none::before { + border-bottom: none !important; } .VotesList .Avatar { @@ -146,3 +144,24 @@ .VoteList__ItemUsername { color: var(--black-800); } + +.VoteList__ItemReason { + display: none !important; +} + +.VoteList__ItemReason.VoteList__ItemReason--active { + display: flex !important; + padding-top: 0px !important; + padding-bottom: 30px !important; +} + +.VoteList__ItemReasonText { + padding: 0 42px; + word-break: break-word; +} + +.VoteList__ItemReasonButton { + cursor: pointer; + background: none; + border: none; +} diff --git a/src/components/Modal/Votes/VotesListModal.tsx b/src/components/Modal/Votes/VotesListModal.tsx index fdd34b780..0cfe2b655 100644 --- a/src/components/Modal/Votes/VotesListModal.tsx +++ b/src/components/Modal/Votes/VotesListModal.tsx @@ -10,6 +10,7 @@ import { VOTES_VP_THRESHOLD } from '../../../constants' import { ProposalAttributes } from '../../../entities/Proposal/types' import { VoteByAddress } from '../../../entities/Votes/types' import useFormatMessage from '../../../hooks/useFormatMessage' +import useProposalChoices from '../../../hooks/useProposalChoices' import FullWidthButton from '../../Common/FullWidthButton' import '../ProposalModal.css' @@ -24,7 +25,7 @@ type Props = Omit & { export default function VotesListModal({ proposal, highQualityVotes, lowQualityVotes, onClose, ...props }: Props) { const t = useFormatMessage() - const choices = useMemo((): string[] => proposal?.snapshot_proposal?.choices || [], [proposal]) + const choices = useProposalChoices(proposal) const [showLowQualityVotes, setShowLowQualityVotes] = useState(false) const sortedHighQualityVotes = useMemo( () => Object.entries(highQualityVotes || {}).sort((a, b) => b[1].vp - a[1].vp), @@ -64,10 +65,10 @@ export default function VotesListModal({ proposal, highQualityVotes, lowQualityV
{t('modal.votes_list.voter')}
- +
{t('modal.votes_list.voted')}
- +
{t('modal.votes_list.vp')}
diff --git a/src/components/Modal/Votes/VotingModal/ReasonVoteArea.css b/src/components/Modal/Votes/VotingModal/ReasonVoteArea.css new file mode 100644 index 000000000..4529151e2 --- /dev/null +++ b/src/components/Modal/Votes/VotingModal/ReasonVoteArea.css @@ -0,0 +1,5 @@ +.ReasonVoteArea__Container { + display: flex; + flex-direction: column; + gap: 11px; +} diff --git a/src/components/Modal/Votes/VotingModal/ReasonVoteArea.tsx b/src/components/Modal/Votes/VotingModal/ReasonVoteArea.tsx new file mode 100644 index 000000000..bb9954518 --- /dev/null +++ b/src/components/Modal/Votes/VotingModal/ReasonVoteArea.tsx @@ -0,0 +1,49 @@ +import { Control, FieldErrors, UseFormWatch } from 'react-hook-form' + +import { Header } from 'decentraland-ui/dist/components/Header/Header' + +import { Reason, reasonSchema } from '../../../../entities/Votes/types' +import useFormatMessage from '../../../../hooks/useFormatMessage' +import TextArea from '../../../Common/Form/TextArea' + +import './ReasonVoteArea.css' + +type Props = { + choice: string + control: Control + errors: FieldErrors + watch: UseFormWatch + isDisabled?: boolean +} + +function ReasonVoteArea({ choice, control, errors, watch, isDisabled }: Props) { + const t = useFormatMessage() + return ( +
+
{t('modal.voting_modal_survey.rationale')}
+