import Maybe, { Just, Nothing } from 'folktale/maybe'; import React, { useState } from 'react'; import * as azimuth from 'azimuth-js'; import { Grid, Flex, Button, Input, IconButton } from 'indigo-react'; import * as need from 'lib/need'; import { hasReceived, sendMail } from 'lib/inviteMail'; import * as wg from 'lib/walletgen'; import { H3, Warning } from 'components/old/Base'; // for transaction generation and signing import { signTransaction, sendSignedTransaction, waitForTransactionConfirm, hexify, fromWei, toWei, } from 'lib/txn'; import * as tank from 'lib/tank'; import { useLocalRouter } from 'lib/LocalRouter'; import View from 'components/View'; import MiniBackButton from 'components/MiniBackButton'; import useRenderCount from 'lib/useRenderCount'; import useInvites from 'lib/useInvites'; import { usePointCursor } from 'store/pointCursor'; import LoadableButton from 'components/LoadableButton'; import useArray from 'lib/useArray'; import useForm from 'indigo-react/lib/useForm'; import { buildEmailInputConfig } from 'components/Inputs'; const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux const GAS_LIMIT = 350000; const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei'); const STATUS = { INPUT: 'Generate invites', GENERATING: 'Generating invites', CAN_SEND: 'Send invites', FUNDING: 'Funding invites', SENDING: 'Sending invites', DONE: 'Done', FAIL: 'Some failed', }; const EMAIL_STATUS = { BUSY: '⋯', DONE: '✔', FAIL: '×', }; // world's simplest uid let id = 0; const buildInputConfig = (extra = {}) => buildEmailInputConfig({ name: `email-${id++}`, ...extra }); export default function NewInviteEmail() { const { pop } = useLocalRouter(); const { pointCursor } = usePointCursor(); const point = need.pointCursor(pointCursor); const { availableInvites } = useInvites(point); const maxInvitesToSend = availableInvites.matchWith({ Nothing: () => 0, Just: p => p.value, }); const [ inputConfigs, { append: appendInput, removeAt: removeInputAt }, ] = useArray( [buildInputConfig({ placeholder: 'Email Addresss' })], buildInputConfig ); const [hovered, _setHovered] = useState({}); const setHovered = (name, value) => () => _setHovered((value = { ...value, [name]: value })); console.log(inputConfigs.length, inputConfigs.map(c => c.name)); const { inputs, pass } = useForm(inputConfigs); const canAddInvite = inputs.length < maxInvitesToSend; useRenderCount('InviteEmail'); return ( pop()} /> + {/* email inputs */} {inputs.map((input, i) => { const isFirst = i === 0; return ( {!isFirst && (input.focused || hovered[input.name]) && ( removeInputAt(i)}>- )} ); })} Generate Invites ); } class InviteEmail extends React.Component { constructor(props) { super(props); this.state = { invitesAvailable: Nothing(), targets: [{ email: '', hasReceived: Nothing(), status: Nothing() }], status: STATUS.INPUT, errors: Nothing(), // fundingMessage: Nothing(), wipInvites: [], }; this.generateInvites = this.generateInvites.bind(this); this.sendInvites = this.sendInvites.bind(this); this.canSend = this.canSend.bind(this); this.addError = this.addError.bind(this); this.handleEmailInput = this.handleEmailInput.bind(this); this.addTarget = this.addTarget.bind(this); this.removeTarget = this.removeTarget.bind(this); this.setEmailStatus = this.setEmailStatus.bind(this); this.askForFunding = this.askForFunding.bind(this); } componentDidMount() { this.point = need.pointCursor(this.props.pointCursor); this.address = need.addressFromWallet(this.props.wallet); this.contracts = need.contracts(this.props.contracts); this.web3 = need.web3(this.props.web3); azimuth.delegatedSending .getTotalUsableInvites(this.contracts, this.point) .then(count => { this.setState({ invitesAvailable: Just(count) }); }); } async getTemporaryWallet() { const ticket = await wg.makeTicket(0x10000); // planet-sized ticket const owner = await wg.generateOwnershipWallet(0, ticket); return { ticket, owner: owner.keys.address }; } async generateInvites() { this.setState({ status: STATUS.GENERATING }); const web3 = this.web3; const targets = this.state.targets; const planets = await azimuth.delegatedSending.getPlanetsToSend( this.contracts, this.point, targets.length ); // account for the race condition where invites got used up while we were // composing our target list if (planets.length < targets.length) { this.addError('Can currently only send ' + planets.length + ' invites!'); return; } const nonce = await web3.eth.getTransactionCount(this.address); const chainId = await web3.eth.net.getId(); // create invite wallets and transactions let invites = []; // { email, ticket, signedTx, rawTx } for (let i = 0; i < targets.length; i++) { console.log('generating email', i, targets[i].email); this.setEmailStatus(i, EMAIL_STATUS.BUSY); const email = targets[i].email; const wallet = await this.getTemporaryWallet(); let inviteTx = azimuth.delegatedSending.sendPoint( this.contracts, this.point, planets[i], wallet.owner ); const signedTx = await signTransaction({ ...this.props, txn: Just(inviteTx), gasPrice: GAS_PRICE_GWEI.toString(), gasLimit: GAS_LIMIT.toString(), nonce: nonce + i, chainId: chainId, setStx: () => {}, }); const rawTx = hexify(signedTx.serialize()); invites.push({ email, ticket: wallet.ticket, signedTx, rawTx }); this.setEmailStatus(i, EMAIL_STATUS.DONE); } this.setState({ wipInvites: invites, status: STATUS.CAN_SEND }); } async sendInvites() { const invites = this.state.wipInvites; const web3 = this.web3; //TODO invite status isn't changing/re-rendering... why? this.setState({ status: STATUS.FUNDING, invites: invites.map(i => { return { ...i, status: Nothing() }; }), }); const tankWasUsed = await tank.ensureFundsFor( web3, this.point, this.address, INVITE_COST * invites.length, invites.map(i => i.rawTx), this.askForFunding, () => { this.setState({ fundingMessage: Nothing() }); } ); this.setState({ status: STATUS.SENDING }); //TODO nasty loop, probably move into lib... but so many callbacks! let promises = []; for (let i = 0; i < invites.length; i++) { const invite = invites[i]; // send transaction. if it fails, inform the user and skip to next let txHash; try { txHash = await sendSignedTransaction( web3, Just(invite.signedTx), tankWasUsed, () => {} ); } catch (err) { console.error('invite tx sending failed', err); this.addError('Invite transaction not sent for ' + invite.email); this.setEmailStatus(i, EMAIL_STATUS.FAIL); continue; } // ask the email sender to send this. we can do this prior to tx confirm, // because the sender service waits for confirm & success for us. // if this fails, inform the user and skip to next try { const mailSuccess = await sendMail( invite.email, invite.ticket, invite.rawTx ); if (!mailSuccess) throw new Error(); this.setEmailStatus(i, EMAIL_STATUS.DONE); } catch (e) { console.log('email send failed'); //NOTE this assumes that the transaction succeeds, but we don't know // that for a fact yet... this.addError( 'Invite email failed to send for ' + invite.email + '. Please give them this ticket: ' + invite.ticket ); this.setEmailStatus(i, EMAIL_STATUS.FAIL); continue; } // update status on transaction confirm, but don't block on it promises.push( waitForTransactionConfirm(web3, txHash).then(success => { if (success) { this.setEmailStatus(i, EMAIL_STATUS.DONE); } else { console.log('invite tx rejected'); this.addError('Invite transaction failed for ' + invite.email); this.setEmailStatus(i, EMAIL_STATUS.FAIL); } return success; }) ); } const txStatus = await Promise.all(promises); if (txStatus.every(e => e)) { this.setState({ status: STATUS.DONE }); } else { this.setState({ status: STATUS.FAIL }); } } setEmailStatus(i, status) { const targets = this.state.targets; targets[i].status = Just(status); this.setState({ targets }); } addError(error) { const newError = this.state.errors.matchWith({ Nothing: () => [error], Just: errs => { errs.value.push(error); return errs.value; }, }); this.setState({ errors: Just(newError) }); } askForFunding(address, minBalance, balance) { this.setState({ fundingMessage: Just( `Please make sure ${address} has at least ${fromWei(minBalance)} ETH,` + `we'll continue once that's true. Current balance: ${fromWei( balance )}.` + `Waiting...` ), }); } handleEmailInput(i, email) { let targets = this.state.targets; targets[i].email = email; // if it's a valid address, check if it has received an invite before if (email.match(/.*@.*\...+/)) { hasReceived(email).then(res => { targets[i].hasReceived = Just(res); this.setState({ targets }); }); } else { targets[i].hasReceived = Nothing(); } this.setState({ targets }); } canSend() { const targets = this.state.targets; // may not have duplicate targets if (new Set(targets.map(t => t.email)).size !== targets.length) return false; // none may have received invites already for (let i = 0; i < targets.length; i++) { const target = targets[i]; const res = target.hasReceived.matchWith({ Nothing: () => true, Just: has => has.value, }); if (res) return false; } return this.state.invitesAvailable.matchWith({ Nothing: () => false, Just: invites => invites.value > 0, }); } addTarget() { let targets = this.state.targets; targets.push({ email: '', hasReceived: Nothing(), status: Nothing() }); this.setState({ targets }); } removeTarget() { let targets = this.state.targets; targets.length = targets.length - 1; this.setState({ targets }); } render() { const dif = this.state.status === STATUS.DONE ? this.state.targets.length : null; const error = this.state.errors.matchWith({ Nothing: () => null, Just: errors => (

{'Something went wrong!'}

{errors.value.map(err => (

{err}

))}
), }); const fundingMessage = this.state.fundingMessage.matchWith({ Nothing: () => null, Just: msg => {msg.value}, }); let inputs; if (this.state.status === STATUS.DONE) { //TODO proper clickable router thing inputs = '<- Back to Home'; } else { const inputDisabled = this.state.status !== STATUS.INPUT; const lessButton = ( ); const moreButton = ( ); let targetInputs = []; for (let i = 0; i < this.state.targets.length; i++) { const target = this.state.targets[i]; let progress = target.status.matchWith({ Nothing: () => '', Just: status => status.value, }); targetInputs.push( <> { this.handleEmailInput(i, value); }} placeholder={'Email address'} disabled={inputDisabled} /> {target.hasReceived.matchWith({ Nothing: () => null, Just: has => has.value ? {'×'} : {'°'}, })} {progress} ); } const submitButton = ( ); inputs = ( <> {lessButton} {moreButton} {targetInputs} {submitButton} ); } return ( {error} {fundingMessage} {inputs} ); } } // export default compose( // withNetwork, // withWallet, // withPointCursor // )(InviteEmail);