on 4da98a7 help sign in
bridge/src/views/Invite/InviteEmail.js
502 lines 15 kB history raw
1import React, { useCallback, useEffect, useMemo, useState } from 'react';
2import cn from 'classnames';
3import Maybe from 'folktale/maybe';
4import * as azimuth from 'azimuth-js';
5import {
6 Grid,
7 Flex,
8 Input,
9 IconButton,
10 HelpText,
11 Text,
12 ErrorText,
13} from 'indigo-react';
14import { uniq } from 'lodash';
16import * as need from 'lib/need';
17import * as wg from 'lib/walletgen';
19// for transaction generation and signing
20import {
21 signTransaction,
22 sendSignedTransaction,
23 waitForTransactionConfirm,
24 hexify,
25 fromWei,
26 toWei,
27} from 'lib/txn';
28import * as tank from 'lib/tank';
29import { useLocalRouter } from 'lib/LocalRouter';
31import MiniBackButton from 'components/MiniBackButton';
32import useInvites from 'lib/useInvites';
33import { usePointCursor } from 'store/pointCursor';
34import LoadableButton from 'components/LoadableButton';
35import useArray from 'lib/useArray';
36import useForm from 'indigo-react/lib/useForm';
37import { buildEmailInputConfig } from 'components/Inputs';
38import { MIN_PLANET } from 'lib/constants';
39import Highlighted from 'components/Highlighted';
40import { useNetwork } from 'store/network';
41import { usePointCache } from 'store/pointCache';
42import { useWallet } from 'store/wallet';
43import useSetState from 'lib/useSetState';
44import pluralize from 'lib/pluralize';
45import useMailer from 'lib/useMailer';
46import useRenderCount from 'lib/useRenderCount';
48const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
49const GAS_LIMIT = 400000;
50const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
52const STATUS = {
53 INPUT: 'INPUT',
54 GENERATING: 'GENERATING',
55 CAN_SEND: 'CAN_SEND',
56 FUNDING: 'FUNDING',
57 SENDING: 'SENDING',
58 SUCCESS: 'SUCCESS',
59 FAILURE: 'FAILURE',
60};
62const buttonText = (status, count) => {
63 switch (status) {
64 case STATUS.INPUT:
65 return `Generate ${pluralize(count, 'Invite')}`;
66 case STATUS.GENERATING:
67 return `Generating ${pluralize(count, 'Invite')}...`;
68 case STATUS.CAN_SEND:
69 return `Send ${pluralize(count, 'Invite')}`;
70 case STATUS.FUNDING:
71 return `Waiting on Funds...`;
72 case STATUS.SENDING:
73 return `Sending ${pluralize(count, 'Invite')}...`;
74 case STATUS.FAILURE:
75 default:
76 return 'Error';
77 }
78};
80const kPendingAccessory = '⋯';
81const kSuccessAccessory = '✔';
82const kFailureAccessory = '×';
84// world's simplest uid
85let id = 0;
86const buildInputConfig = (extra = {}) =>
87 buildEmailInputConfig({ name: `email-${id++}`, ...extra });
89const buildAccessoryFor = (dones, errors) => name => (
90 <Flex justify="center" align="center" style={{ height: '100%' }}>
91 {(() => {
92 if (dones[name]) return kSuccessAccessory;
93 if (errors[name]) return kFailureAccessory;
94 return kPendingAccessory;
95 })()}
96 </Flex>
97);
99// TODO: test with tank, successful txs
100// TODO: put accessory inside of input
101export default function InviteEmail() {
102 // TODO: resumption after error?
103 const { pop } = useLocalRouter();
104 const { contracts, web3, networkType } = useNetwork();
105 const { wallet, walletType, walletHdPath } = useWallet();
106 const { syncInvites } = usePointCache();
107 const { pointCursor } = usePointCursor();
108 const point = need.pointCursor(pointCursor);
110 const { availableInvites } = useInvites(point);
111 const maxInvitesToSend = availableInvites.matchWith({
112 Nothing: () => 0,
113 Just: p => p.value,
114 });
116 const [
117 inputConfigs,
118 { append: appendInput, removeAt: removeInputAt },
119 ] = useArray(
120 [buildInputConfig({ placeholder: 'Email Address' })],
121 buildInputConfig
122 );
124 const { getHasRecieved, syncHasReceivedForEmail, sendMail } = useMailer();
125 const [hovered, setHovered] = useSetState();
126 const [invites, addInvite, clearInvites] = useSetState();
127 const [receipts, addReceipt, clearReceipts] = useSetState();
128 const [errors, addError, clearError] = useSetState();
130 const [status, setStatus] = useState(STATUS.INPUT);
131 const [needFunds, setNeedFunds] = useState(null);
132 const [generalError, setGeneralError] = useState(null);
134 const canInput = status === STATUS.INPUT;
135 const canSend = status === STATUS.CAN_SEND;
136 const isGenerating = status === STATUS.GENERATING;
137 const isSending = status === STATUS.SENDING;
138 const isFunding = status === STATUS.FUNDING;
139 const isFailed = status === STATUS.FAILURE;
140 const isDone = status === STATUS.SUCCESS;
142 const dynamicConfigs = useMemo(
143 () =>
144 inputConfigs.map(config => {
145 config.disabled = !canInput;
146 const hasReceivedError = getHasRecieved(config.name).matchWith({
147 Nothing: () => null, // loading
148 Just: p => p.value && 'This email has already received an invite.',
149 });
150 config.error = hasReceivedError || errors[config.name];
151 return config;
152 }),
153 [inputConfigs, errors, canInput, getHasRecieved]
154 );
155 const { inputs, pass } = useForm(dynamicConfigs);
156 const emails = useMemo(() => inputs.map(i => i.data).filter(d => !!d), [
157 inputs,
158 ]);
160 const canAddInvite = canInput && inputs.length < maxInvitesToSend;
161 const allPass = pass && !generalError;
162 const canGenerate = allPass && status === STATUS.INPUT;
164 const progress = isGenerating
165 ? Object.keys(invites).length
166 : isSending
167 ? Object.keys(receipts).length
168 : null;
169 const visualProgress = progress === null ? '-' : `${progress + 1}`;
171 const accessoryFor = (() => {
172 if (isGenerating || canSend) return buildAccessoryFor(invites, errors);
173 if (isSending) return buildAccessoryFor(receipts, errors);
174 if (isFailed) return buildAccessoryFor({}, errors);
175 return () => null;
176 })();
178 const generateInvites = useCallback(async () => {
179 const _contracts = contracts.getOrElse(null);
180 const _web3 = web3.getOrElse(null);
181 const _wallet = wallet.getOrElse(null);
182 if (!_contracts || !_web3 || !_wallet) {
183 // not using need because we want a custom error
184 throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
185 }
187 const nonce = await _web3.eth.getTransactionCount(_wallet.address);
188 const chainId = await _web3.eth.net.getId();
189 const planets = await azimuth.delegatedSending.getPlanetsToSend(
190 _contracts,
191 point,
192 inputs.length
193 );
195 // account for the race condition where invites got used up while we were
196 // composing our target list
197 if (planets.length < inputs.length) {
198 // resync invites to the cache, since they're out of date
199 syncInvites();
201 throw new Error(
202 `Can currently only send ${planets.length} invites. ` +
203 `Please remove invites until you are within the limit.`
204 );
205 }
207 let errorCount = 0;
208 clearInvites();
209 // NB(shrugs) - must be processed in serial because main thread, etc
210 for (let i = 0; i < inputs.length; i++) {
211 const input = inputs[i];
212 try {
213 const { data: email, name } = inputs[i];
215 const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
216 MIN_PLANET
217 // we're always giving planets, so generate a ticket of the correct size
218 );
220 const inviteTx = azimuth.delegatedSending.sendPoint(
221 _contracts,
222 point,
223 planets[i],
224 owner.keys.address
225 );
226 const signedTx = await signTransaction({
227 wallet,
228 walletType,
229 walletHdPath,
230 networkType,
231 // TODO: ^ make a useTransactionSigner to encapsulate this logic
232 txn: Maybe.Just(inviteTx),
233 gasPrice: GAS_PRICE_GWEI.toString(),
234 gasLimit: GAS_LIMIT.toString(),
235 nonce: nonce + i,
236 chainId,
237 setStx: () => {},
238 });
239 const rawTx = hexify(signedTx.serialize());
241 addInvite({ [name]: { email, ticket, signedTx, rawTx } });
242 } catch (error) {
243 console.error(error);
244 errorCount++;
245 addError({ [input.name]: `Wallet Error: ${input.email}` });
246 }
247 }
249 if (errorCount > 0) {
250 throw new Error(
251 `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
252 errorCount,
253 'error'
254 )} while generating wallets.`
255 );
256 }
257 }, [
258 point,
259 contracts,
260 web3,
261 addInvite,
262 clearInvites,
263 syncInvites,
264 inputs,
265 networkType,
266 wallet,
267 walletType,
268 walletHdPath,
269 addError,
270 ]);
272 const sendInvites = useCallback(async () => {
273 const _web3 = web3.getOrElse(null);
274 const _wallet = wallet.getOrElse(null);
275 if (!_web3 || !_wallet) {
276 throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
277 }
279 setStatus(STATUS.FUNDING);
280 const tankWasUsed = await tank.ensureFundsFor(
281 _web3,
282 point,
283 _wallet.address,
284 INVITE_COST * inputs.length,
285 inputs.map(input => invites[input.name].rawTx),
286 (address, minBalance, balance) =>
287 setNeedFunds({ address, minBalance, balance }),
288 () => setNeedFunds(false)
289 );
291 setStatus(STATUS.SENDING);
292 clearReceipts();
294 let errorCount = 0;
295 const txAndMailings = inputs.map(async input => {
296 const invite = invites[input.name];
297 try {
298 const txHash = await sendSignedTransaction(
299 _web3,
300 Maybe.Just(invite.signedTx),
301 tankWasUsed,
302 () => {}
303 );
305 // TODO: waitForTransactionConfirm never rejects
306 const didConfirm = await waitForTransactionConfirm(_web3, txHash);
307 if (!didConfirm) throw new Error();
308 } catch (error) {
309 console.error(error);
310 errorCount++;
311 addError({
312 [input.name]: `Transaction Failure for ${invite.email}`,
313 });
314 return;
315 }
317 try {
318 const mailSuccess = await sendMail(
319 invite.email,
320 invite.ticket,
321 invite.rawTx
322 );
323 if (!mailSuccess) throw new Error();
324 } catch (error) {
325 console.error(error);
326 errorCount++;
327 addError({
328 [input.name]: `Mailing Failure for ${invite.email}`,
329 });
330 }
332 addReceipt({ [input.name]: true });
333 });
335 await Promise.all(txAndMailings);
336 // if there are any receipt errors, throw a general error
337 if (errorCount > 0) {
338 throw new Error(
339 `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
340 errorCount,
341 'error'
342 )} while sending transactions.`
343 );
344 }
345 }, [
346 web3,
347 inputs,
348 addReceipt,
349 clearReceipts,
350 invites,
351 point,
352 wallet,
353 addError,
354 sendMail,
355 ]);
357 const onClickGenerate = useCallback(async () => {
358 setGeneralError(null);
359 setStatus(STATUS.GENERATING);
360 try {
361 await generateInvites();
362 setStatus(STATUS.CAN_SEND);
363 } catch (error) {
364 console.error(error);
365 setGeneralError(error);
366 setStatus(STATUS.FAILURE);
367 }
368 }, [setStatus, setGeneralError, generateInvites]);
370 const onClickSend = useCallback(async () => {
371 setGeneralError(null);
372 setStatus(STATUS.SENDING);
373 try {
374 await sendInvites();
375 setStatus(STATUS.SUCCESS);
376 } catch (error) {
377 console.error(error);
378 setGeneralError(error);
379 setStatus(STATUS.FAILURE);
380 }
381 }, [setStatus, sendInvites, setGeneralError]);
383 const onClick = useCallback(() => {
384 if (canGenerate) {
385 onClickGenerate();
386 } else if (canSend) {
387 onClickSend();
388 }
389 }, [canGenerate, canSend, onClickGenerate, onClickSend]);
391 useEffect(() => {
392 if (uniq(emails).length !== emails.length) {
393 setGeneralError(new Error(`Duplicate email.`));
394 } else {
395 setGeneralError(null);
396 }
397 }, [emails]);
399 return (
400 <Grid gap={12}>
401 <Grid.Item as={Grid} full>
402 <Grid.Item as={Flex} cols={[1, 11]} align="center">
403 <MiniBackButton onClick={() => pop()} />
404 </Grid.Item>
405 <Grid.Item cols={[11, 13]} justifySelf="end">
406 {/* use hidden class instead of removing component from dom */}
407 {/* in order to avoid janky reflow */}
408 <IconButton
409 onClick={() => appendInput()}
410 disabled={!canAddInvite}
411 className={cn({ hidden: isDone })}
412 solid>
413 +
414 </IconButton>
415 </Grid.Item>
416 </Grid.Item>
418 {isDone && (
419 <>
420 <Grid.Item as={Text} full>
421 <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
422 {pluralize(inputs.length, 'has', 'have')} been successfully sent
423 </Grid.Item>
424 {inputs.map(input => (
425 <Grid.Item as={HelpText} key={input.name} full>
426 {invites[input.name].email}
427 </Grid.Item>
428 ))}
429 </>
430 )}
432 {!isDone && (
433 <>
434 {/* email inputs */}
435 {inputs.map((input, i) => {
436 const isFirst = i === 0;
437 return (
438 <Grid.Item
439 key={input.name}
440 as={Grid}
441 gap={12}
442 onMouseOver={() => setHovered({ [input.name]: true })}
443 onMouseLeave={() => setHovered({ [input.name]: false })}
444 full>
445 <Grid.Item
446 as={Input}
447 cols={[1, 11]}
448 {...input}
449 onValue={email => syncHasReceivedForEmail(email)}
450 // NB(shrugs): ^ this feels like a hack?
451 accessory={accessoryFor(input.name)}
452 />
453 {!isFirst &&
454 (input.focused || hovered[input.name]) &&
455 canInput && (
456 <Grid.Item
457 cols={[11, 13]}
458 justifySelf="end"
459 alignSelf="center">
460 <IconButton
461 onClick={() => removeInputAt(i)}
462 solid
463 secondary>
464 -
465 </IconButton>
466 </Grid.Item>
467 )}
468 </Grid.Item>
469 );
470 })}
472 <Grid.Item
473 full
474 as={LoadableButton}
475 disabled={!canGenerate && !canSend}
476 accessory={`${visualProgress}/${inputs.length}`}
477 onClick={onClick}
478 success={canSend}
479 solid>
480 {buttonText(status, inputs.length)}
481 </Grid.Item>
483 {needFunds && (
484 <Grid.Item full>
485 <Highlighted>
486 Your ownership address {needFunds.address} needs at least{' '}
487 {fromWei(needFunds.minBalance)}ETH and currently has{' '}
488 {fromWei(needFunds.balance)}.
489 </Highlighted>
490 </Grid.Item>
491 )}
493 {generalError && (
494 <Grid.Item full>
495 <ErrorText>{generalError.message.toString()}</ErrorText>
496 </Grid.Item>
497 )}
498 </>
499 )}
500 </Grid>
501 );