@@ -1,13 +1,21 @@
1−import { Just, Nothing } from 'folktale/maybe';
2−import React from 'react';
1+import React, { useCallback, useEffect, useMemo, useState } from 'react';
2+import cn from 'classnames';
3+import Maybe from 'folktale/maybe';
34import * as azimuth from 'azimuth-js';
4−import * as need from 'lib/need';
5−
6−import { hasReceived, sendMail } from 'lib/inviteMail';
7−import { Button, Input, H3, Warning } from 'components/old/Base';
5+import {
6+ Grid,
7+ Flex,
8+ Input,
9+ IconButton,
10+ HelpText,
11+ Text,
12+ ErrorText,
13+ AccessoryIcon,
14+} from 'indigo-react';
15+import { uniq } from 'lodash';
816
9−// for wallet generation
10−import * as wg from '_walletgen/lib/lib';
17+import * as need from 'lib/need';
18+import * as wg from 'lib/walletgen';
1119
1220// for transaction generation and signing
1321import {
@@ -19,483 +27,471 @@ import {
1927 toWei,
2028} from 'lib/txn';
2129import * as tank from 'lib/tank';
22−import { withNetwork } from 'store/network';
23−import { compose } from 'lib/lib';
24−import { withWallet } from 'store/wallet';
25−import { withPointCursor } from 'store/pointCursor';
26−import View from 'components/View';
30+import { useLocalRouter } from 'lib/LocalRouter';
31+
32+import MiniBackButton from 'components/MiniBackButton';
33+import { usePointCursor } from 'store/pointCursor';
34+import LoadableButton from 'components/LoadableButton';
35+import useArray from 'lib/useArray';
36+import useForm from 'indigo-react/lib/useForm';
37+import { buildEmailInputConfig } from 'components/Inputs';
38+import { MIN_PLANET } from 'lib/constants';
39+import Highlighted from 'components/Highlighted';
40+import { useNetwork } from 'store/network';
41+import { usePointCache } from 'store/pointCache';
42+import { useWallet } from 'store/wallet';
43+import useSetState from 'lib/useSetState';
44+import pluralize from 'lib/pluralize';
45+import useMailer from 'lib/useMailer';
2746
2847const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
29−const GAS_LIMIT = 350000;
48+const GAS_LIMIT = 400000;
3049const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
50+const HAS_RECEIVED_TEXT = 'This email has already received an invite.';
3151
3252const STATUS = {
33− INPUT: 'Generate invites',
34− GENERATING: 'Generating invites',
35− CAN_SEND: 'Send invites',
36− FUNDING: 'Funding invites',
37− SENDING: 'Sending invites',
38− DONE: 'Done',
39− FAIL: 'Some failed',
53+ INPUT: 'INPUT',
54+ GENERATING: 'GENERATING',
55+ CAN_SEND: 'CAN_SEND',
56+ FUNDING: 'FUNDING',
57+ SENDING: 'SENDING',
58+ SUCCESS: 'SUCCESS',
59+ FAILURE: 'FAILURE',
4060};
4161
42−const EMAIL_STATUS = {
43− BUSY: '⋯',
44− DONE: '✔',
45− FAIL: '×',
46−};
47−
48−class InviteEmail extends React.Component {
49− constructor(props) {
50− super(props);
51−
52− this.state = {
53− invitesAvailable: Nothing(),
54− invited: Nothing(),
55− targets: [{ email: '', hasReceived: Nothing(), status: Nothing() }],
56− status: STATUS.INPUT,
57− errors: Nothing(),
58− //
59− fundingMessage: Nothing(),
60− wipInvites: [],
61− };
62−
63− this.findInvited = this.findInvited.bind(this);
64− this.generateInvites = this.generateInvites.bind(this);
65− this.sendInvites = this.sendInvites.bind(this);
66− this.canSend = this.canSend.bind(this);
67− this.addError = this.addError.bind(this);
68− this.handleEmailInput = this.handleEmailInput.bind(this);
69− this.addTarget = this.addTarget.bind(this);
70− this.removeTarget = this.removeTarget.bind(this);
71− this.setEmailStatus = this.setEmailStatus.bind(this);
72− this.askForFunding = this.askForFunding.bind(this);
73− }
74−
75− componentDidMount() {
76− this.point = need.pointCursor(this.props.pointCursor);
77− this.address = need.addressFromWallet(this.props.wallet);
78− this.contracts = need.contracts(this.props.contracts);
79− this.web3 = need.web3(this.props.web3);
80−
81− console.log(azimuth.delegatedSending);
82− azimuth.delegatedSending
83− .getTotalUsableInvites(this.contracts, this.point)
84− .then(count => {
85− this.setState({ invitesAvailable: Just(count) });
86− });
87− this.findInvited();
88− }
89−
90− componentDidUpdate(prevProps) {
91− //
92− }
93−
94− async findInvited() {
95− let invited = await azimuth.delegatedSending.getInvited(
96− this.contracts,
97− this.point
98− );
99− invited = invited.map(async point => {
100− const active = await azimuth.azimuth.isActive(this.contracts, point);
101− const res = { point: Number(point), active: active };
102− return res;
103− });
104− invited = await Promise.all(invited);
105− const total = invited.length;
106− const accepted = invited.filter(i => i.active).length;
107− this.setState({ invited: Just({ total, accepted }) });
62+const 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';
10877 }
78+};
10979
110− async getTemporaryWallet() {
111− const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
112− const owner = await wg.generateOwnershipWallet(0, ticket);
113− return { ticket, owner: owner.keys.address };
114− }
80+// world's simplest uid
81+let id = 0;
82+const buildInputConfig = (extra = {}) =>
83+ buildEmailInputConfig({
84+ name: `email-${id++}`,
85+ placeholder: 'Email Address',
86+ ...extra,
87+ });
88+
89+const buildAccessoryFor = (dones, errors) => name => {
90+ if (dones[name]) return <AccessoryIcon.Success />;
91+ if (errors[name]) return <AccessoryIcon.Failure />;
92+ return <AccessoryIcon.Pending />;
93+};
11594
116− async generateInvites() {
117− this.setState({ status: STATUS.GENERATING });
95+// TODO: test with tank, successful txs
96+export default function InviteEmail() {
97+ // TODO: resumption after error?
98+ const { pop } = useLocalRouter();
99+ const { contracts, web3, networkType } = useNetwork();
100+ const { wallet, walletType, walletHdPath } = useWallet();
101+ const { syncInvites, getInvites } = usePointCache();
102+ const { pointCursor } = usePointCursor();
103+ const point = need.pointCursor(pointCursor);
104+ const { getHasRecieved, syncHasReceivedForEmail, sendMail } = useMailer();
105+
106+ const { availableInvites } = getInvites(point);
107+ const maxInvitesToSend = availableInvites.matchWith({
108+ Nothing: () => 0,
109+ Just: p => p.value,
110+ });
111+
112+ // manage the array of input configs
113+ const [
114+ inputConfigs,
115+ { append: appendInput, removeAt: removeInputAt },
116+ ] = useArray(
117+ [buildInputConfig({ label: 'Email Address' })],
118+ buildInputConfig
119+ );
120+
121+ // manage per-input state
122+ const [hovered, setHovered] = useSetState();
123+ const [invites, addInvite, clearInvites] = useSetState();
124+ const [receipts, addReceipt, clearReceipts] = useSetState();
125+ const [errors, addError, clearErrors] = useSetState();
126+
127+ // manage general state that affects the whole form
128+ const [status, setStatus] = useState(STATUS.INPUT);
129+ const [needFunds, setNeedFunds] = useState(null);
130+ const [generalError, setGeneralError] = useState(null);
131+
132+ // derive booleans from status
133+ const canInput = status === STATUS.INPUT;
134+ const canSend = status === STATUS.CAN_SEND;
135+ const isGenerating = status === STATUS.GENERATING;
136+ const isSending = status === STATUS.SENDING;
137+ const isFailed = status === STATUS.FAILURE;
138+ const isDone = status === STATUS.SUCCESS;
139+
140+ // add disabled, error info to input configs
141+ const dynamicConfigs = useMemo(
142+ () =>
143+ inputConfigs.map(config => {
144+ config.disabled = !canInput;
145+ const hasReceivedError = getHasRecieved(config.name).matchWith({
146+ Nothing: () => null, // loading
147+ Just: p => p.value && HAS_RECEIVED_TEXT,
148+ });
149+ config.error = hasReceivedError || errors[config.name];
150+ return config;
151+ }),
152+ [inputConfigs, errors, canInput, getHasRecieved]
153+ );
154+
155+ // construct the state of the set of inputs we're rendering below
156+ const { inputs, pass } = useForm(dynamicConfigs);
157+ // did all of the inputs pass inspection (and there are no general errors)?
158+ const allPass = pass && !generalError;
159+ // the form is submittable iff passing and input is allowed
160+ const canGenerate = allPass && canInput;
161+ // additional inputs can be added iff input is allowed and we have enough
162+ // invites to send
163+ const canAddInvite = canInput && inputConfigs.length < maxInvitesToSend;
164+
165+ // progress is [0, .length] of invites or receipts, as we're generating them
166+ const progress = isGenerating
167+ ? Object.keys(invites).length
168+ : isSending
169+ ? Object.keys(receipts).length
170+ : null;
171+ const visualProgress = progress === null ? '-' : `${progress + 1}`;
172+
173+ // build a builder for the accessory to the input, depending on status
174+ const accessoryFor = (() => {
175+ if (isGenerating || canSend) return buildAccessoryFor(invites, errors);
176+ if (isSending) return buildAccessoryFor(receipts, errors);
177+ if (isFailed) return buildAccessoryFor({}, errors);
178+ return () => null;
179+ })();
180+
181+ const generateInvites = useCallback(async () => {
182+ const _contracts = contracts.getOrElse(null);
183+ const _web3 = web3.getOrElse(null);
184+ const _wallet = wallet.getOrElse(null);
185+ if (!_contracts || !_web3 || !_wallet) {
186+ // not using need because we want a custom error
187+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
188+ }
118189
119− const web3 = this.web3;
120− const targets = this.state.targets;
190+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
191+ const chainId = await _web3.eth.net.getId();
121192 const planets = await azimuth.delegatedSending.getPlanetsToSend(
122− this.contracts,
123− this.point,
124− targets.length
193+ _contracts,
194+ point,
195+ inputs.length
125196 );
126197
127198 // account for the race condition where invites got used up while we were
128199 // composing our target list
129− if (planets.length < targets.length) {
130− this.addError('Can currently only send ' + planets.length + ' invites!');
131− return;
200+ if (planets.length < inputs.length) {
201+ // resync invites to the cache, since they're out of date
202+ syncInvites(point);
203+
204+ throw new Error(
205+ `Can currently only send ${planets.length} invites. ` +
206+ `Please remove invites until you are within the limit.`
207+ );
132208 }
133209
134− const nonce = await web3.eth.getTransactionCount(this.address);
135− const chainId = await web3.eth.net.getId();
210+ let errorCount = 0;
211+ clearInvites();
212+ // NB(shrugs) - must be processed in serial because main thread, etc
213+ for (let i = 0; i < inputs.length; i++) {
214+ const input = inputs[i];
215+ try {
216+ const { data: email, name } = inputs[i];
136217
137− // create invite wallets and transactions
138− let invites = []; // { email, ticket, signedTx, rawTx }
139− for (let i = 0; i < targets.length; i++) {
140− console.log('generating email', i, targets[i].email);
141− this.setEmailStatus(i, EMAIL_STATUS.BUSY);
218+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
219+ MIN_PLANET
220+ // we're always giving planets, so generate a ticket of the correct size
221+ );
142222
143− const email = targets[i].email;
144− const wallet = await this.getTemporaryWallet();
223+ const inviteTx = azimuth.delegatedSending.sendPoint(
224+ _contracts,
225+ point,
226+ planets[i],
227+ owner.keys.address
228+ );
229+ const signedTx = await signTransaction({
230+ wallet,
231+ walletType,
232+ walletHdPath,
233+ networkType,
234+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
235+ txn: Maybe.Just(inviteTx),
236+ gasPrice: GAS_PRICE_GWEI.toString(),
237+ gasLimit: GAS_LIMIT.toString(),
238+ nonce: nonce + i,
239+ chainId,
240+ setStx: () => {},
241+ });
242+ const rawTx = hexify(signedTx.serialize());
145243
146− let inviteTx = azimuth.delegatedSending.sendPoint(
147− this.contracts,
148− this.point,
149− planets[i],
150− wallet.owner
151− );
152− const signedTx = await signTransaction({
153− ...this.props,
154− txn: Just(inviteTx),
155− gasPrice: GAS_PRICE_GWEI.toString(),
156− gasLimit: GAS_LIMIT.toString(),
157− nonce: nonce + i,
158− chainId: chainId,
159− setStx: () => {},
160− });
161− const rawTx = hexify(signedTx.serialize());
162−
163− invites.push({ email, ticket: wallet.ticket, signedTx, rawTx });
164− this.setEmailStatus(i, EMAIL_STATUS.DONE);
244+ addInvite({ [name]: { email, ticket, signedTx, rawTx } });
245+ } catch (error) {
246+ console.error(error);
247+ errorCount++;
248+ addError({ [input.name]: `Wallet Error: ${input.email}` });
249+ }
165250 }
166251
167− this.setState({ wipInvites: invites, status: STATUS.CAN_SEND });
168− }
169−
170− async sendInvites() {
171− const invites = this.state.wipInvites;
172− const web3 = this.web3;
173−
174− //TODO invite status isn't changing/re-rendering... why?
175− this.setState({
176− status: STATUS.FUNDING,
177− invites: invites.map(i => {
178− return { ...i, status: Nothing() };
179− }),
180− });
252+ if (errorCount > 0) {
253+ throw new Error(
254+ `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
255+ errorCount,
256+ 'error'
257+ )} while generating wallets.`
258+ );
259+ }
260+ }, [
261+ point,
262+ contracts,
263+ web3,
264+ addInvite,
265+ clearInvites,
266+ syncInvites,
267+ inputs,
268+ networkType,
269+ wallet,
270+ walletType,
271+ walletHdPath,
272+ addError,
273+ ]);
274+
275+ const sendInvites = useCallback(async () => {
276+ const _web3 = web3.getOrElse(null);
277+ const _wallet = wallet.getOrElse(null);
278+ if (!_web3 || !_wallet) {
279+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
280+ }
181281
282+ setStatus(STATUS.FUNDING);
182283 const tankWasUsed = await tank.ensureFundsFor(
183− web3,
184− this.point,
185− this.address,
186− INVITE_COST * invites.length,
187− invites.map(i => i.rawTx),
188− this.askForFunding,
189− () => {
190− this.setState({ fundingMessage: Nothing() });
191− }
284+ _web3,
285+ point,
286+ _wallet.address,
287+ INVITE_COST * inputs.length,
288+ inputs.map(input => invites[input.name].rawTx),
289+ (address, minBalance, balance) =>
290+ setNeedFunds({ address, minBalance, balance }),
291+ () => setNeedFunds(false)
192292 );
193293
194− this.setState({ status: STATUS.SENDING });
195−
196− //TODO nasty loop, probably move into lib... but so many callbacks!
197− let promises = [];
198− for (let i = 0; i < invites.length; i++) {
199− const invite = invites[i];
294+ setStatus(STATUS.SENDING);
295+ clearReceipts();
200296
201− // send transaction. if it fails, inform the user and skip to next
202− let txHash;
297+ let errorCount = 0;
298+ const txAndMailings = inputs.map(async input => {
299+ const invite = invites[input.name];
203300 try {
204− txHash = await sendSignedTransaction(
205− web3,
206− Just(invite.signedTx),
301+ const txHash = await sendSignedTransaction(
302+ _web3,
303+ Maybe.Just(invite.signedTx),
207304 tankWasUsed,
208305 () => {}
209306 );
210− } catch (err) {
211− console.error('invite tx sending failed', err);
212− this.addError('Invite transaction not sent for ' + invite.email);
213− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
214− continue;
307+
308+ // TODO: waitForTransactionConfirm never rejects
309+ const didConfirm = await waitForTransactionConfirm(_web3, txHash);
310+ if (!didConfirm) throw new Error();
311+ } catch (error) {
312+ console.error(error);
313+ errorCount++;
314+ addError({
315+ [input.name]: `Transaction Failure for ${invite.email}`,
316+ });
317+ return;
215318 }
216319
217− // ask the email sender to send this. we can do this prior to tx confirm,
218− // because the sender service waits for confirm & success for us.
219− // if this fails, inform the user and skip to next
220320 try {
221− const mailSuccess = await sendMail(
222− invite.email,
223− invite.ticket,
224− invite.rawTx
225− );
226− if (!mailSuccess) throw new Error();
227− this.setEmailStatus(i, EMAIL_STATUS.DONE);
228− } catch (e) {
229− console.log('email send failed');
230− //NOTE this assumes that the transaction succeeds, but we don't know
231− // that for a fact yet...
232− this.addError(
233− 'Invite email failed to send for ' +
234− invite.email +
235− '. Please give them this ticket: ' +
236− invite.ticket
237− );
238− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
239− continue;
321+ await sendMail(invite.email, invite.ticket, invite.rawTx);
322+ } catch (error) {
323+ console.error(error);
324+ errorCount++;
325+ addError({
326+ [input.name]: `Mailing Failure for ${invite.email}`,
327+ });
240328 }
241329
242− // update status on transaction confirm, but don't block on it
243− promises.push(
244− waitForTransactionConfirm(web3, txHash).then(success => {
245− if (success) {
246− this.setEmailStatus(i, EMAIL_STATUS.DONE);
247− } else {
248− console.log('invite tx rejected');
249− this.addError('Invite transaction failed for ' + invite.email);
250− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
251− }
252− return success;
253− })
330+ addReceipt({ [input.name]: true });
331+ });
332+
333+ await Promise.all(txAndMailings);
334+ // if there are any receipt errors, throw a general error
335+ if (errorCount > 0) {
336+ throw new Error(
337+ `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
338+ errorCount,
339+ 'error'
340+ )} while sending transactions.`
254341 );
255342 }
256−
257− const txStatus = await Promise.all(promises);
258− if (txStatus.every(e => e)) {
259− this.setState({ status: STATUS.DONE });
260− } else {
261− this.setState({ status: STATUS.FAIL });
343+ }, [
344+ web3,
345+ inputs,
346+ addReceipt,
347+ clearReceipts,
348+ invites,
349+ point,
350+ wallet,
351+ addError,
352+ sendMail,
353+ ]);
354+
355+ const onClick = useCallback(async () => {
356+ setGeneralError(null);
357+ try {
358+ if (canGenerate) {
359+ setStatus(STATUS.GENERATING);
360+ await generateInvites();
361+ setStatus(STATUS.CAN_SEND);
362+ } else if (canSend) {
363+ setStatus(STATUS.SENDING);
364+ await sendInvites();
365+ setStatus(STATUS.SUCCESS);
366+ }
367+ } catch (error) {
368+ console.error(error);
369+ setGeneralError(error);
370+ setStatus(STATUS.FAILURE);
262371 }
263− }
264−
265− setEmailStatus(i, status) {
266− const targets = this.state.targets;
267− targets[i].status = Just(status);
268− this.setState({ targets });
269− }
270−
271− addError(error) {
272− const newError = this.state.errors.matchWith({
273− Nothing: () => [error],
274− Just: errs => {
275− errs.value.push(error);
276− return errs.value;
277− },
278− });
279− this.setState({ errors: Just(newError) });
280− }
281−
282− askForFunding(address, minBalance, balance) {
283− this.setState({
284− fundingMessage: Just(
285− `Please make sure ${address} has at least ${fromWei(minBalance)} ETH,` +
286− `we'll continue once that's true. Current balance: ${fromWei(
287− balance
288− )}.` +
289− `Waiting...`
290− ),
291− });
292− }
293−
294− handleEmailInput(i, email) {
295− let targets = this.state.targets;
296− targets[i].email = email;
297−
298− // if it's a valid address, check if it has received an invite before
299− if (email.match(/.*@.*\...+/)) {
300− hasReceived(email).then(res => {
301− targets[i].hasReceived = Just(res);
302− this.setState({ targets });
303− });
372+ }, [canGenerate, canSend, generateInvites, sendInvites]);
373+
374+ // when inputs update, check to see if any of them are duplicates
375+ useEffect(() => {
376+ // compute the list of valid emails
377+ const emails = inputs.map(i => i.data).filter(d => !!d);
378+ if (uniq(emails).length !== emails.length) {
379+ setGeneralError(new Error(`Duplicate email.`));
304380 } else {
305− targets[i].hasReceived = Nothing();
381+ setGeneralError(null);
306382 }
383+ }, [inputs]);
307384
308− this.setState({ targets });
309− }
310−
311− canSend() {
312− const targets = this.state.targets;
313−
314− // may not have duplicate targets
315− if (new Set(targets.map(t => t.email)).size !== targets.length)
316− return false;
317−
318− // none may have received invites already
319− for (let i = 0; i < targets.length; i++) {
320− const target = targets[i];
321− const res = target.hasReceived.matchWith({
322− Nothing: () => true,
323− Just: has => has.value,
324− });
325− if (res) return false;
385+ // when we transition to done, sync invites because we just sent some
386+ // and therefore know that state has changed
387+ useEffect(() => {
388+ if (isDone) {
389+ syncInvites(point);
326390 }
327−
328− return this.state.invitesAvailable.matchWith({
329− Nothing: () => false,
330− Just: invites => invites.value > 0,
331− });
332− }
333−
334− addTarget() {
335− let targets = this.state.targets;
336− targets.push({ email: '', hasReceived: Nothing(), status: Nothing() });
337− this.setState({ targets });
338− }
339−
340− removeTarget() {
341− let targets = this.state.targets;
342− targets.length = targets.length - 1;
343− this.setState({ targets });
344− }
345−
346− render() {
347− const dif =
348− this.state.status === STATUS.DONE ? this.state.targets.length : null;
349−
350− const invitesAvailable = this.state.invitesAvailable.matchWith({
351− Nothing: () => 'Loading...',
352− Just: invites => {
353− const dia = dif ? `(-${dif})` : '';
354− return `You currently have ${invites.value}${dia} invitations left.`;
355− },
356− });
357−
358− const invitesSent = this.state.invited.matchWith({
359− Nothing: () => <>{'Loading...'}</>,
360− Just: invited => {
361− const dit = dif ? `(+${dif})` : '';
362− return (
363− <>
364− {'Out of the '}
365− {invited.value.total + dit}
366− {' invites you sent, '}
367− {invited.value.accepted}
368− {' have been accepted.'}
369− </>
370− );
371− },
372− });
373−
374− const error = this.state.errors.matchWith({
375− Nothing: () => null,
376− Just: errors => (
377− <Warning className={'mt-8'}>
378− <H3 style={{ marginTop: 0, paddingTop: 0 }}>
379− {'Something went wrong!'}
380− </H3>
381− {errors.value.map(err => (
382− <p>{err}</p>
391+ }, [isDone, syncInvites, point]);
392+
393+ return (
394+ <Grid gap={12}>
395+ <Grid.Item as={Grid} full>
396+ <Grid.Item as={Flex} cols={[1, 11]} align="center">
397+ <MiniBackButton onClick={() => pop()} />
398+ </Grid.Item>
399+ <Grid.Item cols={[11, 13]} justifySelf="end">
400+ {/* use hidden class instead of removing component from dom */}
401+ {/* in order to avoid janky reflow */}
402+ <IconButton
403+ onClick={() => appendInput()}
404+ disabled={!canAddInvite}
405+ className={cn({ hidden: isDone })}
406+ solid>
407+ +
408+ </IconButton>
409+ </Grid.Item>
410+ </Grid.Item>
411+
412+ {isDone && (
413+ <>
414+ <Grid.Item as={Text} full>
415+ <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
416+ {pluralize(inputs.length, 'has', 'have')} been successfully sent
417+ </Grid.Item>
418+ {inputs.map(input => (
419+ <Grid.Item as={HelpText} key={input.name} full>
420+ {invites[input.name].email}
421+ </Grid.Item>
383422 ))}
384− </Warning>
385− ),
386− });
387−
388− const fundingMessage = this.state.fundingMessage.matchWith({
389− Nothing: () => null,
390− Just: msg => <Warning className={'mt-8'}>{msg.value}</Warning>,
391− });
392−
393− let inputs;
394− if (this.state.status === STATUS.DONE) {
395− //TODO proper clickable router thing
396− inputs = '<- Back to Home';
397− } else {
398− const inputDisabled = this.state.status !== STATUS.INPUT;
399−
400− const lessButton = (
401− <Button
402− prop-size={'s narrow'}
403− className={'mt-8'}
404− disabled={inputDisabled || this.state.targets.length === 1}
405− onClick={this.removeTarget}>
406− {'-'}
407− </Button>
408− );
409−
410− const moreButton = (
411− <Button
412− prop-size={'s narrow'}
413− className={'mt-8'}
414− disabled={
415− inputDisabled ||
416− this.state.invitesAvailable.matchWith({
417− Nothing: () => true,
418− Just: inv => inv.value <= this.state.targets.length,
419− })
420− }
421− onClick={this.addTarget}>
422− {'+'}
423− </Button>
424− );
425−
426− let targetInputs = [];
427− for (let i = 0; i < this.state.targets.length; i++) {
428− const target = this.state.targets[i];
429− let progress = target.status.matchWith({
430− Nothing: () => '',
431− Just: status => status.value,
432− });
433− targetInputs.push(
434− <>
435− <Input
436− value={target.email}
437− onChange={value => {
438− this.handleEmailInput(i, value);
439− }}
440− placeholder={'Email address'}
441− disabled={inputDisabled}
442− />
443− {target.hasReceived.matchWith({
444− Nothing: () => null,
445− Just: has =>
446− has.value ? <span>{'×'}</span> : <span>{'°'}</span>,
447− })}
448− {progress}
449− </>
450− );
451− }
452−
453− const submitButton = (
454− <Button
455− prop-size={'xl wide'}
456− className={'mt-8'}
457− disabled={
458− (inputDisabled && this.state.status !== STATUS.CAN_SEND) ||
459− !this.canSend()
460− }
461− onClick={() => {
462− if (this.state.status === STATUS.INPUT) this.generateInvites();
463− if (this.state.status === STATUS.CAN_SEND) this.sendInvites();
464− }}>
465− {this.state.status}
466− </Button>
467− );
423+ </>
424+ )}
468425
469− inputs = (
426+ {!isDone && (
470427 <>
471− {lessButton}
472− {moreButton}
473− {targetInputs}
474− {submitButton}
428+ {/* email inputs */}
429+ {inputs.map((input, i) => {
430+ const isFirst = i === 0;
431+ return (
432+ <Grid.Item
433+ key={input.name}
434+ as={Grid}
435+ gap={12}
436+ onMouseOver={() => setHovered({ [input.name]: true })}
437+ onMouseLeave={() => setHovered({ [input.name]: false })}
438+ full>
439+ <Grid.Item
440+ as={Input}
441+ cols={[1, 11]}
442+ {...input}
443+ onValue={email => syncHasReceivedForEmail(email)}
444+ // NB(shrugs): ^ this feels like a hack?
445+ accessory={accessoryFor(input.name)}
446+ />
447+ {!isFirst &&
448+ (input.focused || hovered[input.name]) &&
449+ canInput && (
450+ <Grid.Item
451+ cols={[11, 13]}
452+ justifySelf="end"
453+ alignSelf="center">
454+ <IconButton
455+ onClick={() => removeInputAt(i)}
456+ solid
457+ secondary>
458+ -
459+ </IconButton>
460+ </Grid.Item>
461+ )}
462+ </Grid.Item>
463+ );
464+ })}
465+
466+ <Grid.Item
467+ full
468+ as={LoadableButton}
469+ disabled={!canGenerate && !canSend}
470+ accessory={`${visualProgress}/${inputs.length}`}
471+ onClick={onClick}
472+ success={canSend}
473+ solid>
474+ {buttonText(status, inputs.length)}
475+ </Grid.Item>
476+
477+ {needFunds && (
478+ <Grid.Item full>
479+ <Highlighted>
480+ Your ownership address {needFunds.address} needs at least{' '}
481+ {fromWei(needFunds.minBalance)} ETH and currently has{' '}
482+ {fromWei(needFunds.balance)} ETH. Waiting until the account has
483+ enough funds.
484+ </Highlighted>
485+ </Grid.Item>
486+ )}
487+
488+ {generalError && (
489+ <Grid.Item full>
490+ <ErrorText>{generalError.message.toString()}</ErrorText>
491+ </Grid.Item>
492+ )}
475493 </>
476− );
477− }
478−
479− return (
480− <View>
481− <p>{'send invites here, for planets'}</p>
482−
483− <p>{invitesAvailable}</p>
484−
485− <ul>{invitesSent}</ul>
486−
487− {error}
488−
489− {fundingMessage}
490−
491− {inputs}
492− </View>
493− );
494− }
494+ )}
495+ </Grid>
496+ );
495497}
496−
497−export default compose(
498− withNetwork,
499− withWallet,
500− withPointCursor
501−)(InviteEmail);