diff --git a/packages/concordia-app/package.json b/packages/concordia-app/package.json index 904518e..aefa23f 100644 --- a/packages/concordia-app/package.json +++ b/packages/concordia-app/package.json @@ -29,6 +29,7 @@ "@ezerous/eth-identity-provider": "~0.1.2", "@reduxjs/toolkit": "~1.4.0", "@welldone-software/why-did-you-render": "~6.0.5", + "apexcharts": "^3.26.0", "concordia-contracts": "~0.1.0", "concordia-shared": "~0.1.0", "crypto-js": "~4.0.0", @@ -38,6 +39,7 @@ "lodash": "^4.17.20", "prop-types": "~15.7.2", "react": "~16.13.1", + "react-apexcharts": "^1.3.7", "react-avatar": "~3.9.7", "react-copy-to-clipboard": "^5.0.3", "react-dom": "~16.13.1", diff --git a/packages/concordia-app/public/locales/en/translation.json b/packages/concordia-app/public/locales/en/translation.json index aa249a2..5c2c92d 100644 --- a/packages/concordia-app/public/locales/en/translation.json +++ b/packages/concordia-app/public/locales/en/translation.json @@ -22,12 +22,12 @@ "edit.information.modal.form.profile.picture.field.placeholder": "URL", "edit.information.modal.form.submit.button": "Submit", "edit.information.modal.title": "Edit profile information", - "poll.create.question.field.label": "Poll Question", - "poll.create.question.field.placeholder": "Question", + "poll.create.add.option.button": "Add Option", "poll.create.allow.vote.changes.field.label": "Allow vote changes", "poll.create.option.field.label": "Option #{{id}}", "poll.create.option.field.placeholder": "Option #{{id}}", - "poll.create.add.option.button": "Add Option", + "poll.create.question.field.label": "Poll Question", + "poll.create.question.field.placeholder": "Question", "post.create.form.send.button": "Post", "post.form.content.field.placeholder": "Message", "post.form.subject.field.placeholder": "Subject", @@ -39,8 +39,8 @@ "profile.general.tab.location.row.title": "Location:", "profile.general.tab.number.of.posts.row.title": "Number of posts:", "profile.general.tab.number.of.topics.row.title": "Number of topics created:", - "profile.general.tab.posts.db.address.row.title": "PostsDB:", "profile.general.tab.polls.db.address.row.title": "PollsDB:", + "profile.general.tab.posts.db.address.row.title": "PostsDB:", "profile.general.tab.registration.date.row.title": "Member since:", "profile.general.tab.save.info.button.title": "Save information", "profile.general.tab.title": "General", @@ -75,14 +75,25 @@ "topbar.button.create.topic": "Create topic", "topbar.button.profile": "Profile", "topbar.button.register": "Sign Up", + "topic.create.form.add.poll.button": "Add Poll", "topic.create.form.content.field.label": "First post content", "topic.create.form.content.field.placeholder": "Message", "topic.create.form.post.button": "Create Topic", + "topic.create.form.remove.poll.button": "Remove Poll", "topic.create.form.subject.field.label": "Topic subject", "topic.create.form.subject.field.placeholder": "Subject", - "topic.create.form.add.poll.button": "Add Poll", - "topic.create.form.remove.poll.button": "Remove Poll", "topic.list.row.topic.id": "#{{id}}", + "topic.poll.guest.header": "Only registered users are able to vote in polls.", + "topic.poll.guest.sub.header.link": "signup", + "topic.poll.guest.sub.header.post": " page.", + "topic.poll.guest.sub.header.pre": "You can register in the ", + "topic.poll.invalid.data.header": "This topic has a poll but the data are untrusted!", + "topic.poll.invalid.data.sub.header": "The poll data downloaded from the poster have been tampered with.", + "topic.poll.tab.graph.title": "Results", + "topic.poll.tab.results.votes.count": "{{totalVotes}} votes casted", + "topic.poll.tab.vote.form.button.submit": "Submit", + "topic.poll.tab.vote.form.radio.label": "Select one of the options:", + "topic.poll.tab.vote.title": "Vote", "username.selector.error.username.empty.message": "Username is required", "username.selector.error.username.taken.message": "The username {{username}} is already taken.", "username.selector.username.field.label": "Username", diff --git a/packages/concordia-app/src/components/PollCreate/index.jsx b/packages/concordia-app/src/components/PollCreate/index.jsx index f631cdc..453c405 100644 --- a/packages/concordia-app/src/components/PollCreate/index.jsx +++ b/packages/concordia-app/src/components/PollCreate/index.jsx @@ -15,7 +15,7 @@ import { breeze, drizzle } from '../../redux/store'; import { TRANSACTION_ERROR, TRANSACTION_SUCCESS } from '../../constants/TransactionStatus'; import './styles.css'; import { POLL_OPTIONS, POLL_QUESTION } from '../../constants/orbit/PollsDatabaseKeys'; -import generateHash from '../../utils/hashUtils'; +import { generatePollHash } from '../../utils/hashUtils'; const { contracts: { [VOTING_CONTRACT]: { methods: { createPoll } } } } = drizzle; const { orbit: { stores } } = breeze; @@ -96,7 +96,7 @@ const PollCreate = forwardRef((props, ref) => { useImperativeHandle(ref, () => ({ createPoll(topicId) { setCreating(true); - const dataHash = generateHash(JSON.stringify({ question, optionValues })); + const dataHash = generatePollHash(question, optionValues); setCreatePollCacheSendStackId(createPoll.cacheSend( ...[topicId, options.length, dataHash, allowVoteChanges], { from: account }, )); diff --git a/packages/concordia-app/src/components/PollCreate/styles.css b/packages/concordia-app/src/components/PollCreate/styles.css index 9fc624c..8a6fb8d 100644 --- a/packages/concordia-app/src/components/PollCreate/styles.css +++ b/packages/concordia-app/src/components/PollCreate/styles.css @@ -3,7 +3,9 @@ padding-bottom: 1em; } -.poll-create .checkbox > label, label:focus, label:hover{ +.poll-create .checkbox > label, +.poll-create .checkbox > label:focus, +.poll-create .checkbox > label:hover{ color: white !important; font-weight: 700; } diff --git a/packages/concordia-app/src/components/PollView/PollDataInvalid/index.jsx b/packages/concordia-app/src/components/PollView/PollDataInvalid/index.jsx new file mode 100644 index 0000000..518dbc0 --- /dev/null +++ b/packages/concordia-app/src/components/PollView/PollDataInvalid/index.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Container, Header, Icon } from 'semantic-ui-react'; +import { useTranslation } from 'react-i18next'; + +const PollDataInvalid = () => { + const { t } = useTranslation(); + + return ( + +
+ + + {t('topic.poll.invalid.data.header')} + + {t('topic.poll.invalid.data.sub.header')} + + +
+
+ ); +}; + +export default PollDataInvalid; diff --git a/packages/concordia-app/src/components/PollView/PollGraph/index.jsx b/packages/concordia-app/src/components/PollView/PollGraph/index.jsx new file mode 100644 index 0000000..12d77d7 --- /dev/null +++ b/packages/concordia-app/src/components/PollView/PollGraph/index.jsx @@ -0,0 +1,79 @@ +import React, { useMemo } from 'react'; +import Chart from 'react-apexcharts'; +import { Grid, Header } from 'semantic-ui-react'; +import PropTypes from 'prop-types'; +import { useTranslation } from 'react-i18next'; +import { CASTED_OPTION_COLOR, DEFAULT_OPTION_COLOR } from '../../../constants/polls/PollGraph'; + +const PollGraph = (props) => { + const { + pollOptions, voteCounts, hasUserVoted, selectedOptionIndex, + } = props; + const { t } = useTranslation(); + + const chartOptions = useMemo(() => ({ + chart: { + id: 'topic-poll', + }, + plotOptions: { + bar: { + horizontal: true, + }, + }, + colors: [ + (value) => { + if (hasUserVoted && value.dataPointIndex === selectedOptionIndex) { + return CASTED_OPTION_COLOR; + } + return DEFAULT_OPTION_COLOR; + }, + ], + xaxis: { + categories: pollOptions, + }, + }), [hasUserVoted, pollOptions, selectedOptionIndex]); + + const chartSeries = useMemo(() => [{ + name: 'votes', + data: voteCounts, + }], [voteCounts]); + + return ( + + + + + + + + + + +
+ {t('topic.poll.tab.results.votes.count', { + totalVotes: voteCounts.reduce((accumulator, voteCount) => accumulator + voteCount, 0), + })} +
+
+
+
+ ); +}; + +PollGraph.defaultProps = { + hasUserVoted: false, + selectedOptionIndex: '', +}; + +PollGraph.propTypes = { + pollOptions: PropTypes.arrayOf(PropTypes.string).isRequired, + voteCounts: PropTypes.arrayOf(PropTypes.number).isRequired, + hasUserVoted: PropTypes.bool, + selectedOptionIndex: PropTypes.string, +}; + +export default PollGraph; diff --git a/packages/concordia-app/src/components/PollView/PollGuestView/index.jsx b/packages/concordia-app/src/components/PollView/PollGuestView/index.jsx new file mode 100644 index 0000000..af23be9 --- /dev/null +++ b/packages/concordia-app/src/components/PollView/PollGuestView/index.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Container, Header, Icon } from 'semantic-ui-react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +const PollGuestView = () => { + const { t } = useTranslation(); + + return ( + +
+ + + {t('topic.poll.guest.header')} + + {t('topic.poll.guest.sub.header.pre')} + {t('topic.poll.guest.sub.header.link')} + {t('topic.poll.guest.sub.header.post')} + + +
+
+ ); +}; + +export default PollGuestView; diff --git a/packages/concordia-app/src/components/PollView/PollVote/index.jsx b/packages/concordia-app/src/components/PollView/PollVote/index.jsx new file mode 100644 index 0000000..836e45e --- /dev/null +++ b/packages/concordia-app/src/components/PollView/PollVote/index.jsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Form } from 'semantic-ui-react'; +import { useTranslation } from 'react-i18next'; +import { VOTING_CONTRACT } from 'concordia-shared/src/constants/contracts/ContractNames'; +import { drizzle } from '../../../redux/store'; + +const { contracts: { [VOTING_CONTRACT]: { methods: { vote } } } } = drizzle; + +const PollVote = (props) => { + const { + topicId, account, pollOptions, enableVoteChanges, hasUserVoted, userVoteIndex, + } = props; + const [selectedOptionIndex, setSelectedOptionIndex] = useState(userVoteIndex); + const [voting, setVoting] = useState(''); + const { t } = useTranslation(); + + const onOptionSelected = (e, { value }) => { + setSelectedOptionIndex(value); + }; + + const onCastVote = () => { + setVoting(true); + vote.cacheSend(...[topicId, selectedOptionIndex + 1], { from: account }); + }; + + return ( +
+ + + {pollOptions.map((pollOption, index) => ( + + ))} + + + {t('topic.poll.tab.vote.form.button.submit')} + +
+ ); +}; + +PollVote.defaultProps = { + userVoteIndex: -1, +}; + +PollVote.propTypes = { + topicId: PropTypes.number.isRequired, + pollOptions: PropTypes.arrayOf(PropTypes.string).isRequired, + enableVoteChanges: PropTypes.bool.isRequired, + hasUserVoted: PropTypes.bool.isRequired, + userVoteIndex: PropTypes.number, +}; + +export default PollVote; diff --git a/packages/concordia-app/src/components/PollView/index.jsx b/packages/concordia-app/src/components/PollView/index.jsx new file mode 100644 index 0000000..c591826 --- /dev/null +++ b/packages/concordia-app/src/components/PollView/index.jsx @@ -0,0 +1,184 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { VOTING_CONTRACT } from 'concordia-shared/src/constants/contracts/ContractNames'; +import { + Container, Header, Icon, Tab, +} from 'semantic-ui-react'; +import { useTranslation } from 'react-i18next'; +import PropTypes from 'prop-types'; +import { POLLS_DATABASE } from 'concordia-shared/src/constants/orbit/OrbitDatabases'; +import { breeze, drizzle } from '../../redux/store'; +import PollGraph from './PollGraph'; +import CustomLoadingTabPane from '../CustomLoadingTabPane'; +import { GRAPH_TAB, VOTE_TAB } from '../../constants/polls/PollTabs'; +import PollVote from './PollVote'; +import { FETCH_USER_DATABASE } from '../../redux/actions/peerDbReplicationActions'; +import { generatePollHash } from '../../utils/hashUtils'; +import { POLL_OPTIONS, POLL_QUESTION } from '../../constants/orbit/PollsDatabaseKeys'; +import PollDataInvalid from './PollDataInvalid'; +import PollGuestView from './PollGuestView'; + +const { contracts: { [VOTING_CONTRACT]: { methods: { getPoll: { cacheCall: getPollChainData } } } } } = drizzle; +const { orbit } = breeze; + +const PollView = (props) => { + const { topicId } = props; + const userAddress = useSelector((state) => state.user.address); + const hasSignedUp = useSelector((state) => state.user.hasSignedUp); + const getPollResults = useSelector((state) => state.contracts[VOTING_CONTRACT].getPoll); + const polls = useSelector((state) => state.orbitData.polls); + const [getPollCallHash, setGetPollCallHash] = useState(null); + const [pollHash, setPollHash] = useState(''); + const [pollChangeVoteEnabled, setPollChangeVoteEnabled] = useState(false); + const [pollOptions, setPollOptions] = useState([]); + const [voteCounts, setVoteCounts] = useState([]); + const [voters, setVoters] = useState([]); + const [pollHashValid, setPollHashValid] = useState(false); + const [pollQuestion, setPollQuestion] = useState(''); + const [chainDataLoading, setChainDataLoading] = useState(true); + const [orbitDataLoading, setOrbitDataLoading] = useState(true); + const dispatch = useDispatch(); + const { t } = useTranslation(); + + useEffect(() => { + if (!getPollCallHash) { + setGetPollCallHash(getPollChainData(topicId)); + } + }, [getPollCallHash, topicId]); + + useEffect(() => { + dispatch({ + type: FETCH_USER_DATABASE, + orbit, + dbName: POLLS_DATABASE, + userAddress, + }); + }, [dispatch, userAddress]); + + useEffect(() => { + if (getPollCallHash && getPollResults && getPollResults[getPollCallHash]) { + setPollHash(getPollResults[getPollCallHash].value[1]); + setPollChangeVoteEnabled(getPollResults[getPollCallHash].value[2]); + setVoteCounts(getPollResults[getPollCallHash].value[4].map((voteCount) => parseInt(voteCount, 10))); + + const cumulativeSum = getPollResults[getPollCallHash].value[4] + .map((voteCount) => parseInt(voteCount, 10)) + .reduce((accumulator, voteCount) => (accumulator.length === 0 + ? [voteCount] + : [...accumulator, accumulator[accumulator.length - 1] + voteCount]), []); + + setVoters(cumulativeSum + .map((subArrayEnd, index) => getPollResults[getPollCallHash].value[5] + .slice(index > 0 ? cumulativeSum[index - 1] : 0, + subArrayEnd))); + + setChainDataLoading(false); + } + }, [getPollCallHash, getPollResults]); + + useEffect(() => { + const pollFound = polls + .find((poll) => poll.id === topicId); + + if (pollHash && pollFound) { + if (generatePollHash(pollFound[POLL_QUESTION], pollFound[POLL_OPTIONS]) === pollHash) { + setPollHashValid(true); + setPollQuestion(pollFound[POLL_QUESTION]); + setPollOptions([...pollFound[POLL_OPTIONS]]); + } else { + setPollHashValid(false); + } + + setOrbitDataLoading(false); + } + }, [pollHash, polls, topicId]); + + const userHasVoted = useMemo(() => hasSignedUp && voters + .some((optionVoters) => optionVoters.includes(userAddress)), + [hasSignedUp, userAddress, voters]); + + const userVoteIndex = useMemo(() => { + if (!chainDataLoading && !orbitDataLoading && userHasVoted) { + return voters + .findIndex((optionVoters) => optionVoters.includes(userAddress)); + } + + return -1; + }, [chainDataLoading, orbitDataLoading, userAddress, userHasVoted, voters]); + + const pollVoteTab = useMemo(() => { + if (!hasSignedUp) { + return ; + } + + if (chainDataLoading || orbitDataLoading) { + return null; + } + + return ( + + ); + }, [ + chainDataLoading, hasSignedUp, orbitDataLoading, pollChangeVoteEnabled, pollOptions, topicId, userHasVoted, + userVoteIndex, + ]); + + const pollGraphTab = useMemo(() => ( + !chainDataLoading || orbitDataLoading + ? ( + + ) + : null + ), [chainDataLoading, orbitDataLoading, pollOptions, userHasVoted, userVoteIndex, voteCounts]); + + const panes = useMemo(() => { + const pollVotePane = ( + + {pollVoteTab} + + ); + const pollGraphPane = ( + + {pollGraphTab} + + ); + + return ([ + { menuItem: t(VOTE_TAB.intl_display_name_id), render: () => pollVotePane }, + { menuItem: t(GRAPH_TAB.intl_display_name_id), render: () => pollGraphPane }, + ]); + }, [chainDataLoading, orbitDataLoading, pollGraphTab, pollVoteTab, t]); + + return ( + + {!chainDataLoading && !orbitDataLoading && pollHashValid + ? ( + <> +
+ + {pollQuestion} +
+ + + ) + : } +
+ ); +}; + +PollView.propTypes = { + topicId: PropTypes.number.isRequired, +}; + +export default PollView; diff --git a/packages/concordia-app/src/constants/polls/PollGraph.js b/packages/concordia-app/src/constants/polls/PollGraph.js new file mode 100644 index 0000000..a19a2ee --- /dev/null +++ b/packages/concordia-app/src/constants/polls/PollGraph.js @@ -0,0 +1,2 @@ +export const DEFAULT_OPTION_COLOR = '#3B5066'; +export const CASTED_OPTION_COLOR = '#0b2540'; diff --git a/packages/concordia-app/src/constants/polls/PollTabs.js b/packages/concordia-app/src/constants/polls/PollTabs.js new file mode 100644 index 0000000..b37feba --- /dev/null +++ b/packages/concordia-app/src/constants/polls/PollTabs.js @@ -0,0 +1,16 @@ +export const VOTE_TAB = { + id: 'vote-tab', + intl_display_name_id: 'topic.poll.tab.vote.title', +}; + +export const GRAPH_TAB = { + id: 'graph-tab', + intl_display_name_id: 'topic.poll.tab.graph.title', +}; + +const pollTabs = [ + VOTE_TAB, + GRAPH_TAB, +]; + +export default pollTabs; diff --git a/packages/concordia-app/src/redux/reducers/peerDbReplicationReducer.js b/packages/concordia-app/src/redux/reducers/peerDbReplicationReducer.js index 117d442..9795819 100644 --- a/packages/concordia-app/src/redux/reducers/peerDbReplicationReducer.js +++ b/packages/concordia-app/src/redux/reducers/peerDbReplicationReducer.js @@ -4,13 +4,16 @@ const initialState = { users: [], topics: [], posts: [], + polls: [], }; const peerDbReplicationReducer = (state = initialState, action) => { const { type } = action; if (type === UPDATE_ORBIT_DATA) { - const { users, topics, posts } = action; + const { + users, topics, posts, polls, + } = action; return { ...state, @@ -23,6 +26,9 @@ const peerDbReplicationReducer = (state = initialState, action) => { posts: [ ...posts, ], + polls: [ + ...polls, + ], }; } diff --git a/packages/concordia-app/src/redux/sagas/peerDbReplicationSaga.js b/packages/concordia-app/src/redux/sagas/peerDbReplicationSaga.js index 251a283..b64d91d 100644 --- a/packages/concordia-app/src/redux/sagas/peerDbReplicationSaga.js +++ b/packages/concordia-app/src/redux/sagas/peerDbReplicationSaga.js @@ -7,12 +7,18 @@ import { ORBIT_DB_REPLICATED, ORBIT_DB_WRITE, } from '@ezerous/breeze/src/orbit/orbitActions'; -import { POSTS_DATABASE, TOPICS_DATABASE, USER_DATABASE } from 'concordia-shared/src/constants/orbit/OrbitDatabases'; +import { + POLLS_DATABASE, + POSTS_DATABASE, + TOPICS_DATABASE, + USER_DATABASE, +} from 'concordia-shared/src/constants/orbit/OrbitDatabases'; import determineKVAddress from '../../utils/orbitUtils'; import { FETCH_USER_DATABASE, UPDATE_ORBIT_DATA } from '../actions/peerDbReplicationActions'; import userDatabaseKeys from '../../constants/orbit/UserDatabaseKeys'; import { TOPIC_SUBJECT } from '../../constants/orbit/TopicsDatabaseKeys'; import { POST_CONTENT } from '../../constants/orbit/PostsDatabaseKeys'; +import { POLL_OPTIONS, POLL_QUESTION } from '../../constants/orbit/PollsDatabaseKeys'; function* fetchUserDb({ orbit, userAddress, dbName }) { const peerDbAddress = yield call(determineKVAddress, { @@ -23,10 +29,13 @@ function* fetchUserDb({ orbit, userAddress, dbName }) { } function* updateReduxState({ database }) { - const { users, topics, posts } = yield select((state) => ({ + const { + users, topics, posts, polls, + } = yield select((state) => ({ users: state.orbitData.users, topics: state.orbitData.topics, posts: state.orbitData.posts, + polls: state.orbitData.polls, })); if (database.dbname === USER_DATABASE) { @@ -53,6 +62,7 @@ function* updateReduxState({ database }) { ], topics: [...topics], posts: [...posts], + polls: [...polls], }); } @@ -76,6 +86,7 @@ function* updateReduxState({ database }) { })), ], posts: [...posts], + polls: [...polls], }); } @@ -97,6 +108,32 @@ function* updateReduxState({ database }) { [POST_CONTENT]: value[POST_CONTENT], })), ], + polls: [...polls], + }); + } + + if (database.dbname === POLLS_DATABASE) { + const oldPollsUnchanged = polls + .filter((poll) => !Object + .keys(database.all) + .map((key) => parseInt(key, 10)) + .includes(poll.id)); + + yield put({ + type: UPDATE_ORBIT_DATA, + users: [...users], + topics: [...topics], + posts: [...posts], + polls: [ + ...oldPollsUnchanged, + ...Object.entries(database.all).map(([key, value]) => ({ + id: parseInt(key, 10), + [POLL_QUESTION]: value[POLL_QUESTION], + [POLL_OPTIONS]: [ + ...value[POLL_OPTIONS], + ], + })), + ], }); } } diff --git a/packages/concordia-app/src/utils/hashUtils.js b/packages/concordia-app/src/utils/hashUtils.js index 3a6d269..f544be3 100644 --- a/packages/concordia-app/src/utils/hashUtils.js +++ b/packages/concordia-app/src/utils/hashUtils.js @@ -1,7 +1,6 @@ import sha256 from 'crypto-js/sha256'; -function generateHash(message) { - return sha256(message).toString().substring(0, 16); -} +export const generateHash = (message) => sha256(message).toString().substring(0, 16); -export default generateHash; +export const generatePollHash = (pollQuestion, pollOptions) => generateHash(JSON + .stringify({ question: pollQuestion, optionValues: pollOptions })); diff --git a/packages/concordia-app/src/views/Topic/TopicView/index.jsx b/packages/concordia-app/src/views/Topic/TopicView/index.jsx index 602fa54..7546616 100644 --- a/packages/concordia-app/src/views/Topic/TopicView/index.jsx +++ b/packages/concordia-app/src/views/Topic/TopicView/index.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { @@ -6,19 +6,25 @@ import { } from 'semantic-ui-react'; import { Link } from 'react-router-dom'; import { useHistory } from 'react-router'; -import { FORUM_CONTRACT } from 'concordia-shared/src/constants/contracts/ContractNames'; +import { FORUM_CONTRACT, VOTING_CONTRACT } from 'concordia-shared/src/constants/contracts/ContractNames'; import { TOPICS_DATABASE, USER_DATABASE } from 'concordia-shared/src/constants/orbit/OrbitDatabases'; import ReactMarkdown from 'react-markdown'; import { breeze, drizzle } from '../../../redux/store'; import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions'; import './styles.css'; import TopicPostList from './TopicPostList'; +import PollView from '../../../components/PollView'; import determineKVAddress from '../../../utils/orbitUtils'; import { TOPIC_SUBJECT } from '../../../constants/orbit/TopicsDatabaseKeys'; import PostCreate from '../../../components/PostCreate'; import targetBlank from '../../../utils/markdownUtils'; -const { contracts: { [FORUM_CONTRACT]: { methods: { getTopic: { cacheCall: getTopicChainData } } } } } = drizzle; +const { + contracts: { + [FORUM_CONTRACT]: { methods: { getTopic: { cacheCall: getTopicChainData } } }, + [VOTING_CONTRACT]: { methods: { pollExists: { cacheCall: pollExistsChainData } } }, + }, +} = drizzle; const { orbit } = breeze; const TopicView = (props) => { @@ -29,15 +35,18 @@ const TopicView = (props) => { const userAddress = useSelector((state) => state.user.address); const hasSignedUp = useSelector((state) => state.user.hasSignedUp); const getTopicResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getTopic); + const pollExistsResults = useSelector((state) => state.contracts[VOTING_CONTRACT].pollExists); const topics = useSelector((state) => state.orbitData.topics); const users = useSelector((state) => state.orbitData.users); - const [getTopicCallHash, setGetTopicCallHash] = useState([]); + const [getTopicCallHash, setGetTopicCallHash] = useState(null); + const [pollExistsCallHash, setPollExistsCallHash] = useState(null); const [topicAuthorAddress, setTopicAuthorAddress] = useState(initialTopicAuthorAddress || null); const [topicAuthor, setTopicAuthor] = useState(initialTopicAuthor || null); const [timestamp, setTimestamp] = useState(initialTimestamp || null); const [postIds, setPostIds] = useState(initialPostIds || null); const [numberOfReplies, setReplyCount] = useState(0); const [topicSubject, setTopicSubject] = useState(null); + const [hasPoll, setHasPoll] = useState(false); const history = useHistory(); const dispatch = useDispatch(); @@ -52,6 +61,12 @@ const TopicView = (props) => { } }, [postIds, timestamp, topicAuthor, topicAuthorAddress, topicId]); + useEffect(() => { + if (!pollExistsCallHash) { + setPollExistsCallHash(pollExistsChainData(topicId)); + } + }, [pollExistsCallHash, topicId]); + useEffect(() => { if (getTopicCallHash && getTopicResults && getTopicResults[getTopicCallHash]) { if (getTopicResults[getTopicCallHash].value == null) { @@ -62,9 +77,9 @@ const TopicView = (props) => { setTopicAuthorAddress(getTopicResults[getTopicCallHash].value[0]); setTopicAuthor(getTopicResults[getTopicCallHash].value[1]); setTimestamp(getTopicResults[getTopicCallHash].value[2] * 1000); - const postIds = getTopicResults[getTopicCallHash].value[3].map((postId) => parseInt(postId, 10)); - setPostIds(postIds); - setReplyCount(postIds.length - 1); + const fetchedPostIds = getTopicResults[getTopicCallHash].value[3].map((postId) => parseInt(postId, 10)); + setPostIds(fetchedPostIds); + setReplyCount(fetchedPostIds.length - 1); const topicFound = topics .find((topic) => topic.id === topicId); @@ -80,6 +95,12 @@ const TopicView = (props) => { } }, [dispatch, getTopicCallHash, getTopicResults, history, topicId, topics, userAddress]); + useEffect(() => { + if (pollExistsCallHash && pollExistsResults && pollExistsResults[pollExistsCallHash]) { + setHasPoll(pollExistsResults[pollExistsCallHash].value); + } + }, [pollExistsCallHash, pollExistsResults, topicId]); + useEffect(() => { if (topicAuthorAddress !== null) { determineKVAddress({ orbit, dbName: USER_DATABASE, userAddress: topicAuthorAddress }) @@ -111,6 +132,8 @@ const TopicView = (props) => { } }, [topicId, topics]); + const poll = useMemo(() => hasPoll && , [hasPoll, topicId]); + const stopClickPropagation = (event) => { event.stopPropagation(); }; @@ -147,7 +170,9 @@ const TopicView = (props) => {       - { topicAuthor } + + {topicAuthor} +       @@ -155,6 +180,16 @@ const TopicView = (props) => { + { + hasPoll && ( + <> +
+ {poll} +
+ + + ) + } diff --git a/packages/concordia-contracts/contracts/Voting.sol b/packages/concordia-contracts/contracts/Voting.sol index b4ce20d..b11d9b1 100644 --- a/packages/concordia-contracts/contracts/Voting.sol +++ b/packages/concordia-contracts/contracts/Voting.sol @@ -68,7 +68,41 @@ contract Voting { ); } - function isOptionValid(uint topicID, uint option) public view returns (bool) { + function getPoll(uint topicID) public view returns (uint, string memory, bool, uint, uint[] memory, address[] memory, uint) { + require(pollExists(topicID), POLL_DOES_NOT_EXIST); + + uint totalVotes = getTotalVotes(topicID); + uint[] memory voteCounts = getVoteCounts(topicID); + address[] memory voters = getSerializedVoters(topicID, voteCounts, totalVotes); + + return ( + polls[topicID].numOptions, + polls[topicID].dataHash, + polls[topicID].enableVoteChanges, + polls[topicID].timestamp, + voteCounts, + voters, + totalVotes + ); + } + + function getSerializedVoters(uint topicID, uint[] memory voteCounts, uint totalVotes) private view returns (address[] memory) { + + address[] memory voters = new address[](totalVotes); + uint serializationIndex = 0; + + for (uint pollOption = 1; pollOption <= polls[topicID].numOptions; pollOption++) { + address[] memory optionVoters = getVoters(topicID, pollOption); + + for (uint voteIndex = 0; voteIndex < voteCounts[pollOption - 1]; voteIndex++) { + voters[serializationIndex++] = optionVoters[voteIndex]; + } + } + + return (voters); + } + + function isOptionValid(uint topicID, uint option) private view returns (bool) { require(pollExists(topicID), POLL_DOES_NOT_EXIST); if (option <= polls[topicID].numOptions) // Option 0 is valid as well (no option chosen) return true; @@ -93,6 +127,18 @@ contract Voting { return (polls[topicID].voters[option].length); } + function getVoteCounts(uint topicID) public view returns (uint[] memory) { + require(pollExists(topicID), POLL_DOES_NOT_EXIST); + + uint[] memory voteCounts = new uint[](polls[topicID].numOptions); + + for (uint pollOption = 1; pollOption <= polls[topicID].numOptions; pollOption++) { + voteCounts[pollOption - 1] = getVoteCount(topicID, pollOption); + } + + return voteCounts; + } + function getTotalVotes(uint topicID) public view returns (uint) { require(pollExists(topicID), POLL_DOES_NOT_EXIST); @@ -111,7 +157,7 @@ contract Voting { return (polls[topicID].voters[option]); } - function getVoterIndex(uint topicID, address voter) public view returns (uint) { + function getVoterIndex(uint topicID, address voter) private view returns (uint) { require(pollExists(topicID), POLL_DOES_NOT_EXIST); require(hasVoted(topicID, voter), USER_HAS_NOT_VOTED); Poll storage poll = polls[topicID]; diff --git a/yarn.lock b/yarn.lock index 54416c0..957d618 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3084,6 +3084,18 @@ anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" +apexcharts@^3.26.0: + version "3.26.0" + resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.26.0.tgz#a78abc108b2e1b3086a738f0ec7c98e292f4a14b" + integrity sha512-zdYHs3k3tgmCn1BpYLj7rhGEndBYF33Pq1+g0ora37xAr+3act5CJrpdXM2jx2boVUyXgavoSp6sa8WpK7RkSA== + dependencies: + svg.draggable.js "^2.2.2" + svg.easing.js "^2.0.0" + svg.filter.js "^2.0.2" + svg.pathmorphing.js "^0.1.3" + svg.resize.js "^1.4.3" + svg.select.js "^3.0.1" + app-module-path@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5" @@ -14915,7 +14927,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@~15.7.2: +prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@~15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -15190,6 +15202,13 @@ rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-apexcharts@^1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/react-apexcharts/-/react-apexcharts-1.3.7.tgz#42c8785e260535a4f8db1aadbe7b66552770944d" + integrity sha512-2OFhEHd70/WHN0kmrJtVx37UfaL71ZogVkwezmDqwQWgwhK6upuhlnEEX7tEq4xvjA+RFDn6hiUTNIuC/Q7Zqw== + dependencies: + prop-types "^15.5.7" + react-app-polyfill@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/react-app-polyfill/-/react-app-polyfill-1.0.6.tgz#890f8d7f2842ce6073f030b117de9130a5f385f0" @@ -17168,6 +17187,61 @@ svg-parser@^2.0.0: resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== +svg.draggable.js@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz#c514a2f1405efb6f0263e7958f5b68fce50603ba" + integrity sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw== + dependencies: + svg.js "^2.0.1" + +svg.easing.js@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/svg.easing.js/-/svg.easing.js-2.0.0.tgz#8aa9946b0a8e27857a5c40a10eba4091e5691f12" + integrity sha1-iqmUawqOJ4V6XEChDrpAkeVpHxI= + dependencies: + svg.js ">=2.3.x" + +svg.filter.js@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/svg.filter.js/-/svg.filter.js-2.0.2.tgz#91008e151389dd9230779fcbe6e2c9a362d1c203" + integrity sha1-kQCOFROJ3ZIwd5/L5uLJo2LRwgM= + dependencies: + svg.js "^2.2.5" + +svg.js@>=2.3.x, svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5: + version "2.7.1" + resolved "https://registry.yarnpkg.com/svg.js/-/svg.js-2.7.1.tgz#eb977ed4737001eab859949b4a398ee1bb79948d" + integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA== + +svg.pathmorphing.js@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz#c25718a1cc7c36e852ecabc380e758ac09bb2b65" + integrity sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww== + dependencies: + svg.js "^2.4.0" + +svg.resize.js@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/svg.resize.js/-/svg.resize.js-1.4.3.tgz#885abd248e0cd205b36b973c4b578b9a36f23332" + integrity sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw== + dependencies: + svg.js "^2.6.5" + svg.select.js "^2.1.2" + +svg.select.js@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-2.1.2.tgz#e41ce13b1acff43a7441f9f8be87a2319c87be73" + integrity sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ== + dependencies: + svg.js "^2.2.5" + +svg.select.js@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-3.0.1.tgz#a4198e359f3825739226415f82176a90ea5cc917" + integrity sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw== + dependencies: + svg.js "^2.6.5" + svgo@^1.0.0, svgo@^1.2.2: version "1.3.2" resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"