import React, { useMemo, useCallback, useState, useEffect } from 'react'; import { Grid, ErrorText } from 'indigo-react'; import { get, uniq, zip, compact, isEqual } from 'lodash'; import * as azimuth from 'azimuth-js'; import { FORM_ERROR } from 'final-form'; import { toWei, toBN } from 'web3-utils'; import * as ob from 'urbit-ob'; import * as need from 'lib/need'; import * as wg from 'lib/walletgen'; import * as tank from 'lib/tank'; import useCopiable from 'lib/useCopiable'; import Tabs from 'components/Tabs'; import ProgressButton from 'components/ProgressButton'; import { ForwardButton } from 'components/Buttons'; import NeedFundsNotice from 'components/NeedFundsNotice'; import { useNetwork } from 'store/network'; import { useWallet } from 'store/wallet'; import { usePointCursor } from 'store/pointCursor'; import { usePointCache } from 'store/pointCache'; import { signTransaction, sendSignedTransaction, waitForTransactionConfirm, hexify, } from 'lib/txn'; import { MIN_PLANET, GAS_LIMITS } from 'lib/constants'; import pluralize from 'lib/pluralize'; import useGasPrice from 'lib/useGasPrice'; import EmailChipInput from 'form/EmailChipInput'; import FormError from 'form/FormError'; import SubmitButton from 'form/SubmitButton'; import BridgeForm from 'form/BridgeForm'; import { composeValidator, hasErrors, buildEmailValidator, buildArrayValidator, } from 'form/validators'; import { WARNING } from 'form/helpers'; import useMailer from 'lib/useMailer'; const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET; const STATUS = { INPUT: 'INPUT', SENDING: 'SENDING', SUCCESS: 'SUCCESS', FAILURE: 'FAILURE', }; const HAS_RECEIVED_TEXT = 'This email has already received an invite.'; const useInviter = sendInvites => { const { contracts, web3, networkType } = useNetwork(); const { wallet, walletType, walletHdPath } = useWallet(); const { pointCursor } = usePointCursor(); const { syncInvites } = usePointCache(); const { gasPrice } = useGasPrice(); const point = need.point(pointCursor); const [needFunds, setNeedFunds] = useState(null); const [invites, setInvites] = useState([]); const [progress, setProgress] = useState(0); const [txStatus, setTxStatus] = useState(STATUS.INPUT); // const [errors, addError] = useSetState(); const isDone = txStatus === STATUS.SUCCESS; useEffect(() => { if (isDone) { syncInvites(point); } }, [isDone, syncInvites, point]); const resetInvites = useCallback(() => { setProgress(0); setInvites([]); setTxStatus(STATUS.INPUT); }, [setProgress, setInvites, setTxStatus]); const generateInvites = useCallback( async numInvites => { const _contracts = contracts.getOrElse(null); const _web3 = web3.getOrElse(null); const _wallet = wallet.getOrElse(null); if (!_contracts || !_web3 || !_wallet) { // not using need because we want a custom error throw new Error('Internal Error: Missing Contracts/Web3/Wallet'); } const nonce = await _web3.eth.getTransactionCount(_wallet.address); const chainId = await _web3.eth.net.getId(); debugger; const planets = await azimuth.delegatedSending.getPlanetsToSend( _contracts, point, numInvites ); setTxStatus(STATUS.SENDING); // account for the race condition where invites got used up while we were // composing our target list if (planets.length < numInvites) { // resync invites to the cache, since they're out of date syncInvites(point); setTxStatus(STATUS.FAILURE); return { errors: { [FORM_ERROR]: `Can currently only send ${planets.length} invites. ` + `Please remove invites until you are within the limit.`, }, }; } // NB(shrugs) - must be processed in serial because main thread, etc let signedInvites = []; let errorCount = 0; for (let i = 0; i < numInvites; i++) { setProgress(x => x + 1); try { const planet = planets[i]; const { ticket, owner } = await wg.generateTemporaryTicketAndWallet( MIN_PLANET // we're always giving planets, so generate a ticket of the correct size ); const inviteTx = azimuth.delegatedSending.sendPoint( _contracts, point, planet, owner.keys.address ); const signedTx = await signTransaction({ wallet: _wallet, walletType, walletHdPath, networkType, chainId, nonce: nonce + i, // TODO: ^ make a useTransactionSigner to encapsulate this logic txn: inviteTx, gasPrice: gasPrice.toFixed(), // expects string gwei gasLimit: GAS_LIMIT.toFixed(), }); const rawTx = hexify(signedTx.serialize()); signedInvites.push({ ticket, signedTx, rawTx }); } catch (error) { console.error(error); return { errors: { [WARNING]: `There was an error while generating wallets.`, }, }; } } const totalCost = toBN(GAS_LIMIT) .mul(toBN(gasPrice)) .mul(toBN(numInvites)); const tankWasUsed = await tank.ensureFundsFor( _web3, point, _wallet.address, toWei(totalCost.toString(), 'gwei'), Object.keys(invites).map(name => invites[name].rawTx), (address, minBalance, balance) => setNeedFunds({ address, minBalance, balance }), () => setNeedFunds(undefined) ); let unsentInvites = []; let confirmedInvites = []; const txAndMailings = signedInvites.map(async invite => { try { const txHash = await sendSignedTransaction( _web3, invite.signedTx, tankWasUsed ); await waitForTransactionConfirm(_web3, txHash); confirmedInvites.push(invite); } catch (error) { console.error(error); unsentInvites.push(invite); return; } }); await Promise.all(txAndMailings); setTxStatus(STATUS.SUCCESS); setInvites(confirmedInvites); console.log(unsentInvites); console.log(confirmedInvites); if (unsentInvites.length > 0) { return { errors: { [FORM_ERROR]: unsentInvites } }; } if (errorCount > 0) { return { errors: { [WARNING]: `There ${pluralize( errorCount, 'was', 'were' )} ${pluralize( errorCount, 'error' )} while generating wallets. You can still send the invites that generated correctly.`, }, }; } return { invites: confirmedInvites }; }, [ contracts, gasPrice, invites, networkType, point, syncInvites, wallet, walletHdPath, walletType, web3, ] ); return { progress, txStatus, needFunds, invites, generateInvites, resetInvites, }; }; const InviteMail = () => { const { progress, txStatus, needFunds, generateInvites } = useInviter(); const { sendMail, getHasReceived } = useMailer(); const [status, setStatus] = useState(STATUS.INPUT); const [count, setCount] = useState(0); const canInput = status === STATUS.INPUT; const isSending = txStatus === STATUS.SENDING || status === STATUS.SENDING; const isDone = status === STATUS.SUCCESS; const isFailed = status === STATUS.FAILED; const { pointCursor } = usePointCursor(); const point = need.point(pointCursor); const sendInvites = useCallback( async (emails, invites) => { const mailingErrors = compact( await Promise.all( zip(emails, invites).map(([email, invite]) => sendMail(email, invite.ticket, ob.patp(point), '', invite.rawTx) .then(() => null) .catch( () => `Sending ticket ${invite.ticket} to ${email} failed.` ) ) ) ).join(', '); if (mailingErrors.length) { return { [FORM_ERROR]: mailingErrors }; } }, [point, sendMail] ); const onSubmit = useCallback( async values => { const emailCount = values.emails.length; setCount(emailCount); setStatus(STATUS.SENDING); const { errors, invites } = await generateInvites(emailCount); if (errors) { setStatus(STATUS.FAILED); return errors; } const mailErrors = await sendInvites(values.emails, invites); console.log(mailErrors); if (mailErrors) { console.log(mailErrors); setStatus(STATUS.FAILED); return mailErrors; } setStatus(STATUS.SUCCESS); }, [generateInvites, setCount, setStatus, sendInvites] ); const validateHasReceived = useCallback( async email => { const hasReceived = await getHasReceived(email); if (hasReceived) { return HAS_RECEIVED_TEXT; } }, [getHasReceived] ); const buttonText = () => { switch (status) { case STATUS.INPUT: return 'Add to Invite Group'; case STATUS.SENDING: return `Sending ${pluralize(count, 'Invite')}...`; case STATUS.SUCCESS: return `✓ ${pluralize(count, 'Invite')} sent`; case STATUS.FAILURE: default: return 'Error'; } }; const renderButton = handleSubmit => { if (canInput) { return ( {buttonText()} ); } if (isSending) { const percentProgress = progress / count; return ( {buttonText(count)} ); } if (isDone) { return ( {buttonText(count)} ); } if (isFailed) { return ( {buttonText(count)} ); } return null; }; const validateForm = useCallback((values, errors) => { if (hasErrors(errors)) { return errors; } // check for email uniqenesss const emails = values.emails.filter(d => !!d); if (uniq(emails).length !== emails.length) { return { [FORM_ERROR]: 'Duplicate email.' }; } }, []); const validate = useMemo( () => composeValidator( { emails: buildArrayValidator(buildEmailValidator(validateHasReceived)), }, validateForm ), [validateHasReceived, validateForm] ); const initialValues = { emails: [] }; return ( {({ handleSubmit, values }) => ( <> {console.log(values) && null} {renderButton(handleSubmit)} {needFunds && ( )} )} ); }; const InviteUrl = () => { const { txStatus, needFunds, invites, generateInvites } = useInviter(); const [error, setError] = useState(); const generateInvite = useCallback(async () => { const errors = await generateInvites(1); if (hasErrors(errors)) { setError(errors); } }, [generateInvites]); const renderGenerateButton = () => { if (error) { return ( Error generating invite ); } switch (txStatus) { case STATUS.INPUT: return ( generateInvite()}> Generate Invite URL ); case STATUS.SENDING: return ( Generating Invite URL ); default: return null; } }; const url = `https://bridge.urbit.org/#${get(invites, '[0].ticket', '')}`; const [doCopy, didCopy] = useCopiable(url); return ( {invites.length > 0 && ( <>
{url}
doCopy()}> {didCopy ? 'Copied' : 'Copy'}
Your invite link expires after consumption. )} {invites.length === 0 && renderGenerateButton()} {needFunds && } {error && {JSON.stringify(error)}} ); }; const InviteCohort = () => { const [tab, setTab] = useState(NAMES.MAIL); return ( ); }; const NAMES = { MAIL: 'MAIL', URL: 'URL', }; const VIEWS = { [NAMES.MAIL]: InviteMail, [NAMES.URL]: InviteUrl, }; const OPTIONS = [ { text: 'Email', value: NAMES.MAIL }, { text: 'URL', value: NAMES.URL }, ]; export default InviteCohort;