Browse Source

Merge branch 'feature/87-polls-poll-view' into 'develop'

Feature/87 polls poll view

See merge request ecentrics/concordia!26
develop
Apostolos Fanakis 4 years ago
parent
commit
fcde993c78
  1. 2
      packages/concordia-app/package.json
  2. 23
      packages/concordia-app/public/locales/en/translation.json
  3. 4
      packages/concordia-app/src/components/PollCreate/index.jsx
  4. 4
      packages/concordia-app/src/components/PollCreate/styles.css
  5. 23
      packages/concordia-app/src/components/PollView/PollDataInvalid/index.jsx
  6. 79
      packages/concordia-app/src/components/PollView/PollGraph/index.jsx
  7. 26
      packages/concordia-app/src/components/PollView/PollGuestView/index.jsx
  8. 64
      packages/concordia-app/src/components/PollView/PollVote/index.jsx
  9. 184
      packages/concordia-app/src/components/PollView/index.jsx
  10. 2
      packages/concordia-app/src/constants/polls/PollGraph.js
  11. 16
      packages/concordia-app/src/constants/polls/PollTabs.js
  12. 8
      packages/concordia-app/src/redux/reducers/peerDbReplicationReducer.js
  13. 41
      packages/concordia-app/src/redux/sagas/peerDbReplicationSaga.js
  14. 7
      packages/concordia-app/src/utils/hashUtils.js
  15. 51
      packages/concordia-app/src/views/Topic/TopicView/index.jsx
  16. 50
      packages/concordia-contracts/contracts/Voting.sol
  17. 76
      yarn.lock

2
packages/concordia-app/package.json

@ -29,6 +29,7 @@
"@ezerous/eth-identity-provider": "~0.1.2", "@ezerous/eth-identity-provider": "~0.1.2",
"@reduxjs/toolkit": "~1.4.0", "@reduxjs/toolkit": "~1.4.0",
"@welldone-software/why-did-you-render": "~6.0.5", "@welldone-software/why-did-you-render": "~6.0.5",
"apexcharts": "^3.26.0",
"concordia-contracts": "~0.1.0", "concordia-contracts": "~0.1.0",
"concordia-shared": "~0.1.0", "concordia-shared": "~0.1.0",
"crypto-js": "~4.0.0", "crypto-js": "~4.0.0",
@ -38,6 +39,7 @@
"lodash": "^4.17.20", "lodash": "^4.17.20",
"prop-types": "~15.7.2", "prop-types": "~15.7.2",
"react": "~16.13.1", "react": "~16.13.1",
"react-apexcharts": "^1.3.7",
"react-avatar": "~3.9.7", "react-avatar": "~3.9.7",
"react-copy-to-clipboard": "^5.0.3", "react-copy-to-clipboard": "^5.0.3",
"react-dom": "~16.13.1", "react-dom": "~16.13.1",

