@@ -1,14 +1,14 @@
1+import React, { useCallback, useEffect, useState } from 'react';
2+import cn from 'classnames';
1import Maybe, { Just, Nothing } from 'folktale/maybe';
3import Maybe, { Just, Nothing } from 'folktale/maybe';
2−import React, { useState } from 'react';
3import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
4−import { Grid, Flex, Button, Input, IconButton } from 'indigo-react';
5+import { Grid, Flex, Input, IconButton, HelpText, Text } from 'indigo-react';
6+import { uniq } from 'lodash';
6import * as need from 'lib/need';
8import * as need from 'lib/need';
7import { hasReceived, sendMail } from 'lib/inviteMail';
9import { hasReceived, sendMail } from 'lib/inviteMail';
8import * as wg from 'lib/walletgen';
10import * as wg from 'lib/walletgen';
10−import { H3, Warning } from 'components/old/Base';
12// for transaction generation and signing
12// for transaction generation and signing
14 signTransaction,
14 signTransaction,
@@ -21,43 +21,84 @@ import {
21import * as tank from 'lib/tank';
21import * as tank from 'lib/tank';
22import { useLocalRouter } from 'lib/LocalRouter';
22import { useLocalRouter } from 'lib/LocalRouter';
24−import View from 'components/View';
25import MiniBackButton from 'components/MiniBackButton';
24import MiniBackButton from 'components/MiniBackButton';
26−import useRenderCount from 'lib/useRenderCount';
27import useInvites from 'lib/useInvites';
25import useInvites from 'lib/useInvites';
28import { usePointCursor } from 'store/pointCursor';
26import { usePointCursor } from 'store/pointCursor';
29import LoadableButton from 'components/LoadableButton';
27import LoadableButton from 'components/LoadableButton';
30import useArray from 'lib/useArray';
28import useArray from 'lib/useArray';
31import useForm from 'indigo-react/lib/useForm';
29import useForm from 'indigo-react/lib/useForm';
32import { buildEmailInputConfig } from 'components/Inputs';
30import { buildEmailInputConfig } from 'components/Inputs';
31+import { MIN_PLANET } from 'lib/constants';
32+import Highlighted from 'components/Highlighted';
33+import { useNetwork } from 'store/network';
34+import { usePointCache } from 'store/pointCache';
35+import { useWallet } from 'store/wallet';
36+import useSetState from 'lib/useSetState';
37+import pluralize from 'lib/pluralize';
39+const kStubMailer = process.env.REACT_APP_STUB_MAILER === 'true';
34const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
41const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
35const GAS_LIMIT = 350000;
42const GAS_LIMIT = 350000;
36const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
43const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
38const STATUS = {
45const STATUS = {
39− INPUT: 'Generate invites',
46+ INPUT: 'INPUT',
40− GENERATING: 'Generating invites',
47+ GENERATING: 'GENERATING',
41− CAN_SEND: 'Send invites',
48+ CAN_SEND: 'CAN_SEND',
42− FUNDING: 'Funding invites',
49+ FUNDING: 'FUNDING',
43− SENDING: 'Sending invites',
50+ SENDING: 'SENDING',
44− DONE: 'Done',
51+ SUCCESS: 'SUCCESS',
45− FAIL: 'Some failed',
52+ FAILURE: 'FAILURE',
48−const EMAIL_STATUS = {
55+const buttonText = (status, count) => {
49− BUSY: '⋯',
56+ switch (status) {
50− DONE: '✔',
57+ case STATUS.INPUT:
51− FAIL: '×',
58+ return `Generate ${pluralize(count, 'Invite')}`;
59+ case STATUS.GENERATING:
60+ return `Generating ${pluralize(count, 'Invite')}...`;
61+ case STATUS.CAN_SEND:
62+ return `Send ${pluralize(count, 'Invite')}`;
64+ return `Waiting on Funds...`;
66+ return `Sending ${pluralize(count, 'Invite')}...`;
73+const kPendingAccessory = '⋯';
74+const kSuccessAccessory = '✔';
75+const kFailureAccessory = '×';
54// world's simplest uid
77// world's simplest uid
55let id = 0;
78let id = 0;
56const buildInputConfig = (extra = {}) =>
79const buildInputConfig = (extra = {}) =>
57 buildEmailInputConfig({ name: `email-${id++}`, ...extra });
80 buildEmailInputConfig({ name: `email-${id++}`, ...extra });
82+const buildAccessoryFor = (dones, errors) => name => (
83+ <Flex justify="center" align="center" style={{ height: '100%' }}>
85+ if (dones[name]) return kSuccessAccessory;
86+ if (errors[name]) return kFailureAccessory;
87+ return kPendingAccessory;
92+// TODO: refactor transactions + mailer into hooks
93+// TODO: include input-specific error info in form elements
94+// TODO: put accessory inside of input
95+// TODO: implement hasReceived cache with useHasReceived
59export default function NewInviteEmail() {
96export default function NewInviteEmail() {
97+ // TODO: resumption after error?
60 const { pop } = useLocalRouter();
98 const { pop } = useLocalRouter();
99+ const { contracts, web3, networkType } = useNetwork();
100+ const { wallet, walletType, walletHdPath } = useWallet();
101+ const { syncInvites } = usePointCache();
61 const { pointCursor } = usePointCursor();
102 const { pointCursor } = usePointCursor();
62 const point = need.pointCursor(pointCursor);
103 const point = need.pointCursor(pointCursor);
@@ -79,452 +120,361 @@ export default function NewInviteEmail() {
79 const setHovered = (name, value) => () =>
120 const setHovered = (name, value) => () =>
80 _setHovered((value = { ...value, [name]: value }));
121 _setHovered((value = { ...value, [name]: value }));
82− console.log(inputConfigs.length, inputConfigs.map(c => c.name));
123+ // TODO: pass disabled, error info into the useForm hook?
84 const { inputs, pass } = useForm(inputConfigs);
124 const { inputs, pass } = useForm(inputConfigs);
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 allPass = pass && !generalError;
136+ const canInput = status === STATUS.INPUT;
137+ const canGenerate = allPass && status === STATUS.INPUT;
138+ const canSend = status === STATUS.CAN_SEND;
139+ const isGenerating = status === STATUS.GENERATING;
140+ const isSending = status === STATUS.SENDING;
141+ const isFunding = status === STATUS.FUNDING;
142+ const isFailed = status === STATUS.FAILURE;
143+ const isDone = status === STATUS.SUCCESS;
86− const canAddInvite = inputs.length < maxInvitesToSend;
144+ const canAddInvite = canInput && inputs.length < maxInvitesToSend;
88− useRenderCount('InviteEmail');
146+ const progress = isGenerating
89−
147+ ? Object.keys(invites).length
90− return (
148+ : isSending
91− <Grid gap={12}>
149+ ? Object.keys(receipts).length
92− <Grid.Item as={Grid} className="mb4" full>
150+ : null;
93− <Grid.Item as={Flex} cols={[1, 11]} align="center">
151+ const visualProgress = progress === null ? '-' : `${progress + 1}`;
94− <MiniBackButton onClick={() => pop()} />
152+
95− </Grid.Item>
153+ const accessoryFor = (() => {
96− <Grid.Item cols={[11, 13]} justifySelf="end">
154+ if (isGenerating || canSend) return buildAccessoryFor(invites, errors);
97− <IconButton onClick={appendInput} disabled={!canAddInvite} solid>
155+ if (isSending) return buildAccessoryFor(receipts, errors);
98− +
156+ if (isFailed) return buildAccessoryFor({}, errors);
99− </IconButton>
157+ return () => null;
100− </Grid.Item>
158+ })();
102−
160+ const generateInvites = useCallback(async () => {
103− {/* email inputs */}
161+ const _contracts = contracts.getOrElse(null);
104− {inputs.map((input, i) => {
162+ const _web3 = web3.getOrElse(null);
105− const isFirst = i === 0;
163+ const _wallet = wallet.getOrElse(null);
106− return (
164+ if (!_contracts || !_web3 || !_wallet) {
107− <Grid.Item
165+ // not using need because we want a custom error
108− key={input.name}
166+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
111− onMouseEnter={setHovered(input.name, true)}
112− onMouseLeave={setHovered(input.name, false)}
114− <Grid.Item as={Input} cols={[1, 11]} {...input} />
115− {!isFirst && (input.focused || hovered[input.name]) && (
116− <Grid.Item cols={[11, 13]} justifySelf="end" alignSelf="center">
117− <IconButton onClick={() => removeInputAt(i)}>-</IconButton>
124− <Grid.Item as={LoadableButton} full disabled={!pass} solid>
131−class InviteEmail extends React.Component {
132− constructor(props) {
136− invitesAvailable: Nothing(),
137− targets: [{ email: '', hasReceived: Nothing(), status: Nothing() }],
138− status: STATUS.INPUT,
141− fundingMessage: Nothing(),
145− this.generateInvites = this.generateInvites.bind(this);
146− this.sendInvites = this.sendInvites.bind(this);
147− this.canSend = this.canSend.bind(this);
148− this.addError = this.addError.bind(this);
149− this.handleEmailInput = this.handleEmailInput.bind(this);
150− this.addTarget = this.addTarget.bind(this);
151− this.removeTarget = this.removeTarget.bind(this);
152− this.setEmailStatus = this.setEmailStatus.bind(this);
153− this.askForFunding = this.askForFunding.bind(this);
156− componentDidMount() {
157− this.point = need.pointCursor(this.props.pointCursor);
158− this.address = need.addressFromWallet(this.props.wallet);
159− this.contracts = need.contracts(this.props.contracts);
160− this.web3 = need.web3(this.props.web3);
162− azimuth.delegatedSending
163− .getTotalUsableInvites(this.contracts, this.point)
165− this.setState({ invitesAvailable: Just(count) });
169− async getTemporaryWallet() {
170− const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
171− const owner = await wg.generateOwnershipWallet(0, ticket);
172− return { ticket, owner: owner.keys.address };
175− async generateInvites() {
176− this.setState({ status: STATUS.GENERATING });
178− const web3 = this.web3;
169+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
179− const targets = this.state.targets;
170+ const chainId = await _web3.eth.net.getId();
180 const planets = await azimuth.delegatedSending.getPlanetsToSend(
171 const planets = await azimuth.delegatedSending.getPlanetsToSend(
181− this.contracts,
172+ _contracts,
182− this.point,
173+ point,
183− targets.length
174+ inputs.length
186 // account for the race condition where invites got used up while we were
177 // account for the race condition where invites got used up while we were
187 // composing our target list
178 // composing our target list
188− if (planets.length < targets.length) {
179+ if (planets.length < inputs.length) {
180+ // resync invites to the cache, since they're out of date
189− this.addError('Can currently only send ' + planets.length + ' invites!');
184+ `Can currently only send ${planets.length} invites. ` +
190− return;
185+ `Please remove invites until you are within the limit.`
193− const nonce = await web3.eth.getTransactionCount(this.address);
189+ let errorCount = 0;
194− const chainId = await web3.eth.net.getId();
190+ clearInvites();
191+ // NB(shrugs) - must be processed in serial because main thread, etc
192+ for (let i = 0; i < inputs.length; i++) {
193+ const input = inputs[i];
195+ const { data: email, name } = inputs[i];
196− // create invite wallets and transactions
197+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
197− let invites = []; // { email, ticket, signedTx, rawTx }
198+ MIN_PLANET
198− for (let i = 0; i < targets.length; i++) {
199+ // we're always giving planets, so generate a ticket of the correct size
199− console.log('generating email', i, targets[i].email);
200+ );
200− this.setEmailStatus(i, EMAIL_STATUS.BUSY);
202− const email = targets[i].email;
202+ const inviteTx = azimuth.delegatedSending.sendPoint(
203− const wallet = await this.getTemporaryWallet();
203+ _contracts,
208+ const signedTx = await signTransaction({
213+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
214+ txn: Just(inviteTx),
215+ gasPrice: GAS_PRICE_GWEI.toString(),
216+ gasLimit: GAS_LIMIT.toString(),
221+ const rawTx = hexify(signedTx.serialize());
205− let inviteTx = azimuth.delegatedSending.sendPoint(
223+ addInvite({ [name]: { email, ticket, signedTx, rawTx } });
206− this.contracts,
224+ } catch (error) {
207− this.point,
225+ console.error(error);
208− planets[i],
226+ errorCount++;
209− wallet.owner
227+ addError({ [input.name]: `Wallet Error: ${input.email}` });
211− const signedTx = await signTransaction({
213− txn: Just(inviteTx),
214− gasPrice: GAS_PRICE_GWEI.toString(),
215− gasLimit: GAS_LIMIT.toString(),
220− const rawTx = hexify(signedTx.serialize());
222− invites.push({ email, ticket: wallet.ticket, signedTx, rawTx });
223− this.setEmailStatus(i, EMAIL_STATUS.DONE);
226− this.setState({ wipInvites: invites, status: STATUS.CAN_SEND });
231+ if (errorCount > 0) {
233+ `There were ${errorCount} errors while generating wallets.`
229− async sendInvites() {
251+ const sendInvites = useCallback(async () => {
230− const invites = this.state.wipInvites;
252+ const _web3 = web3.getOrElse(null);
231− const web3 = this.web3;
253+ const _wallet = wallet.getOrElse(null);
232−
254+ if (!_web3 || !_wallet) {
233− //TODO invite status isn't changing/re-rendering... why?
255+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
235− status: STATUS.FUNDING,
236− invites: invites.map(i => {
237− return { ...i, status: Nothing() };
258+ setStatus(STATUS.FUNDING);
241 const tankWasUsed = await tank.ensureFundsFor(
259 const tankWasUsed = await tank.ensureFundsFor(
243− this.point,
261+ point,
244− this.address,
262+ _wallet.address,
245− INVITE_COST * invites.length,
263+ INVITE_COST * inputs.length,
246− invites.map(i => i.rawTx),
264+ inputs.map(input => invites[input.name].rawTx),
247− this.askForFunding,
265+ (address, minBalance, balance) =>
248− () => {
266+ setNeedFunds({ address, minBalance, balance }),
249− this.setState({ fundingMessage: Nothing() });
267+ () => setNeedFunds(false)
253− this.setState({ status: STATUS.SENDING });
270+ setStatus(STATUS.SENDING);
255− //TODO nasty loop, probably move into lib... but so many callbacks!
273+ let errorCount = 0;
256− let promises = [];
274+ const txAndMailings = inputs.map(async input => {
257− for (let i = 0; i < invites.length; i++) {
258− const invite = invites[i];
275+ const invite = invites[input.name];
260− // send transaction. if it fails, inform the user and skip to next
263− txHash = await sendSignedTransaction(
277+ const txHash = await sendSignedTransaction(
265 Just(invite.signedTx),
279 Just(invite.signedTx),
266 tankWasUsed,
280 tankWasUsed,
284+ // TODO: waitForTransactionConfirm never rejects
285+ const didConfirm = await waitForTransactionConfirm(_web3, txHash);
286+ if (!didConfirm) throw new Error();
269− } catch (err) {
287+ } catch (error) {
270− console.error('invite tx sending failed', err);
288+ console.error(error);
271− this.addError('Invite transaction not sent for ' + invite.email);
289+ errorCount++;
272− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
290+ addError({
273− continue;
291+ [input.name]: `Transaction Failure for ${invite.email}}`,
276− // ask the email sender to send this. we can do this prior to tx confirm,
277− // because the sender service waits for confirm & success for us.
278− // if this fails, inform the user and skip to next
297+ console.log(`${invite.email} - ${invite.ticket}`);
280 const mailSuccess = await sendMail(
301 const mailSuccess = await sendMail(
281 invite.email,
302 invite.email,
282 invite.ticket,
303 invite.ticket,
283 invite.rawTx
304 invite.rawTx
285 if (!mailSuccess) throw new Error();
306 if (!mailSuccess) throw new Error();
286− this.setEmailStatus(i, EMAIL_STATUS.DONE);
287− } catch (e) {
307+ } catch (error) {
288− console.log('email send failed');
308+ console.error(error);
289− //NOTE this assumes that the transaction succeeds, but we don't know
309+ errorCount++;
290− // that for a fact yet...
291− this.addError(
310+ addError({
292− 'Invite email failed to send for ' +
311+ [input.name]: `Mailing Failure for ${invite.email}}`,
293− invite.email +
312+ });
294− '. Please give them this ticket: ' +
297− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
301− // update status on transaction confirm, but don't block on it
315+ addReceipt({ [input.name]: true });
302− promises.push(
316+ });
303− waitForTransactionConfirm(web3, txHash).then(success => {
317+
304− if (success) {
318+ await Promise.all(txAndMailings);
305− this.setEmailStatus(i, EMAIL_STATUS.DONE);
319+ // if there are any receipt errors, throw a general error
306− } else {
320+ if (errorCount > 0) {
307− console.log('invite tx rejected');
321+ throw new Error(
308− this.addError('Invite transaction failed for ' + invite.email);
322+ `There were ${errorCount} errors while sending transactions.`
309− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
316− const txStatus = await Promise.all(promises);
326+ web3,
317− if (txStatus.every(e => e)) {
327+ inputs,
318− this.setState({ status: STATUS.DONE });
328+ addReceipt,
319− } else {
329+ clearReceipts,
320− this.setState({ status: STATUS.FAIL });
330+ invites,
336+ const onClickGenerate = useCallback(async () => {
337+ setGeneralError(null);
338+ setStatus(STATUS.GENERATING);
340+ await generateInvites();
341+ setStatus(STATUS.CAN_SEND);
343+ console.error(error);
344+ setGeneralError(error);
345+ setStatus(STATUS.FAILURE);
322− }
347+ }, [setStatus, setGeneralError, generateInvites]);
324− setEmailStatus(i, status) {
349+ const onClickSend = useCallback(async () => {
325− const targets = this.state.targets;
350+ setGeneralError(null);
326− targets[i].status = Just(status);
351+ setStatus(STATUS.SENDING);
327− this.setState({ targets });
352+ try {
328− }
353+ await sendInvites();
329−
354+ setStatus(STATUS.SUCCESS);
330− addError(error) {
355+ } catch (error) {
331− const newError = this.state.errors.matchWith({
356+ console.error(error);
332− Nothing: () => [error],
357+ setGeneralError(error);
333− Just: errs => {
358+ setStatus(STATUS.FAILURE);
334− errs.value.push(error);
338− this.setState({ errors: Just(newError) });
341− askForFunding(address, minBalance, balance) {
343− fundingMessage: Just(
344− `Please make sure ${address} has at least ${fromWei(minBalance)} ETH,` +
345− `we'll continue once that's true. Current balance: ${fromWei(
353− handleEmailInput(i, email) {
354− let targets = this.state.targets;
355− targets[i].email = email;
357− // if it's a valid address, check if it has received an invite before
358− if (email.match(/.*@.*\...+/)) {
359− hasReceived(email).then(res => {
360− targets[i].hasReceived = Just(res);
361− this.setState({ targets });
364− targets[i].hasReceived = Nothing();
360+ }, [setStatus, sendInvites, setGeneralError]);
367− this.setState({ targets });
362+ const onClick = useCallback(() => {
368− }
363+ if (canGenerate) {
369−
364+ onClickGenerate();
370− canSend() {
365+ } else if (canSend) {
371− const targets = this.state.targets;
366+ onClickSend();
373− // may not have duplicate targets
374− if (new Set(targets.map(t => t.email)).size !== targets.length)
377− // none may have received invites already
378− for (let i = 0; i < targets.length; i++) {
379− const target = targets[i];
380− const res = target.hasReceived.matchWith({
381− Nothing: () => true,
382− Just: has => has.value,
384− if (res) return false;
368+ }, [canGenerate, canSend, onClickGenerate, onClickSend]);
387− return this.state.invitesAvailable.matchWith({
370+ useEffect(() => {
388− Nothing: () => false,
371+ if (uniq(inputs.map(i => i.data)).length !== inputs.length) {
389− Just: invites => invites.value > 0,
372+ setGeneralError(new Error(`Duplicate email.`));
394− let targets = this.state.targets;
395− targets.push({ email: '', hasReceived: Nothing(), status: Nothing() });
396− this.setState({ targets });
400− let targets = this.state.targets;
401− targets.length = targets.length - 1;
402− this.setState({ targets });
407− this.state.status === STATUS.DONE ? this.state.targets.length : null;
409− const error = this.state.errors.matchWith({
410− Nothing: () => null,
412− <Warning className={'mt-8'}>
413− <H3 style={{ marginTop: 0, paddingTop: 0 }}>
414− {'Something went wrong!'}
416− {errors.value.map(err => (
423− const fundingMessage = this.state.fundingMessage.matchWith({
424− Nothing: () => null,
425− Just: msg => <Warning className={'mt-8'}>{msg.value}</Warning>,
429− if (this.state.status === STATUS.DONE) {
430− //TODO proper clickable router thing
431− inputs = '<- Back to Home';
433− const inputDisabled = this.state.status !== STATUS.INPUT;
374+ setGeneralError(null);
435− const lessButton = (
376+ }, [inputs]);
437− prop-size={'s narrow'}
439− disabled={inputDisabled || this.state.targets.length === 1}
440− onClick={this.removeTarget}>
445− const moreButton = (
447− prop-size={'s narrow'}
451− this.state.invitesAvailable.matchWith({
452− Nothing: () => true,
453− Just: inv => inv.value <= this.state.targets.length,
456− onClick={this.addTarget}>
461− let targetInputs = [];
462− for (let i = 0; i < this.state.targets.length; i++) {
463− const target = this.state.targets[i];
464− let progress = target.status.matchWith({
466− Just: status => status.value,
471− value={target.email}
472− onChange={value => {
473− this.handleEmailInput(i, value);
475− placeholder={'Email address'}
476− disabled={inputDisabled}
478− {target.hasReceived.matchWith({
479− Nothing: () => null,
481− has.value ? <span>{'×'}</span> : <span>{'°'}</span>,
488− const submitButton = (
378+ return (
489− <Button
379+ <Grid gap={12}>
490− prop-size={'xl wide'}
380+ <Grid.Item as={Grid} full>
491− className={'mt-8'}
381+ <Grid.Item as={Flex} cols={[1, 11]} align="center">
382+ <MiniBackButton onClick={() => pop()} />
384+ <Grid.Item cols={[11, 13]} justifySelf="end">
385+ {/* use hidden class instead of removing component from dom */}
386+ {/* in order to avoid janky reflow */}
388+ onClick={() => appendInput()}
492− disabled={
389+ disabled={!canAddInvite}
493− (inputDisabled && this.state.status !== STATUS.CAN_SEND) ||
390+ className={cn({ hidden: isDone })}
494− !this.canSend()
391+ solid>
496− onClick={() => {
393+ </IconButton>
497− if (this.state.status === STATUS.INPUT) this.generateInvites();
394+ </Grid.Item>
498− if (this.state.status === STATUS.CAN_SEND) this.sendInvites();
395+ </Grid.Item>
504− inputs = (
397+ {isDone && (
506− {lessButton}
399+ <Grid.Item as={Text} full>
507− {moreButton}
400+ <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
508− {targetInputs}
401+ {pluralize(inputs.length, 'has', 'have')} been successfully sent
509− {submitButton}
402+ </Grid.Item>
403+ {inputs.map(input => (
404+ <Grid.Item as={HelpText} key={input.name} full>
405+ {invites[input.name].email}
514− return (
411+ {!isDone && (
516− {error}
413+ {/* email inputs */}
414+ {inputs.map((input, i) => {
415+ const isFirst = i === 0;
421+ onMouseEnter={setHovered(input.name, true)}
422+ onMouseLeave={setHovered(input.name, false)}
428+ accessory={accessoryFor(input.name)}
429+ disabled={!canInput}
432+ (input.focused || hovered[input.name]) &&
439+ onClick={() => removeInputAt(i)}
518− {fundingMessage}
450+ <Grid.Item
453+ disabled={!canGenerate && !canSend}
454+ accessory={`${visualProgress}/${inputs.length}`}
458+ {buttonText(status, inputs.length)}
520− {inputs}
461+ {needFunds && (
521− </View>
462+ <Grid.Item full>
464+ Your ownership address {needFunds.address} needs at least{' '}
465+ {fromWei(needFunds.minBalance)}ETH and currently has{' '}
466+ {fromWei(needFunds.balance)}.
473+ <Highlighted>{generalError.message.toString()}</Highlighted>
526−// export default compose(