@@ -1,20 +1,19 @@
1−import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+import React, { useCallback, useEffect, useRef, useState } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3import * as ob from 'urbit-ob';
3import * as ob from 'urbit-ob';
4import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
9 IconButton,
8 IconButton,
12 ErrorText,
11 ErrorText,
13 AccessoryIcon,
12 AccessoryIcon,
15} from 'indigo-react';
13} from 'indigo-react';
16import { uniq } from 'lodash';
14import { uniq } from 'lodash';
17import { fromWei, toWei } from 'web3-utils';
15import { fromWei, toWei } from 'web3-utils';
16+import { FieldArray } from 'react-final-form-arrays';
19import { usePointCursor } from 'store/pointCursor';
18import { usePointCursor } from 'store/pointCursor';
20import { useNetwork } from 'store/network';
19import { useNetwork } from 'store/network';
@@ -29,8 +28,6 @@ import {
30} from 'lib/txn';
29} from 'lib/txn';
31import * as tank from 'lib/tank';
30import * as tank from 'lib/tank';
32−import useArray from 'lib/useArray';
33−import { buildEmailInputConfig } from 'lib/useInputs';
34import { MIN_PLANET, GAS_LIMITS, DEFAULT_GAS_PRICE_GWEI } from 'lib/constants';
31import { MIN_PLANET, GAS_LIMITS, DEFAULT_GAS_PRICE_GWEI } from 'lib/constants';
35import * as need from 'lib/need';
32import * as need from 'lib/need';
36import * as wg from 'lib/walletgen';
33import * as wg from 'lib/walletgen';
@@ -40,6 +37,14 @@ import useMailer from 'lib/useMailer';
41import LoadableButton from 'components/LoadableButton';
38import LoadableButton from 'components/LoadableButton';
42import Highlighted from 'components/Highlighted';
39import Highlighted from 'components/Highlighted';
40+import BridgeForm from 'form/BridgeForm';
41+import { Field } from 'react-final-form';
42+import { EmailInput, buildEmailValidator } from 'form/Inputs';
43+import { FORM_ERROR } from 'final-form';
44+import SubmitButton from 'form/SubmitButton';
45+import FormError from 'form/FormError';
47+const INITIAL_VALUES = { emails: [''] };
44const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
49const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
45const INVITE_COST = toWei(
50const INVITE_COST = toWei(
@@ -76,21 +81,14 @@ const buttonText = (status, count) => {
79−// world's simplest uid
81−const buildInputConfig = (extra = {}) =>
82− buildEmailInputConfig({
83− name: `email-${id++}`,
84− placeholder: 'Email Address',
88const buildAccessoryFor = (dones, errors) => name => {
84const buildAccessoryFor = (dones, errors) => name => {
89 if (dones[name]) return <AccessoryIcon.Success />;
85 if (dones[name]) return <AccessoryIcon.Success />;
90 if (errors[name]) return <AccessoryIcon.Failure />;
86 if (errors[name]) return <AccessoryIcon.Failure />;
91 return <AccessoryIcon.Pending />;
87 return <AccessoryIcon.Pending />;
90+const emailFormatValidator = buildEmailValidator();
94// TODO: test with tank, successful txs
92// TODO: test with tank, successful txs
95export default function InviteEmail() {
93export default function InviteEmail() {
96 // TODO: resumption after error?
94 // TODO: resumption after error?
@@ -99,7 +97,9 @@ export default function InviteEmail() {
99 const { syncInvites, getInvites } = usePointCache();
97 const { syncInvites, getInvites } = usePointCache();
100 const { pointCursor } = usePointCursor();
98 const { pointCursor } = usePointCursor();
101 const point = need.point(pointCursor);
99 const point = need.point(pointCursor);
102− const { getHasReceived, syncHasReceivedForEmail, sendMail } = useMailer();
100+ const { getHasReceived, sendMail } = useMailer();
102+ const cachedEmails = useRef([]);
104 const { availableInvites } = getInvites(point);
104 const { availableInvites } = getInvites(point);
105 const maxInvitesToSend = availableInvites.matchWith({
105 const maxInvitesToSend = availableInvites.matchWith({
@@ -107,15 +107,6 @@ export default function InviteEmail() {
107 Just: p => p.value,
107 Just: p => p.value,
110− // manage the array of input configs
113− { append: appendInput, removeAt: removeInputAt },
115− [buildInputConfig({ label: 'Email Address', autoFocus: true })],
119 // manage per-input state
110 // manage per-input state
120 const [hovered, setHovered] = useSetState();
111 const [hovered, setHovered] = useSetState();
121 const [invites, addInvite, clearInvites] = useSetState();
112 const [invites, addInvite, clearInvites] = useSetState();
@@ -135,30 +126,40 @@ export default function InviteEmail() {
135 const isFailed = status === STATUS.FAILURE;
126 const isFailed = status === STATUS.FAILURE;
136 const isDone = status === STATUS.SUCCESS;
127 const isDone = status === STATUS.SUCCESS;
138− // add disabled, error info to input configs
129+ const validate = useCallback(
139− const dynamicConfigs = useMemo(
130+ async values => {
140− () =>
131+ // compute the list of valid emails
141− inputConfigs.map(config => {
132+ const emails = values.emails.filter(d => !!d);
142− config.disabled = !canInput;
133+ if (uniq(emails).length !== emails.length) {
143− const hasReceivedError = getHasReceived(config.name).matchWith({
134+ return { [FORM_ERROR]: 'Duplicate email.' };
144− Nothing: () => null, // loading
135+ }
145− Just: p => p.value && HAS_RECEIVED_TEXT,
147− config.error = hasReceivedError || errors[config.name];
150− [inputConfigs, errors, canInput, getHasReceived]
153− // construct the state of the set of inputs we're rendering below
137+ let errors = {};
154− const { inputs, pass } = useForm(dynamicConfigs);
138+ for (let i = 0; i < values.emails.length; i++) {
155− // did all of the inputs pass inspection (and there are no general errors)?
139+ const name = `emails[${i}]`;
156− const allPass = pass && !generalError;
140+ const email = values.emails[i];
157− // the form is submittable iff passing and input is allowed
141+
158− const canGenerate = allPass && canInput;
142+ // check individual email validity
159− // additional inputs can be added iff input is allowed and we have enough
143+ // TODO: validate each email individually using similar pattern from form/Inputs
160− // invites to send
144+ // and then reutrn that
161− const canAddInvite = canInput && inputConfigs.length < maxInvitesToSend;
145+ const error = await emailFormatValidator(email);
147+ errors[name] = error;
151+ const hasReceived = await getHasReceived(email);
153+ errors[name] = HAS_RECEIVED_TEXT;
158+ console.log(errors);
163 // progress is [0, .length] of invites or receipts, as we're generating them
164 // progress is [0, .length] of invites or receipts, as we're generating them
164 const progress = isGenerating
165 const progress = isGenerating
@@ -176,125 +177,102 @@ export default function InviteEmail() {
176 return () => null;
177 return () => null;
179− const generateInvites = useCallback(async () => {
180+ const generateInvites = useCallback(
180− const _contracts = contracts.getOrElse(null);
183+ const _contracts = contracts.getOrElse(null);
181− const _web3 = web3.getOrElse(null);
184+ const _web3 = web3.getOrElse(null);
182− const _wallet = wallet.getOrElse(null);
185+ const _wallet = wallet.getOrElse(null);
183− if (!_contracts || !_web3 || !_wallet) {
186+ if (!_contracts || !_web3 || !_wallet) {
184− // not using need because we want a custom error
187+ // not using need because we want a custom error
185− throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
188+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
188− //TODO want to do this on-input, but that gets weird. see #188
190− let alreadyReceived = [];
192− inputs.map(async input => {
193− const email = input.data;
194− getHasReceived(email).matchWith({
199− if (p.value) alreadyReceived.push(email);
205− throw new Error('No word yet from email service...');
207− if (alreadyReceived.length > 0) {
209− 'The following recipients already own a point: ' +
210− alreadyReceived.join(', ')
214− const nonce = await _web3.eth.getTransactionCount(_wallet.address);
215− const chainId = await _web3.eth.net.getId();
216− const planets = await azimuth.delegatedSending.getPlanetsToSend(
222− // account for the race condition where invites got used up while we were
223− // composing our target list
224− if (planets.length < inputs.length) {
225− // resync invites to the cache, since they're out of date
228− throw new Error(
191+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
229− `Can currently only send ${planets.length} invites. ` +
192+ const chainId = await _web3.eth.net.getId();
230− `Please remove invites until you are within the limit.`
193+ const planets = await azimuth.delegatedSending.getPlanetsToSend(
196+ values.emails.length
234− clearInvites();
199+ // account for the race condition where invites got used up while we were
235− // NB(shrugs) - must be processed in serial because main thread, etc
200+ // composing our target list
236− let errorCount = 0;
201+ if (planets.length < values.emails.length) {
237− for (let i = 0; i < inputs.length; i++) {
202+ // resync invites to the cache, since they're out of date
238− const input = inputs[i];
203+ syncInvites(point);
240− const { data: email, name } = inputs[i];
205+ return {
207+ `Can currently only send ${planets.length} invites. ` +
208+ `Please remove invites until you are within the limit.`,
213+ // NB(shrugs) - must be processed in serial because main thread, etc
215+ for (let i = 0; i < values.emails.length; i++) {
217+ const email = values.emails[i];
242− const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
219+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
243− MIN_PLANET
220+ MIN_PLANET
244− // we're always giving planets, so generate a ticket of the correct size
221+ // we're always giving planets, so generate a ticket of the correct size
224+ const inviteTx = azimuth.delegatedSending.sendPoint(
230+ const signedTx = await signTransaction({
235+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
237+ gasPrice: DEFAULT_GAS_PRICE_GWEI.toString(),
238+ gasLimit: GAS_LIMIT.toString(),
242+ const rawTx = hexify(signedTx.serialize());
244+ addInvite({ [email]: { email, ticket, signedTx, rawTx } });
246+ console.error(error);
249+ [FORM_ERROR]: `Wallet Error: unable to generate invite wallets.`,
247− const inviteTx = azimuth.delegatedSending.sendPoint(
254+ if (errorCount > 0) {
248− _contracts,
255+ throw new Error(
249− point,
256+ `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
250− planets[i],
257+ errorCount,
251− owner.keys.address
258+ 'error'
259+ )} while generating wallets.`
253− const signedTx = await signTransaction({
258− // TODO: ^ make a useTransactionSigner to encapsulate this logic
260− gasPrice: DEFAULT_GAS_PRICE_GWEI.toString(),
261− gasLimit: GAS_LIMIT.toString(),
265− const rawTx = hexify(signedTx.serialize());
267− addInvite({ [name]: { email, ticket, signedTx, rawTx } });
269− console.error(error);
271− addError({ [input.name]: `Wallet Error: ${input.email}` });
275− if (errorCount > 0) {
277− `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
280− )} while generating wallets.`
284− contracts,
264+ contracts,
289− clearInvites,
268+ clearInvites,
292− syncInvites,
269+ syncInvites,
293− walletType,
270+ walletType,
294− walletHdPath,
271+ walletHdPath,
295− networkType,
272+ networkType,
296− addInvite,
273+ addInvite,
299 const sendInvites = useCallback(async () => {
277 const sendInvites = useCallback(async () => {
300 const _web3 = web3.getOrElse(null);
278 const _web3 = web3.getOrElse(null);
@@ -308,8 +286,8 @@ export default function InviteEmail() {
310 _wallet.address,
288 _wallet.address,
311− INVITE_COST * inputs.length,
289+ INVITE_COST * cachedEmails.current.length,
312− inputs.map(input => invites[input.name].rawTx),
290+ cachedEmails.current.map(email => invites[email].rawTx),
313 (address, minBalance, balance) =>
291 (address, minBalance, balance) =>
314 setNeedFunds({ address, minBalance, balance }),
292 setNeedFunds({ address, minBalance, balance }),
315 () => setNeedFunds(undefined)
293 () => setNeedFunds(undefined)
@@ -320,8 +298,8 @@ export default function InviteEmail() {
321 let unsentInvites = [];
299 let unsentInvites = [];
322 let orphanedInvites = [];
300 let orphanedInvites = [];
323− const txAndMailings = inputs.map(async input => {
301+ const txAndMailings = cachedEmails.current.map(async email => {
324− const invite = invites[input.name];
302+ const invite = invites[email];
326 const txHash = await sendSignedTransaction(
304 const txHash = await sendSignedTransaction(
@@ -339,19 +317,18 @@ export default function InviteEmail() {
342− const success = await sendMail(
320+ await sendMail(
343 invite.email,
321 invite.email,
344 invite.ticket,
322 invite.ticket,
345 ob.patp(point),
323 ob.patp(point),
346 invite.rawTx
324 invite.rawTx
348− if (!success) throw new Error('Failed to send mail');
349 } catch (error) {
326 } catch (error) {
350 console.error(error);
327 console.error(error);
351 orphanedInvites.push(invite);
328 orphanedInvites.push(invite);
354− addReceipt({ [input.name]: true });
331+ addReceipt({ [email]: true });
357 await Promise.all(txAndMailings);
334 await Promise.all(txAndMailings);
@@ -375,47 +352,30 @@ export default function InviteEmail() {
375 if (errorString !== '') {
352 if (errorString !== '') {
376 throw new Error(errorString);
353 throw new Error(errorString);
378− }, [
355+ }, [web3, addReceipt, clearReceipts, invites, point, wallet, sendMail]);
380− inputs,
357+ const onSubmit = useCallback(
381− addReceipt,
358+ async values => {
382− clearReceipts,
359+ setStatus(STATUS.GENERATING);
383− invites,
360+ await generateInvites(values);
384− point,
361+ cachedEmails.current = values.emails;
385− wallet,
362+ setStatus(STATUS.CAN_SEND);
389− const onClick = useCallback(async () => {
367+ const doSend = useCallback(async () => {
390 setGeneralError(null);
368 setGeneralError(null);
393− setStatus(STATUS.GENERATING);
394− await generateInvites();
395− setStatus(STATUS.CAN_SEND);
396− } else if (canSend) {
397− setStatus(STATUS.SENDING);
370+ setStatus(STATUS.SENDING);
398− await sendInvites();
371+ await sendInvites();
399− setStatus(STATUS.SUCCESS);
372+ setStatus(STATUS.SUCCESS);
401 } catch (error) {
373 } catch (error) {
402 console.error(error);
374 console.error(error);
403 setGeneralError(error);
375 setGeneralError(error);
404 setStatus(STATUS.FAILURE);
376 setStatus(STATUS.FAILURE);
406− }, [canGenerate, canSend, generateInvites, sendInvites]);
378+ }, [sendInvites]);
408− // when inputs update, check to see if any of them are duplicates
410− // compute the list of valid emails
411− const emails = inputs.map(i => i.data).filter(d => !!d);
412− if (uniq(emails).length !== emails.length) {
413− setGeneralError(new Error(`Duplicate email.`));
415− setGeneralError(null);
416− setStatus(STATUS.INPUT);
420 // when we transition to done, sync invites because we just sent some
380 // when we transition to done, sync invites because we just sent some
421 // and therefore know that state has changed
381 // and therefore know that state has changed
@@ -425,109 +385,137 @@ export default function InviteEmail() {
426 }, [isDone, syncInvites, point]);
386 }, [isDone, syncInvites, point]);
429− for (const input of inputs) {
431− syncHasReceivedForEmail(input.data);
434− }, [inputs, syncHasReceivedForEmail]);
437 <Grid gap={3}>
389 <Grid gap={3}>
393+ initialValues={INITIAL_VALUES}>
394+ {({ handleSubmit, valid, values }) => (
395+ <FieldArray name="emails">
438− <Grid.Item full as={Flex} justify="end">
398+ <Grid.Item full as={Flex} justify="end">
439− {/* use hidden class instead of removing component from dom */}
399+ {/* use hidden class instead of removing component from dom */}
440− {/* in order to avoid janky reflow */}
400+ {/* in order to avoid janky reflow */}
441− <IconButton
401+ <IconButton
442− onClick={() => appendInput()}
402+ onClick={() => fields.push('')}
443− disabled={!canAddInvite}
403+ disabled={!canInput || fields.length >= maxInvitesToSend}
444− className={cn({ hidden: isDone })}
404+ className={cn({ hidden: isDone })}
447− </IconButton>
407+ </IconButton>
448− </Grid.Item>
408+ </Grid.Item>
450− {isDone && (
410+ {isDone && (
452− <Grid.Item as={Text} className="f5" full>
412+ <Grid.Item as={Text} className="f5" full>
453− <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
414+ {pluralize(fields.length, 'invite')}
454− {pluralize(inputs.length, 'has', 'have')} been successfully sent
416+ {pluralize(fields.length, 'has', 'have')} been
455− </Grid.Item>
418+ </Grid.Item>
456− {inputs.map(input => (
419+
420+ {fields.map(name => (
457− <Grid.Item as={HelpText} key={input.name} full>
421+ <Grid.Item as={HelpText} key={name} full>
458− {invites[input.name].email}
422+ <Field name={name}>{({ value }) => value}</Field>
459− </Grid.Item>
423+ </Grid.Item>
464− {!isDone && (
428+ {!isDone && (
466− {/* email inputs */}
430+ {fields.map((name, i) => {
467− {inputs.map((input, i) => {
468− const isFirst = i === 0;
431+ const isFirst = i === 0;
469− return (
432+ return (
470− <Grid.Item
433+ <Grid.Item
471− key={input.name}
434+ key={name}
472− as={Grid}
436+ as={Grid}
474− onMouseOver={() => setHovered({ [input.name]: true })}
438+ onMouseOver={() => setHovered({ [name]: true })}
475− onMouseLeave={() => setHovered({ [input.name]: false })}
439+ onMouseLeave={() => setHovered({ [name]: false })}>
477− <Grid.Item
440+ <Grid.Item
479− cols={[1, 11]}
441+ cols={[1, 11]}
480− {...input}
442+ as={EmailInput}
444+ label={isFirst ? 'Email Address' : undefined}
481− accessory={accessoryFor(input.name)}
445+ accessory={accessoryFor(name)}
448+ {({ meta: { active } }) => {
483− {!isFirst &&
451+ {!isFirst &&
484− (input.focused || hovered[input.name]) &&
452+ (active || hovered[name]) &&
459+ onClick={() => fields.remove(i)}
485− canInput && (
474+ {canInput && (
478+ handleSubmit={handleSubmit}>
479+ {buttonText(status, fields.length)}
486 <Grid.Item
483 <Grid.Item
487− cols={[11, 13]}
484+ full
488− justifySelf="end"
485+ as={LoadableButton}
489− alignSelf="center">
486+ disabled={!(canInput || canSend || valid)}
490− <IconButton
487+ accessory={`${visualProgress}/${fields.length}`}
491− onClick={() => removeInputAt(i)}
488+ onClick={doSend}
493− secondary>
491+ {buttonText(status, fields.length)}
496 </Grid.Item>
492 </Grid.Item>
505− disabled={!canGenerate && !canSend}
506− accessory={`${visualProgress}/${inputs.length}`}
510− {buttonText(status, inputs.length)}
513− {needFunds && (
494+ {needFunds && (
514− <Grid.Item full>
495+ <Grid.Item full>
515− <Highlighted warning>
496+ <Highlighted warning>
516− Your ownership address {needFunds.address} needs at least{' '}
497+ Your ownership address {needFunds.address} needs at
517− {fromWei(needFunds.minBalance)} ETH and currently has{' '}
498+ least {fromWei(needFunds.minBalance)} ETH and
518− {fromWei(needFunds.balance)} ETH. Waiting until the account has
499+ currently has {fromWei(needFunds.balance)} ETH.
519− enough funds.
500+ Waiting until the account has enough funds.
520− </Highlighted>
501+ </Highlighted>
521− </Grid.Item>
502+ </Grid.Item>
505+ <Grid.Item full as={FormError} />
524− {generalError && (
507+ {generalError && (
525− <Grid.Item full>
508+ <Grid.Item full>
526− <ErrorText>{generalError.message.toString()}</ErrorText>
509+ <ErrorText>{generalError.message.toString()}</ErrorText>
527− </Grid.Item>
510+ </Grid.Item>