23
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.profile.picture.field.placeholder": "URL",
"edit.information.modal.form.submit.button": "Submit", "edit.information.modal.form.submit.button": "Submit",
"edit.information.modal.title": "Edit profile information", "edit.information.modal.title": "Edit profile information",
"poll.create.question.field.label": "Poll Question", "poll.create.add.option.button": "Add Option",
"poll.create.question.field.placeholder": "Question",
"poll.create.allow.vote.changes.field.label": "Allow vote changes", "poll.create.allow.vote.changes.field.label": "Allow vote changes",
"poll.create.option.field.label": "Option #{{id}}", "poll.create.option.field.label": "Option #{{id}}",
"poll.create.option.field.placeholder": "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.create.form.send.button": "Post",
"post.form.content.field.placeholder": "Message", "post.form.content.field.placeholder": "Message",
"post.form.subject.field.placeholder": "Subject", "post.form.subject.field.placeholder": "Subject",
@ -39,8 +39,8 @@
"profile.general.tab.location.row.title": "Location:", "profile.general.tab.location.row.title": "Location:",
"profile.general.tab.number.of.posts.row.title": "Number of posts:", "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.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.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.registration.date.row.title": "Member since:",
"profile.general.tab.save.info.button.title": "Save information", "profile.general.tab.save.info.button.title": "Save information",
"profile.general.tab.title": "General", "profile.general.tab.title": "General",
@ -75,14 +75,25 @@
"topbar.button.create.topic": "Create topic", "topbar.button.create.topic": "Create topic",
"topbar.button.profile": "Profile", "topbar.button.profile": "Profile",
"topbar.button.register": "Sign Up", "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.label": "First post content",
"topic.create.form.content.field.placeholder": "Message", "topic.create.form.content.field.placeholder": "Message",
"topic.create.form.post.button": "Create Topic", "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.label": "Topic subject",
"topic.create.form.subject.field.placeholder": "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.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.empty.message": "Username is required",
"username.selector.error.username.taken.message": "The username {{username}} is already taken.", "username.selector.error.username.taken.message": "The username {{username}} is already taken.",
"username.selector.username.field.label": "Username", "username.selector.username.field.label": "Username",

4
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 { TRANSACTION_ERROR, TRANSACTION_SUCCESS } from '../../constants/TransactionStatus';
import './styles.css'; import './styles.css';
import { POLL_OPTIONS, POLL_QUESTION } from '../../constants/orbit/PollsDatabaseKeys'; 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 { contracts: { [VOTING_CONTRACT]: { methods: { createPoll } } } } = drizzle;
const { orbit: { stores } } = breeze; const { orbit: { stores } } = breeze;
@ -96,7 +96,7 @@ const PollCreate = forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
createPoll(topicId) { createPoll(topicId) {
setCreating(true); setCreating(true);
const dataHash = generateHash(JSON.stringify({ question, optionValues })); const dataHash = generatePollHash(question, optionValues);
setCreatePollCacheSendStackId(createPoll.cacheSend( setCreatePollCacheSendStackId(createPoll.cacheSend(
...[topicId, options.length, dataHash, allowVoteChanges], { from: account }, ...[topicId, options.length, dataHash, allowVoteChanges], { from: account },
)); ));

4
packages/concordia-app/src/components/PollCreate/styles.css

@ -3,7 +3,9 @@
padding-bottom: 1em; 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; color: white !important;
font-weight: 700; font-weight: 700;
} }

23
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 (
<Container id="topic-poll-data-invalid-container" textAlign="center">
<Header as="h3" icon textAlign="center">
<Icon name="warning sign" color="red" />
<Header.Content>
{t('topic.poll.invalid.data.header')}
<Header.Subheader>
{t('topic.poll.invalid.data.sub.header')}
</Header.Subheader>
</Header.Content>
</Header>
</Container>
);
};
export default PollDataInvalid;

79
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 (
<Grid columns="equal">
<Grid.Row>
<Grid.Column />
<Grid.Column width={8}>
<Chart
options={chartOptions}
series={chartSeries}
type="bar"
/>
</Grid.Column>
<Grid.Column />
</Grid.Row>
<Grid.Row>
<Grid.Column textAlign="center">
<Header as="h4">
{t('topic.poll.tab.results.votes.count', {
totalVotes: voteCounts.reduce((accumulator, voteCount) => accumulator + voteCount, 0),
})}
</Header>
</Grid.Column>
</Grid.Row>
</Grid>
);
};
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;

26
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 (
<Container id="topic-poll-guest-view-container" textAlign="center">
<Header as="h3" icon textAlign="center">
<Icon name="signup" />
<Header.Content>
{t('topic.poll.guest.header')}
<Header.Subheader>
{t('topic.poll.guest.sub.header.pre')}
<Link to="/auth/register">{t('topic.poll.guest.sub.header.link')}</Link>
{t('topic.poll.guest.sub.header.post')}
</Header.Subheader>
</Header.Content>
</Header>
</Container>
);
};
export default PollGuestView;

