From 0be36045c9b6d97808b3b78e614529284aa0509a Mon Sep 17 00:00:00 2001 From: Paul Chen Date: Wed, 24 Apr 2024 13:00:52 +0800 Subject: [PATCH] feat: override proposal_details components for Callisto v4 --- .../components/metadata_loader/index.tsx | 90 ++++++++ .../components/overview/index.tsx | 212 ++++++++++++++++++ .../components/overview/styles.ts | 77 +++++++ .../components/votes_graph/hooks.ts | 71 ++++++ .../src/screens/proposal_details/hooks.ts | 97 ++++++++ .../src/screens/proposal_details/types.ts | 25 +++ 6 files changed, 572 insertions(+) create mode 100644 apps/web-cheqd/src/screens/proposal_details/components/overview/components/metadata_loader/index.tsx create mode 100644 apps/web-cheqd/src/screens/proposal_details/components/overview/index.tsx create mode 100644 apps/web-cheqd/src/screens/proposal_details/components/overview/styles.ts create mode 100644 apps/web-cheqd/src/screens/proposal_details/components/votes_graph/hooks.ts create mode 100644 apps/web-cheqd/src/screens/proposal_details/hooks.ts create mode 100644 apps/web-cheqd/src/screens/proposal_details/types.ts diff --git a/apps/web-cheqd/src/screens/proposal_details/components/overview/components/metadata_loader/index.tsx b/apps/web-cheqd/src/screens/proposal_details/components/overview/components/metadata_loader/index.tsx new file mode 100644 index 0000000000..d744daa02c --- /dev/null +++ b/apps/web-cheqd/src/screens/proposal_details/components/overview/components/metadata_loader/index.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react'; +import Loading from '@/components/loading'; + +interface MetadataLoaderProps { + metadata: string; +} + +// Checks if a string is a valid URL +const isValidUrl = (url: string) => { + const pattern = /^(ftp|http|https|ipfs):\/\/[^ "]+$/; + return pattern.test(url); +}; + +// Checks if a string is a IPFS URL +const isIPFSUrl = (url: string) => { + const pattern = /^(ipfs):\/\/[^ "]+$/; + return pattern.test(url); +}; + +// Removes ipfs prefix from metadata +const removeIPFSPrefix = (metadata: string): string => { + if (metadata.startsWith('ipfs://')) { + return metadata.substring('ipfs://'.length); + } + return metadata; +}; + +const MetadataLoader: React.FC = ({ metadata }) => { + const [metadataContent, setMetadataContent] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + + const fetchMetadata = async () => { + try { + if (!isValidUrl(metadata)) { + setMetadataContent(metadata); + return; + } + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // Abort the fetch after 10 seconds + + let response: Response; + if (!isIPFSUrl(metadata)) { + response = await fetch(metadata, { signal: controller.signal }); + } else { + const modifiedMetadata = removeIPFSPrefix(metadata); + response = await fetch(`https://ipfs.io/ipfs/${modifiedMetadata}`, { + signal: controller.signal, + }); + } + clearTimeout(timeoutId); // Clear the timeout + if (!isMounted) { + setMetadataContent(metadata); + return; + } + if (!response.ok) { + setMetadataContent(metadata); + return; + } + const text = await response.text(); + setMetadataContent(text); + } catch (err) { + if (!isMounted) return; // Exit if the component is unmounted + setMetadataContent(metadata); + } finally { + setLoading(false); + } + }; + + fetchMetadata(); + + return () => { + isMounted = false; // Set isMounted to false when unmounting + }; + }, [metadata]); + + if (loading) { + return ; + } + + if (metadataContent) { + return {metadataContent}; + } + + return null; +}; + +export default MetadataLoader; diff --git a/apps/web-cheqd/src/screens/proposal_details/components/overview/index.tsx b/apps/web-cheqd/src/screens/proposal_details/components/overview/index.tsx new file mode 100644 index 0000000000..d7759cfae5 --- /dev/null +++ b/apps/web-cheqd/src/screens/proposal_details/components/overview/index.tsx @@ -0,0 +1,212 @@ +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import numeral from 'numeral'; +import * as R from 'ramda'; +import { FC, useMemo, useCallback } from 'react'; +import { useRecoilValue } from 'recoil'; +import Box from '@/components/box'; +import Markdown from '@/components/markdown'; +import Name from '@/components/name'; +import SingleProposal from '@/components/single_proposal'; +import { useProfileRecoil } from '@/recoil/profiles/hooks'; +import { readDate, readTimeFormat } from '@/recoil/settings'; +import CommunityPoolSpend from '@/screens/proposal_details/components/overview/components/community_pool_spend'; +import UpdateParams from '@/screens/proposal_details/components/overview/components/update_params'; +import ParamsChange from '@/screens/proposal_details/components/overview/components/params_change'; +import SoftwareUpgrade from '@/screens/proposal_details/components/overview/components/software_upgrade'; +import type { OverviewType } from '@/screens/proposal_details/types'; +import { getProposalType } from '@/screens/proposal_details/utils'; +import dayjs, { formatDayJs } from '@/utils/dayjs'; +import { formatNumber, formatToken } from '@/utils/format_token'; +import MetadataLoader from './components/metadata_loader'; +import useStyles from './styles'; + +const Overview: FC<{ className?: string; overview: OverviewType }> = ({ className, overview }) => { + const dateFormat = useRecoilValue(readDate); + const timeFormat = useRecoilValue(readTimeFormat); + const { classes, cx } = useStyles(); + const { t } = useAppTranslation('proposals'); + + const types = useMemo(() => { + if (Array.isArray(overview.content)) { + const typeArray: string[] = []; + overview.content.forEach((type: { params: JSON; type: string }) => + typeArray.push(getProposalType(R.pathOr('', ['@type'], type))) + ); + return typeArray; + } + const typeArray: string[] = []; + typeArray.push(getProposalType(R.pathOr('', ['@type'], overview.content))); + return typeArray; + }, [overview.content]); + + const changes = useMemo(() => { + const changeList: any[] = []; + if (Array.isArray(overview.content)) { + overview.content.forEach((type: { params: JSON; type: string }) => { + changeList.push({ params: type.params, type: R.pathOr('', ['@type'], type) }); + }); + + return changeList; + } + return changeList; + }, [overview.content]); + + const { address: proposerAddress, name: proposerName } = useProfileRecoil(overview.proposer); + const { name: recipientName } = useProfileRecoil(overview?.content?.recipient); + const proposerMoniker = proposerName || overview.proposer; + const recipientMoniker = recipientName || overview?.content?.recipient; + const amountRequested = overview.content?.amount + ? formatToken(overview.content?.amount[0]?.amount, overview.content?.amount[0]?.denom) + : null; + const parsedAmountRequested = amountRequested + ? `${formatNumber( + amountRequested.value, + amountRequested.exponent + )} ${amountRequested.displayDenom.toUpperCase()}` + : ''; + + const getExtraDetails = useCallback(() => { + let extraDetails = null; + types.forEach((type: string) => { + if (type === 'parameterChangeProposal') { + extraDetails = ( + <> + + {t('changes')} + + + + ); + } + if (type === 'softwareUpgradeProposal') { + extraDetails = ( + <> + + {t('plan')} + + + + ); + } + if (type === 'communityPoolSpendProposal') { + extraDetails = ( + <> + + {t('content')} + + + + ); + } + + if (type.includes('MsgUpdateParams')) { + extraDetails = ( + <> + {changes.map((change) => ( + + ))} + + ); + } + }); + return extraDetails; + }, [changes, overview.content, parsedAmountRequested, recipientMoniker, t, types]); + + const extra = getExtraDetails(); + + return ( + + + +
+ {types.length > 0 && ( + <> + + {t('type')} + + + {types.map((type: string) => ( + + {t(type)} + + ))} + + + )} + + {t('proposer')} + + + {overview?.submitTime && ( + <> + + {t('submitTime')} + + + {formatDayJs(dayjs.utc(overview.submitTime), dateFormat, timeFormat)} + + + )} + {overview?.depositEndTime && ( + <> + + {t('depositEndTime')} + + + {formatDayJs(dayjs.utc(overview.depositEndTime), dateFormat, timeFormat)} + + + )} + {overview?.votingStartTime && ( + <> + + {t('votingStartTime')} + + + {formatDayJs(dayjs.utc(overview.votingStartTime), dateFormat, timeFormat)} + + + )} + {overview?.votingEndTime && ( + <> + + {t('votingEndTime')} + + + {formatDayJs(dayjs.utc(overview.votingEndTime), dateFormat, timeFormat)} + + + )} + + {t('description')} + + + {overview?.metadata && ( + <> + + {t('metadata')} + + + + )} + {extra} +
+
+ ); +}; + +export default Overview; diff --git a/apps/web-cheqd/src/screens/proposal_details/components/overview/styles.ts b/apps/web-cheqd/src/screens/proposal_details/components/overview/styles.ts new file mode 100644 index 0000000000..55ec30709d --- /dev/null +++ b/apps/web-cheqd/src/screens/proposal_details/components/overview/styles.ts @@ -0,0 +1,77 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + root: { + '& .label': { + color: theme.palette.custom.fonts.fontThree, + }, + '& .content': { + marginBottom: theme.spacing(2), + display: 'block', + [theme.breakpoints.up('lg')]: { + display: 'flex', + }, + }, + '& .recipient': { + marginBottom: theme.spacing(2), + [theme.breakpoints.up('lg')]: { + display: 'block', + }, + }, + '& .amountRequested': { + marginBottom: theme.spacing(2), + display: 'block', + padding: '0', + [theme.breakpoints.up('lg')]: { + display: 'block', + paddingLeft: '30px', + }, + }, + '& .accordion': { + background: '#151519', + }, + }, + content: { + marginTop: theme.spacing(2), + display: 'grid', + p: { + lineHeight: 1.8, + }, + '& ul': { + padding: '0.25rem 0.5rem', + [theme.breakpoints.up('lg')]: { + padding: '0.5rem 1rem', + }, + }, + '& li': { + padding: '0.25rem 0.5rem', + [theme.breakpoints.up('lg')]: { + padding: '0.5rem 1rem', + }, + }, + '& > *': { + marginBottom: theme.spacing(1), + [theme.breakpoints.up('lg')]: { + marginBottom: theme.spacing(2), + }, + }, + [theme.breakpoints.up('lg')]: { + gridTemplateColumns: '200px auto', + }, + }, + time: { + marginTop: theme.spacing(2), + display: 'grid', + '& > *': { + marginBottom: theme.spacing(1), + [theme.breakpoints.up('md')]: { + marginBottom: theme.spacing(2), + }, + }, + [theme.breakpoints.up('md')]: { + gridTemplateColumns: 'repeat(2, 1fr)', + }, + }, +})); + +export default useStyles; diff --git a/apps/web-cheqd/src/screens/proposal_details/components/votes_graph/hooks.ts b/apps/web-cheqd/src/screens/proposal_details/components/votes_graph/hooks.ts new file mode 100644 index 0000000000..f086ae829d --- /dev/null +++ b/apps/web-cheqd/src/screens/proposal_details/components/votes_graph/hooks.ts @@ -0,0 +1,71 @@ +import Big from 'big.js'; +import { useRouter } from 'next/router'; +import * as R from 'ramda'; +import { useCallback, useState } from 'react'; +import chainConfig from '@/chainConfig'; +import { + ProposalDetailsTallyQuery, + useProposalDetailsTallyQuery, +} from '@/graphql/types/general_types'; +import type { VotesGraphState } from '@/screens/proposal_details/components/votes_graph/types'; +import { formatToken } from '@/utils/format_token'; + +const { votingPowerTokenUnit } = chainConfig(); + +const defaultTokenUnit: TokenUnit = { + value: '0', + baseDenom: '', + displayDenom: '', + exponent: 0, +}; + +export const useVotesGraph = () => { + const router = useRouter(); + const [state, setState] = useState({ + votes: { + yes: defaultTokenUnit, + no: defaultTokenUnit, + abstain: defaultTokenUnit, + veto: defaultTokenUnit, + }, + bonded: defaultTokenUnit, + quorum: '0', + }); + + const handleSetState = useCallback( + (stateChange: (prevState: VotesGraphState) => VotesGraphState) => { + setState((prevState) => { + const newState = stateChange(prevState); + return R.equals(prevState, newState) ? prevState : newState; + }); + }, + [] + ); + + useProposalDetailsTallyQuery({ + variables: { + proposalId: parseFloat((router?.query?.id as string) ?? '0'), + }, + onCompleted: (data) => { + handleSetState((prevState) => ({ ...prevState, ...foramtProposalTally(data) })); + }, + }); + + const foramtProposalTally = (data: ProposalDetailsTallyQuery) => { + const quorumRaw = data.quorum?.[0]?.tallyParams?.quorum ?? '0'; + return { + votes: { + yes: formatToken(data?.proposalTallyResult?.[0]?.yes ?? '0', votingPowerTokenUnit), + no: formatToken(data?.proposalTallyResult?.[0]?.no ?? '0', votingPowerTokenUnit), + veto: formatToken(data?.proposalTallyResult?.[0]?.noWithVeto ?? '0', votingPowerTokenUnit), + abstain: formatToken(data?.proposalTallyResult?.[0]?.abstain ?? '0', votingPowerTokenUnit), + }, + bonded: formatToken(data?.stakingPool?.[0]?.bondedTokens ?? '0', votingPowerTokenUnit), + quorum: Big(quorumRaw)?.times(100).toFixed(2), + }; + }; + + return { + state, + }; +}; diff --git a/apps/web-cheqd/src/screens/proposal_details/hooks.ts b/apps/web-cheqd/src/screens/proposal_details/hooks.ts new file mode 100644 index 0000000000..ef8792e5d1 --- /dev/null +++ b/apps/web-cheqd/src/screens/proposal_details/hooks.ts @@ -0,0 +1,97 @@ +import { useRouter } from 'next/router'; +import * as R from 'ramda'; +import { useCallback, useState } from 'react'; +import type { ProposalState } from '@/screens/proposal_details/types'; +import { ProposalDetailsQuery, useProposalDetailsQuery } from '@/graphql/types/general_types'; + +// ========================= +// overview +// ========================= +const formatOverview = (data: ProposalDetailsQuery) => { + const DEFAULT_TIME = '0001-01-01T00:00:00'; + let votingStartTime = data?.proposal?.[0]?.votingStartTime ?? DEFAULT_TIME; + votingStartTime = votingStartTime === DEFAULT_TIME ? '' : votingStartTime; + let votingEndTime = data?.proposal?.[0]?.votingEndTime ?? DEFAULT_TIME; + votingEndTime = votingEndTime === DEFAULT_TIME ? '' : votingEndTime; + + const overview = { + proposer: data?.proposal?.[0]?.proposer ?? '', + content: data?.proposal?.[0]?.content ?? '', + title: data?.proposal?.[0]?.title ?? '', + id: data?.proposal?.[0]?.proposalId ?? '', + description: data?.proposal?.[0]?.description ?? '', + metadata: data?.proposal?.[0]?.metadata ?? '', + status: data?.proposal?.[0]?.status ?? '', + submitTime: data?.proposal?.[0]?.submitTime ?? '', + depositEndTime: data?.proposal?.[0]?.depositEndTime ?? '', + votingStartTime, + votingEndTime, + }; + + return overview; +}; + +// ========================== +// parsers +// ========================== +const formatProposalQuery = (data: ProposalDetailsQuery) => { + const stateChange: Partial = { + loading: false, + }; + + if (!data.proposal.length) { + stateChange.exists = false; + return stateChange; + } + + stateChange.overview = formatOverview(data); + + return stateChange; +}; + +export const useProposalDetails = () => { + const router = useRouter(); + const [state, setState] = useState({ + loading: true, + exists: true, + overview: { + proposer: '', + content: { + recipient: '', + amount: [], + }, + title: '', + id: 0, + description: '', + metadata: '', + status: '', + submitTime: '', + depositEndTime: '', + votingStartTime: '', + votingEndTime: '', + }, + }); + + const handleSetState = useCallback((stateChange: (prevState: ProposalState) => ProposalState) => { + setState((prevState) => { + const newState = stateChange(prevState); + return R.equals(prevState, newState) ? prevState : newState; + }); + }, []); + + // ========================== + // fetch data + // ========================== + useProposalDetailsQuery({ + variables: { + proposalId: parseFloat((router?.query?.id as string) ?? '0'), + }, + onCompleted: (data) => { + handleSetState((prevState) => ({ ...prevState, ...formatProposalQuery(data) })); + }, + }); + + return { + state, + }; +}; diff --git a/apps/web-cheqd/src/screens/proposal_details/types.ts b/apps/web-cheqd/src/screens/proposal_details/types.ts new file mode 100644 index 0000000000..e23d117311 --- /dev/null +++ b/apps/web-cheqd/src/screens/proposal_details/types.ts @@ -0,0 +1,25 @@ +export interface OverviewType { + title: string; + id: number; + proposer: string; + description: string; + metadata: string; + status: string; + submitTime: string; + depositEndTime: string; + votingStartTime: string | null; + votingEndTime: string | null; + content: { + recipient: string; + amount: Array<{ + amount: string; + denom: string; + }>; + }; +} + +export interface ProposalState { + loading: boolean; + exists: boolean; + overview: OverviewType; +}