@@ -1,20 +1,19 @@
1−import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+import React, { useCallback, useEffect, useRef, useState } from 'react';
22import cn from 'classnames';
33import * as ob from 'urbit-ob';
44import * as azimuth from 'azimuth-js';
55import {
66 Grid,
77 Flex,
8− Input,
98 IconButton,
109 HelpText,
1110 Text,
1211 ErrorText,
1312 AccessoryIcon,
14− useForm,
1513} from 'indigo-react';
1614import { uniq } from 'lodash';
1715import { fromWei, toWei } from 'web3-utils';
16+import { FieldArray } from 'react-final-form-arrays';
1817
1918import { usePointCursor } from 'store/pointCursor';
2019import { useNetwork } from 'store/network';
@@ -29,8 +28,6 @@ import {
2928 hexify,
3029} from 'lib/txn';
3130import * as tank from 'lib/tank';
32−import useArray from 'lib/useArray';
33−import { buildEmailInputConfig } from 'lib/useInputs';
3431import { MIN_PLANET, GAS_LIMITS, DEFAULT_GAS_PRICE_GWEI } from 'lib/constants';
3532import * as need from 'lib/need';
3633import * as wg from 'lib/walletgen';
@@ -40,6 +37,14 @@ import useMailer from 'lib/useMailer';
4037
4138import LoadableButton from 'components/LoadableButton';
4239import 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';
46+
47+const INITIAL_VALUES = { emails: [''] };
4348
4449const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
4550const INVITE_COST = toWei(
@@ -76,21 +81,14 @@ const buttonText = (status, count) => {
7681 }
7782};
7883
79−// world's simplest uid
80−let id = 0;
81−const buildInputConfig = (extra = {}) =>
82− buildEmailInputConfig({
83− name: `email-${id++}`,
84− placeholder: 'Email Address',
85− ...extra,
86− });
87−
8884const buildAccessoryFor = (dones, errors) => name => {
8985 if (dones[name]) return <AccessoryIcon.Success />;
9086 if (errors[name]) return <AccessoryIcon.Failure />;
9187 return <AccessoryIcon.Pending />;
9288};
9389
90+const emailFormatValidator = buildEmailValidator();
91+
9492// TODO: test with tank, successful txs
9593export default function InviteEmail() {
9694 // TODO: resumption after error?
@@ -99,7 +97,9 @@ export default function InviteEmail() {
9997 const { syncInvites, getInvites } = usePointCache();
10098 const { pointCursor } = usePointCursor();
10199 const point = need.point(pointCursor);
102− const { getHasReceived, syncHasReceivedForEmail, sendMail } = useMailer();
100+ const { getHasReceived, sendMail } = useMailer();
101+
102+ const cachedEmails = useRef([]);
103103
104104 const { availableInvites } = getInvites(point);
105105 const maxInvitesToSend = availableInvites.matchWith({
@@ -107,15 +107,6 @@ export default function InviteEmail() {
107107 Just: p => p.value,
108108 });
109109
110− // manage the array of input configs
111− const [
112− inputConfigs,
113− { append: appendInput, removeAt: removeInputAt },
114− ] = useArray(
115− [buildInputConfig({ label: 'Email Address', autoFocus: true })],
116− buildInputConfig
117− );
118−
119110 // manage per-input state
120111 const [hovered, setHovered] = useSetState();
121112 const [invites, addInvite, clearInvites] = useSetState();
@@ -135,30 +126,40 @@ export default function InviteEmail() {
135126 const isFailed = status === STATUS.FAILURE;
136127 const isDone = status === STATUS.SUCCESS;
137128
138− // add disabled, error info to input configs
139− const dynamicConfigs = useMemo(
140− () =>
141− inputConfigs.map(config => {
142− config.disabled = !canInput;
143− const hasReceivedError = getHasReceived(config.name).matchWith({
144− Nothing: () => null, // loading
145− Just: p => p.value && HAS_RECEIVED_TEXT,
146− });
147− config.error = hasReceivedError || errors[config.name];
148− return config;
149− }),
150− [inputConfigs, errors, canInput, getHasReceived]
151− );
129+ const validate = useCallback(
130+ async values => {
131+ // compute the list of valid emails
132+ const emails = values.emails.filter(d => !!d);
133+ if (uniq(emails).length !== emails.length) {
134+ return { [FORM_ERROR]: 'Duplicate email.' };
135+ }
152136
153− // construct the state of the set of inputs we're rendering below
154− const { inputs, pass } = useForm(dynamicConfigs);
155− // did all of the inputs pass inspection (and there are no general errors)?
156− const allPass = pass && !generalError;
157− // the form is submittable iff passing and input is allowed
158− const canGenerate = allPass && canInput;
159− // additional inputs can be added iff input is allowed and we have enough
160− // invites to send
161− const canAddInvite = canInput && inputConfigs.length < maxInvitesToSend;
137+ let errors = {};
138+ for (let i = 0; i < values.emails.length; i++) {
139+ const name = `emails[${i}]`;
140+ const email = values.emails[i];
141+
142+ // check individual email validity
143+ // TODO: validate each email individually using similar pattern from form/Inputs
144+ // and then reutrn that
145+ const error = await emailFormatValidator(email);
146+ if (error) {
147+ errors[name] = error;
148+ continue;
149+ }
150+
151+ const hasReceived = await getHasReceived(email);
152+ if (hasReceived) {
153+ errors[name] = HAS_RECEIVED_TEXT;
154+ continue;
155+ }
156+ }
157+
158+ console.log(errors);
159+ return errors;
160+ },
161+ [getHasReceived]
162+ );
162163
163164 // progress is [0, .length] of invites or receipts, as we're generating them
164165 const progress = isGenerating
@@ -176,125 +177,102 @@ export default function InviteEmail() {
176177 return () => null;
177178 })();
178179
179− const generateInvites = useCallback(async () => {
180− const _contracts = contracts.getOrElse(null);
181− const _web3 = web3.getOrElse(null);
182− const _wallet = wallet.getOrElse(null);
183− if (!_contracts || !_web3 || !_wallet) {
184− // not using need because we want a custom error
185− throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
186− }
187−
188− //TODO want to do this on-input, but that gets weird. see #188
189− let knowAll = true;
190− let alreadyReceived = [];
191− await Promise.all(
192− inputs.map(async input => {
193− const email = input.data;
194− getHasReceived(email).matchWith({
195− Nothing: () => {
196− knowAll = false;
197− },
198− Just: p => {
199− if (p.value) alreadyReceived.push(email);
200− },
201− });
202− })
203− );
204− if (!knowAll) {
205− throw new Error('No word yet from email service...');
206− }
207− if (alreadyReceived.length > 0) {
208− throw new Error(
209− 'The following recipients already own a point: ' +
210− alreadyReceived.join(', ')
211− );
212− }
213−
214− const nonce = await _web3.eth.getTransactionCount(_wallet.address);
215− const chainId = await _web3.eth.net.getId();
216− const planets = await azimuth.delegatedSending.getPlanetsToSend(
217− _contracts,
218− point,
219− inputs.length
220− );
221−
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
226− syncInvites(point);
180+ const generateInvites = useCallback(
181+ async values => {
182+ debugger;
183+ const _contracts = contracts.getOrElse(null);
184+ const _web3 = web3.getOrElse(null);
185+ const _wallet = wallet.getOrElse(null);
186+ if (!_contracts || !_web3 || !_wallet) {
187+ // not using need because we want a custom error
188+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
189+ }
227190
228− throw new Error(
229− `Can currently only send ${planets.length} invites. ` +
230− `Please remove invites until you are within the limit.`
191+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
192+ const chainId = await _web3.eth.net.getId();
193+ const planets = await azimuth.delegatedSending.getPlanetsToSend(
194+ _contracts,
195+ point,
196+ values.emails.length
231197 );
232− }
233198
234− clearInvites();
235− // NB(shrugs) - must be processed in serial because main thread, etc
236− let errorCount = 0;
237− for (let i = 0; i < inputs.length; i++) {
238− const input = inputs[i];
239− try {
240− const { data: email, name } = inputs[i];
199+ // account for the race condition where invites got used up while we were
200+ // composing our target list
201+ if (planets.length < values.emails.length) {
202+ // resync invites to the cache, since they're out of date
203+ syncInvites(point);
204+
205+ return {
206+ [FORM_ERROR]:
207+ `Can currently only send ${planets.length} invites. ` +
208+ `Please remove invites until you are within the limit.`,
209+ };
210+ }
241211
242− const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
243− MIN_PLANET
244− // we're always giving planets, so generate a ticket of the correct size
245− );
212+ clearInvites();
213+ // NB(shrugs) - must be processed in serial because main thread, etc
214+ let errorCount = 0;
215+ for (let i = 0; i < values.emails.length; i++) {
216+ try {
217+ const email = values.emails[i];
218+
219+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
220+ MIN_PLANET
221+ // we're always giving planets, so generate a ticket of the correct size
222+ );
223+
224+ const inviteTx = azimuth.delegatedSending.sendPoint(
225+ _contracts,
226+ point,
227+ planets[i],
228+ owner.keys.address
229+ );
230+ const signedTx = await signTransaction({
231+ wallet: _wallet,
232+ walletType,
233+ walletHdPath,
234+ networkType,
235+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
236+ txn: inviteTx,
237+ gasPrice: DEFAULT_GAS_PRICE_GWEI.toString(),
238+ gasLimit: GAS_LIMIT.toString(),
239+ nonce: nonce + i,
240+ chainId,
241+ });
242+ const rawTx = hexify(signedTx.serialize());
243+
244+ addInvite({ [email]: { email, ticket, signedTx, rawTx } });
245+ } catch (error) {
246+ console.error(error);
247+ errorCount++;
248+ return {
249+ [FORM_ERROR]: `Wallet Error: unable to generate invite wallets.`,
250+ };
251+ }
252+ }
246253
247− const inviteTx = azimuth.delegatedSending.sendPoint(
248− _contracts,
249− point,
250− planets[i],
251− owner.keys.address
254+ if (errorCount > 0) {
255+ throw new Error(
256+ `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
257+ errorCount,
258+ 'error'
259+ )} while generating wallets.`
252260 );
253− const signedTx = await signTransaction({
254− wallet: _wallet,
255− walletType,
256− walletHdPath,
257− networkType,
258− // TODO: ^ make a useTransactionSigner to encapsulate this logic
259− txn: inviteTx,
260− gasPrice: DEFAULT_GAS_PRICE_GWEI.toString(),
261− gasLimit: GAS_LIMIT.toString(),
262− nonce: nonce + i,
263− chainId,
264− });
265− const rawTx = hexify(signedTx.serialize());
266−
267− addInvite({ [name]: { email, ticket, signedTx, rawTx } });
268− } catch (error) {
269− console.error(error);
270− errorCount++;
271− addError({ [input.name]: `Wallet Error: ${input.email}` });
272261 }
273− }
274−
275− if (errorCount > 0) {
276− throw new Error(
277− `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
278− errorCount,
279− 'error'
280− )} while generating wallets.`
281− );
282− }
283− }, [
284− contracts,
285− web3,
286− wallet,
287− inputs,
288− point,
289− clearInvites,
290− getHasReceived,
291− addError,
292− syncInvites,
293− walletType,
294− walletHdPath,
295− networkType,
296− addInvite,
297− ]);
262+ },
263+ [
264+ contracts,
265+ web3,
266+ wallet,
267+ point,
268+ clearInvites,
269+ syncInvites,
270+ walletType,
271+ walletHdPath,
272+ networkType,
273+ addInvite,
274+ ]
275+ );
298276
299277 const sendInvites = useCallback(async () => {
300278 const _web3 = web3.getOrElse(null);
@@ -308,8 +286,8 @@ export default function InviteEmail() {
308286 _web3,
309287 point,
310288 _wallet.address,
311− INVITE_COST * inputs.length,
312− inputs.map(input => invites[input.name].rawTx),
289+ INVITE_COST * cachedEmails.current.length,
290+ cachedEmails.current.map(email => invites[email].rawTx),
313291 (address, minBalance, balance) =>
314292 setNeedFunds({ address, minBalance, balance }),
315293 () => setNeedFunds(undefined)
@@ -320,8 +298,8 @@ export default function InviteEmail() {
320298
321299 let unsentInvites = [];
322300 let orphanedInvites = [];
323− const txAndMailings = inputs.map(async input => {
324− const invite = invites[input.name];
301+ const txAndMailings = cachedEmails.current.map(async email => {
302+ const invite = invites[email];
325303 try {
326304 const txHash = await sendSignedTransaction(
327305 _web3,
@@ -339,19 +317,18 @@ export default function InviteEmail() {
339317 }
340318
341319 try {
342− const success = await sendMail(
320+ await sendMail(
343321 invite.email,
344322 invite.ticket,
345323 ob.patp(point),
346324 invite.rawTx
347325 );
348− if (!success) throw new Error('Failed to send mail');
349326 } catch (error) {
350327 console.error(error);
351328 orphanedInvites.push(invite);
352329 }
353330
354− addReceipt({ [input.name]: true });
331+ addReceipt({ [email]: true });
355332 });
356333
357334 await Promise.all(txAndMailings);
@@ -375,47 +352,30 @@ export default function InviteEmail() {
375352 if (errorString !== '') {
376353 throw new Error(errorString);
377354 }
378− }, [
379− web3,
380− inputs,
381− addReceipt,
382− clearReceipts,
383− invites,
384− point,
385− wallet,
386− sendMail,
387− ]);
388−
389− const onClick = useCallback(async () => {
355+ }, [web3, addReceipt, clearReceipts, invites, point, wallet, sendMail]);
356+
357+ const onSubmit = useCallback(
358+ async values => {
359+ setStatus(STATUS.GENERATING);
360+ await generateInvites(values);
361+ cachedEmails.current = values.emails;
362+ setStatus(STATUS.CAN_SEND);
363+ },
364+ [generateInvites]
365+ );
366+
367+ const doSend = useCallback(async () => {
390368 setGeneralError(null);
391369 try {
392− if (canGenerate) {
393− setStatus(STATUS.GENERATING);
394− await generateInvites();
395− setStatus(STATUS.CAN_SEND);
396− } else if (canSend) {
397− setStatus(STATUS.SENDING);
398− await sendInvites();
399− setStatus(STATUS.SUCCESS);
400− }
370+ setStatus(STATUS.SENDING);
371+ await sendInvites();
372+ setStatus(STATUS.SUCCESS);
401373 } catch (error) {
402374 console.error(error);
403375 setGeneralError(error);
404376 setStatus(STATUS.FAILURE);
405377 }
406− }, [canGenerate, canSend, generateInvites, sendInvites]);
407−
408− // when inputs update, check to see if any of them are duplicates
409− useEffect(() => {
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.`));
414− } else {
415− setGeneralError(null);
416− setStatus(STATUS.INPUT);
417− }
418− }, [inputs]);
378+ }, [sendInvites]);
419379
420380 // when we transition to done, sync invites because we just sent some
421381 // and therefore know that state has changed
@@ -425,109 +385,137 @@ export default function InviteEmail() {
425385 }
426386 }, [isDone, syncInvites, point]);
427387
428− useEffect(() => {
429− for (const input of inputs) {
430− if (input.pass) {
431− syncHasReceivedForEmail(input.data);
432− }
433− }
434− }, [inputs, syncHasReceivedForEmail]);
435−
436388 return (
437389 <Grid gap={3}>
438− <Grid.Item full as={Flex} justify="end">
439− {/* use hidden class instead of removing component from dom */}
440− {/* in order to avoid janky reflow */}
441− <IconButton
442− onClick={() => appendInput()}
443− disabled={!canAddInvite}
444− className={cn({ hidden: isDone })}
445− solid>
446− +
447− </IconButton>
448− </Grid.Item>
449−
450− {isDone && (
451− <>
452− <Grid.Item as={Text} className="f5" full>
453− <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
454− {pluralize(inputs.length, 'has', 'have')} been successfully sent
455− </Grid.Item>
456− {inputs.map(input => (
457− <Grid.Item as={HelpText} key={input.name} full>
458− {invites[input.name].email}
459− </Grid.Item>
460− ))}
461− </>
462− )}
463−
464− {!isDone && (
465− <>
466− {/* email inputs */}
467− {inputs.map((input, i) => {
468− const isFirst = i === 0;
469− return (
470− <Grid.Item
471− key={input.name}
472− as={Grid}
473− gap={3}
474− onMouseOver={() => setHovered({ [input.name]: true })}
475− onMouseLeave={() => setHovered({ [input.name]: false })}
476− full>
477− <Grid.Item
478− as={Input}
479− cols={[1, 11]}
480− {...input}
481− accessory={accessoryFor(input.name)}
482− />
483− {!isFirst &&
484− (input.focused || hovered[input.name]) &&
485− canInput && (
390+ <BridgeForm
391+ validate={validate}
392+ onSubmit={onSubmit}
393+ initialValues={INITIAL_VALUES}>
394+ {({ handleSubmit, valid, values }) => (
395+ <FieldArray name="emails">
396+ {({ fields }) => (
397+ <>
398+ <Grid.Item full as={Flex} justify="end">
399+ {/* use hidden class instead of removing component from dom */}
400+ {/* in order to avoid janky reflow */}
401+ <IconButton
402+ onClick={() => fields.push('')}
403+ disabled={!canInput || fields.length >= maxInvitesToSend}
404+ className={cn({ hidden: isDone })}
405+ solid>
406+ +
407+ </IconButton>
408+ </Grid.Item>
409+
410+ {isDone && (
411+ <>
412+ <Grid.Item as={Text} className="f5" full>
413+ <Highlighted>
414+ {pluralize(fields.length, 'invite')}
415+ </Highlighted>{' '}
416+ {pluralize(fields.length, 'has', 'have')} been
417+ successfully sent
418+ </Grid.Item>
419+
420+ {fields.map(name => (
421+ <Grid.Item as={HelpText} key={name} full>
422+ <Field name={name}>{({ value }) => value}</Field>
423+ </Grid.Item>
424+ ))}
425+ </>
426+ )}
427+
428+ {!isDone && (
429+ <>
430+ {fields.map((name, i) => {
431+ const isFirst = i === 0;
432+ return (
433+ <Grid.Item
434+ key={name}
435+ full
436+ as={Grid}
437+ gap={3}
438+ onMouseOver={() => setHovered({ [name]: true })}
439+ onMouseLeave={() => setHovered({ [name]: false })}>
440+ <Grid.Item
441+ cols={[1, 11]}
442+ as={EmailInput}
443+ name={name}
444+ label={isFirst ? 'Email Address' : undefined}
445+ accessory={accessoryFor(name)}
446+ />
447+ <Field name={name}>
448+ {({ meta: { active } }) => {
449+ return (
450+ <>
451+ {!isFirst &&
452+ (active || hovered[name]) &&
453+ canInput && (
454+ <Grid.Item
455+ cols={[11, 13]}
456+ justifySelf="end"
457+ alignSelf="center">
458+ <IconButton
459+ onClick={() => fields.remove(i)}
460+ solid
461+ secondary>
462+ -
463+ </IconButton>
464+ </Grid.Item>
465+ )}
466+ </>
467+ );
468+ }}
469+ </Field>
470+ </Grid.Item>
471+ );
472+ })}
473+
474+ {canInput && (
475+ <Grid.Item
476+ full
477+ as={SubmitButton}
478+ handleSubmit={handleSubmit}>
479+ {buttonText(status, fields.length)}
480+ </Grid.Item>
481+ )}
482+
486483 <Grid.Item
487− cols={[11, 13]}
488− justifySelf="end"
489− alignSelf="center">
490− <IconButton
491− onClick={() => removeInputAt(i)}
492− solid
493− secondary>
494− -
495− </IconButton>
484+ full
485+ as={LoadableButton}
486+ disabled={!(canInput || canSend || valid)}
487+ accessory={`${visualProgress}/${fields.length}`}
488+ onClick={doSend}
489+ success={canSend}
490+ solid>
491+ {buttonText(status, fields.length)}
496492 </Grid.Item>
497− )}
498− </Grid.Item>
499− );
500− })}
501−
502− <Grid.Item
503− full
504− as={LoadableButton}
505− disabled={!canGenerate && !canSend}
506− accessory={`${visualProgress}/${inputs.length}`}
507− onClick={onClick}
508− success={canSend}
509− solid>
510− {buttonText(status, inputs.length)}
511− </Grid.Item>
512−
513− {needFunds && (
514− <Grid.Item full>
515− <Highlighted warning>
516− Your ownership address {needFunds.address} needs at least{' '}
517− {fromWei(needFunds.minBalance)} ETH and currently has{' '}
518− {fromWei(needFunds.balance)} ETH. Waiting until the account has
519− enough funds.
520− </Highlighted>
521− </Grid.Item>
522− )}
523−
524− {generalError && (
525− <Grid.Item full>
526− <ErrorText>{generalError.message.toString()}</ErrorText>
527− </Grid.Item>
528− )}
529− </>
530− )}
493+
494+ {needFunds && (
495+ <Grid.Item full>
496+ <Highlighted warning>
497+ Your ownership address {needFunds.address} needs at
498+ least {fromWei(needFunds.minBalance)} ETH and
499+ currently has {fromWei(needFunds.balance)} ETH.
500+ Waiting until the account has enough funds.
501+ </Highlighted>
502+ </Grid.Item>
503+ )}
504+
505+ <Grid.Item full as={FormError} />
506+
507+ {generalError && (
508+ <Grid.Item full>
509+ <ErrorText>{generalError.message.toString()}</ErrorText>
510+ </Grid.Item>
511+ )}
512+ </>
513+ )}
514+ </>
515+ )}
516+ </FieldArray>
517+ )}
518+ </BridgeForm>
531519 </Grid>
532520 );
533521}