64
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 (
<Form onSubmit={onCastVote}>
<Form.Group grouped>
<label htmlFor="poll">{t('topic.poll.tab.vote.form.radio.label')}</label>
{pollOptions.map((pollOption, index) => (
<Form.Radio
key={pollOption}
label={pollOption}
value={index}
checked={index === selectedOptionIndex}
disabled={hasUserVoted && !enableVoteChanges && index !== selectedOptionIndex}
onChange={onOptionSelected}
/>
))}
</Form.Group>
<Form.Button
type="submit"
disabled={voting || (hasUserVoted && !enableVoteChanges) || (selectedOptionIndex === userVoteIndex)}
>
{t('topic.poll.tab.vote.form.button.submit')}
</Form.Button>
</Form>
);
};
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;

184
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 <PollGuestView />;
}
if (chainDataLoading || orbitDataLoading) {
return null;
}
return (
<PollVote
topicId={topicId}
pollOptions={pollOptions}
enableVoteChanges={pollChangeVoteEnabled}
hasUserVoted={userHasVoted}
userVoteIndex={userVoteIndex}
/>
);
}, [
chainDataLoading, hasSignedUp, orbitDataLoading, pollChangeVoteEnabled, pollOptions, topicId, userHasVoted,
userVoteIndex,
]);
const pollGraphTab = useMemo(() => (
!chainDataLoading || orbitDataLoading
? (
<PollGraph
pollOptions={pollOptions}
voteCounts={voteCounts}
hasUserVoted={userHasVoted}
userVoteIndex={userVoteIndex}
/>
)
: null
), [chainDataLoading, orbitDataLoading, pollOptions, userHasVoted, userVoteIndex, voteCounts]);
const panes = useMemo(() => {
const pollVotePane = (
<CustomLoadingTabPane loading={chainDataLoading || orbitDataLoading}>
{pollVoteTab}
</CustomLoadingTabPane>
);
const pollGraphPane = (
<CustomLoadingTabPane loading={chainDataLoading || orbitDataLoading}>
{pollGraphTab}
</CustomLoadingTabPane>
);
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 (
<Container id="topic-poll-container" textAlign="left">
{!chainDataLoading && !orbitDataLoading && pollHashValid
? (
<>
<Header as="h3">
<Icon name="chart pie" size="large" />
{pollQuestion}
</Header>
<Tab panes={panes} />
</>
)
: <PollDataInvalid />}
</Container>
);
};
PollView.propTypes = {
topicId: PropTypes.number.isRequired,
};
export default PollView;

2
packages/concordia-app/src/constants/polls/PollGraph.js

@ -0,0 +1,2 @@
export const DEFAULT_OPTION_COLOR = '#3B5066';
export const CASTED_OPTION_COLOR = '#0b2540';

16
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;

8
packages/concordia-app/src/redux/reducers/peerDbReplicationReducer.js

@ -4,13 +4,16 @@ const initialState = {
users: [], users: [],
topics: [], topics: [],
posts: [], posts: [],
polls: [],
}; };
const peerDbReplicationReducer = (state = initialState, action) => { const peerDbReplicationReducer = (state = initialState, action) => {
const { type } = action; const { type } = action;
if (type === UPDATE_ORBIT_DATA) { if (type === UPDATE_ORBIT_DATA) {
const { users, topics, posts } = action; const {
users, topics, posts, polls,
} = action;
return { return {
...state, ...state,
@ -23,6 +26,9 @@ const peerDbReplicationReducer = (state = initialState, action) => {
posts: [ posts: [
...posts, ...posts,
], ],
polls: [
...polls,
],
}; };
} }

41
packages/concordia-app/src/redux/sagas/peerDbReplicationSaga.js

@ -7,12 +7,18 @@ import {
ORBIT_DB_REPLICATED, ORBIT_DB_REPLICATED,
ORBIT_DB_WRITE, ORBIT_DB_WRITE,
} from '@ezerous/breeze/src/orbit/orbitActions'; } 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 determineKVAddress from '../../utils/orbitUtils';
import { FETCH_USER_DATABASE, UPDATE_ORBIT_DATA } from '../actions/peerDbReplicationActions'; import { FETCH_USER_DATABASE, UPDATE_ORBIT_DATA } from '../actions/peerDbReplicationActions';
import userDatabaseKeys from '../../constants/orbit/UserDatabaseKeys'; import userDatabaseKeys from '../../constants/orbit/UserDatabaseKeys';
import { TOPIC_SUBJECT } from '../../constants/orbit/TopicsDatabaseKeys'; import { TOPIC_SUBJECT } from '../../constants/orbit/TopicsDatabaseKeys';
import { POST_CONTENT } from '../../constants/orbit/PostsDatabaseKeys'; import { POST_CONTENT } from '../../constants/orbit/PostsDatabaseKeys';
import { POLL_OPTIONS, POLL_QUESTION } from '../../constants/orbit/PollsDatabaseKeys';
function* fetchUserDb({ orbit, userAddress, dbName }) { function* fetchUserDb({ orbit, userAddress, dbName }) {
const peerDbAddress = yield call(determineKVAddress, { const peerDbAddress = yield call(determineKVAddress, {
@ -23,10 +29,13 @@ function* fetchUserDb({ orbit, userAddress, dbName }) {
} }
function* updateReduxState({ database }) { function* updateReduxState({ database }) {
const { users, topics, posts } = yield select((state) => ({ const {
users, topics, posts, polls,
} = yield select((state) => ({
users: state.orbitData.users, users: state.orbitData.users,
topics: state.orbitData.topics, topics: state.orbitData.topics,
posts: state.orbitData.posts, posts: state.orbitData.posts,
polls: state.orbitData.polls,
})); }));
if (database.dbname === USER_DATABASE) { if (database.dbname === USER_DATABASE) {
@ -53,6 +62,7 @@ function* updateReduxState({ database }) {
], ],
topics: [...topics], topics: [...topics],
posts: [...posts], posts: [...posts],
polls: [...polls],
}); });
} }
@ -76,6 +86,7 @@ function* updateReduxState({ database }) {
})), })),
], ],
posts: [...posts], posts: [...posts],
polls: [...polls],
}); });
} }
@ -97,6 +108,32 @@ function* updateReduxState({ database }) {
[POST_CONTENT]: value[POST_CONTENT], [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],
],
})),
],
}); });
} }
} }

