diff --git a/admin/src/components/ContributionDetail/index.tsx b/admin/src/components/ContributionDetail/index.tsx index 869275a5..61f9894f 100644 --- a/admin/src/components/ContributionDetail/index.tsx +++ b/admin/src/components/ContributionDetail/index.tsx @@ -46,7 +46,7 @@ class ContributionDetail extends React.Component { ); const renderSendRefund = () => { - if (c.staking || !c.refundAddress || c.refundTxId || !c.proposal.isFailed) { + if (c.staking || !c.refundAddress || c.refundTxId || !c.proposal.isFailed || !c.user) { return; } const percent = c.proposal.milestones.reduce((prev, m) => { @@ -98,9 +98,10 @@ class ContributionDetail extends React.Component {
{JSON.stringify(c.addresses, null, 4)}
- - - + + + {c.user ? : Anonymous contribution} + diff --git a/admin/src/components/ContributionForm/index.tsx b/admin/src/components/ContributionForm/index.tsx index a12fd4a1..506be031 100644 --- a/admin/src/components/ContributionForm/index.tsx +++ b/admin/src/components/ContributionForm/index.tsx @@ -40,7 +40,7 @@ class ContributionForm extends React.Component { } defaults = { proposalId: contribution.proposal.proposalId, - userId: contribution.user.userid, + userId: contribution.user ? contribution.user.userid : '', amount: contribution.amount, txId: contribution.txId || '', }; @@ -68,14 +68,11 @@ class ContributionForm extends React.Component { {getFieldDecorator('userId', { initialValue: defaults.userId, - rules: [ - { required: true, message: 'User ID is required' }, - ], })( , )} @@ -152,6 +149,10 @@ class ContributionForm extends React.Component { }; let msg; if (id) { + // Explicitly make it zero of omitted to indicate to remove user + if (!args.userId) { + args.userId = 0; + } await store.editContribution(id, args); msg = 'Successfully updated contribution'; } else { diff --git a/admin/src/components/Contributions/ContributionItem.tsx b/admin/src/components/Contributions/ContributionItem.tsx index 3cbcbb8f..f0f99d3f 100644 --- a/admin/src/components/Contributions/ContributionItem.tsx +++ b/admin/src/components/Contributions/ContributionItem.tsx @@ -22,7 +22,7 @@ export default class ContributionItem extends React.PureComponent { >

- {user.displayName} for {proposal.title} + {user ? user.displayName : Anonymous} for {proposal.title} {status.tagDisplay} diff --git a/admin/src/types.ts b/admin/src/types.ts index eae184c1..22184c00 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -141,7 +141,7 @@ export interface Contribution { txId: null | string; amount: string; dateCreated: number; - user: User; + user: User | null; proposal: Proposal; addresses: { transparent: string; diff --git a/admin/src/util/statuses.ts b/admin/src/util/statuses.ts index ef96a633..71cce6d7 100644 --- a/admin/src/util/statuses.ts +++ b/admin/src/util/statuses.ts @@ -128,7 +128,8 @@ export const PROPOSAL_STAGES: Array> = [ id: PROPOSAL_STAGE.CANCELED, tagDisplay: 'Canceled', tagColor: '#eb4118', - hint: 'Proposal was canceled by an admin and is currently refunding all contributors.', + hint: + 'Proposal was canceled by an admin and is currently refunding all contributors.', }, ]; @@ -190,9 +191,10 @@ export const CONTRIBUTION_STATUSES: Array> = [ }, { id: CONTRIBUTION_STATUS.DELETED, - tagDisplay: 'Closed', + tagDisplay: 'Deleted', tagColor: '#eb4118', - hint: 'User deleted the contribution before it was sent or confirmed', + hint: + 'User deleted the contribution before it was sent, or after it didn’t confirm after 24 hours', }, ]; diff --git a/backend/grant/admin/example_emails.py b/backend/grant/admin/example_emails.py index 5d08bf94..508a3072 100644 --- a/backend/grant/admin/example_emails.py +++ b/backend/grant/admin/example_emails.py @@ -80,9 +80,11 @@ example_email_args = { 'proposal': proposal, 'contribution': contribution, 'contributor': user, + # 'contributor': None, 'funded': '50', 'proposal_url': 'http://someproposal.com', 'contributor_url': 'http://someuser.com', + # 'contributor_url': None, }, 'proposal_comment': { 'author': user, diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 0b2b2665..5c673072 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -590,7 +590,7 @@ def get_contributions(page, filters, search, sort): @blueprint.route('/contributions', methods=['POST']) @endpoint.api( parameter('proposalId', type=int, required=True), - parameter('userId', type=int, required=True), + parameter('userId', type=int, required=False, default=None), parameter('status', type=str, required=True), parameter('amount', type=str, required=True), parameter('txId', type=str, required=False), @@ -653,12 +653,15 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_ if not proposal: return {"message": "No proposal matching that id"}, 400 contribution.proposal_id = proposal_id - # User ID (must belong to an existing user) - if user_id: - user = User.query.filter(User.id == user_id).first() - if not user: - return {"message": "No user matching that id"}, 400 - contribution.user_id = user_id + # User ID (must belong to an existing user or 0 to unset) + if user_id is not None: + if user_id == 0: + contribution.user_id = None + else: + user = User.query.filter(User.id == user_id).first() + if not user: + return {"message": "No user matching that id"}, 400 + contribution.user_id = user_id # Status (must be in list of statuses) if status: if not ContributionStatus.includes(status): diff --git a/backend/grant/blockchain/bootstrap.py b/backend/grant/blockchain/bootstrap.py index 40f4af4b..6d87dba2 100644 --- a/backend/grant/blockchain/bootstrap.py +++ b/backend/grant/blockchain/bootstrap.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta + from grant.proposal.models import ( ProposalContribution, proposal_contributions_schema, @@ -9,6 +11,7 @@ from grant.utils.enums import ContributionStatus def make_bootstrap_data(): pending_contributions = ProposalContribution.query \ .filter_by(status=ContributionStatus.PENDING) \ + .filter(ProposalContribution.date_created + timedelta(hours=24) > datetime.now()) \ .all() latest_contribution = ProposalContribution.query \ .filter_by(status=ContributionStatus.CONFIRMED) \ diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index 6022a23d..1fc7fbf2 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -86,7 +86,7 @@ def proposal_contribution(email_args): 'subject': 'You just got a contribution!', 'title': 'You just got a contribution', 'preview': '{} just contributed {} to your proposal {}'.format( - email_args['contributor'].display_name, + email_args['contributor'].display_name if email_args['contributor'] else 'An anonymous contributor', email_args['contribution'].amount, email_args['proposal'].title, ), diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index e4ac19e5..518d7a98 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -3,6 +3,7 @@ from functools import reduce from sqlalchemy import func, or_ from sqlalchemy.ext.hybrid import hybrid_property from decimal import Decimal +from marshmallow import post_dump from grant.comment.models import Comment from grant.email.send import send_email @@ -19,6 +20,7 @@ from grant.utils.enums import ( ProposalArbiterStatus, MilestoneStage ) +from grant.utils.stubs import anonymous_user proposal_team = db.Table( 'proposal_team', db.Model.metadata, @@ -87,13 +89,13 @@ class ProposalContribution(db.Model): def __init__( self, proposal_id: int, - user_id: int, amount: str, + user_id: int = None, staking: bool = False, ): self.proposal_id = proposal_id - self.user_id = user_id self.amount = amount + self.user_id = user_id self.staking = staking self.date_created = datetime.datetime.now() self.status = ContributionStatus.PENDING @@ -163,7 +165,7 @@ class ProposalContribution(db.Model): @hybrid_property def refund_address(self): - return self.user.settings.refund_address + return self.user.settings.refund_address if self.user else None class ProposalArbiter(db.Model): @@ -337,11 +339,11 @@ class Proposal(db.Model): self.deadline_duration = deadline_duration Proposal.validate(vars(self)) - def create_contribution(self, user_id: int, amount, staking: bool = False): + def create_contribution(self, amount, user_id: int = None, staking: bool = False): contribution = ProposalContribution( proposal_id=self.id, - user_id=user_id, amount=amount, + user_id=user_id, staking=staking, ) db.session.add(contribution) @@ -713,12 +715,14 @@ class ProposalContributionSchema(ma.Schema): "amount", "date_created", "addresses", + "is_anonymous", ) proposal = ma.Nested("ProposalSchema") - user = ma.Nested("UserSchema") + user = ma.Nested("UserSchema", default=anonymous_user) date_created = ma.Method("get_date_created") addresses = ma.Method("get_addresses") + is_anonymous = ma.Method("get_is_anonymous") def get_date_created(self, obj): return dt_to_unix(obj.date_created) @@ -730,6 +734,15 @@ class ProposalContributionSchema(ma.Schema): return { 'transparent': addresses['transparent'], } + + def get_is_anonymous(self, obj): + return not obj.user_id + + @post_dump + def stub_anonymous_user(self, data): + if 'user' in data and data['user'] is None: + data['user'] = anonymous_user + return data proposal_contribution_schema = ProposalContributionSchema() diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 308be669..9c2543db 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -375,11 +375,12 @@ def post_proposal_update(proposal_id, title, content): # Send email to all contributors (even if contribution failed) contributions = ProposalContribution.query.filter_by(proposal_id=proposal_id).all() for c in contributions: - send_email(c.user.email_address, 'contribution_update', { - 'proposal': g.current_proposal, - 'proposal_update': update, - 'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'), - }) + if c.user: + send_email(c.user.email_address, 'contribution_update', { + 'proposal': g.current_proposal, + 'proposal_update': update, + 'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'), + }) dumped_update = proposal_update_schema.dump(update) return dumped_update, 201 @@ -481,22 +482,31 @@ def get_proposal_contribution(proposal_id, contribution_id): @blueprint.route("//contributions", methods=["POST"]) -@requires_auth @endpoint.api( - parameter('amount', type=str, required=True) + parameter('amount', type=str, required=True), + parameter('anonymous', type=bool, required=False) ) -def post_proposal_contribution(proposal_id, amount): +def post_proposal_contribution(proposal_id, amount, anonymous): proposal = Proposal.query.filter_by(id=proposal_id).first() if not proposal: return {"message": "No proposal matching id"}, 404 code = 200 - contribution = ProposalContribution \ - .get_existing_contribution(g.current_user.id, proposal_id, amount) + user = None + contribution = None + + if not anonymous: + user = get_authed_user() + + if user: + contribution = ProposalContribution.get_existing_contribution(user.id, proposal_id, amount) if not contribution: code = 201 - contribution = proposal.create_contribution(g.current_user.id, amount) + contribution = proposal.create_contribution( + amount=amount, + user_id=user.id if user else None, + ) dumped_contribution = proposal_contribution_schema.dump(contribution) return dumped_contribution, code @@ -544,11 +554,12 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): else: # Send to the user - send_email(contribution.user.email_address, 'contribution_confirmed', { - 'contribution': contribution, - 'proposal': contribution.proposal, - 'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}', - }) + if contribution.user: + send_email(contribution.user.email_address, 'contribution_confirmed', { + 'contribution': contribution, + 'proposal': contribution.proposal, + 'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}', + }) # Send to the full proposal gang for member in contribution.proposal.team: @@ -558,7 +569,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): 'contributor': contribution.user, 'funded': contribution.proposal.funded, 'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'), - 'contributor_url': make_url(f'/profile/{contribution.user.id}'), + 'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '', }) # TODO: Once we have a task queuer in place, queue emails to everyone diff --git a/backend/grant/task/jobs.py b/backend/grant/task/jobs.py index 7a5108f4..5d41c28a 100644 --- a/backend/grant/task/jobs.py +++ b/backend/grant/task/jobs.py @@ -78,12 +78,13 @@ class ProposalDeadline: 'proposal': proposal, }) for c in proposal.contributions: - send_email(c.user.email_address, 'contribution_proposal_failed', { - 'contribution': c, - 'proposal': proposal, - 'refund_address': c.user.settings.refund_address, - 'account_settings_url': make_url('/profile/settings?tab=account') - }) + if c.user: + send_email(c.user.email_address, 'contribution_proposal_failed', { + 'contribution': c, + 'proposal': proposal, + 'refund_address': c.user.settings.refund_address, + 'account_settings_url': make_url('/profile/settings?tab=account') + }) JOBS = { diff --git a/backend/grant/templates/emails/proposal_contribution.html b/backend/grant/templates/emails/proposal_contribution.html index f2f1cfde..b21232bf 100644 --- a/backend/grant/templates/emails/proposal_contribution.html +++ b/backend/grant/templates/emails/proposal_contribution.html @@ -1,7 +1,11 @@

Your proposal {{ args.proposal.title }} just got a {{ args.contribution.amount }} ZEC contribution from + {% if args.contributor %} {{ args.contributor.display_name }}. + {% else %} + an anonymous contributor. + {% endif %} Your proposal is now at {{ args.funded }} / {{ args.proposal.target }} ZEC.

diff --git a/backend/grant/templates/emails/proposal_contribution.txt b/backend/grant/templates/emails/proposal_contribution.txt index ddabad05..c73e428b 100644 --- a/backend/grant/templates/emails/proposal_contribution.txt +++ b/backend/grant/templates/emails/proposal_contribution.txt @@ -1,6 +1,8 @@ Your proposal "{{ args.proposal.title }}" just got a -{{ args.contribution.amount }} ZEC contribution from -{{ args.contributor.display_name }}. Your proposal is now at {{ args.funded }} / {{ args.proposal.target }} ZEC. +{{ args.contribution.amount }} ZEC contribution from {{ args.contributor.display_name if args.contributor else 'an anonymous contributor' }}. +Your proposal is now at {{ args.funded }} / {{ args.proposal.target }} ZEC. +{% if args.contributor %} See {{ args.contributor.display_name }}'s profile: {{ args.contributor_url }} +{% endif %} View your proposal: {{ args.proposal_url }} \ No newline at end of file diff --git a/backend/grant/utils/stubs.py b/backend/grant/utils/stubs.py new file mode 100644 index 00000000..36794638 --- /dev/null +++ b/backend/grant/utils/stubs.py @@ -0,0 +1,9 @@ +# This should match UserSchema in grant.user.models +anonymous_user = { + 'userid': 0, + 'display_name': 'Anonymous', + 'title': 'N/A', + 'avatar': None, + 'social_medias': [], + 'email_verified': True, +} diff --git a/blockchain/package.json b/blockchain/package.json index c83973c0..9aa91ec6 100755 --- a/blockchain/package.json +++ b/blockchain/package.json @@ -30,7 +30,7 @@ "typescript": "^3.2.1" }, "dependencies": { - "@sentry/node": "4.5.2", + "@sentry/node": "4.4.2", "@types/cors": "2.8.4", "@types/dotenv": "^6.1.0", "@types/ws": "^6.0.1", diff --git a/blockchain/src/webhooks/index.ts b/blockchain/src/webhooks/index.ts index 95eddddc..d7e0e6e7 100755 --- a/blockchain/src/webhooks/index.ts +++ b/blockchain/src/webhooks/index.ts @@ -63,7 +63,6 @@ async function scanBlock(height: number) { consecutiveBlockFailures++; // If we fail a certain number of times, it's reasonable to // assume that the blockchain is down, and we should just quit. - // TODO: Scream at sentry or something! if (consecutiveBlockFailures >= MAXIMUM_BLOCK_FAILURES) { captureException(err); log.error('Maximum consecutive failures reached, exiting!'); diff --git a/blockchain/yarn.lock b/blockchain/yarn.lock index 2bcca62f..1810869e 100755 --- a/blockchain/yarn.lock +++ b/blockchain/yarn.lock @@ -2,57 +2,62 @@ # yarn lockfile v1 -"@sentry/core@4.5.2": - version "4.5.2" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-4.5.2.tgz#b103b659857e0fabf7219e9f34c4070bcb2af0c2" +"@sentry/core@4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-4.4.2.tgz#562526bc634c087f04bbca68b09cedc4b41cc64d" + integrity sha512-hJyAodTCf4sZfVdf41Rtuzj4EsyzYq5rdMZ+zc2Vinwdf8D0/brHe91fHeO0CKXEb2P0wJsrjwMidG/ccq/M8A== dependencies: - "@sentry/hub" "4.5.2" - "@sentry/minimal" "4.5.2" - "@sentry/types" "4.5.0" - "@sentry/utils" "4.5.2" + "@sentry/hub" "4.4.2" + "@sentry/minimal" "4.4.2" + "@sentry/types" "4.4.2" + "@sentry/utils" "4.4.2" tslib "^1.9.3" -"@sentry/hub@4.5.2": - version "4.5.2" - resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-4.5.2.tgz#b1df97dccc8644f6ef9b36e8747fe04342cb92ef" +"@sentry/hub@4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-4.4.2.tgz#1399556fda06fb83c4f186c4aa842725f520159c" + integrity sha512-oe9ytXkTWyD+QmOpVzHAqTbRV4Hc0ee2Nt6HvrDtRmlXzQxfvTWG2F8KYT6w8kzqg5klnuRpnsmgTTV3KuNBVQ== dependencies: - "@sentry/types" "4.5.0" - "@sentry/utils" "4.5.2" + "@sentry/types" "4.4.2" + "@sentry/utils" "4.4.2" tslib "^1.9.3" -"@sentry/minimal@4.5.2": - version "4.5.2" - resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-4.5.2.tgz#c090427f6ac276b4dc449e8be460ca5b35ce4fb3" +"@sentry/minimal@4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-4.4.2.tgz#13fffc6b17a2401b6a79947838a637626ab80b10" + integrity sha512-GEZZiNvVgqFAESZhAe3vjwTInn13lI2bSI3ItQN4RUWKL/W4n/fwVoDJbkb1U8aWxanuMnRDEpKwyQv6zYTZfw== dependencies: - "@sentry/hub" "4.5.2" - "@sentry/types" "4.5.0" + "@sentry/hub" "4.4.2" + "@sentry/types" "4.4.2" tslib "^1.9.3" -"@sentry/node@4.5.2": - version "4.5.2" - resolved "https://registry.yarnpkg.com/@sentry/node/-/node-4.5.2.tgz#b90747749cf919006ca44fd873da87fd3608f8e0" +"@sentry/node@4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-4.4.2.tgz#549921d2df3cbf58ebcfb525c3005c3fec4739a3" + integrity sha512-8/KlSdfVhledZ6PS6muxZY5r2pqhw8MNSXP7AODR2qRrHwsbnirVgV21WIAYAjKXEfYQGbm69lyoaTJGazlQ3Q== dependencies: - "@sentry/core" "4.5.2" - "@sentry/hub" "4.5.2" - "@sentry/types" "4.5.0" - "@sentry/utils" "4.5.2" + "@sentry/core" "4.4.2" + "@sentry/hub" "4.4.2" + "@sentry/types" "4.4.2" + "@sentry/utils" "4.4.2" "@types/stack-trace" "0.0.29" cookie "0.3.1" - https-proxy-agent "2.2.1" - lru_map "0.3.3" + https-proxy-agent "^2.2.1" lsmod "1.0.0" stack-trace "0.0.10" tslib "^1.9.3" -"@sentry/types@4.5.0": - version "4.5.0" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.5.0.tgz#59e2a27d48b01b44e8959aa5c8a30514fe1086a9" +"@sentry/types@4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.4.2.tgz#f38dd3bc671cd2f5983a85553aebeac9c2286b17" + integrity sha512-QyQd6PKKIyjJgaq/RQjsxPJEWbXcuiWZ9RvSnhBjS5jj53HEzkM1qkbAFqlYHJ1DTJJ1EuOM4+aTmGzHe93zuA== -"@sentry/utils@4.5.2": - version "4.5.2" - resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-4.5.2.tgz#0de5bef1c71e0dd800d378c010e5bfad91add91a" +"@sentry/utils@4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-4.4.2.tgz#e05a47e135ecef29e63a996f59aee8c8f792c222" + integrity sha512-j/Ad8G1abHlJdD2q7aWWbSOSeWB5M5v1R1VKL8YPlwEbSvvmEQWePhBKFI0qlnKd2ObdUQsj86pHEXJRSFNfCw== dependencies: - "@sentry/types" "4.5.0" + "@sentry/types" "4.4.2" tslib "^1.9.3" "@types/body-parser@*", "@types/body-parser@1.17.0": @@ -1000,9 +1005,10 @@ http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3: setprototypeof "1.1.0" statuses ">= 1.4.0 < 2" -https-proxy-agent@2.2.1: +https-proxy-agent@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz#51552970fa04d723e04c56d04178c3f92592bbc0" + integrity sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ== dependencies: agent-base "^4.1.0" debug "^3.1.0" @@ -1314,10 +1320,6 @@ lru-cache@^4.0.1: pseudomap "^1.0.2" yallist "^2.1.2" -lru_map@0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd" - lsmod@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lsmod/-/lsmod-1.0.0.tgz#9a00f76dca36eb23fa05350afe1b585d4299e64b" diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index c719e4a7..37641f65 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -8,7 +8,7 @@ import { TeamInvite, TeamInviteWithProposal, SOCIAL_SERVICE, - ContributionWithAddresses, + ContributionWithAddressesAndUser, EmailSubscriptions, RFP, ProposalPageParams, @@ -309,9 +309,11 @@ export function putInviteResponse( export function postProposalContribution( proposalId: number, amount: string, -): Promise<{ data: ContributionWithAddresses }> { + anonymous?: boolean, +): Promise<{ data: ContributionWithAddressesAndUser }> { return axios.post(`/api/v1/proposals/${proposalId}/contributions`, { amount, + anonymous, }); } @@ -331,13 +333,13 @@ export function deleteProposalContribution(contributionId: string | number) { export function getProposalContribution( proposalId: number, contributionId: number, -): Promise<{ data: ContributionWithAddresses }> { +): Promise<{ data: ContributionWithAddressesAndUser }> { return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`); } export function getProposalStakingContribution( proposalId: number, -): Promise<{ data: ContributionWithAddresses }> { +): Promise<{ data: ContributionWithAddressesAndUser }> { return axios.get(`/api/v1/proposals/${proposalId}/stake`); } diff --git a/frontend/client/components/ContributionModal/PaymentInfo.less b/frontend/client/components/ContributionModal/PaymentInfo.less index d819671b..df76607a 100644 --- a/frontend/client/components/ContributionModal/PaymentInfo.less +++ b/frontend/client/components/ContributionModal/PaymentInfo.less @@ -1,6 +1,11 @@ @import '~styles/variables.less'; .PaymentInfo { + &-anonymous { + margin-top: -0.5rem; + margin-bottom: -0.5rem; + } + &-text { margin-top: -0.25rem; font-size: 0.95rem; diff --git a/frontend/client/components/ContributionModal/PaymentInfo.tsx b/frontend/client/components/ContributionModal/PaymentInfo.tsx index ed8a0df0..654958ae 100644 --- a/frontend/client/components/ContributionModal/PaymentInfo.tsx +++ b/frontend/client/components/ContributionModal/PaymentInfo.tsx @@ -26,8 +26,9 @@ export default class PaymentInfo extends React.Component { }; render() { - const { contribution, text } = this.props; + const { contribution } = this.props; const { sendType } = this.state; + let text = this.props.text; let address; let memo; let amount; @@ -103,14 +104,24 @@ export default class PaymentInfo extends React.Component { ); } + if (!text) { + if (contribution && contribution.isAnonymous) { + text = ` + Thank you for contributing! Just send using whichever method works best for + you, and your contribution will show up anonymously once it's been confirmed. + ` + } else { + text = ` + Thank you for contributing! Just send using whichever method works best for + you, and we'll let you know once it's been confirmed. + `; + } + } + return (
- {text || - ` - Thank you for contributing! Just send using whichever method works best for - you, and we'll let you know when your contribution has been confirmed. - `} + {text}
{ state: State = { + hasConfirmedAnonymous: false, hasSent: false, contribution: null, + isFetchingContribution: false, error: null, }; @@ -43,34 +48,83 @@ export default class ContributionModal extends React.Component { } componentWillUpdate(nextProps: Props) { - const { isVisible, proposalId, contributionId, contribution } = nextProps; + const { + isVisible, + isAnonymous, + proposalId, + contributionId, + contribution, + } = nextProps; // When modal is opened and proposalId is provided or changed - if (isVisible && proposalId) { + // But not if we're anonymous, that will happen in confirmAnonymous + if (isVisible && proposalId && !isAnonymous) { if (this.props.isVisible !== isVisible || proposalId !== this.props.proposalId) { this.fetchAddresses(proposalId, contributionId); } } - // If contribution is provided + // If contribution is provided, update it if (contribution !== this.props.contribution) { this.setState({ contribution: contribution || null }); } + // When the modal is closed, clear out the contribution and anonymous check + if (this.props.isVisible && !isVisible) { + this.setState({ + contribution: null, + hasConfirmedAnonymous: false, + }); + } } render() { - const { isVisible, handleClose, hasNoButtons, text } = this.props; - const { hasSent, contribution, error } = this.state; + const { isVisible, isAnonymous, handleClose, hasNoButtons, text } = this.props; + const { hasSent, hasConfirmedAnonymous, contribution, error } = this.state; + let okText; + let onOk; let content; - if (hasSent) { + if (isAnonymous && !hasConfirmedAnonymous) { + okText = 'I accept'; + onOk = this.confirmAnonymous; + content = ( + + You are about to contribute anonymously. Your contribution will show up + without attribution, and even if you're logged in, will not + appear anywhere on your account after you close this modal. +

+ In the case of a refund, your contribution will be treated as a donation to + the Zcash Foundation instead. +

+ If you would like to have your contribution attached to an account, you + can close this modal, make sure you're logged in, and don't check the + "Contribute anonymously" checkbox. + + } + /> + ); + } else if (hasSent) { + okText = 'Done'; + onOk = handleClose; content = ( - Your contribution should be confirmed in about 20 minutes. You can keep an - eye on it at the{' '} - funded tab on your profile. + Your transaction should be confirmed in about 20 minutes.{' '} + {isAnonymous + ? 'Once it’s confirmed, it’ll show up in the contributions tab.' + : ( + <> + You can keep an eye on it at the{' '} + funded tab on your profile. + + ) + } } style={{ width: '90%' }} @@ -78,8 +132,12 @@ export default class ContributionModal extends React.Component { ); } else { if (error) { + okText = 'Done'; + onOk = handleClose; content = error; } else { + okText = 'I’ve sent it'; + onOk = this.confirmSend; content = ; } } @@ -90,8 +148,8 @@ export default class ContributionModal extends React.Component { visible={isVisible} closable={hasSent || hasNoButtons} maskClosable={hasSent || hasNoButtons} - okText={hasSent ? 'Done' : 'I’ve sent it'} - onOk={hasSent ? handleClose : this.confirmSend} + okText={okText} + onOk={onOk} onCancel={handleClose} footer={hasNoButtons ? '' : undefined} centered @@ -102,21 +160,31 @@ export default class ContributionModal extends React.Component { } private async fetchAddresses(proposalId: number, contributionId?: number) { + this.setState({ isFetchingContribution: true }); try { + const { amount, isAnonymous } = this.props; let res; if (contributionId) { res = await getProposalContribution(proposalId, contributionId); } else { - res = await postProposalContribution(proposalId, this.props.amount || '0'); + res = await postProposalContribution(proposalId, amount || '0', isAnonymous); } this.setState({ contribution: res.data }); } catch (err) { this.setState({ error: err.message }); } + this.setState({ isFetchingContribution: false }); } + private confirmAnonymous = () => { + const { state, props } = this; + this.setState({ hasConfirmedAnonymous: true }); + if (!state.contribution && !props.contribution && props.proposalId) { + this.fetchAddresses(props.proposalId, props.contributionId); + } + }; + private confirmSend = () => { - // TODO: Mark on backend this.setState({ hasSent: true }); }; } diff --git a/frontend/client/components/Profile/ProfilePending.tsx b/frontend/client/components/Profile/ProfilePending.tsx index 972a3ccb..ba0c120f 100644 --- a/frontend/client/components/Profile/ProfilePending.tsx +++ b/frontend/client/components/Profile/ProfilePending.tsx @@ -1,7 +1,7 @@ import React, { ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { Button, Popconfirm, message, Tag } from 'antd'; -import { UserProposal, STATUS, ContributionWithAddresses } from 'types'; +import { UserProposal, STATUS, ContributionWithAddressesAndUser } from 'types'; import ContributionModal from 'components/ContributionModal'; import { getProposalStakingContribution } from 'api/api'; import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions'; @@ -29,7 +29,7 @@ interface State { isDeleting: boolean; isPublishing: boolean; isLoadingStake: boolean; - stakeContribution: ContributionWithAddresses | null; + stakeContribution: ContributionWithAddressesAndUser | null; } class ProfilePending extends React.Component { diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index e6d053ab..9593b871 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import moment from 'moment'; -import { Form, Input, Button, Icon, Popover } from 'antd'; +import { Form, Input, Checkbox, Button, Icon, Popover, Tooltip } from 'antd'; +import { CheckboxChangeEvent } from 'antd/lib/checkbox'; import { Proposal, STATUS } from 'types'; import classnames from 'classnames'; import { fromZat } from 'utils/units'; @@ -21,7 +22,7 @@ interface OwnProps { } interface StateProps { - sendLoading: boolean; + authUser: AppState['auth']['user']; } type Props = OwnProps & StateProps; @@ -29,6 +30,7 @@ type Props = OwnProps & StateProps; interface State { amountToRaise: string; amountError: string | null; + isAnonymous: boolean; isContributing: boolean; } @@ -38,13 +40,14 @@ export class ProposalCampaignBlock extends React.Component { this.state = { amountToRaise: '', amountError: null, + isAnonymous: false, isContributing: false, }; } render() { - const { proposal, sendLoading, isPreview } = this.props; - const { amountToRaise, amountError, isContributing } = this.state; + const { proposal, isPreview, authUser } = this.props; + const { amountToRaise, amountError, isAnonymous, isContributing } = this.state; const amountFloat = parseFloat(amountToRaise) || 0; let content; if (proposal) { @@ -165,7 +168,7 @@ export class ProposalCampaignBlock extends React.Component { /> - + { disabled={isPreview} /> + {amountToRaise && + !!authUser && ( + + Contribute anonymously + + + + + )}