diff --git a/.gitignore b/.gitignore index b23d2558d..5be211036 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ ## Custom config/production/settings.json config/production/decentraland.json +config/production/colorado.json config/staging/settings.json + .vscode _deprecations diff --git a/config/development/settings.json b/config/development/settings.json index 56a4e8265..f2a3b71a2 100644 --- a/config/development/settings.json +++ b/config/development/settings.json @@ -57,7 +57,6 @@ "codename": "Quixote", "config": { - "blockchainLogin": false, "commentRanking": true, "proposalDrafting": true, "appCache": false, @@ -71,7 +70,32 @@ "checkList": true }, "displayLanding": false, - "allowWebVotes": true + "allowWebVotes": true, + "defaultRules": { + "quadraticVoting": false, + "balanceVoting": false, + "pollVoting": false + }, + "interface": { + "showTransactions": false, + "adminBallotCreatorOnly": { + "active": false, + "email": "hello@democracy.earth" + } + }, + "governance": { + "dictatorship": true, + "dictator": { + "userId": "y2RPht3xHDLf7KN3u", + "publicAddress": "" + } + }, + "loginOptions": { + "blockstack": true, + "metamask": true, + "email": true, + "blockchainLogin": false + } } }, diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 922c3d90e..8a57f88b7 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -200,7 +200,7 @@ "new-profile-pic": "Profile picture updated.", "valid-country": "The typed nationality does not exist.", "missing-name": "At least a first name is required.", - "missing-username": "Username is required without spaces.", + "missing-username": "This username is invalid.", "digital-citizen": "Digital", "pending-verification": "E-mail not verified. Resend link?", "user-not-found": "User not found.", @@ -230,6 +230,12 @@ "hours-compressed" : "hours", "minutes-compressed" : "min", "seconds-compressed" : "sec", + "years-singular": "year", + "months-singular": "month", + "days-singular": "day", + "hours-singular": "hour", + "minutes-singular": "min", + "seconds-singular": "sec", "latest-proposals": "Latest Proposals", "all-proposals": "All Proposals", "drafts": "Drafts", @@ -426,6 +432,7 @@ "remove-all-votes": "remove all votes", "empty-values-ballot": "No values found on ballot.", "not-enough-funds": "Not enough funds.", + "not-enough-funds-qv": "Not enough funds. The cost of voting for this option is {{qvCost}}", "invalid-transaction": "Self voting is not allowed.", "events": "Events", "actions": "Actions", @@ -519,7 +526,7 @@ "hashtag-tag-description": "All posts tagged #{{hashtag}} on {{collective}}.", "country-tag-title": "{{country}} posts on {{collective}}.", "country-tag-description": "All posts from citizens of {{country}} on {{collective}}.", - "recent-activity": "Recent Transactions", + "recent-activity": "Recent Activity", "bitcoin": "Bitcoin", "fund-organization": "Fund Organization", "btc": "BTC", @@ -547,7 +554,7 @@ "valid-coin": "This cryptocurrency does not exist.", "from": "from", "holding": "holding", - "valid-email-domain": "with a {{domain}} valid e-mail", + "valid-email-domain": "with a verified {{domain}} e-mail address", "electorate-incompatible-warning": "To vote on this proposal you must meet the requirements.", "log-in-network": "Login to network.", "authenticate-self": "Authenticate", @@ -566,7 +573,7 @@ "post-idea": "Post Idea", "happening-now": "Happening Now", "requisites": "Requisites", - "post-new-idea": "A new idea...", + "post-new-idea": "A new decision...", "insufficient-votes": "Unable to find the required token balance in your wallet", "tokenless-post": "No tokens specified for voting here", "get-tokens": "Get tokens", @@ -599,9 +606,9 @@ "transaction-broadcast": "{{token}} Transaction sent to blockchain.", "ethereum-metamask": "Ethereum", "no-chain-found": "No blockchain address found.", - "metamask-confirm-transaction": "Confirm transaction in your blockchain wallet.
Delegate responsible for post tokens:", + "metamask-confirm-transaction": "Confirm transaction in your blockchain wallet.
To be included Delegate list:", "ticket": "ticket", - "transaction-status-confirmed-onchain": "Confirmed on chain transaciton", + "transaction-status-confirmed-onchain": "Confirmed on chain transaction", "transaction-status-signed-offchain": "Signed off chain stake", "transaction-status-pending-onchain": "Pending on chain transaction", "transaction-status-fail-onchain": "Failed on chain transaction", @@ -670,5 +677,52 @@ "token-erc-20": "ERC 20 Token", "token-blockchain": "Blockchain Activity", "token-saft": "SAFT Purchase", - "off-chain": "off chain" + "off-chain": "off chain", + "voting-editor-offchain-toggle": "Off-chain", + "voting-editor-balance-toggle": "Coin vote", + "voting-editor-quadratic-toggle": "Quadratic vote", + "voting-editor-poll-voting": "Include poll", + "advanced-rules": "Advanced", + "voting-editor-balance-tooltip": "Account balance gets tallied after signing with private key.", + "voting-editor-quadratic-tooltip": "The cost of voting for a choice increases at a quadratic rate.", + "voting-editor-poll-tooltip": "Include a poll with multiple options on this post.", + "poll-choice": "Poll choice {{number}}", + "poll-default-title-0": "Downvote 👎", + "poll-default-title-1": "Upvote 👍", + "poll-inside": "Election Poll", + "ticker-rule-quadratic": "", + "ticker-rule-balance": "Balance", + "decision-rules": "Decision Mechanism", + "remove-vote": "Remove vote from this choice.", + "election-rule-quadratic": "Quadratic Vote", + "election-rule-balance": "Coin Vote", + "token-budget": "Budget", + "closing-date": "Closing", + "always-on-tooltip": "An Always On decision does not have a closing date.", + "decision-deadline": "Decision Deadline", + "blockchain-time": "Blockchain Time", + "blockchain-time-daily": "24 hours", + "blockchain-time-weekly": "7 days", + "blockchain-time-monthly": "30 days", + "blockchain-time-quarterly": "1 quarter", + "blockchain-time-annual": "1 year", + "duration": "Duration", + "blockchain-time-closing-criteria": "Poll ends when {{blockchain}} reaches a block height of {{height}} blocks estimated on {{date}}.", + "blockchain-time-always-on": "This poll never ends and tallies an ongoing result.", + "countdown-expiration": "Poll ends in {{days}} {{hours}} {{minutes}} {{seconds}} (in {{blocks}})", + "blocks-compressed": "blocks", + "blocks-singular": "block", + "poll-closed-after-time": "Poll ended after {{days}} (at block {{height}})", + "poll-never-ends": "This poll never ends (always on).", + "height-compressed": "blocks", + "height-singular": "block", + "poll-is-closed": "This poll is no longer open for voting.", + "feeds": "Feeds", + "poll-hypothetical": "Poll will end in {{days}} {{hours}} {{minutes}} {{seconds}} (in {{blocks}})", + "coinvote-signature": "A total of {{quantity}} {{ticker}} will be tallied in support of {{choice}} in the proposal available on {{url}}. No tokens will be spent.", + "metamask-confirm-tally": "Sign vote with your private key.
To be included on Delegate list:", + "transaction-tally": "{{token}} Signature verified, account balance tallied.", + "transaction-tally-denied": "{{token}} Signature denied, no votes tallied.", + "already-voted": "Already Voted", + "already-voted-detail": "Your account balance has already been tallied on this decision." } diff --git a/imports/api/blockchain/modules/web3Util.js b/imports/api/blockchain/modules/web3Util.js index d8c7e4f18..d52448c74 100644 --- a/imports/api/blockchain/modules/web3Util.js +++ b/imports/api/blockchain/modules/web3Util.js @@ -2,8 +2,10 @@ import { Meteor } from 'meteor/meteor'; import Web3 from 'web3'; import abi from 'human-standard-token-abi'; import { BigNumber } from 'bignumber.js'; + import { token } from '/lib/token'; +const numeral = require('numeral'); // Set web3 provider let web3; @@ -172,23 +174,79 @@ const _getTokenData = async (_publicAddress) => { let _balance; for (let i = 0; i < token.coin.length; i++) { - _balance = await _getTokenBalance(_publicAddress, token.coin[i].contractAddress); - if (_balance.toNumber() !== 0) { - const withoutDecimal = _removeDecimal(_balance.toNumber(), token.coin[i].decimals); - const tokenObj = { - balance: withoutDecimal.toNumber(), - placed: 0, - available: withoutDecimal.toNumber(), - token: token.coin[i].code, - publicAddress: _publicAddress, - }; - - tokenData.push(tokenObj); + if (token.coin[i].type === 'ERC20') { + _balance = await _getTokenBalance(_publicAddress, token.coin[i].contractAddress); + if (_balance.toNumber() !== 0) { + const withoutDecimal = _removeDecimal(_balance.toNumber(), token.coin[i].decimals); + const tokenObj = { + balance: withoutDecimal.toNumber(), + placed: 0, + available: withoutDecimal.toNumber(), + token: token.coin[i].code, + publicAddress: _publicAddress, + }; + + tokenData.push(tokenObj); + } } } return tokenData; }; +/** +* @summary shows balance in currency not decimals +* @param {object} value value to be changed +* @param {string} token currency being used +* @returns {number} +*/ +const _currencyValue = (value, tokenCode) => { + switch (tokenCode) { + case 'WEI': + return _wei2eth(value.toString()); + // case 'VOTE': + // return adjustDecimal(value); + default: + return value; + } +}; + + +/** +* @summary format currency display according to crypto rules +* @param {string} value value to be changed +* @param {string} tokenCode currency being used +* @returns {string} formatted number +*/ +const _formatCryptoValue = (value, tokenCode) => { + let tokenFinal; + if (!tokenCode) { tokenFinal = 'ETH'; } else { tokenFinal = tokenCode; } + return numeral(_currencyValue(value, tokenFinal)).format(_getCoin(tokenFinal).format); +}; + + +/** +* @summary get the token balance a user has for a given contract coin +* @param {object} user with token +* @param {object} contract to be checked +* @return {string} the balance quantity +*/ +const _getBalance = (user, contract) => { + let result; + for (let i = 0; i < user.profile.wallet.reserves.length; i += 1) { + const coin = _getCoin(user.profile.wallet.reserves[i].token); + if (coin.code === contract.blockchain.coin.code) { + if (coin.code === 'ETH') { + result = _formatCryptoValue(_removeDecimal(Meteor.user().profile.wallet.reserves[i].balance, coin.decimals).toNumber(), coin.code); + console.log(result); + } else { + result = _formatCryptoValue(Meteor.user().profile.wallet.reserves[i].balance, coin.code); + } + return result; + } + } + return undefined; +} + export const wei2eth = _wei2eth; export const getEthBalance = _getEthBalance; export const getWeiBalance = _getWeiBalance; @@ -199,3 +257,4 @@ export const smallNumber = _smallNumber; export const addDecimal = _addDecimal; export const getCoin = _getCoin; export const getTokenData = _getTokenData; +export const getBalance = _getBalance; diff --git a/imports/api/contracts/Contracts.js b/imports/api/contracts/Contracts.js index 1be4a0c83..8ce247eff 100644 --- a/imports/api/contracts/Contracts.js +++ b/imports/api/contracts/Contracts.js @@ -52,6 +52,11 @@ Schema.Tally = new SimpleSchema({ type: Number, optional: true, }, + 'voter.$.qVotes': { + type: Number, + optional: true, + decimal: true, + }, 'voter.$.ballotList': { type: [String], optional: true, @@ -62,6 +67,66 @@ Schema.Tally = new SimpleSchema({ }, }); + +Schema.Poll = new SimpleSchema({ + contractId: { + type: String, + optional: true, + }, + totalStaked: { + type: String, + optional: true, + }, +}); + +Schema.Closing = new SimpleSchema({ + blockchain: { + type: String, + defaultValue: 'ETH', + }, + height: { + type: Number, + defaultValue: 0, + }, + calendar: { + type: Date, + autoValue() { + const creationDate = new Date(); + if (this.isInsert) { + creationDate.setDate(creationDate.getDate() + 1); + } + return creationDate; + }, + }, + delta: { + type: Number, + defaultValue: 0, + }, + urgency: { + type: Number, + optional: true, + }, +}); + +Schema.Rules = new SimpleSchema({ + alwaysOn: { + type: Boolean, + defaultValue: true, + }, + quadraticVoting: { + type: Boolean, + defaultValue: Meteor.settings.public.app.config.defaultRules.quadraticVoting, + }, + balanceVoting: { + type: Boolean, + defaultValue: Meteor.settings.public.app.config.defaultRules.balanceVoting, + }, + pollVoting: { + type: Boolean, + defaultValue: Meteor.settings.public.app.config.defaultRules.pollVoting, + }, +}); + Schema.Constituency = new SimpleSchema({ kind: { type: String, @@ -127,7 +192,7 @@ Schema.Contract = new SimpleSchema({ kind: { // kind of contract type: String, - allowedValues: ['DRAFT', 'VOTE', 'DELEGATION', 'MEMBERSHIP', 'DISCIPLINE'], + allowedValues: ['DRAFT', 'VOTE', 'DELEGATION', 'MEMBERSHIP', 'DISCIPLINE', 'POLL'], autoValue() { if (this.isInsert) { if (this.field('kind').value === undefined) { @@ -150,7 +215,7 @@ Schema.Contract = new SimpleSchema({ // URL inside the instance of .Earth type: String, autoValue() { - const slug = convertToSlug(this.field('title').value); + let slug = convertToSlug(this.field('title').value); if (this.isInsert) { if (this.field('kind').value === 'DELEGATION') { if (this.field('keyword').value !== undefined) { @@ -159,6 +224,9 @@ Schema.Contract = new SimpleSchema({ return 'delegation'; } if (this.field('title').value !== undefined) { + if (this.field('kind'.value === 'POLL') && this.field('keyword').value) { + slug = this.field('keyword').value; + } if (Contracts.findOne({ keyword: slug }) === undefined) { if (this.field('title').value !== '') { const time = this.field('createdAt').value; @@ -526,10 +594,25 @@ Schema.Contract = new SimpleSchema({ return Math.floor(Math.random() * 10000000000000000); }, }, - quadraticVoting: { - type: Boolean, + rules: { + type: Schema.Rules, + optional: true, + }, + poll: { + type: [Schema.Poll], + defaultValue: [], + }, + pollId: { + type: String, + optional: true, + }, + pollChoiceId: { + type: String, + optional: true, + }, + closing: { + type: Schema.Closing, optional: true, - defaultValue: false, }, }); diff --git a/imports/api/server/publications.js b/imports/api/server/publications.js index cf7290c50..8a420993a 100644 --- a/imports/api/server/publications.js +++ b/imports/api/server/publications.js @@ -79,6 +79,24 @@ Meteor.publish('delegates', function (terms) { return this.ready(); }); +/** +* @summary returns list of contracts from a poll +* @return {Object} querying terms +*/ +Meteor.publish('poll', function (terms) { + check(terms, Object); + + const parameters = query(terms); + log(`{ publish: 'poll', user: ${logUser()}, pollId: '${terms.pollId}' }`); + + const pollFeed = Contracts.find(parameters.find, parameters.options); + if (pollFeed) { + return pollFeed; + } + + return this.ready(); +}); + /** * @summary files related to a user account * @return {Object} file diff --git a/imports/api/transactions/transaction.js b/imports/api/transactions/transaction.js index ae05cceb0..292f933ff 100644 --- a/imports/api/transactions/transaction.js +++ b/imports/api/transactions/transaction.js @@ -1,5 +1,6 @@ import { Meteor } from 'meteor/meteor'; import { Session } from 'meteor/session'; +import { TAPi18n } from 'meteor/tap:i18n'; import { rules } from '/lib/const'; import { BigNumber } from 'bignumber.js'; @@ -11,8 +12,7 @@ import { getTime } from '/imports/api/time'; import { Transactions } from '/imports/api/transactions/Transactions'; import { getTotalVoters } from '/imports/ui/modules/ballot'; import { notify } from '/imports/api/notifier/notifier'; -import { getWeiBalance, getTokenData } from '/imports/api/blockchain/modules/web3Util'; -import { smallNumber } from '/imports/api/blockchain/modules/web3Util.js'; +import { getWeiBalance, getTokenData, smallNumber } from '/imports/api/blockchain/modules/web3Util'; /** * @summary looks at what type of entity (collective or individual) doing transaction @@ -168,14 +168,14 @@ const _updateWallet = (entityId, entityType, profileSettings) => { * @param {string} contractId - contractId to be checked */ const _getTransactions = (userId, contractId) => { - // const tx = Transactions.find({ $and: [{ $or: [{ 'output.entityId': userId }, { 'input.entityId': userId }] }, { contractId }] }, { sort: { timestamp: -1 } }).fetch(); - // return tx; + // const tx = Transactions.find({ $and: [{ $or: [{ 'output.entityId': userId }, { 'input.entityId': userId }] }, { contractId }] }, { sort: { timestamp: -1 } }).fetch(); + // return tx; return _.sortBy( _.union( _.filter(Transactions.find({ 'input.entityId': userId }).fetch(), (item) => { return (item.output.entityId === contractId); }, 0), _.filter(Transactions.find({ 'output.entityId': userId }).fetch(), (item) => { return (item.input.entityId === contractId); }, 0)), - 'timestamp'); + 'timestamp'); }; /** @@ -258,11 +258,14 @@ const _restoredTokens = (quantity, totals) => { return quantity; }; -const _transactionMessage = (code) => { - switch (code) { +const _transactionMessage = (processingStatus) => { + switch (processingStatus.code) { case 'INSUFFICIENT': displayNotice('not-enough-funds', true); return false; + case 'INSUFFICIENT-QV': + displayNotice(`${TAPi18n.__('not-enough-funds-qv').replace('{{qvCost}}', processingStatus.qvCost)}`, true, true); + return false; case 'INVALID': displayNotice('invalid-transaction', true); return false; @@ -345,38 +348,90 @@ const _processDelegation = (transaction) => { }; /** -* @summary processes de transaction after insert and updates wallet of involved parties -* @param {string} txId - transaction identificator -* @param {string} success - INSUFFICIENT, +* @summary updates user wallet when transaction is quadratic, whether voting or revoking, +* according to the number of votes associated with said user in the contract tally +* @param {string} senderId +* @param {object} senderProfile +* @param {string} receiverId +* @param {object} receiverProfile +* @return {object} _userWallet - updated wallet assigned to userProfile.wallet */ -const _processTransaction = (ticket) => { - const txId = ticket; - const transaction = Transactions.findOne({ _id: txId }); - const senderProfile = _getProfile(transaction.input); - const receiverProfile = _getProfile(transaction.output); +const _processQuadraticTransaction = (senderId, senderProfile, receiverId, receiverProfile) => { + let _userWallet; + let userId; + let userProfile; + let contract; + let revoke = false; + let found = false; - // verify transaction - if (senderProfile.wallet.available < transaction.input.quantity) { - return 'INSUFFICIENT'; - } else if (transaction.input.entityId === transaction.output.entityId) { - return 'INVALID'; + // Set up + if (receiverProfile.tally) { + // user is voting + _userWallet = senderProfile.wallet; + userId = senderId; + userProfile = senderProfile; + contract = Contracts.findOne({ _id: receiverId }); + } else if (senderProfile.tally) { + // user is revoking votes + _userWallet = receiverProfile.wallet; + userId = receiverId; + userProfile = receiverProfile; + contract = Contracts.findOne({ _id: senderId }); + revoke = true; } - // transact - senderProfile.wallet = _pay(senderProfile.wallet, 'INPUT', transaction, transaction.input.quantity); - receiverProfile.wallet = _pay(receiverProfile.wallet, 'OUTPUT', transaction, transaction.output.quantity); + for (let i = 0; i < contract.tally.voter.length; i += 1) { + if (contract.tally.voter[i]._id === userId) { + const votesPerUserPerBallot = contract.tally.voter[i].votes; + // TODO - here .balance needs to be updated too to be compatible with legacy code + if (revoke) { + _userWallet.available += Math.pow(votesPerUserPerBallot + 1, 2); + _userWallet.placed -= 1; + found = true; + } else { + _userWallet.available -= Math.pow(votesPerUserPerBallot, 2); + _userWallet.placed += 1; + found = true; + } + } + } - // update wallets - _updateWallet(transaction.input.entityId, transaction.input.entityType, senderProfile); - _updateWallet(transaction.output.entityId, transaction.output.entityType, receiverProfile); + if (!found && revoke) { + // Edge case of revoking when only one vote has been placed + _userWallet.available += 1; + _userWallet.placed -= 1; + } - // delegation - if (transaction.kind === 'DELEGATION') { - _processDelegation(transaction); + return Object.assign(userProfile.wallet, _userWallet); +}; + + +/** +* @summary get what would be the cost of this vote should it go through +* @param {string} senderId +* @param {string} receiverId +* @return {number} quadraticVoteCost +*/ +const _getQuadraticVoteCost = (senderId, receiverId) => { + const userId = senderId; + const contract = Contracts.findOne({ _id: receiverId }); + let votesPerUserPerBallot; + let quadraticVoteCost; + let found = false; + + for (let i = 0; i < contract.tally.voter.length; i += 1) { + if (contract.tally.voter[i]._id === userId) { + votesPerUserPerBallot = contract.tally.voter[i].votes; + quadraticVoteCost = Math.pow(votesPerUserPerBallot + 1, 2); + found = true; + } } - // set this transaction as processed - return Transactions.update({ _id: txId }, { $set: { status: 'CONFIRMED' } }); + if (!found) { + // User is voting for the first time + return 1; + } + return quadraticVoteCost; }; /** @@ -651,6 +706,7 @@ const _tally = (transaction) => { if ((contract.tally.voter[i]._id === transaction.input.entityId) || (contract.tally.voter[i]._id === transaction.output.entityId)) { found = true; contract.tally.voter[i].votes += _tallyAddition(transaction); + contract.tally.voter[i].qVotes = Math.sqrt(contract.tally.voter[i].votes); if (contract.tally.voter[i].votes === 0) { contract.tally.voter.splice(i, 1); break; @@ -700,6 +756,66 @@ const _tally = (transaction) => { Contracts.update({ _id: transaction.contractId }, { $set: { tally: contract.tally, ballot: contract.ballot } }); }; +/** +* @summary processes the transaction after insert and updates wallet of involved parties +* @param {string} txId - transaction identificator +* @param {string} success - INSUFFICIENT, +*/ +const _processTransaction = (ticket) => { + const txId = ticket; + const transaction = Transactions.findOne({ _id: txId }); + const senderProfile = _getProfile(transaction.input); + const receiverProfile = _getProfile(transaction.output); + const senderId = transaction.input.entityId; + const receiverId = transaction.output.entityId; + + // verify transaction + if (receiverProfile.rules && receiverProfile.rules.quadraticVoting) { + // This is a WEBVOTE quadratic vote, and user is voting (not revoking) + const quadraticCost = _getQuadraticVoteCost(senderId, receiverId); + if (senderProfile.wallet.available < quadraticCost) { + return { code: 'INSUFFICIENT-QV', qvCost: quadraticCost }; + } + } else if (senderProfile.wallet.available < transaction.input.quantity) { + return { code: 'INSUFFICIENT', qvCost: null }; + } else if (transaction.input.entityId === transaction.output.entityId) { + return { code: 'INVALID', qvCost: null }; + } + + // Invoke tally first only if it is a transaction between + // an individual and a contract, tally needs to be updated + // before processing quadratic vote + if (transaction.input.entityType !== 'COLLECTIVE') { + _tally(transaction); + } + + if (receiverProfile.rules && receiverProfile.rules.quadraticVoting) { + // Quadratic transaction, user is sender therefore voting + senderProfile.wallet = _processQuadraticTransaction(senderId, senderProfile, receiverId, receiverProfile); + receiverProfile.wallet = _pay(receiverProfile.wallet, 'OUTPUT', transaction, transaction.output.quantity); + } else if (senderProfile.rules && senderProfile.rules.quadraticVoting) { + // Quadratic transaction, user is receiver therefore revoking + senderProfile.wallet = _pay(senderProfile.wallet, 'INPUT', transaction, transaction.input.quantity); + receiverProfile.wallet = _processQuadraticTransaction(senderId, senderProfile, receiverId, receiverProfile); + } else { + // Non-quadratic + senderProfile.wallet = _pay(senderProfile.wallet, 'INPUT', transaction, transaction.input.quantity); + receiverProfile.wallet = _pay(receiverProfile.wallet, 'OUTPUT', transaction, transaction.output.quantity); + } + + // update wallets + _updateWallet(transaction.input.entityId, transaction.input.entityType, senderProfile); + _updateWallet(transaction.output.entityId, transaction.output.entityType, receiverProfile); + + // delegation + if (transaction.kind === 'DELEGATION') { + _processDelegation(transaction); + } + + // set this transaction as processed + return Transactions.update({ _id: txId }, { $set: { status: 'CONFIRMED' } }); +}; + /** * @summary inserts ticket data to a contract affected by a crypto transaction * @param {string} _id of contract to update @@ -822,7 +938,8 @@ const _transact = (senderId, receiverId, votes, settings, callback) => { } if (senderId === receiverId) { - _transactionMessage('INVALID'); + const status = { code: 'INVALID', qvCost: null }; + _transactionMessage(status); return null; } @@ -856,6 +973,7 @@ const _transact = (senderId, receiverId, votes, settings, callback) => { geo: settings.geo, }; + // blockchain transaction if (settings.kind === 'CRYPTO') { // input carries information of sender @@ -877,16 +995,15 @@ const _transact = (senderId, receiverId, votes, settings, callback) => { newTx = Transactions.findOne({ _id: txId }); // adds voter - _addVoter(newTransaction.output.entityId, senderId, newTx._id) + _addVoter(newTransaction.output.entityId, senderId, newTx._id); // go to interface if (callback !== undefined) { callback(); } return txId; + } else if (settings.kind === 'VOTE') { + // web transaciton - // web transaciton - } /* - else { // executes the transaction txId = Transactions.insert(newTransaction); processing = _processTransaction(txId); @@ -895,8 +1012,8 @@ const _transact = (senderId, receiverId, votes, settings, callback) => { // NOTE: uncomment for testing - // console.log(txId); - // console.log(processing); + // console.log(`txId: ${txId}`); + // console.log(`processing: ${processing}`); if (_transactionMessage(processing)) { @@ -909,16 +1026,11 @@ const _transact = (senderId, receiverId, votes, settings, callback) => { _updateWalletCache(newTx, false); } - // update tally in contract excluding subsidy - if (newTx.kind === 'VOTE' && newTx.input.entityType !== 'COLLECTIVE' && newTx.output.entityType !== 'COLLECTIVE') { - _tally(newTx); - } - notify(newTx); return txId; } - } */ + } return null; }; @@ -928,22 +1040,26 @@ const _transact = (senderId, receiverId, votes, settings, callback) => { */ const _genesisTransaction = (userId) => { const user = Meteor.users.findOne({ _id: userId }); + const userTransactions = Transactions.find({ 'output.entityId': userId }).fetch(); // veryfing genesis... - // TODO this is not right, should check against Transactions collection. - if (user.profile.wallet !== undefined) { - if (user.profile.wallet.ledger.length > 0) { - if (user.profile.wallet.ledger[0].entityType === 'COLLECTIVE') { - // this user already had a genesis - return; - } - } + const genesisCheck = userTransactions.find(function (tx) { + return tx.input.entityType === 'COLLECTIVE' && tx.input.quantity === 1000; + }); + + const userBalance = user.profile.wallet.balance; + + // TODO - add emailListCheck condition if set true from Meteor.settings: user.emails && emailListCheck(user.emails[0].address) + if (genesisCheck === undefined && userBalance === 0) { + // generate first transaction from collective to new member + user.profile.wallet = _generateWalletAddress(user.profile.wallet); + Meteor.users.update({ _id: userId }, { $set: { profile: user.profile } }); + const transactSettings = { + kind: 'VOTE', + currency: 'WEB VOTE', + }; + _transact(Meteor.settings.public.Collective._id, userId, rules.VOTES_INITIAL_QUANTITY, transactSettings); } - - // generate first transaction from collective to new member - user.profile.wallet = _generateWalletAddress(user.profile.wallet); - Meteor.users.update({ _id: userId }, { $set: { profile: user.profile } }); - _transact(Meteor.settings.public.Collective._id, userId, rules.VOTES_INITIAL_QUANTITY); }; /** diff --git a/imports/api/users/Wallet.js b/imports/api/users/Wallet.js index 8fcd0d52a..8d31cc33d 100644 --- a/imports/api/users/Wallet.js +++ b/imports/api/users/Wallet.js @@ -14,7 +14,7 @@ const Schema = {}; const _coins = () => { const coins = []; coins.push('VOTES'); // backwards compatibility; - coins.push('NONE'); // for tokenless users + // coins.push('WEB VOTE'); // for tokenless users for (let i = 0; i < token.coin.length; i += 1) { coins.push(token.coin[i].code); if (token.coin[i].subcode) { @@ -43,7 +43,7 @@ Schema.Wallet = new SimpleSchema({ autoValue() { if (this.isInsert) { if (this.field('currency').value === undefined) { - return 'NONE'; + return 'WEB VOTE'; } } }, diff --git a/imports/startup/both/modules/Contract.js b/imports/startup/both/modules/Contract.js index 3b134c364..f9b72010e 100644 --- a/imports/startup/both/modules/Contract.js +++ b/imports/startup/both/modules/Contract.js @@ -107,7 +107,8 @@ const _entangle = (draft) => { return _getPublicAddress(constituency[i].code); } } - return undefined; + + return draft; }; /** @@ -125,40 +126,76 @@ const _contractHasToken = (contract) => { return false; }; +const _webVoteChain = (contract) => { + const draft = contract; + + draft.blockchain = { + coin: { code: 'WEB VOTE' }, + votePrice: '1', + }; + draft.constituency = [{ + kind: 'TOKEN', + code: 'WEB VOTE', + check: 'EQUAL', + }]; + + return draft; +}; + +const _blockstackChain = (contract) => { + const draft = contract; + + draft.blockchain = { + coin: { code: 'STX' }, + publicAddress: Meteor.user().profile.wallet.reserves[0].publicAddress, + votePrice: '1', + }; + draft.constituency = [{ + kind: 'TOKEN', + code: 'STX', + check: 'EQUAL', + }]; + draft.wallet.currency = 'STX'; + + return draft; +}; + +const _ethereumChain = (contract) => { + const draft = contract; + // ERC20 tokens + if (!_contractHasToken(draft)) { + draft.constituency.push(defaultConstituency); + } + for (let i = 0; i < draft.constituency.length; i += 1) { + if (draft.constituency[i].kind === 'TOKEN') { + draft.wallet.currency = draft.constituency[i].code; + } + } + + if (draft.blockchain.coin === undefined) { + // set coin.code to whats in wallet.currency + draft.blockchain.coin = {}; + draft.blockchain.coin.code = draft.wallet.currency; + } else { + draft.blockchain.coin.code = draft.wallet.currency; + } + + return draft; +}; + /** * @summary inserts default blockchain data to a contract * @param {object} contract contract to include chain data */ const _chain = (contract) => { - const draft = contract; - if (Meteor.user().profile.wallet.reserves[0].token === 'STX') { - // Blockstack tokens only - draft.blockchain = { - coin: { code: 'STX' }, - publicAddress: Meteor.user().profile.wallet.reserves[0].publicAddress, - votePrice: '1', - }; - draft.constituency = [{ - kind: 'TOKEN', - code: 'STX', - check: 'EQUAL', - }]; - draft.wallet.currency = 'STX'; - } else { - // ERC20 tokens - if (!_contractHasToken(draft)) { - draft.constituency.push(defaultConstituency); - } - for (let i = 0; i < draft.constituency.length; i += 1) { - if (draft.constituency[i].kind === 'TOKEN') { - draft.wallet.currency = draft.constituency[i].code; - } - } + let draft = contract; - // blockchain - if (!draft.blockchain.publicAddress) { - draft.blockchain = _entangle(draft); - } + if (draft.wallet.currency === 'WEB VOTE') { + draft = _webVoteChain(draft); + } else if (draft.wallet.currency === 'STX') { + draft = _blockstackChain(draft); + } else { + draft = _ethereumChain(draft); } return draft; }; @@ -181,16 +218,13 @@ const _createContract = (newkeyword, newtitle) => { // sign by author _sign(contract._id, Meteor.user(), 'AUTHOR'); - // Omit for tokenless users - if (Meteor.user().profile.wallet.reserves !== undefined) { - // chain by author - const chainedContract = _chain(contract); - Contracts.update({ _id: contract._id }, { $set: { - blockchain: chainedContract.blockchain, - wallet: chainedContract.wallet, - constituency: chainedContract.constituency, - } }); - } + // chain by author + const chainedContract = _chain(contract); + Contracts.update({ _id: contract._id }, { $set: { + blockchain: chainedContract.blockchain, + wallet: chainedContract.wallet, + constituency: chainedContract.constituency, + } }); } return Contracts.findOne({ keyword: `draft-${Meteor.userId()}` }); // has title & keyword, used for forks @@ -200,11 +234,99 @@ const _createContract = (newkeyword, newtitle) => { } else { Contracts.insert({ keyword: newkeyword, title: newtitle }); } - return Contracts.find({ keyword: newkeyword }).fetch(); + return Contracts.findOne({ keyword: newkeyword }); } return false; }; +/** +* @summary saves poll on database +* @param {object} draft being checked for poll creation. +* @param {object} pollContract poll data +*/ +const _savePoll = (draft, pollContract) => { + // update db + Contracts.update({ _id: pollContract._id }, { + $set: { + blockchain: draft.blockchain, + constituency: draft.constituency, + constituencyEnabled: draft.constituencyEnabled, + rules: draft.rules, + wallet: draft.wallet, + kind: pollContract.kind, + pollId: pollContract.pollId, + pollChoiceId: pollContract.pollChoiceId, + signatures: draft.signatures, + closing: draft.closing, + stage: 'LIVE', + }, + }); +}; + +/** +* @summary removes all poll contracts +* @param {object} draft being checked for poll erasure +*/ +const _removePoll = (draft) => { + for (let k = 0; k < draft.poll.length; k += 1) { + Contracts.remove({ _id: draft.poll[k].contractId }); + } +}; + +/** +* @summary create a basic poll inside a contract +* @param {object} draft being checked for poll creation. +* @return {object} draft created with poll settings included +*/ +const _createPoll = (draft) => { + let pollContract; + const newDraft = draft; + + // is a draft configured for polling without a poll + if (draft.rules && draft.rules.pollVoting === true) { + if (draft.poll.length === 0) { + const options = []; + let pollContractURI; + for (let i = 0; i < 2; i += 1) { + // creaate uri reference + pollContractURI = _contractURI(`${TAPi18n.__('poll-choice').replace('{{number}}', i.toString())} ${document.getElementById('titleContent').innerText} ${TAPi18n.__(`poll-default-title-${i}`)}`); + + // create contract to be used as poll option + pollContract = _createContract(pollContractURI, TAPi18n.__(`poll-default-title-${i}`)); + + // attach id of parent contract to poll option contract + pollContract.pollId = draft._id; + pollContract.kind = 'POLL'; + pollContract.pollChoiceId = i; + + _savePoll(draft, pollContract); + + // add to array to be stored in parent contract + options.push({ + contractId: pollContract._id, + totalStaked: '0', + }); + } + + // store array in parent contract + newDraft.poll = options; + + return newDraft; + } else if (draft.poll.length > 0) { + // change info of existing poll + + _removePoll(draft); + newDraft.poll = []; + newDraft.poll = _createPoll(newDraft).poll; + return newDraft; + } + } + + // return same draft + return draft; +}; + + /** * @summary verifies if there's already a precedent among delegator and delegate * @param {string} delegatorId - identity assigning the tokens (usually currentUser) @@ -466,6 +588,8 @@ const _land = (draft) => { return land; }; + + /** * @summary publishes a contract and goes to home * @param {string} contractId - id of the contract to publish @@ -473,7 +597,6 @@ const _land = (draft) => { */ const _publish = (contractId, keyword) => { let draft = Session.get('draftContract'); - // status draft.stage = 'LIVE'; @@ -508,12 +631,7 @@ const _publish = (contractId, keyword) => { } // chain - if (Meteor.user().profile.wallet.reserves) { - draft = _chain(draft); - if (draft.wallet.currency) { - draft.blockchain.coin.code = draft.wallet.currency; - } - } + draft = _chain(draft); // db Contracts.update({ _id: contractId }, { $set: { @@ -529,9 +647,26 @@ const _publish = (contractId, keyword) => { constituency: draft.constituency, wallet: draft.wallet, blockchain: draft.blockchain, + rules: draft.rules, + poll: draft.poll, + closing: draft.closing, }, }); + // polls must live under parent rules + if (draft.poll.length > 0) { + for (let k = 0; k < draft.poll.length; k += 1) { + Contracts.update({ _id: draft.poll[k].contractId }, { + $set: { + closing: draft.closing, + rules: draft.rules, + constituency: draft.constituency, + constituencyEnabled: draft.constituencyEnabled, + }, + }); + } + } + // add reply to counter in contract if (draft.replyId) { // count @@ -602,5 +737,7 @@ export const removeContract = _remove; export const createDelegation = _newDelegation; export const getURLDate = _getURLDate; export const sendDelegationVotes = _sendDelegation; +export const createPoll = _createPoll; +export const removePoll = _removePoll; export const createContract = _createContract; export const getDelegationContract = _getDelegationContract; diff --git a/imports/startup/both/modules/User.js b/imports/startup/both/modules/User.js index 624aaf816..d74ae49e2 100644 --- a/imports/startup/both/modules/User.js +++ b/imports/startup/both/modules/User.js @@ -163,6 +163,9 @@ const _createUser = (data) => { console.log('does user exist?'); console.log(Meteor.user()); + // TODO - here we could add Meteor.settings check for webvotes bool + genesisTransaction(Meteor.user()._id); + return resolve(result); }); }); diff --git a/imports/startup/both/modules/metamask.js b/imports/startup/both/modules/metamask.js index 7864d0783..fa9b4a665 100644 --- a/imports/startup/both/modules/metamask.js +++ b/imports/startup/both/modules/metamask.js @@ -5,6 +5,7 @@ import { Router } from 'meteor/iron:router'; import { Session } from 'meteor/session'; import { $ } from 'meteor/jquery'; +import { Contracts } from '/imports/api/contracts/Contracts'; import { displayModal } from '/imports/ui/modules/modal'; import { transact } from '/imports/api/transactions/transaction'; import { displayNotice } from '/imports/ui/modules/notice'; @@ -14,6 +15,7 @@ import { Transactions } from '/imports/api/transactions/Transactions'; import abi from 'human-standard-token-abi'; +import { debug } from 'util'; const Web3 = require('web3'); const ethUtil = require('ethereumjs-util'); @@ -185,8 +187,8 @@ const _delegate = (sourceId, targetId, contractId, hash, value) => { tickets: [{ hash, status: 'PENDING', - value - }] + value, + }], }); Meteor.users.update({ _id: sourceId }, { $set: { profile: source.profile }}); @@ -201,15 +203,14 @@ const _delegate = (sourceId, targetId, contractId, hash, value) => { tickets: [{ hash, status: 'PENDING', - value - }] + value, + }], }); console.log(target); Meteor.users.update({ _id: targetId }, { $set: { profile: target.profile }}); } -} - +}; /** * @summary send crypto with mask; @@ -329,49 +330,234 @@ const _transactWithMetamask = (from, to, quantity, tokenCode, contractAddress, s } }; -if (Meteor.isClient) { - const handleSignMessage = (publicAddress) => { - return new Promise((resolve, reject) => { - web3.eth.personal.sign( - web3.utils.utf8ToHex(`${TAPi18n.__('metamask-sign-nonce').replace('{{collectiveName}}', Meteor.settings.public.Collective.name)}`), - publicAddress, - function (err, signature) { - if (err) return reject(err); - return resolve({ signature }); - } - ); - }); - }; - const verifySignature = (signature, publicAddress) => { - const msg = `${TAPi18n.__('metamask-sign-nonce').replace('{{collectiveName}}', Meteor.settings.public.Collective.name)}`; - let res; - - // Perform an elliptic curve signature verification with ecrecover - const msgBuffer = ethUtil.toBuffer(msg); - const msgHash = ethUtil.hashPersonalMessage(msgBuffer); - const signatureBuffer = ethUtil.toBuffer(signature.signature); - const signatureParams = ethUtil.fromRpcSig(signatureBuffer); - const publicKey = ethUtil.ecrecover( - msgHash, - signatureParams.v, - signatureParams.r, - signatureParams.s +const handleSignMessage = (publicAddress, nonce, message) => { + return new Promise((resolve, reject) => { + web3.eth.personal.sign( + web3.utils.utf8ToHex(`${message}`), + publicAddress, + function (err, signature) { + if (err) return reject(err); + return resolve({ signature }); + } ); - const addressBuffer = ethUtil.publicToAddress(publicKey); - const address = ethUtil.bufferToHex(addressBuffer); + }); +}; + +const verifySignature = (signature, publicAddress, nonce, message) => { + let msg; + console.log(message); + if (!message) { + msg = `${TAPi18n.__('metamask-sign-nonce').replace('{{collectiveName}}', Meteor.settings.public.Collective.name)}`; + } else { + msg = message; + } + let res; + + // Perform an elliptic curve signature verification with ecrecover + const msgBuffer = ethUtil.toBuffer(msg); + const msgHash = ethUtil.hashPersonalMessage(msgBuffer); + const signatureBuffer = ethUtil.toBuffer(signature.signature); + const signatureParams = ethUtil.fromRpcSig(signatureBuffer); + const publicKey = ethUtil.ecrecover( + msgHash, + signatureParams.v, + signatureParams.r, + signatureParams.s + ); + const addressBuffer = ethUtil.publicToAddress(publicKey); + const address = ethUtil.bufferToHex(addressBuffer); + + // The signature verification is successful if the address found with + // ecrecover matches the initial publicAddress + console.log(`address.toLowerCase(): ${address.toLowerCase()}`); + console.log(`publicAddress.toLowerCase(): ${publicAddress.toLowerCase()}`); + + if (address.toLowerCase() === publicAddress.toLowerCase()) { + return 'success'; + } + return res + .status(401) + .send({ error: TAPi18n.__('metamask-sign-fail') }); +}; - // The signature verification is successful if the address found with - // ecrecover matches the initial publicAddress - if (address.toLowerCase() === publicAddress.toLowerCase()) { - return 'success'; +/** +* @summary persist in db the coin vote +* @param {string} from blockchain address +* @param {string} to blockchain destination +* @param {string} tokenCode currency +* @param {string} value the value +* @param {string} sourceId sender in sovereign +* @param {string} targetId receiver in sovereign +* @param {string} delegateId user to delegate into +*/ +const _transactCoinVote = (sourceId, targetId, tokenCode, from, to, value, delegateId, publicAddress) => { + console.log(value); + transact( + sourceId, + targetId, + 0, + { + currency: tokenCode, + status: 'PENDING', + kind: 'CRYPTO', + contractId: targetId, + input: { + address: from, + }, + output: { + address: to, + }, + blockchain: { + tickets: [{ + hash: publicAddress, + status: 'CONFIRMED', + value, + }], + coin: { + code: tokenCode, + }, + }, + geo: Meteor.user().profile.country ? Meteor.user().profile.country.code : '', + }, + () => { + _delegate(Meteor.userId(), delegateId, targetId, publicAddress, value); + displayModal(false, modal); + displayNotice(`${TAPi18n.__('transaction-tally').replace('{{token}}', tokenCode)}`, true, true); + } + ); +}; + +/** +* @summary do a coinvote with account stake +* @param {string} from blockchain address +* @param {string} to blockchain destination +* @param {string} quantity amount transacted +* @param {string} tokenCode currency +* @param {string} contractAddress +* @param {string} sourceId sender in sovereign +* @param {string} targetId receiver in sovereign +* @param {string} delegateId user to delegate into +*/ +const _coinvote = (from, to, quantity, tokenCode, contractAddress, sourceId, targetId, delegateId, choice, url) => { + if (_web3(true)) { + const nonce = Math.floor(Math.random() * 10000); + let publicAddress; + + let message = TAPi18n.__('coinvote-signature'); + message = message.replace('{{quantity}}', quantity); + message = message.replace('{{ticker}}', tokenCode); + message = message.replace('{{choice}}', choice); + message = message.replace('{{url}}', url); + + let value; + if (tokenCode === 'ETH') { + value = web3.utils.toWei(quantity, _convertToEther(tokenCode)).toString(); + } else { + const quantityWithDecimals = addDecimal(quantity.toNumber(), 18).toString(); + value = quantityWithDecimals; } - return res - .status(401) - .send({ error: TAPi18n.__('metamask-sign-fail') }); - }; + if (Meteor.Device.isPhone()) { + return web3.eth.getCoinbase().then(function (coinbaseAddress) { + publicAddress = coinbaseAddress.toLowerCase(); + return handleSignMessage(publicAddress, nonce, message); + }).then(function (signature) { + const verification = verifySignature(signature, publicAddress, nonce, message); + + if (verification === 'success') { + _transactCoinVote(sourceId, targetId, tokenCode, from, to, value, delegateId, publicAddress); + } else { + console.log(TAPi18n.__('metamask-login-error')); + } + }); + } + + // Support privacy-mode in desktop only for now + window.ethereum.enable().then(function () { + return web3.eth.getCoinbase(); + }).then(function (coinbaseAddress) { + publicAddress = coinbaseAddress.toLowerCase(); + return handleSignMessage(publicAddress, nonce, message); + }).then(function (signature) { + const verification = verifySignature(signature, publicAddress, nonce, message); + + if (verification === 'success') { + _transactCoinVote(sourceId, targetId, tokenCode, from, to, value, delegateId, publicAddress); + } else { + console.log(TAPi18n.__('metamask-login-error')); + } + }) + .catch((e) => { + displayModal(false, modal); + displayNotice(`${TAPi18n.__('transaction-tally-denied').replace('{{token}}', tokenCode)}`, true, true); + }); + } else { + modal.message = TAPi18n.__('metamask-activate'); + displayModal(true, modal); + } +}; + +const _scanCoinVote = (contract) => { + if (contract.blockchain && contract.blockchain.tickets) { + for (let i = 0; i < contract.blockchain.tickets.length; i += 1) { + for (let k = 0; k < Meteor.user().profile.wallet.reserves.length; k += 1) { + if (contract.blockchain.tickets[i].hash === Meteor.user().profile.wallet.reserves[k].publicAddress) { + return true; + } + } + } + } + return false; +}; +/** +* @summary checks if user didn't coinvoted already +* @param {object} contract with the settings +* @return {boolean} if voted already or not +*/ +const _verifyCoinVote = (contract) => { + let poll; + let check; + if (contract.rules && contract.rules.balanceVoting) { + if (contract.poll && contract.poll.length > 0) { + for (let i = 0; i < contract.poll.length; i += 1) { + poll = Contracts.findOne({ _id: contract.poll[i].contractId }); + if (poll) { + check = _scanCoinVote(poll); + if (check) { break; } + } + } + return check; + } + return _scanCoinVote(contract); + } + return false; +}; + + +/** +* @summary get current height of the blockchain +*/ +const _getBlockHeight = async () => { + let height = 0; + if (_web3()) { + height = await web3.eth.isSyncing().then( + async (res) => { + if (!res) { + return await web3.eth.getBlockNumber().then((blockNumber) => { + return blockNumber; + }); + } + return false; + } + ); + } + return height; +}; + + +if (Meteor.isClient) { /** * @summary log in signing public blockchain address with private key */ @@ -379,14 +565,13 @@ if (Meteor.isClient) { if (_web3(false)) { const nonce = Math.floor(Math.random() * 10000); let publicAddress; - Session.set('newLogin', true); if (Meteor.Device.isPhone()) { // When mobile, not supporting privacy-mode for now // https://github.com/DemocracyEarth/sovereign/issues/421 return web3.eth.getCoinbase().then(function (coinbaseAddress) { publicAddress = coinbaseAddress.toLowerCase(); - return handleSignMessage(publicAddress, nonce); + return handleSignMessage(publicAddress, nonce, TAPi18n.__('metamask-sign-nonce').replace('{{collectiveName}}', Meteor.settings.public.Collective.name)); }).then(function (signature) { const verification = verifySignature(signature, publicAddress, nonce); @@ -403,6 +588,7 @@ if (Meteor.isClient) { methodName, methodArguments, }); + Session.set('newLogin', true); Router.go('/'); }, }); @@ -417,7 +603,7 @@ if (Meteor.isClient) { return web3.eth.getCoinbase(); }).then(function (coinbaseAddress) { publicAddress = coinbaseAddress.toLowerCase(); - return handleSignMessage(publicAddress, nonce); + return handleSignMessage(publicAddress, nonce, TAPi18n.__('metamask-sign-nonce').replace('{{collectiveName}}', Meteor.settings.public.Collective.name)); }).then(function (signature) { const verification = verifySignature(signature, publicAddress, nonce); @@ -493,7 +679,10 @@ if (Meteor.isServer) { } export const transactWithMetamask = _transactWithMetamask; +export const coinvote = _coinvote; export const getTransactionStatus = _getTransactionStatus; export const setupWeb3 = _web3; export const syncBlockchain = _syncBlockchain; export const hideLogin = _hideLogin; +export const getBlockHeight = _getBlockHeight; +export const verifyCoinVote = _verifyCoinVote; diff --git a/imports/startup/both/routes.js b/imports/startup/both/routes.js index c4d6e854f..70292c829 100644 --- a/imports/startup/both/routes.js +++ b/imports/startup/both/routes.js @@ -119,10 +119,6 @@ Router.route('/', { }, onAfterAction() { _boilerPlate(); - if (!setupWeb3(false)) { - // If user is not logged in to Metamask then logout of Sovereign - Meteor.logout(); - } }, }); diff --git a/imports/startup/server/accounts/accounts.js b/imports/startup/server/accounts/accounts.js index 34d4cdb19..54b2c2620 100644 --- a/imports/startup/server/accounts/accounts.js +++ b/imports/startup/server/accounts/accounts.js @@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { _ } from 'meteor/underscore'; import { Accounts } from 'meteor/accounts-base'; +import { rules } from '/lib/const'; import { convertToSlug } from '/lib/utils'; import { deburr, toLower, camelCase } from 'lodash'; @@ -138,8 +139,12 @@ function normalizeMetamaskUser(profile, user) { const anonymousUser = 'anonymous' + publicAddress.slice(0,7); const username = generateAvailableUsername(deburr(toLower(camelCase(anonymousUser)))); + // other option is to web votes here const walletInit = { - currency: 'ETH', + currency: 'WEB VOTE', + balance: rules.VOTES_INITIAL_QUANTITY, + placed: 0, + available: rules.VOTES_INITIAL_QUANTITY, reserves: [{ balance: 0, placed: 0, diff --git a/imports/ui/.DS_Store b/imports/ui/.DS_Store deleted file mode 100644 index 6cc8000c5..000000000 Binary files a/imports/ui/.DS_Store and /dev/null differ diff --git a/imports/ui/css/earth.css b/imports/ui/css/earth.css index c1f252c8f..e253277ce 100755 --- a/imports/ui/css/earth.css +++ b/imports/ui/css/earth.css @@ -6290,6 +6290,7 @@ blockquote { .token-wrap { display: inline-block; margin-right: 10px; + margin-top: 5px; float: left; } diff --git a/imports/ui/modules/split.js b/imports/ui/modules/split.js index ae49342e2..704817b36 100644 --- a/imports/ui/modules/split.js +++ b/imports/ui/modules/split.js @@ -31,7 +31,9 @@ const _saveSplitSettings = (left, right) => { * @param {boolean} winResize if call is coming from a window resize */ const _resizeSplit = (diff, winResize) => { - if ($('.split-right') && $('.split-left')) { + if (!Meteor.settings.public.app.config.interface.showTransactions) { + $('.split-left').width('100%'); + } else if ($('.split-right') && $('.split-left')) { const contentWidth = $('.right').width(); const agoraWidth = parseInt((contentWidth * RIGHTHALF) - diff, 10); const contractWidth = parseInt((contentWidth * LEFTHALF) + diff, 10); @@ -44,7 +46,9 @@ const _resizeSplit = (diff, winResize) => { }; const _resetSplit = () => { - if ($('.split-right')) { + if (!Meteor.settings.public.app.config.interface.showTransactions) { + $('.split-left').width('100%'); + } else if ($('.split-right')) { event.preventDefault(); if ($(window).width() < gui.DESKTOP_MIN_WIDTH) { $('.split-left').width('100%'); diff --git a/imports/ui/templates/components/decision/balance/balance.html b/imports/ui/templates/components/decision/balance/balance.html index 957ba4fd9..e178b2db3 100644 --- a/imports/ui/templates/components/decision/balance/balance.html +++ b/imports/ui/templates/components/decision/balance/balance.html @@ -26,11 +26,5 @@ {{_ 'available'}} {{/unless}} - {{#unless disableBar}} -
-
-
-
- {{/unless}} diff --git a/imports/ui/templates/components/decision/balance/balance.js b/imports/ui/templates/components/decision/balance/balance.js index d0654c4f0..82a3f9c4c 100644 --- a/imports/ui/templates/components/decision/balance/balance.js +++ b/imports/ui/templates/components/decision/balance/balance.js @@ -67,6 +67,7 @@ Template.balance.onCreated(function () { Template.balance.helpers({ balanceStyle() { let style = ''; + Template.instance().coin = getCoin(Template.currentData().token); const coin = Template.instance().coin; if (coin.color) { style = `border-color: ${coin.color}; `; @@ -112,7 +113,14 @@ Template.balance.helpers({ return ''; }, ticker() { - return Template.instance().coin.code; + const label = Template.instance().coin.code; + /* if (this.rules && this.rules.quadraticVoting) { + label = `${TAPi18n.__('ticker-rule-quadratic')} ${label}`; + } + if (this.rules && this.rules.balanceVoting) { + label = `${label} ${TAPi18n.__('ticker-rule-balance')}`; + } */ + return label; }, available() { return _formatCryptoValue(this.available, this.token); @@ -122,6 +130,14 @@ Template.balance.helpers({ }, balance() { const instance = Template.instance(); + + if (this.token === 'WEB VOTE' && this.isButton) { + const contract = Contracts.findOne({ _id: this.contract._id }); + if (contract) { + const balance = _currencyValue(contract.wallet.available, this.token); + return numeral(balance).format(Template.instance().coin.format); + } + } if (this.blockchain && this.isButton) { if (this.contract._id) { const contract = Contracts.findOne({ _id: this.contract._id }); @@ -142,6 +158,10 @@ Template.balance.helpers({ const coinData = getCoin(instance.coin.code); return _formatCryptoValue(removeDecimal(this.value, coinData.decimals).toNumber(), instance.coin.code); } + if (this.token === 'WEB VOTE' && !this.blockchain) { + const balance = _currencyValue(this.available, this.token); + return numeral(balance).format(Template.instance().coin.format); + } const balance = _currencyValue(this.balance, this.token); return numeral(balance).format(Template.instance().coin.format); }, @@ -171,6 +191,10 @@ Template.balance.helpers({ } return ''; }, + disableBar() { + console.log(`this.disableBar: ${this.disableBar}`); + return this.disableBar; + }, }); export const formatCryptoValue = _formatCryptoValue; diff --git a/imports/ui/templates/components/decision/ballot/ballot.html b/imports/ui/templates/components/decision/ballot/ballot.html index 498cd6fd9..a92ef8a76 100644 --- a/imports/ui/templates/components/decision/ballot/ballot.html +++ b/imports/ui/templates/components/decision/ballot/ballot.html @@ -1,111 +1,159 @@ diff --git a/imports/ui/templates/components/decision/ballot/ballot.js b/imports/ui/templates/components/decision/ballot/ballot.js index 43debf8b4..bea3b0cb1 100644 --- a/imports/ui/templates/components/decision/ballot/ballot.js +++ b/imports/ui/templates/components/decision/ballot/ballot.js @@ -5,91 +5,168 @@ import { Session } from 'meteor/session'; import { ReactiveVar } from 'meteor/reactive-var'; import { TAPi18n } from 'meteor/tap:i18n'; import { Router } from 'meteor/iron:router'; +import { BigNumber } from 'bignumber.js'; import { removeFork, updateBallotRank, addChoiceToBallot, getTickValue, getTotalVoters } from '/imports/ui/modules/ballot'; import { getContractToken } from '/imports/ui/templates/widgets/transaction/transaction'; +import { transact } from '/imports/api/transactions/transaction'; import { displayTimedWarning } from '/lib/utils'; import { Contracts } from '/imports/api/contracts/Contracts'; import { timers } from '/lib/const'; import { verifyConstituencyRights, getTokenAddress, getTokenContractAddress, checkTokenAvailability } from '/imports/ui/templates/components/decision/electorate/electorate.js'; import { introEditor } from '/imports/ui/templates/widgets/compose/compose'; import { createContract } from '/imports/startup/both/modules/Contract'; -import { transactWithMetamask, setupWeb3 } from '/imports/startup/both/modules/metamask'; +import { transactWithMetamask, setupWeb3, coinvote, verifyCoinVote } from '/imports/startup/both/modules/metamask'; import { displayModal } from '/imports/ui/modules/modal'; import { templetize, getImage } from '/imports/ui/templates/layout/templater'; +import { currentBlock, isPollOpen } from '/imports/ui/templates/components/decision/countdown/countdown'; +import { getBalance } from '/imports/api/blockchain/modules/web3Util'; import '/imports/ui/templates/components/decision/ballot/ballot.html'; import '/imports/ui/templates/components/decision/fork/fork.js'; import '/imports/ui/templates/components/decision/liquid/liquid.js'; import '/imports/ui/templates/widgets/warning/warning.js'; + +const numeral = require('numeral'); + +/** +* @summary reject vote message; +*/ +const _rejectVote = () => { + if (!checkTokenAvailability(Meteor.user(), Template.instance().ticket.get().token) && Template.instance().ticket.get().token !== 'WEB VOTE') { + // lack of token + displayModal( + true, + { + icon: 'images/olive.png', + title: TAPi18n.__('place-vote'), + message: TAPi18n.__('insufficient-votes'), + action: TAPi18n.__('get-tokens'), + cancel: TAPi18n.__('not-now'), + }, + () => { + window.open(Meteor.settings.public.web.sites.tokens, '_blank'); + } + ); + } else { + // wrong requisites + displayModal( + true, + { + icon: 'images/olive.png', + title: TAPi18n.__('place-vote'), + message: TAPi18n.__('incompatible-requisites'), + cancel: TAPi18n.__('close'), + alertMode: true, + }, + ); + } +}; + +/** +* @summary poll no longer open; +*/ +const _pollClosed = () => { + // poll already closed + displayModal( + true, + { + icon: 'images/olive.png', + title: TAPi18n.__('poll-closed'), + message: TAPi18n.__('poll-is-closed'), + cancel: TAPi18n.__('close'), + alertMode: true, + }, + ); +}; + +/** +* @summary already voted here +*/ +const _alreadyVoted = () => { + // poll already closed + displayModal( + true, + { + icon: 'images/olive.png', + title: TAPi18n.__('already-voted'), + message: TAPi18n.__('already-voted-detail'), + cancel: TAPi18n.__('close'), + alertMode: true, + }, + ); +}; + /** * @summary executes token vote */ const _cryptoVote = () => { - Template.instance().voteEnabled = verifyConstituencyRights(Template.currentData().contract); + const contract = Template.currentData().contract; + Template.instance().voteEnabled = verifyConstituencyRights(contract); if (Meteor.user()) { if (Template.instance().voteEnabled) { - if (setupWeb3(true)) { - // wallet alert - let icon; - if (Meteor.settings.public.Collective.profile.logo) { - icon = Meteor.settings.public.Collective.profile.logo; + if (isPollOpen(Template.instance().now.get(), contract)) { + if (!verifyCoinVote(contract.pollId ? Contracts.findOne({ _id: contract.pollId }) : contract)) { + if (setupWeb3(true)) { + // wallet alert + + let icon; + if (Meteor.settings.public.Collective.profile.logo) { + icon = Meteor.settings.public.Collective.profile.logo; + } else { + icon = 'images/olive.png'; + } + displayModal( + true, + { + icon, + title: TAPi18n.__('place-vote'), + message: contract.rules.balanceVoting ? TAPi18n.__('metamask-confirm-tally') : TAPi18n.__('metamask-confirm-transaction'), + cancel: TAPi18n.__('close'), + awaitMode: true, + displayProfile: true, + profileId: Template.currentData().contract.signatures[0]._id, + }, + ); + + if (contract.rules.balanceVoting) { + // off chain vote + coinvote( + getTokenAddress(Meteor.user(), Template.instance().ticket.get().token), + Template.currentData().contract.blockchain.publicAddress, + getBalance(Meteor.user(), Template.currentData().contract), + Template.instance().ticket.get().token, + getTokenContractAddress(Template.instance().ticket.get().token), + Meteor.userId(), + Template.currentData().contract._id, + Template.currentData().contract.signatures[0]._id, + contract.title, + `${(window.location.origin)}${contract.url}`, + ); + } else { + // on chain vote + transactWithMetamask( + getTokenAddress(Meteor.user(), Template.instance().ticket.get().token), + Template.currentData().contract.blockchain.publicAddress, + Template.currentData().contract.blockchain.votePrice, + Template.instance().ticket.get().token, + getTokenContractAddress(Template.instance().ticket.get().token), + Meteor.userId(), + Template.currentData().contract._id, + Template.currentData().contract.signatures[0]._id, + + ); + } + } } else { - icon = 'images/olive.png'; + _alreadyVoted(); } - displayModal( - true, - { - icon, - title: TAPi18n.__('place-vote'), - message: TAPi18n.__('metamask-confirm-transaction'), - cancel: TAPi18n.__('close'), - awaitMode: true, - displayProfile: true, - profileId: Template.currentData().contract.signatures[0]._id, - }, - ); - - // prompt metamask - transactWithMetamask( - getTokenAddress(Meteor.user(), Template.instance().ticket.get().token), - Template.currentData().contract.blockchain.publicAddress, - Template.currentData().contract.blockchain.votePrice, - Template.instance().ticket.get().token, - getTokenContractAddress(Template.instance().ticket.get().token), - Meteor.userId(), - Template.currentData().contract._id, - Template.currentData().contract.signatures[0]._id, - ); + } else { + _pollClosed(); } - } else if (!checkTokenAvailability(Meteor.user(), Template.instance().ticket.get().token)) { - // lack of token - displayModal( - true, - { - icon: 'images/olive.png', - title: TAPi18n.__('place-vote'), - message: TAPi18n.__('insufficient-votes'), - action: TAPi18n.__('get-tokens'), - cancel: TAPi18n.__('not-now'), - }, - () => { - window.open(Meteor.settings.public.web.sites.tokens, '_blank'); - } - ); } else { - // wrong requisites - displayModal( - true, - { - icon: 'images/olive.png', - title: TAPi18n.__('place-vote'), - message: TAPi18n.__('incompatible-requisites'), - cancel: TAPi18n.__('close'), - alertMode: true, - }, - ); + _rejectVote(); } } else { // not logged @@ -106,6 +183,23 @@ const _cryptoVote = () => { } }; +/** +* @summary checks if a given user voted on this contract +* @param {object} contract to get data from +* @param {string} userId to check +* @return ticker string +*/ +const _checkUserVoted = (contract, userId) => { + switch (contract && contract.blockchain.coin.code) { + case 'WEB VOTE': + return _.contains(_.pluck(contract.tally.voter, '_id'), userId); + default: + if (contract.rules && contract.rules.balanceVoting) { + return verifyCoinVote(contract); + } + } +}; + /** * @summary composes url to share stuff on twitter * @param {object} contract to get data from @@ -269,6 +363,14 @@ function activateDragging() { }).disableSelection(); } +/* +const _getPollSstatus = async (contract) => { + const pollOpen = await isPollOpen(contract); + console.log(`pollOpen: ${pollOpen}`); + return pollOpen; +}; +*/ + Template.ballot.onCreated(() => { Template.instance().forks = _generateForks(this.contract); Template.instance().emptyBallot = new ReactiveVar(); @@ -277,12 +379,19 @@ Template.ballot.onCreated(() => { Template.instance().contract = new ReactiveVar(Template.currentData().contract); Template.instance().ticket = new ReactiveVar(getContractToken({ contract: Template.currentData().contract, isButton: true })); Template.instance().voteEnabled = verifyConstituencyRights(Template.currentData().contract); + Template.instance().pollScore = new ReactiveVar(0); Template.instance().imageTemplate = new ReactiveVar(); templetize(Template.instance()); + + Template.instance().now = new ReactiveVar(); + currentBlock(Template.instance()); }); Template.ballot.helpers({ + poll() { + return this.poll; + }, allowForks() { return this.contract.allowForks; }, @@ -560,10 +669,50 @@ Template.ballot.helpers({ return label; }, token() { - return Template.instance().ticket.get(); + Template.instance().ticket.set(getContractToken({ contract: Template.currentData().contract, isButton: true })); + const instance = Template.instance(); + const ticket = instance.ticket.get(); + ticket.rules = this.contract.rules; + return ticket; + }, + hasPoll() { + return (this.contract.poll && this.contract.poll.length > 0); + }, + pollScore() { + // color + let score = ''; + + // score + let choiceVotes; + if (this.pollTotals) { + switch (this.contract.blockchain.coin.code) { + case 'WEB VOTE': + choiceVotes = 0; + for (let k = 0; k < this.contract.tally.voter.length; k += 1) { + choiceVotes += this.contract.tally.voter[k].votes; + } + break; + default: + choiceVotes = this.contract.blockchain.score ? this.contract.blockchain.score.totalConfirmed : '0'; + } + } + const bnVotes = new BigNumber(choiceVotes); + const bnTotal = new BigNumber(this.pollTotals); + let percentage; + // eslint-disable-next-line eqeqeq + if (bnTotal != 0) { + percentage = new BigNumber(bnVotes.multipliedBy(100)).dividedBy(bnTotal); + } else { + percentage = 0; + } + Template.instance().pollScore.set(percentage); + score = `${numeral(percentage).format('0.00')}%`; + + return score; }, tokenFriendly() { - return Template.instance().ticket.get().token !== 'NONE'; + // return Template.instance().ticket.get().token !== 'NONE'; + return true; }, castSingleVote() { return (Session.get('castSingleVote') === this.contract.keyword); @@ -616,6 +765,34 @@ Template.ballot.helpers({ getImage(pic) { return getImage(Template.instance().imageTemplate.get(), pic); }, + checkSelected(element) { + const contract = Contracts.findOne({ _id: this.contract._id }); + if (_checkUserVoted(contract, Meteor.userId())) { + return `check-mini-selected-${element}`; + } + return `check-mini-unselected-${element}`; + }, + removableVotes() { + const contract = Contracts.findOne({ _id: this.contract._id }); + if (_checkUserVoted(contract, Meteor.userId())) { + return (contract.blockchain.coin.code === 'WEB VOTE'); + } + return false; + }, + firstPollChoice() { + const contract = Contracts.findOne({ _id: this.contract._id }); + if (contract.poll && contract.poll.length > 0) { + const choice = Contracts.findOne({ _id: contract.poll[0].contractId }); + return choice; + } + return contract; + }, + smallPercentageStyle() { + if (Template.instance().pollScore.get() < 10) { + return 'poll-score-small'; + } + return ''; + }, }); Template.ballot.events({ @@ -623,31 +800,84 @@ Template.ballot.events({ event.preventDefault(); event.stopPropagation(); const currency = Template.currentData().contract.wallet.currency; - if (currency === 'NONE') { - displayModal( - true, - { - icon: 'images/olive.png', - title: TAPi18n.__('place-vote'), - message: TAPi18n.__('tokenless-post'), - cancel: TAPi18n.__('close'), - alertMode: true, - }, - ); - } else if (currency === 'STX') { - displayModal( - true, - { - icon: 'images/olive.png', - title: TAPi18n.__('place-vote'), - message: TAPi18n.__('insufficient-votes'), - cancel: TAPi18n.__('close'), - alertMode: true, - }, - ); - } else { - // ERC20 token - _cryptoVote(); + if (!this.editorMode) { + const contractData = Template.currentData().contract; + // contract in which to evaluate if it can vote + let contract = Template.currentData().contract; + if (contract.poll.length > 0) { + contract = Contracts.findOne({ _id: contract.poll[0].contractId }); + } + Template.instance().voteEnabled = verifyConstituencyRights(contract); + + if (Meteor.user()) { + if (Template.instance().voteEnabled) { + if (isPollOpen(Template.instance().now.get(), contractData)) { + if (currency === 'WEB VOTE') { + const userId = Meteor.user()._id; + const _contractId = contractData._id; + const voteAmount = 1; // Template.currentData().voteAmount or something similar + + const transactSettings = { + kind: 'VOTE', + currency: 'WEB VOTE', + contractId: _contractId, + quadraticVoting: contractData.rules.quadraticVoting, + }; + + transact(userId, _contractId, voteAmount, transactSettings, undefined); + } else if (currency === 'STX') { + displayModal( + true, + { + icon: 'images/olive.png', + title: TAPi18n.__('place-vote'), + message: TAPi18n.__('insufficient-votes'), + cancel: TAPi18n.__('close'), + alertMode: true, + }, + ); + } else { + // ERC20 token + _cryptoVote(); + } + } else { + _pollClosed(); + } + } else { + _rejectVote(); + } + } else { + // not logged + displayModal( + true, + { + icon: 'images/olive.png', + title: TAPi18n.__('place-vote'), + message: TAPi18n.__('unlogged-cant-vote'), + cancel: TAPi18n.__('close'), + alertMode: true, + }, + ); + } + } + }, + 'click #single-remove'(event) { + event.preventDefault(); + event.stopPropagation(); + const currency = Template.currentData().contract.wallet.currency; + if (currency === 'WEB VOTE') { + const userId = Meteor.user()._id; + const _contractId = Template.currentData().contract._id; + const voteAmount = 1; + + const transactSettings = { + kind: 'VOTE', + currency: 'WEB VOTE', + contractId: _contractId, + quadraticVoting: Template.currentData().contract.rules.quadraticVoting, + }; + + transact(_contractId, userId, voteAmount, transactSettings, undefined); } }, 'click #edit-reply'(event) { diff --git a/imports/ui/templates/components/decision/blockchain/blockchain.html b/imports/ui/templates/components/decision/blockchain/blockchain.html index 984dc99aa..0f3c3d647 100644 --- a/imports/ui/templates/components/decision/blockchain/blockchain.html +++ b/imports/ui/templates/components/decision/blockchain/blockchain.html @@ -1,8 +1,11 @@ diff --git a/imports/ui/templates/components/decision/blockchain/blockchain.js b/imports/ui/templates/components/decision/blockchain/blockchain.js index 1d18ab640..6fabeede5 100644 --- a/imports/ui/templates/components/decision/blockchain/blockchain.js +++ b/imports/ui/templates/components/decision/blockchain/blockchain.js @@ -3,12 +3,13 @@ import { Template } from 'meteor/templating'; import { Session } from 'meteor/session'; import { $ } from 'meteor/jquery'; import { TAPi18n } from 'meteor/tap:i18n'; +import { ReactiveVar } from 'meteor/reactive-var'; import { displayPopup, animatePopup } from '/imports/ui/modules/popup'; -import { getContractToken } from '/imports/ui/templates/widgets/transaction/transaction'; import { getCoin } from '/imports/api/blockchain/modules/web3Util.js'; import { token } from '/lib/token'; import { formatCryptoValue } from '/imports/ui/templates/components/decision/balance/balance'; +import { templetize, getImage } from '/imports/ui/templates/layout/templater'; import '/imports/ui/templates/components/decision/blockchain/blockchain.html'; @@ -83,6 +84,7 @@ const _writeRule = (contract, textOnly) => { } let sentence = TAPi18n.__(`electorate-sentence-anyone${format}`); let setting; + if (contract.constituency) { sentence = TAPi18n.__(`electorate-sentence-only${format}`); let coin; @@ -123,6 +125,9 @@ Template.blockchain.onCreated(() => { } Session.set('showCoinSettings', false); Template.instance().voteEnabled = _verifyConstituencyRights(contract); + + Template.instance().imageTemplate = new ReactiveVar(); + templetize(Template.instance()); }); const _toggleCoinSettings = () => { @@ -149,6 +154,15 @@ Template.blockchain.onRendered(function () { } } }); + + instance.autorun(function () { + $('.right').scroll(() => { + if (Session.get('showCoinSettings')) { + Session.set('showCoinSettings', false); + animatePopup(false, 'blockchain-popup'); + } + }); + }); }); Template.blockchain.helpers({ @@ -164,11 +178,20 @@ Template.blockchain.helpers({ } return ''; }, + pollInside() { + const contract = Session.get('draftContract'); + return (contract.rules && contract.rules.pollVoting); + }, ticker() { - if (Meteor.user().profile.wallet.reserves) { - return Session.get('draftContract').wallet.currency; + const contract = Session.get('draftContract'); + const label = contract.wallet.currency; + /* if (contract.rules && contract.rules.quadraticVoting) { + label = `${TAPi18n.__('ticker-rule-quadratic')} ${label}`; } - return `${TAPi18n.__('no-tokens')}`; + if (contract.rules && contract.rules.balanceVoting) { + label = `${label} ${TAPi18n.__('ticker-rule-balance')}`; + }*/ + return label; }, tickerStyle() { let color; @@ -216,6 +239,14 @@ Template.blockchain.helpers({ check() { return Template.instance().voteEnabled; }, + getImage() { + if (!this.readOnly) { + if (Session.get('showCoinSettings')) { + return getImage(Template.instance().imageTemplate.get(), 'vote-active'); + } + } + return getImage(Template.instance().imageTemplate.get(), 'vote-enabled'); + }, icon() { if (!this.readOnly) { if (Session.get('showCoinSettings')) { @@ -240,7 +271,7 @@ Template.blockchain.helpers({ Template.blockchain.events({ 'click #blockchain-button'() { - if (!this.readOnly && Meteor.user().profile.wallet.reserves !== undefined) { + if (!this.readOnly) { killPopup(); } }, diff --git a/imports/ui/templates/components/decision/calendar/calendar.html b/imports/ui/templates/components/decision/calendar/calendar.html new file mode 100644 index 000000000..d909f60a9 --- /dev/null +++ b/imports/ui/templates/components/decision/calendar/calendar.html @@ -0,0 +1,35 @@ + diff --git a/imports/ui/templates/components/decision/calendar/calendar.js b/imports/ui/templates/components/decision/calendar/calendar.js new file mode 100644 index 000000000..4a26f8e87 --- /dev/null +++ b/imports/ui/templates/components/decision/calendar/calendar.js @@ -0,0 +1,151 @@ +import { Session } from 'meteor/session'; +import { Template } from 'meteor/templating'; +import { animatePopup } from '/imports/ui/modules/popup'; + +import { blocktimes } from '/lib/const'; +import { token } from '/lib/token'; +import { getBlockHeight } from '/imports/startup/both/modules/metamask.js'; + +import { TAPi18n } from 'meteor/tap:i18n'; + +import '/imports/ui/templates/components/decision/calendar/calendar.html'; +import '/imports/ui/templates/widgets/switcher/switcher.js'; + +const _save = () => { + const draft = Session.get('draftContract'); + const cache = Session.get('cachedDraft'); + + draft.closingm = cache.closing; + draft.rules.alwaysOn = cache.rules.alwaysOn; + + Session.set('draftContract', cache); +}; + +/** +* @summary sets the block time configuration for closing in cached contract +* @param {number} blocks length in blockchain +*/ +const _setBlockTime = async (blocks) => { + const cache = Session.get('cachedDraft'); + const blockHeight = await getBlockHeight(); + let days = 0; + + if (blockHeight) { + cache.closing.delta = blocks; + cache.closing.height = blockHeight + blocks; + + switch (blocks) { + case blocktimes.ETHEREUM_DAY: + days = 1; + break; + case blocktimes.ETHEREUM_WEEK: + days = 7; + break; + case blocktimes.ETHEREUM_MONTH: + days = 30; + break; + case blocktimes.ETHEREUM_QUARTER: + days = 90; + break; + case blocktimes.ETHEREUM_YEAR: + default: + days = 365; + } + const today = new Date(); + cache.closing.calendar = today.setDate(today.getDate() + days); + } + + Session.set('cachedDraft', cache); +}; + +Template.calendar.onRendered(function () { + const instance = Template.instance(); + Session.set('cachedDraft', Session.get('draftContract')); + + // default setting + if (Session.get('cachedDraft') && Session.get('cachedDraft').closing.delta === 0) { + _setBlockTime(blocktimes.ETHEREUM_WEEK); + } + + window.addEventListener('click', function (e) { + if (document.getElementById('card-calendar-popup') && !document.getElementById('card-calendar-popup').contains(e.target)) { + if (!instance.data.readOnly) { + Session.set('showClosingEditor', false); + animatePopup(false, 'calendar-popup'); + } + } + }); +}); + +Template.calendar.helpers({ + alwaysOn() { + return (Session.get('cachedDraft') && Session.get('cachedDraft').rules) ? Session.get('cachedDraft').rules.alwaysOn : false; + }, + criteria() { + const cache = Session.get('cachedDraft'); + if (cache && !cache.rules.alwaysOn) { + let criteria = TAPi18n.__('blockchain-time-closing-criteria'); + const result = _.where(token.coin, { code: cache.closing.blockchain }); + criteria = criteria.replace('{{blockchain}}', result[0].name); + criteria = criteria.replace('{{height}}', cache.closing.height.toLocaleString(undefined, [{ style: 'decimal' }])); + criteria = criteria.replace('{{date}}', new Date(cache.closing.calendar).format('{Month} {d}, {yyyy}')); + return criteria; + } + return TAPi18n.__('blockchain-time-always-on'); + }, + timers() { + const cache = Session.get('cachedDraft'); + return { + enabled: cache ? !cache.rules.alwaysOn : false, + option: [ + { + value: cache ? (cache.closing.delta === blocktimes.ETHEREUM_DAY) : false, + label: 'blockchain-time-daily', + action: () => { + _setBlockTime(blocktimes.ETHEREUM_DAY); + }, + }, + { + value: cache ? (cache.closing.delta === blocktimes.ETHEREUM_WEEK) : false, + label: 'blockchain-time-weekly', + action: () => { + _setBlockTime(blocktimes.ETHEREUM_WEEK); + }, + }, + { + value: cache ? (cache.closing.delta === blocktimes.ETHEREUM_MONTH) : false, + label: 'blockchain-time-monthly', + action: () => { + _setBlockTime(blocktimes.ETHEREUM_MONTH); + }, + }, + { + value: cache ? (cache.closing.delta === blocktimes.ETHEREUM_QUARTER) : false, + label: 'blockchain-time-quarterly', + action: () => { + _setBlockTime(blocktimes.ETHEREUM_QUARTER); + }, + }, + { + value: cache ? (cache.closing.delta === blocktimes.ETHEREUM_YEAR) : false, + label: 'blockchain-time-annual', + action: () => { + _setBlockTime(blocktimes.ETHEREUM_YEAR); + }, + }, + ], + }; + }, +}); + +Template.calendar.events({ + 'click #cancel-calendar'() { + animatePopup(false, 'calendar-popup'); + Session.set('showClosingEditor', false); + }, + 'click #execute-calendar'() { + _save(); + animatePopup(false, 'calendar-popup'); + Session.set('showClosingEditor', false); + }, +}); diff --git a/imports/ui/templates/components/decision/closing/closing.html b/imports/ui/templates/components/decision/closing/closing.html new file mode 100644 index 000000000..47c14b720 --- /dev/null +++ b/imports/ui/templates/components/decision/closing/closing.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/imports/ui/templates/components/decision/closing/closing.js b/imports/ui/templates/components/decision/closing/closing.js new file mode 100644 index 000000000..3a120174e --- /dev/null +++ b/imports/ui/templates/components/decision/closing/closing.js @@ -0,0 +1,109 @@ +import { Meteor } from 'meteor/meteor'; +import { $ } from 'meteor/jquery'; +import { Session } from 'meteor/session'; +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { TAPi18n } from 'meteor/tap:i18n'; + +import { blocktimes } from '/lib/const'; +import { animatePopup, displayPopup } from '/imports/ui/modules/popup'; +import { templetize, getImage } from '/imports/ui/templates/layout/templater'; + +import '/imports/ui/templates/components/decision/closing/closing.html'; +import '/imports/ui/templates/components/decision/calendar/calendar.js'; + +const _killPopup = () => { + if (Session.get('showClosingEditor')) { + Session.set('showClosingEditor', false); + } else { + Session.set('showClosingEditor', true); + } + displayPopup($('#closing-button')[0], 'calendar', Meteor.userId(), 'click', 'calendar-popup'); +}; + +/** +* @summary the closing criteria for this post +* @return {string} closing rule +*/ +const _draftClosing = () => { + const draft = Session.get('draftContract'); + const closing = TAPi18n.__('closing-date'); + let status = closing; + + if (draft.rules.alwaysOn) { + status += ` · ${TAPi18n.__('always-on')}`; + } else { + switch (draft.closing.delta) { + case blocktimes.ETHEREUM_DAY: + status += ` · ${TAPi18n.__('blockchain-time-daily')}`; + break; + case blocktimes.ETHEREUM_WEEK: + status += ` · ${TAPi18n.__('blockchain-time-weekly')}`; + break; + case blocktimes.ETHEREUM_MONTH: + status += ` · ${TAPi18n.__('blockchain-time-monthly')}`; + break; + case blocktimes.ETHEREUM_QUARTER: + status += ` · ${TAPi18n.__('blockchain-time-quarterly')}`; + break; + case blocktimes.ETHEREUM_YEAR: + default: + status += ` · ${TAPi18n.__('blockchain-time-annual')}`; + break; + } + } + return status; +}; + +Template.closing.onCreated(function () { + Template.instance().imageTemplate = new ReactiveVar(); + templetize(Template.instance()); +}); + +Template.closing.onRendered(function () { + const instance = Template.instance(); + + instance.autorun(function () { + $('.right').scroll(() => { + if (Session.get('showClosingEditor')) { + Session.set('showClosingEditor', false); + animatePopup(false, 'calendar-popup'); + } + }); + }); +}); + +Template.closing.helpers({ + getImage(pic) { + if (Session.get('showClosingEditor')) { + return getImage(Template.instance().imageTemplate.get(), 'calendar-active'); + } + return getImage(Template.instance().imageTemplate.get(), pic); + }, + status() { + return _draftClosing(); + }, + closingId() { + if (!this.readOnly) { + return 'closing-button'; + } + return ''; + }, + icon() { + if (!this.readOnly) { + if (Session.get('showClosingEditor')) { + return 'active'; + } + } + return 'enabled'; + }, +}); + +Template.closing.events({ + 'click #closing-button'() { + if (!this.readOnly) { + _killPopup(); + } + }, +}); + diff --git a/imports/ui/templates/components/decision/coin/coin.html b/imports/ui/templates/components/decision/coin/coin.html index c6665f8d6..2f817e940 100644 --- a/imports/ui/templates/components/decision/coin/coin.html +++ b/imports/ui/templates/components/decision/coin/coin.html @@ -1,28 +1,57 @@ + diff --git a/imports/ui/templates/components/decision/coin/coin.js b/imports/ui/templates/components/decision/coin/coin.js index 1e282fd46..64a0874b7 100644 --- a/imports/ui/templates/components/decision/coin/coin.js +++ b/imports/ui/templates/components/decision/coin/coin.js @@ -1,12 +1,17 @@ import { Template } from 'meteor/templating'; import { Session } from 'meteor/session'; import { ReactiveVar } from 'meteor/reactive-var'; +import { Meteor } from 'meteor/meteor'; import { templetize, getImage } from '/imports/ui/templates/layout/templater'; import { animatePopup } from '/imports/ui/modules/popup'; import { searchJSON } from '/imports/ui/modules/JSON'; import { token } from '/lib/token'; +import { createPoll, removePoll } from '/imports/startup/both/modules/Contract'; +import { Contracts } from '/imports/api/contracts/Contracts'; +import { getCoin } from '/imports/api/blockchain/modules/web3Util'; +import '/imports/ui/templates/widgets/setting/setting.js'; import '/imports/ui/templates/components/decision/coin/coin.html'; const Web3 = require('web3'); @@ -29,6 +34,7 @@ const _save = () => { check: 'EQUAL', }); draft.wallet.currency = coin.code; + draft.blockchain.coin.code = coin.code; } if (draft.constituency.length === 0) { @@ -44,21 +50,52 @@ const _save = () => { } } + draft.rules = Session.get('cachedDraft').rules; + if (draft.rules && draft.rules.pollVoting === true) { + draft.poll = createPoll(draft).poll; + } else { + if (draft.rules && !draft.rules.pollVoting && draft.poll.length > 0) { + removePoll(draft); + } + draft.poll = []; + } + Session.set('draftContract', draft); }; +/** +* @summary checks if there's an issue with lockchain address +* @return {boolean} true or false baby +*/ +const _verifyBlockchainAddress = () => { + const draft = Session.get('draftContract'); + if (draft.blockchain.coin.code !== 'STX') { + // return Session.get('checkBlockchainAddress'); + if (document.getElementById('editBlockchainAddress') && document.getElementById('editBlockchainAddress').value) { + return !web3.utils.isAddress(document.getElementById('editBlockchainAddress').value); + } + } + return false; +}; + /** * @summary check form inputs are ok * @return {boolean} true or false baby */ const _checkInputs = () => { - return !(Session.get('noCoinFound') || Session.get('newCoin') === '' || (Session.get('draftContract').blockchain.publicAddress && !Session.get('checkBlockchainAddress'))); + return !(Session.get('noCoinFound') + || Session.get('newCoin') === '' + || (Session.get('draftContract').blockchain.publicAddress && !Session.get('checkBlockchainAddress')) + || (!Meteor.user().profile.wallet.reserves && Session.get('draftContract').blockchain.coin.code !== 'WEB VOTE') + || (_verifyBlockchainAddress() && Session.get('newCoin') && Session.get('newCoin').code !== 'WEB VOTE')); }; Template.coin.onCreated(() => { Session.set('showTokens', false); Session.set('suggestDisplay', ''); + Template.instance().currentCoin = new ReactiveVar(); + Template.instance().showAdvanced = new ReactiveVar(false); Template.instance().imageTemplate = new ReactiveVar(); templetize(Template.instance()); }); @@ -77,6 +114,14 @@ Template.coin.onRendered(function () { break; } } + + // let advancedSettings = false; + // _.find(Session.get('draftContract').rules, function (num) { if (num) { advancedSettings = true; } }); + // Template.instance().showAdvanced.set(advancedSettings); + Template.instance().showAdvanced.set((Session.get('draftContract').rules.quadraticVoting === true)); + + + Session.set('cachedDraft', Session.get('draftContract')); }); Template.coin.helpers({ @@ -91,7 +136,7 @@ Template.coin.helpers({ }, address() { const draft = Session.get('draftContract'); - if (draft.blockchain.publicAddress) { + if (draft.blockchain && draft.blockchain.publicAddress && Session.get('newCoin').code !== 'WEB VOTE') { Session.set('checkBlockchainAddress', web3.utils.isAddress(draft.blockchain.publicAddress)); return draft.blockchain.publicAddress; } @@ -104,22 +149,93 @@ Template.coin.helpers({ } return ''; }, - wrongAddress() { - const draft = Session.get('draftContract'); - if (draft.blockchain.coin.code !== 'STX') { - return !Session.get('checkBlockchainAddress'); + contract() { + return Session.get('draftContract'); + }, + balanceVoting() { + if (Session.get('newCoin')) { + const coin = getCoin(Session.get('newCoin').code); + if (!coin.editor.allowBalanceToggle) { + const cache = Session.get('cachedDraft'); + cache.rules.balanceVoting = false; + Session.set('cachedDraft', cache); + } + return Session.get('cachedDraft').rules ? (Session.get('cachedDraft').rules.balanceVoting && coin.editor.allowBalanceToggle) : false; + } + return false; + }, + quadraticVoting() { + if (Session.get('newCoin')) { + const coin = getCoin(Session.get('newCoin').code); + if (!coin.editor.allowQuadraticToggle) { + const cache = Session.get('cachedDraft'); + cache.rules.quadraticVoting = false; + Session.set('cachedDraft', cache); + } + return Session.get('cachedDraft').rules ? (Session.get('cachedDraft').rules.quadraticVoting && coin.editor.allowQuadraticToggle) : false; + } + return false; + }, + pollVoting() { + return (Session.get('cachedDraft') && Session.get('cachedDraft').rules) ? Session.get('cachedDraft').rules.pollVoting : false; + }, + allowBalance() { + if (Session.get('newCoin')) { + const coin = getCoin(Session.get('newCoin').code); + return coin.editor.allowBalanceToggle; } return false; }, + allowQuadratic() { + if (Session.get('newCoin')) { + const coin = getCoin(Session.get('newCoin').code); + return coin.editor.allowQuadraticToggle; + } + return false; + }, + wrongAddress() { + if (Session.get('isAddressWrong')) { return true; } + if (Session.get('newCoin')) { + const coin = getCoin(Session.get('newCoin').code); + if (coin.type === 'ERC20' || coin.type === 'NATIVE') { + if (document.getElementById('editBlockchainAddress') && document.getElementById('editBlockchainAddress').value === '') { + if (Meteor.user().profile.wallet.reserves.length > 0 && Meteor.user().profile.wallet.reserves[0].publicAddress) { + document.getElementById('editBlockchainAddress').value = Meteor.user().profile.wallet.reserves[0].publicAddress; + Session.set('isAddressWrong', false); + } + } + return _verifyBlockchainAddress(); + } + } + return Session.get('isAddressWrong'); + }, + addressStyle() { + if (Session.get('newCoin') && Session.get('newCoin').code === 'WEB VOTE') { + return 'display: none;'; + } + return ''; + }, buttonDisable() { - if (!_checkInputs()) { - return 'button-disabled'; + if (Session.get('isAddressWrong')) { return 'button-disabled'; } + if (Session.get('newCoin')) { + if (!_checkInputs() || (document.getElementById('editBlockchainAddress') && document.getElementById('editBlockchainAddress').value === '')) { + return 'button-disabled'; + } } return ''; }, getImage(pic) { return getImage(Template.instance().imageTemplate.get(), pic); }, + showAdvanced() { + return Template.instance().showAdvanced.get(); + }, + showReserves() { + if (Meteor.user() && Meteor.user().profile.wallet.reserves) { + return 'display:auto;'; + } + return 'display:none;'; + }, }); Template.coin.events({ @@ -127,8 +243,13 @@ Template.coin.events({ animatePopup(false, 'blockchain-popup'); Session.set('showCoinSettings', false); }, + 'click #advanced'(event) { + event.preventDefault(); + const advanced = Template.instance().showAdvanced.get(); + Template.instance().showAdvanced.set(!advanced); + }, 'click #execute-coin'() { - if (_checkInputs()) { + if (_checkInputs() || !Session.get('isAddressWrong')) { _save(); animatePopup(false, 'blockchain-popup'); Session.set('showCoinSettings', false); @@ -136,8 +257,7 @@ Template.coin.events({ }, 'input #editBlockchainAddress'() { if (document.getElementById('editBlockchainAddress')) { - const address = document.getElementById('editBlockchainAddress').value; - Session.set('checkBlockchainAddress', web3.utils.isAddress(address)); + Session.set('isAddressWrong', _verifyBlockchainAddress()); } }, 'input #editVotePrice'() { diff --git a/imports/ui/templates/components/decision/constituency/constituency.html b/imports/ui/templates/components/decision/constituency/constituency.html index 088288d01..aff0a7813 100644 --- a/imports/ui/templates/components/decision/constituency/constituency.html +++ b/imports/ui/templates/components/decision/constituency/constituency.html @@ -6,7 +6,7 @@ {{_ 'voter-nationality'}} - + {{#if showNations}} {{> suggest}} {{/if}} @@ -16,7 +16,7 @@ {{_ 'email-valid-domain'}} - + {{#if wrongAddress}}
{{> warning label='domain-wrong-address'}} diff --git a/imports/ui/templates/components/decision/countdown/countdown.html b/imports/ui/templates/components/decision/countdown/countdown.html new file mode 100644 index 000000000..15a962041 --- /dev/null +++ b/imports/ui/templates/components/decision/countdown/countdown.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/imports/ui/templates/components/decision/countdown/countdown.js b/imports/ui/templates/components/decision/countdown/countdown.js new file mode 100644 index 000000000..241666c86 --- /dev/null +++ b/imports/ui/templates/components/decision/countdown/countdown.js @@ -0,0 +1,158 @@ +import { Template } from 'meteor/templating'; +import { TAPi18n } from 'meteor/tap:i18n'; +import { ReactiveVar } from 'meteor/reactive-var'; + +import { getBlockHeight } from '/imports/startup/both/modules/metamask.js'; +import { blocktimes } from '/lib/const'; + +import '/imports/ui/templates/components/decision/countdown/countdown.html'; + + +/** +* @summary returns if effectively the poll is within a valid date +* @param {number} now current block +* @param {object} contract being analysed +* @return {boolean} true or fase +*/ +const _isPollOpen = (now, contract) => { + if (contract && contract.rules.alwaysOn) { + return true; + } + if (contract && contract.closing && contract.rules) { + return (now < contract.closing.height); + } + return true; +}; + +/** +* @summary percentage of time already transcurred for this decision +* @param {number} currentBlock now +* @param {number} delta length +* @param {number} finalBlock end + +* @return {number} percentage in float +*/ +const _getPercentage = (currentBlock, delta, finalBlock) => { + if (finalBlock <= currentBlock) { + return 0; + } + const initialBlock = parseInt(finalBlock - delta, 10); + const confirmedBlocks = parseInt(currentBlock - initialBlock, 10); + const percentage = parseFloat((confirmedBlocks * 100) / delta); + + return parseFloat(100 - percentage, 10); +}; + +/** +* @summary percentage of time already transcurred for this decision +* @param {number} remainingBlocks until dedadline +* @param {number} height final block +* @param {boolean} alwaysOn if on always +* @param {boolean} editorMode if editor +* @return {string} with countdown sentence +*/ +const _getDeadline = (now, remainingBlocks, length, height, alwaysOn, editorMode) => { + let countdown = TAPi18n.__('countdown-expiration'); + let count = remainingBlocks; + + if (editorMode) { + if (!alwaysOn) { + countdown = TAPi18n.__('poll-hypothetical'); + } else { + countdown = TAPi18n.__('poll-never-ends'); + } + } else if (alwaysOn) { + countdown = TAPi18n.__('poll-never-ends'); + } else if (height <= now) { + countdown = TAPi18n.__('poll-closed-after-time'); + count = length; + } + + // get total seconds between the times + let delta = parseInt(count * blocktimes.ETHEREUM_SECONDS_PER_BLOCK, 10); + + // calculate (and subtract) whole days + const days = Math.floor(delta / 86400); + delta -= days * 86400; + + // calculate (and subtract) whole hours + const hours = Math.floor(delta / 3600) % 24; + delta -= hours * 3600; + + // calculate (and subtract) whole minutes + const minutes = Math.floor(delta / 60) % 60; + delta -= minutes * 60; + + // what's left is seconds + const seconds = delta % 60; + + + if (days > 0) { + countdown = countdown.replace('{{days}}', `${days} ${days > 1 ? TAPi18n.__('days-compressed') : TAPi18n.__('days-singular')}`); + } else { + countdown = countdown.replace('{{days}}', ''); + } + if (hours > 0) { + countdown = countdown.replace('{{hours}}', `${hours} ${hours > 1 ? TAPi18n.__('hours-compressed') : TAPi18n.__('hours-singular')}`); + } else { + countdown = countdown.replace('{{hours}}', ''); + } + if (minutes > 0) { + countdown = countdown.replace('{{minutes}}', `${minutes} ${minutes > 1 ? TAPi18n.__('minutes-compressed') : TAPi18n.__('minutes-singular')}`); + } else { + countdown = countdown.replace('{{minutes}}', ''); + } + if (seconds > 0) { + countdown = countdown.replace('{{seconds}}', `${seconds} ${seconds > 1 ? TAPi18n.__('seconds-compressed') : TAPi18n.__('seconds-singular')}`); + } else { + countdown = countdown.replace('{{seconds}}', ''); + } + + if (height) { + countdown = countdown.replace('{{height}}', `${height.toLocaleString(undefined, [{ style: 'decimal' }])}`); + } + + countdown = countdown.replace('{{blocks}}', `${remainingBlocks.toLocaleString(undefined, [{ style: 'decimal' }])} ${remainingBlocks > 1 ? TAPi18n.__('blocks-compressed') : TAPi18n.__('blocks-singular')}`); + + return `${countdown}`; +}; + + +/** +* @summary determines deadline status based on current blockheight +* @param {object} instance where to write last block number +*/ +const _currentBlock = async (instance) => { + const now = await getBlockHeight().then((resolved) => { instance.now.set(resolved); }); + return now; +}; + +Template.countdown.onCreated(function () { + Template.instance().now = new ReactiveVar(); + _currentBlock(Template.instance()); +}); + +Template.countdown.helpers({ + label() { + const now = Template.instance().now.get(); + const confirmed = parseInt(this.delta - (this.height - now), 10); + const deadline = _getDeadline(now, parseInt(this.delta - confirmed, 10), this.delta, this.height, this.alwaysOn, this.editorMode); + return deadline; + }, + timerStyle() { + return `width: ${_getPercentage(Template.instance().now.get(), this.delta, this.height)}%`; + }, + alertMode() { + const percentage = _getPercentage(Template.instance().now.get(), this.delta, this.height); + + if (percentage && percentage < 25) { + return 'countdown-timer-final'; + } else if (percentage && percentage < 5) { + return 'countdown-timer-final'; + } + return ''; + }, +}); + +export const currentBlock = _currentBlock; +export const isPollOpen = _isPollOpen; diff --git a/imports/ui/templates/components/decision/editor/editor.html b/imports/ui/templates/components/decision/editor/editor.html index 58b3a5c1a..5cccc36c6 100644 --- a/imports/ui/templates/components/decision/editor/editor.html +++ b/imports/ui/templates/components/decision/editor/editor.html @@ -1,23 +1,25 @@ \ No newline at end of file diff --git a/imports/ui/templates/components/decision/electorate/electorate.js b/imports/ui/templates/components/decision/electorate/electorate.js index 006347624..d1e8da8de 100644 --- a/imports/ui/templates/components/decision/electorate/electorate.js +++ b/imports/ui/templates/components/decision/electorate/electorate.js @@ -2,12 +2,14 @@ import { Meteor } from 'meteor/meteor'; import { Template } from 'meteor/templating'; import { Session } from 'meteor/session'; import { $ } from 'meteor/jquery'; +import { ReactiveVar } from 'meteor/reactive-var'; import { TAPi18n } from 'meteor/tap:i18n'; import { displayPopup, animatePopup } from '/imports/ui/modules/popup'; import { toggle } from '/imports/ui/templates/components/decision/editor/editor.js'; import { geo } from '/lib/geo'; import { token } from '/lib/token'; +import { templetize, getImage } from '/imports/ui/templates/layout/templater'; import '/imports/ui/templates/components/decision/electorate/electorate.html'; import '/imports/ui/templates/components/decision/blockchain/blockchain'; @@ -61,6 +63,9 @@ const _checkTokenAvailability = (user, ticker) => { * @return {string} address */ const _getTokenAddress = (user, ticker) => { + if (ticker === 'WEB VOTE') { + return (user.profile.wallet.currency === ticker); + } if (user.profile.wallet.reserves && user.profile.wallet.reserves.length > 0) { for (let i = 0; i < user.profile.wallet.reserves.length; i += 1) { for (let k = 0; k < token.coin.length; k += 1) { @@ -93,7 +98,7 @@ const _getTokenContractAddress = (ticker) => { const _verifyConstituencyRights = (contract) => { let legitimacy = true; - if (Meteor.user() && contract.wallet.currency !== 'NONE') { + if (Meteor.user() && contract && contract.wallet.currency !== 'NONE') { if (contract.constituency && contract.constituency.length > 0) { for (const i in contract.constituency) { switch (contract.constituency[i].kind) { @@ -118,7 +123,7 @@ const _verifyConstituencyRights = (contract) => { break; case 'NATION': default: - if (Meteor.user().profile.country && Meteor.user().profile.country.code !== contract.constituency[i].code) { + if ((Meteor.user().profile.country && Meteor.user().profile.country.code !== contract.constituency[i].code) || !Meteor.user().profile.country) { legitimacy = false; } break; @@ -214,6 +219,9 @@ Template.electorate.onCreated(() => { } Session.set('showConstituencyEditor', false); Template.instance().voteEnabled = _verifyConstituencyRights(contract); + + Template.instance().imageTemplate = new ReactiveVar(); + templetize(Template.instance()); }); const killPopup = () => { @@ -237,9 +245,30 @@ Template.electorate.onRendered(function () { } } }); + + instance.autorun(function () { + $('.right').scroll(() => { + if (Session.get('showConstituencyEditor')) { + Session.set('showConstituencyEditor', false); + animatePopup(false, 'constituency-popup'); + } + }); + }); }); Template.electorate.helpers({ + getImage() { + if (!this.readOnly) { + if (Session.get('showConstituencyEditor')) { + return getImage(Template.instance().imageTemplate.get(), 'electorate-check-active'); + } + return getImage(Template.instance().imageTemplate.get(), 'electorate-check-editor'); + } + if (!Template.instance().voteEnabled) { + return getImage(Template.instance().imageTemplate.get(), 'electorate-check-reject-enabled'); + } + return getImage(Template.instance().imageTemplate.get(), 'electorate-check-enabled'); + }, status() { let rule; if (!this.readOnly) { diff --git a/imports/ui/templates/components/decision/poll/poll.html b/imports/ui/templates/components/decision/poll/poll.html new file mode 100644 index 000000000..825373b55 --- /dev/null +++ b/imports/ui/templates/components/decision/poll/poll.html @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/imports/ui/templates/components/decision/poll/poll.js b/imports/ui/templates/components/decision/poll/poll.js new file mode 100644 index 000000000..77e07f7f5 --- /dev/null +++ b/imports/ui/templates/components/decision/poll/poll.js @@ -0,0 +1,92 @@ +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; + +import { Contracts } from '/imports/api/contracts/Contracts'; +import { query } from '/lib/views'; + +import '/imports/ui/templates/components/decision/ballot/ballot.js'; +import '/imports/ui/templates/components/decision/poll/poll.html'; + +Template.poll.onCreated(function () { + Template.instance().ready = new ReactiveVar(false); + Template.instance().contracts = new ReactiveVar(); +}); + +Template.poll.onRendered(function () { + const instance = this; + const pollId = instance.data.pollId; + + // instance.autorun(function (computation) { + if (pollId) { + const options = { view: 'poll', pollId }; + const parameters = query(options); + + const dbQuery = Contracts.find(parameters.find, parameters.options); + + instance.handle = dbQuery.observeChanges({ + addedBefore: (id, fields) => { + const currentFeed = instance.contracts.get(); + const post = fields; + + post._id = id; + + if (!currentFeed) { + instance.contracts.set([post]); + } else { + currentFeed.push(post); + instance.contracts.set(_.uniq(currentFeed)); + } + instance.ready.set(true); + }, + changed: (id, fields) => { + const feed = instance.contracts.get(); + + for (let i = 0; i < feed.length; i += 1) { + if (feed[i]._id === id) { + feed[i] = Object.assign(feed[i], fields); + break; + } + } + + instance.contracts.set(feed); + }, + removed: (id) => { + const feed = instance.contracts.get(); + + for (let i = 0; i < feed.length; i += 1) { + if (feed[i]._id === id) { + feed.splice(i, 1); + break; + } + } + + instance.contracts.set(feed); + }, + }); + } + // }); +}); + +Template.poll.helpers({ + ready() { + return Template.instance().ready.get(); + }, + item() { + const contracts = Template.instance().contracts.get(); + const item = []; + for (let i = 0; i < contracts.length; i += 1) { + item.push({ + contract: contracts[i], + totals: this.pollTotals, + editor: this.editorMode, + }); + } + return item; + }, + quadratic() { + return this.quadratic; + }, + balance() { + return this.balance; + }, +}); diff --git a/imports/ui/templates/components/identity/avatar/avatar.js b/imports/ui/templates/components/identity/avatar/avatar.js index 1b77ae1ed..c27b561d9 100644 --- a/imports/ui/templates/components/identity/avatar/avatar.js +++ b/imports/ui/templates/components/identity/avatar/avatar.js @@ -80,7 +80,7 @@ const getNation = (profile, flagOnly, nameOnly, codeOnly) => { const country = searchJSON(geo.country, Meteor.user().profile.country.name); if (country !== undefined) { if (flagOnly) { - return `${country[0].emoji}`; + return `${country[0] ? country[0].emoji : ''}`; } if (nameOnly) { return `${country[0].name}`; @@ -88,7 +88,7 @@ const getNation = (profile, flagOnly, nameOnly, codeOnly) => { if (codeOnly) { return `${country[0].code}`; } - return `${Meteor.user().profile.country.name} ${country[0].emoji}`; + return `${Meteor.user().profile.country.name} ${country[0] ? country[0].emoji : ''}`; } } if (flagOnly || nameOnly || codeOnly) { return ''; } @@ -97,7 +97,7 @@ const getNation = (profile, flagOnly, nameOnly, codeOnly) => { } else if (profile.country !== undefined) { if (profile.country.name !== TAPi18n.__('unknown')) { if (flagOnly) { - return `${searchJSON(geo.country, profile.country.name)[0].emoji}`; + return `${searchJSON(geo.country, profile.country.name)[0] ? searchJSON(geo.country, profile.country.name)[0].emoji : ''}`; } if (nameOnly) { return `${searchJSON(geo.country, profile.country.name)[0].name}`; @@ -105,7 +105,7 @@ const getNation = (profile, flagOnly, nameOnly, codeOnly) => { if (codeOnly) { return `${searchJSON(geo.country, profile.country.name)[0].code}`; } - return `${profile.country.name} ${searchJSON(geo.country, profile.country.name)[0].emoji}`; + return `${profile.country.name} ${searchJSON(geo.country, profile.country.name)[0] ? searchJSON(geo.country, profile.country.name)[0].emoji : ''}`; } if (flagOnly) { return ''; } return TAPi18n.__('unknown'); @@ -116,7 +116,7 @@ const getNation = (profile, flagOnly, nameOnly, codeOnly) => { const country = searchJSON(geo.country, user.profile.country.name); if (user.profile.country.name !== TAPi18n.__('unknown') && country !== undefined) { if (flagOnly) { - return `${country[0].emoji}`; + return `${country[0] ? country[0].emoji : ''}`; } if (nameOnly) { return user.profile.country.name; @@ -124,7 +124,7 @@ const getNation = (profile, flagOnly, nameOnly, codeOnly) => { if (codeOnly) { return user.profile.country.code; } - return `${user.profile.country.name} ${country[0].emoji}`; + return `${user.profile.country.name} ${country[0] ? country[0].emoji : ''}`; } if (flagOnly || nameOnly || codeOnly) { return ''; } return TAPi18n.__('unknown'); diff --git a/imports/ui/templates/components/identity/card/card.html b/imports/ui/templates/components/identity/card/card.html index 5dfb1246b..3a93818b0 100644 --- a/imports/ui/templates/components/identity/card/card.html +++ b/imports/ui/templates/components/identity/card/card.html @@ -1,5 +1,5 @@ diff --git a/imports/ui/templates/components/identity/login/profile/multiTokenProfile.js b/imports/ui/templates/components/identity/login/profile/multiTokenProfile.js index 727b5fb79..5a3c7258c 100644 --- a/imports/ui/templates/components/identity/login/profile/multiTokenProfile.js +++ b/imports/ui/templates/components/identity/login/profile/multiTokenProfile.js @@ -1,24 +1,34 @@ import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; + +import { rules } from '/lib/const'; import '/imports/ui/templates/components/identity/login/profile/multiTokenProfile.html'; import '/imports/ui/templates/components/decision/balance/balance.js'; +const numeral = require('numeral'); + +Template.multiTokenProfile.onCreated(function () { + Template.instance().pollScore = new ReactiveVar(0); +}); + Template.multiTokenProfile.helpers({ tokens() { const wallet = this.profile.wallet; const tokens = []; - // push VOTE balance as first element in tokens array - /* - @ NOTE: this type of token push is for off-chain tokens, not supported in first beta release. - const voteToken = { - token: wallet.currency, - balance: wallet.balance, - available: wallet.available, - placed: wallet.placed, - }; - tokens.push(voteToken); - */ + // push WEB VOTE balance as first element in tokens array, if present + if (wallet.balance > 0) { + const webVote = { + token: wallet.currency, + balance: wallet.balance, + available: wallet.available, + placed: wallet.placed, + disableStake: true, + disableBar: false, + }; + tokens.push(webVote); + } // loop through reserves array and push each to tokens for (const i in wallet.reserves) { @@ -34,4 +44,53 @@ Template.multiTokenProfile.helpers({ } return tokens; }, + noBarStyle() { + if (this.disableBar) { + return 'token-wrap-multi'; + } + return ''; + }, + pollScore(available, balance) { + const percentage = ((available * 100) / rules.VOTES_INITIAL_QUANTITY); + Template.instance().pollScore.set(percentage); + return `${numeral(percentage).format('0.00')}%`; + /* + // color + let score = ''; + + // score + let choiceVotes; + if (this.pollTotals) { + switch (this.contract.blockchain.coin.code) { + case 'WEB VOTE': + choiceVotes = 0; + for (let k = 0; k < this.contract.tally.voter.length; k += 1) { + choiceVotes += this.contract.tally.voter[k].votes; + } + break; + default: + choiceVotes = this.contract.blockchain.score ? this.contract.blockchain.score.totalConfirmed : '0'; + } + } + const bnVotes = new BigNumber(choiceVotes); + const bnTotal = new BigNumber(this.pollTotals); + let percentage; + // eslint-disable-next-line eqeqeq + if (bnTotal != 0) { + percentage = new BigNumber(bnVotes.multipliedBy(100)).dividedBy(bnTotal); + } else { + percentage = 0; + } + Template.instance().pollScore.set(percentage); + score = `${numeral(percentage).format('0.00')}%`; + + return score; + */ + }, + smallPercentageStyle() { + if (Template.instance().pollScore.get() < 10) { + return 'poll-score-small'; + } + return ''; + }, }); diff --git a/imports/ui/templates/components/identity/login/profile/profile.html b/imports/ui/templates/components/identity/login/profile/profile.html index d79c7b1df..846359fb4 100644 --- a/imports/ui/templates/components/identity/login/profile/profile.html +++ b/imports/ui/templates/components/identity/login/profile/profile.html @@ -4,13 +4,17 @@ {{else}}
{{> avatar includeName=true includeNation=true editable=false includeAddress=true}} - {{#if isMultiTokenUser}} + + + - {{/if}}
{{_ 'edit-profile'}}
diff --git a/imports/ui/templates/components/identity/login/profile/profile.js b/imports/ui/templates/components/identity/login/profile/profile.js index 083b8b0c0..0705a80d5 100644 --- a/imports/ui/templates/components/identity/login/profile/profile.js +++ b/imports/ui/templates/components/identity/login/profile/profile.js @@ -51,12 +51,6 @@ Template.profile.helpers({ totalVotes() { return `${TAPi18n.__('total-votes')} ${Meteor.user().profile.wallet.balance.toLocaleString()} `; }, - isMultiTokenUser() { - if (Meteor.user().profile.wallet.reserves != null) { - return true; - } - return false; - }, getImage(pic) { return getImage(Template.instance().imageTemplate.get(), pic); }, diff --git a/imports/ui/templates/components/identity/login/profile/profileEditor.html b/imports/ui/templates/components/identity/login/profile/profileEditor.html index fc8130460..1ac05043e 100644 --- a/imports/ui/templates/components/identity/login/profile/profileEditor.html +++ b/imports/ui/templates/components/identity/login/profile/profileEditor.html @@ -3,7 +3,7 @@ {{> avatar includeName=true includeNation=true editor=true includeAddress=true}}
- --}} {{/if}} + + {{#if invalidEmail}} +
+ {{> warning label="invalid-email"}} +
+ {{/if}} {{/each}} +
- {{#if electionData}} - {{#if onScreen}} - {{> ballot editorMode=false feedMode=true contract=feedContract url=url rightToVote=rightToVote candidateBallot=candidateBallot displayResults=displayResults readOnly=readOnly displayActions=displayActions}} - {{else}} - {{#nrr nrrargs 'ballot' editorMode=false feedMode=true contract=feedContract url=url rightToVote=rightToVote candidateBallot=candidateBallot displayResults=displayResults readOnly=readOnly displayActions=displayActions}}{{/nrr}} - {{/if}} + {{#if quadraticEnabled}} +
+ {{{_ 'election-rule-quadratic'}}} + {{> help tooltip='voting-editor-quadratic-tooltip'}} +
+ {{/if}} + {{#if balanceEnabled}} +
+ {{{_ 'election-rule-balance'}}} + {{> help tooltip='voting-editor-balance-tooltip'}} +
+ {{/if}} + {{#if pollingEnabled}} + {{> poll list=pollList pollId=pollId rules=rules pollTotals=pollTotals quadratic=quadraticEnabled balance=balanceEnabled}} + {{/if}} + + {{#if requiresClosing}} + {{#with closingData}} + {{> countdown}} + {{/with}} + {{/if}} + + {{#if tally}} + {{> ballot editorMode=false feedMode=true contract=feedContract url=url rightToVote=rightToVote candidateBallot=candidateBallot displayResults=displayResults readOnly=readOnly displayActions=displayActions rules=rules}} {{else}} - {{#if currentUser}} -
-
- {{_ 'get-election-votes'}} - {{> spinner id=_id style=spinnerStyle}} + + {{#if electionData}} + {{#if onScreen}} + {{> ballot editorMode=false feedMode=true contract=feedContract url=url rightToVote=rightToVote candidateBallot=candidateBallot displayResults=displayResults readOnly=readOnly displayActions=displayActions rules=rules}} + {{else}} + {{#nrr nrrargs 'ballot' editorMode=false feedMode=true contract=feedContract url=url rightToVote=rightToVote candidateBallot=candidateBallot displayResults=displayResults readOnly=readOnly displayActions=displayActions rules=rules}}{{/nrr}} + {{/if}} + {{else}} + {{#if currentUser}} +
+
+ {{_ 'get-election-votes'}} + {{> spinner id=_id style=spinnerStyle}} +
-
- {{/if}} + {{/if}} + {{/if}} {{/if}} - {{/if}} +
-
- {{#unless isPhone}} - {{#if replyEditor}} - {{#with replyData}} - {{> compose}} - {{/with}} - {{/if}} + {{#unless isPhone}} + {{#if replyEditor}} + {{#with replyData}} + {{> compose}} + {{/with}} + {{/if}} + {{/unless}} {{/unless}} {{/if}} {{/if}} diff --git a/imports/ui/templates/widgets/feed/feedItem.js b/imports/ui/templates/widgets/feed/feedItem.js index 2600af804..122652c58 100644 --- a/imports/ui/templates/widgets/feed/feedItem.js +++ b/imports/ui/templates/widgets/feed/feedItem.js @@ -21,6 +21,9 @@ import '/imports/ui/templates/widgets/feed/feedItem.html'; import '/imports/ui/templates/widgets/transaction/transaction.js'; import '/imports/ui/templates/widgets/spinner/spinner.js'; import '/imports/ui/templates/components/identity/avatar/avatar.js'; +import '/imports/ui/templates/components/decision/countdown/countdown.js'; + +import BigNumber from 'bignumber.js'; /** * @summary determines whether this decision can display results or notice @@ -72,6 +75,26 @@ const _here = (item) => { return (window.location.pathname.substring(0, item.url.length) === `${item.url}`); }; +/** +* @summary Strips markdown format to render HTML link correctly +* @param {string} text - Expected format is: +* +* "[Click me](www.test.com)" +* +* @param {string} humanStr - Refers to part within brackets, 'Click me' in the example above +* @returns {string} HTML format that actually contains the human readable part, as in: +* +* "Click me" +* +*/ +const stripMarkdownLink = (text, humanStr) => { + text = text.slice(text.search('") + 16); + text = text + humanStr + ''; + + return text; +}; + /** * @summary parses a url in a plain text and returns link html * @param {string} text to be parsed @@ -79,6 +102,12 @@ const _here = (item) => { */ const parseURL = (text) => { const exp = /(\b(((https?|ftp|file|):\/\/)|www[.])[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig; + const markdownLinkExp = /\[(.*?)\]\((.+?)\)/g; + const markdownImgExp = /(?:!\[(.*?)\]\((.*?)\))/ig; + + // If markdown image format present, ignore + if (text.search(markdownImgExp) !== -1) return text; + let temp = text.replace(exp, "$1"); let result = ''; @@ -96,6 +125,9 @@ const parseURL = (text) => { } } + // If markdown link format (`[]()`) present, strip for correct rendering + result = result.replace(markdownLinkExp, stripMarkdownLink(result, '$1')); + return result; }; @@ -123,7 +155,6 @@ const _hasSubstring = (str, items) => { * @return {string} html with linked url */ const _clickOnWhitespace = (className) => { - console.log(className); return _hasSubstring(className, ['checkbox', 'title-input', 'option-title', 'identity-list']); }; @@ -156,14 +187,18 @@ const renderMarkup = (text) => { html = _replaceAll(html, "href='/@@", "href='/@"); // tokens - html = html.replace(/(^|\s)(\$[a-z\d][\w-]*)/ig, "$1$2"); - html = _replaceAll(html, "href='/token/$", "href='$"); + // html = html.replace(/(^|\s)(\$[a-z\d][\w-]*)/ig, "$1$2"); + // html = _replaceAll(html, "href='/token/$", "href='$"); // markup html = html.replace(/\*\*(.*?)\*\*/g, '$1'); html = html.replace(/__(.*?)__/g, '$1'); html = html.replace(/--(.*?)--/g, '$1'); html = html.replace(/~~(.*?)~~/g, '$1'); + html = html.replace(/##(.*?)##/g, '$1'); + + // images + html = html.replace(/(?:!\[(.*?)\]\((.*?)\))/g, '$1'); // paragraphs html = html.replace(/\n/g, '
'); @@ -353,14 +388,66 @@ Template.feedItem.helpers({ feedContract() { return Template.instance().contract.get(); }, + pollingEnabled() { + return this.rules ? this.rules.pollVoting : false; + }, + quadraticEnabled() { + return this.rules ? this.rules.quadraticVoting : false; + }, + balanceEnabled() { + return this.rules ? this.rules.balanceVoting : false; + }, + pollList() { + return this.poll; + }, + pollId() { + return this._id; + }, + pollTotals() { + const choices = Contracts.find({ pollId: this._id }).fetch(); + let total = new BigNumber(0); + for (let i = 0; i < choices.length; i += 1) { + switch (this.blockchain.coin.code) { + case 'WEB VOTE': + for (let k = 0; k < choices[i].tally.voter.length; k += 1) { + total = total.plus(choices[i].tally.voter[k].votes); + } + break; + default: + if (choices[i].blockchain.score && choices[i].blockchain.score.totalConfirmed) { + total = total.plus(choices[i].blockchain.score.totalConfirmed); + } + } + } + return total.toString(); + }, + rules() { + return this.rules; + }, voters() { let total; - const dbContract = Contracts.findOne({ _id: this._id }); - if (dbContract && dbContract.tally) { - total = dbContract.tally.voter.length; + let list = []; + const contract = Contracts.findOne({ _id: this._id }); + let choice; + + if (contract.poll && contract.poll.length > 0) { + // poll contract + total = 0; + for (let i = 0; i < contract.poll.length; i += 1) { + choice = Contracts.findOne({ _id: contract.poll[i].contractId }); + + if (choice) { + list = list.concat(_.pluck(choice.tally.voter, '_id')); + } + } + total = _.uniq(list).length; + } else if (contract && contract.tally) { + // normal + total = contract.tally.voter.length; } else { total = getTotalVoters(this); } + if (total === 1) { return `${total} ${TAPi18n.__('voter').toLowerCase()}`; } else if (total === 0) { @@ -419,6 +506,25 @@ Template.feedItem.helpers({ getImage(pic) { return getImage(Template.instance().imageTemplate.get(), pic); }, + pollContent() { + return this.pollId; + }, + pollStyle() { + if (this.poll && this.poll.length > 0) { + return 'vote-poll'; + } + return ''; + }, + requiresClosing() { + return ((this.rules.alwaysOn === false) || this.rules.pollVoting); + }, + closingData() { + const closing = this.closing; + if (closing) { + closing.alwaysOn = this.rules.alwaysOn; + } + return closing; + }, }); Template.feedItem.events({ diff --git a/imports/ui/templates/widgets/feed/paginator.js b/imports/ui/templates/widgets/feed/paginator.js index 1a54ab138..d91a307e3 100644 --- a/imports/ui/templates/widgets/feed/paginator.js +++ b/imports/ui/templates/widgets/feed/paginator.js @@ -60,7 +60,6 @@ Template.paginator.onRendered(function () { Template.paginator.helpers({ end() { - console.log(`end why? skip: ${this.options.skip} + limit: ${this.options.limit} < count: ${this.count}`); return !((this.options.skip + this.options.limit) < this.count); }, empty() { diff --git a/imports/ui/templates/widgets/help/help.html b/imports/ui/templates/widgets/help/help.html new file mode 100644 index 000000000..8634a3b12 --- /dev/null +++ b/imports/ui/templates/widgets/help/help.html @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/imports/ui/templates/widgets/help/help.js b/imports/ui/templates/widgets/help/help.js new file mode 100644 index 000000000..e2a244b25 --- /dev/null +++ b/imports/ui/templates/widgets/help/help.js @@ -0,0 +1,23 @@ +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; + +import '/imports/ui/templates/widgets/help/help.html'; + +Template.help.onCreated(function () { + Template.instance().showTooltip = new ReactiveVar(false); +}); + +Template.help.helpers({ + showTooltip() { + return Template.instance().showTooltip.get(); + }, +}); + +Template.help.events({ + 'mouseenter .help'() { + Template.instance().showTooltip.set(true); + }, + 'mouseleave .help'() { + Template.instance().showTooltip.set(false); + }, +}); diff --git a/imports/ui/templates/widgets/setting/setting.html b/imports/ui/templates/widgets/setting/setting.html new file mode 100644 index 000000000..c2cce6304 --- /dev/null +++ b/imports/ui/templates/widgets/setting/setting.html @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/imports/ui/templates/widgets/setting/setting.js b/imports/ui/templates/widgets/setting/setting.js new file mode 100644 index 000000000..778b0d43a --- /dev/null +++ b/imports/ui/templates/widgets/setting/setting.js @@ -0,0 +1,4 @@ +import '/imports/ui/templates/widgets/help/help.js'; +import '/imports/ui/templates/widgets/toggle/toggle.js'; +import '/imports/ui/templates/widgets/setting/setting.html'; +import '/imports/ui/templates/widgets/help/help.html'; diff --git a/imports/ui/templates/widgets/switcher/switcher.html b/imports/ui/templates/widgets/switcher/switcher.html new file mode 100644 index 000000000..29bade7d8 --- /dev/null +++ b/imports/ui/templates/widgets/switcher/switcher.html @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/imports/ui/templates/widgets/switcher/switcher.js b/imports/ui/templates/widgets/switcher/switcher.js new file mode 100644 index 000000000..1046b431d --- /dev/null +++ b/imports/ui/templates/widgets/switcher/switcher.js @@ -0,0 +1,44 @@ +import { Session } from 'meteor/session'; +import { Template } from 'meteor/templating'; + +import '/imports/ui/templates/widgets/switcher/switcher.html'; + +Template.switcher.onRendered(function () { + Session.set('cachedDraft', Session.get('draftContract')); +}); + +Template.switcher.helpers({ + option() { + const option = this.option; + for (let i = 0; i < option.length; i += 1) { + option[i].enabled = this.enabled; + } + return this.option; + }, + style() { + if (!this.enabled) { + return 'switcher-disabled'; + } + return ''; + }, +}); + +Template.switch.helpers({ + selected() { + if (!this.enabled) { + return 'switch-button-disabled'; + } + if (this.value) { + return 'switch-button-selected'; + } + return ''; + }, +}); + +Template.switcher.events({ + 'click #switch-button'() { + if (this.enabled) { + this.action(); + } + }, +}); diff --git a/imports/ui/templates/widgets/tally/tally.js b/imports/ui/templates/widgets/tally/tally.js index 18bf1f5e2..87bc87a45 100644 --- a/imports/ui/templates/widgets/tally/tally.js +++ b/imports/ui/templates/widgets/tally/tally.js @@ -95,14 +95,20 @@ const _buildFeed = (id, fields, instance, contract, noTitle) => { if (userSubscriptionId) { getUser(userSubscriptionId); } + let skipPending = false; + if (fields.kind === 'VOTE' && fields.status === 'PENDING') { + skipPending = true; + } const voteContract = _voteToContract(post, contract, noTitle, _isWinningVote(instance.data.winningBallot, post.condition.ballot), instance.openFeed); - if (!currentFeed) { - instance.feed.set([voteContract]); - } else if (!here(voteContract, currentFeed)) { - currentFeed.push(voteContract); - instance.feed.set(_.uniq(currentFeed)); + if (!skipPending) { + if (!currentFeed) { + instance.feed.set([voteContract]); + } else if (!here(voteContract, currentFeed)) { + currentFeed.push(voteContract); + instance.feed.set(_.uniq(currentFeed)); + } } }; diff --git a/imports/ui/templates/widgets/toggle/toggle.html b/imports/ui/templates/widgets/toggle/toggle.html index c666c7c59..74b387459 100644 --- a/imports/ui/templates/widgets/toggle/toggle.html +++ b/imports/ui/templates/widgets/toggle/toggle.html @@ -1,9 +1,13 @@ -