7
packages/concordia-app/src/utils/hashUtils.js

@ -1,7 +1,6 @@
import sha256 from 'crypto-js/sha256'; import sha256 from 'crypto-js/sha256';
function generateHash(message) { export const generateHash = (message) => sha256(message).toString().substring(0, 16);
return sha256(message).toString().substring(0, 16);
}
export default generateHash; export const generatePollHash = (pollQuestion, pollOptions) => generateHash(JSON
.stringify({ question: pollQuestion, optionValues: pollOptions }));

51
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 PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { import {
@ -6,19 +6,25 @@ import {
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useHistory } from 'react-router'; 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 { TOPICS_DATABASE, USER_DATABASE } from 'concordia-shared/src/constants/orbit/OrbitDatabases';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { breeze, drizzle } from '../../../redux/store'; import { breeze, drizzle } from '../../../redux/store';
import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions'; import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions';
import './styles.css'; import './styles.css';
import TopicPostList from './TopicPostList'; import TopicPostList from './TopicPostList';
import PollView from '../../../components/PollView';
import determineKVAddress from '../../../utils/orbitUtils'; import determineKVAddress from '../../../utils/orbitUtils';
import { TOPIC_SUBJECT } from '../../../constants/orbit/TopicsDatabaseKeys'; import { TOPIC_SUBJECT } from '../../../constants/orbit/TopicsDatabaseKeys';
import PostCreate from '../../../components/PostCreate'; import PostCreate from '../../../components/PostCreate';
import targetBlank from '../../../utils/markdownUtils'; 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 { orbit } = breeze;
const TopicView = (props) => { const TopicView = (props) => {
@ -29,15 +35,18 @@ const TopicView = (props) => {
const userAddress = useSelector((state) => state.user.address); const userAddress = useSelector((state) => state.user.address);
const hasSignedUp = useSelector((state) => state.user.hasSignedUp); const hasSignedUp = useSelector((state) => state.user.hasSignedUp);
const getTopicResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getTopic); 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 topics = useSelector((state) => state.orbitData.topics);
const users = useSelector((state) => state.orbitData.users); 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 [topicAuthorAddress, setTopicAuthorAddress] = useState(initialTopicAuthorAddress || null);
const [topicAuthor, setTopicAuthor] = useState(initialTopicAuthor || null); const [topicAuthor, setTopicAuthor] = useState(initialTopicAuthor || null);
const [timestamp, setTimestamp] = useState(initialTimestamp || null); const [timestamp, setTimestamp] = useState(initialTimestamp || null);
const [postIds, setPostIds] = useState(initialPostIds || null); const [postIds, setPostIds] = useState(initialPostIds || null);
const [numberOfReplies, setReplyCount] = useState(0); const [numberOfReplies, setReplyCount] = useState(0);
const [topicSubject, setTopicSubject] = useState(null); const [topicSubject, setTopicSubject] = useState(null);
const [hasPoll, setHasPoll] = useState(false);
const history = useHistory(); const history = useHistory();
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -52,6 +61,12 @@ const TopicView = (props) => {
} }
}, [postIds, timestamp, topicAuthor, topicAuthorAddress, topicId]); }, [postIds, timestamp, topicAuthor, topicAuthorAddress, topicId]);
useEffect(() => {
if (!pollExistsCallHash) {
setPollExistsCallHash(pollExistsChainData(topicId));
}
}, [pollExistsCallHash, topicId]);
useEffect(() => { useEffect(() => {
if (getTopicCallHash && getTopicResults && getTopicResults[getTopicCallHash]) { if (getTopicCallHash && getTopicResults && getTopicResults[getTopicCallHash]) {
if (getTopicResults[getTopicCallHash].value == null) { if (getTopicResults[getTopicCallHash].value == null) {
@ -62,9 +77,9 @@ const TopicView = (props) => {
setTopicAuthorAddress(getTopicResults[getTopicCallHash].value[0]); setTopicAuthorAddress(getTopicResults[getTopicCallHash].value[0]);
setTopicAuthor(getTopicResults[getTopicCallHash].value[1]); setTopicAuthor(getTopicResults[getTopicCallHash].value[1]);
setTimestamp(getTopicResults[getTopicCallHash].value[2] * 1000); setTimestamp(getTopicResults[getTopicCallHash].value[2] * 1000);
const postIds = getTopicResults[getTopicCallHash].value[3].map((postId) => parseInt(postId, 10)); const fetchedPostIds = getTopicResults[getTopicCallHash].value[3].map((postId) => parseInt(postId, 10));
setPostIds(postIds); setPostIds(fetchedPostIds);
setReplyCount(postIds.length - 1); setReplyCount(fetchedPostIds.length - 1);
const topicFound = topics const topicFound = topics
.find((topic) => topic.id === topicId); .find((topic) => topic.id === topicId);
@ -80,6 +95,12 @@ const TopicView = (props) => {
} }
}, [dispatch, getTopicCallHash, getTopicResults, history, topicId, topics, userAddress]); }, [dispatch, getTopicCallHash, getTopicResults, history, topicId, topics, userAddress]);
useEffect(() => {
if (pollExistsCallHash && pollExistsResults && pollExistsResults[pollExistsCallHash]) {
setHasPoll(pollExistsResults[pollExistsCallHash].value);
}
}, [pollExistsCallHash, pollExistsResults, topicId]);
useEffect(() => { useEffect(() => {
if (topicAuthorAddress !== null) { if (topicAuthorAddress !== null) {
determineKVAddress({ orbit, dbName: USER_DATABASE, userAddress: topicAuthorAddress }) determineKVAddress({ orbit, dbName: USER_DATABASE, userAddress: topicAuthorAddress })
@ -111,6 +132,8 @@ const TopicView = (props) => {
} }
}, [topicId, topics]); }, [topicId, topics]);
const poll = useMemo(() => hasPoll && <PollView topicId={topicId} />, [hasPoll, topicId]);
const stopClickPropagation = (event) => { const stopClickPropagation = (event) => {
event.stopPropagation(); event.stopPropagation();
}; };
@ -147,7 +170,9 @@ const TopicView = (props) => {
&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;
<Icon name="user" fitted /> <Icon name="user" fitted />
&nbsp; &nbsp;
<Link to={`/users/${topicAuthorAddress}`} onClick={stopClickPropagation}>{ topicAuthor }</Link> <Link to={`/users/${topicAuthorAddress}`} onClick={stopClickPropagation}>
{topicAuthor}
</Link>
&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;
<Icon name="reply" fitted /> <Icon name="reply" fitted />
&nbsp; &nbsp;
@ -155,6 +180,16 @@ const TopicView = (props) => {
</div> </div>
</div> </div>
<Divider /> <Divider />
{
hasPoll && (
<>
<div id="topic-poll">
{poll}
</div>
<Divider />
</>
)
}
</Dimmer.Dimmable> </Dimmer.Dimmable>
<TopicPostList topicId={topicId} loading={postIds === null} focusOnPost={focusOnPost} /> <TopicPostList topicId={topicId} loading={postIds === null} focusOnPost={focusOnPost} />
</Segment> </Segment>

50
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); require(pollExists(topicID), POLL_DOES_NOT_EXIST);
if (option <= polls[topicID].numOptions) // Option 0 is valid as well (no option chosen) if (option <= polls[topicID].numOptions) // Option 0 is valid as well (no option chosen)
return true; return true;
@ -93,6 +127,18 @@ contract Voting {
return (polls[topicID].voters[option].length); 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) { function getTotalVotes(uint topicID) public view returns (uint) {
require(pollExists(topicID), POLL_DOES_NOT_EXIST); require(pollExists(topicID), POLL_DOES_NOT_EXIST);
@ -111,7 +157,7 @@ contract Voting {
return (polls[topicID].voters[option]); 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(pollExists(topicID), POLL_DOES_NOT_EXIST);
require(hasVoted(topicID, voter), USER_HAS_NOT_VOTED); require(hasVoted(topicID, voter), USER_HAS_NOT_VOTED);
Poll storage poll = polls[topicID]; Poll storage poll = polls[topicID];

76
yarn.lock

@ -3084,6 +3084,18 @@ anymatch@~3.1.1:
normalize-path "^3.0.0" normalize-path "^3.0.0"
picomatch "^2.0.4" 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: app-module-path@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5" 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" kleur "^3.0.3"
sisteransi "^1.0.5" 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" version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@ -15190,6 +15202,13 @@ rc@^1.2.7, rc@^1.2.8:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" 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: react-app-polyfill@^1.0.6:
version "1.0.6" version "1.0.6"
resolved "https://registry.yarnpkg.com/react-app-polyfill/-/react-app-polyfill-1.0.6.tgz#890f8d7f2842ce6073f030b117de9130a5f385f0" 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" resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5"
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== 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: svgo@^1.0.0, svgo@^1.2.2:
version "1.3.2" version "1.3.2"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167" resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"

Loading…
Cancel
Save