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
+ ? (
+ <>
+
+
+ >
+ )
+ : }
+
+ );
+};
+
+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"