on 04765bb help sign in
bridge/src/views/Invite/InviteEmail.js
504 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(point);
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 await sendMail(invite.email, invite.ticket, invite.rawTx);
319 } catch (error) {
320 console.error(error);
321 errorCount++;
322 addError({
323 [input.name]: `Mailing Failure for ${invite.email}`,
324 });
325 }
327 addReceipt({ [input.name]: true });
328 });
330 await Promise.all(txAndMailings);
331 // if there are any receipt errors, throw a general error
332 if (errorCount > 0) {
333 throw new Error(
334 `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
335 errorCount,
336 'error'
337 )} while sending transactions.`
338 );
339 }
340 }, [
341 web3,
342 inputs,
343 addReceipt,
344 clearReceipts,
345 invites,
346 point,
347 wallet,
348 addError,
349 sendMail,
350 ]);
352 const onClickGenerate = useCallback(async () => {
353 setGeneralError(null);
354 setStatus(STATUS.GENERATING);
355 try {
356 await generateInvites();
357 setStatus(STATUS.CAN_SEND);
358 } catch (error) {
359 console.error(error);
360 setGeneralError(error);
361 setStatus(STATUS.FAILURE);
362 }
363 }, [setStatus, setGeneralError, generateInvites]);
365 const onClickSend = useCallback(async () => {
366 setGeneralError(null);
367 setStatus(STATUS.SENDING);
368 try {
369 await sendInvites();
370 setStatus(STATUS.SUCCESS);
371 } catch (error) {
372 console.error(error);
373 setGeneralError(error);
374 setStatus(STATUS.FAILURE);
375 }
376 }, [setStatus, sendInvites, setGeneralError]);
378 const onClick = useCallback(() => {
379 if (canGenerate) {
380 onClickGenerate();
381 } else if (canSend) {
382 onClickSend();
383 }
384 }, [canGenerate, canSend, onClickGenerate, onClickSend]);
386 useEffect(() => {
387 if (uniq(emails).length !== emails.length) {
388 setGeneralError(new Error(`Duplicate email.`));
389 } else {
390 setGeneralError(null);
391 }
392 }, [emails]);
394 // when we transition to done, sync invites because we just sent some
395 useEffect(() => {
396 if (isDone) {
397 syncInvites(point);
398 }
399 }, [isDone, syncInvites, point]);
401 return (
402 <Grid gap={12}>
403 <Grid.Item as={Grid} full>
404 <Grid.Item as={Flex} cols={[1, 11]} align="center">
405 <MiniBackButton onClick={() => pop()} />
406 </Grid.Item>
407 <Grid.Item cols={[11, 13]} justifySelf="end">
408 {/* use hidden class instead of removing component from dom */}
409 {/* in order to avoid janky reflow */}
410 <IconButton
411 onClick={() => appendInput()}
412 disabled={!canAddInvite}
413 className={cn({ hidden: isDone })}
414 solid>
415 +
416 </IconButton>
417 </Grid.Item>
418 </Grid.Item>
420 {isDone && (
421 <>
422 <Grid.Item as={Text} full>
423 <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
424 {pluralize(inputs.length, 'has', 'have')} been successfully sent
425 </Grid.Item>
426 {inputs.map(input => (
427 <Grid.Item as={HelpText} key={input.name} full>
428 {invites[input.name].email}
429 </Grid.Item>
430 ))}
431 </>
432 )}
434 {!isDone && (
435 <>
436 {/* email inputs */}
437 {inputs.map((input, i) => {
438 const isFirst = i === 0;
439 return (
440 <Grid.Item
441 key={input.name}
442 as={Grid}
443 gap={12}
444 onMouseOver={() => setHovered({ [input.name]: true })}
445 onMouseLeave={() => setHovered({ [input.name]: false })}
446 full>
447 <Grid.Item
448 as={Input}
449 cols={[1, 11]}
450 {...input}
451 onValue={email => syncHasReceivedForEmail(email)}
452 // NB(shrugs): ^ this feels like a hack?
453 accessory={accessoryFor(input.name)}
454 />
455 {!isFirst &&
456 (input.focused || hovered[input.name]) &&
457 canInput && (
458 <Grid.Item
459 cols={[11, 13]}
460 justifySelf="end"
461 alignSelf="center">
462 <IconButton
463 onClick={() => removeInputAt(i)}
464 solid
465 secondary>
466 -
467 </IconButton>
468 </Grid.Item>
469 )}
470 </Grid.Item>
471 );
472 })}
474 <Grid.Item
475 full
476 as={LoadableButton}
477 disabled={!canGenerate && !canSend}
478 accessory={`${visualProgress}/${inputs.length}`}
479 onClick={onClick}
480 success={canSend}
481 solid>
482 {buttonText(status, inputs.length)}
483 </Grid.Item>
485 {needFunds && (
486 <Grid.Item full>
487 <Highlighted>
488 Your ownership address {needFunds.address} needs at least{' '}
489 {fromWei(needFunds.minBalance)}ETH and currently has{' '}
490 {fromWei(needFunds.balance)}.
491 </Highlighted>
492 </Grid.Item>
493 )}
495 {generalError && (
496 <Grid.Item full>
497 <ErrorText>{generalError.message.toString()}</ErrorText>
498 </Grid.Item>
499 )}
500 </>
501 )}
502 </Grid>
503 );