on master help sign in
checkpoint: nearly finished with hooks rearchitecture, getting revert errors for eth txs
commitd49f0606e1865ad70a3c0503d5819c4d30481cd1 authorMatt Condon (shrugs) <mattgcondon@gmail.com> dateTue 25 Jun 2019, 17:40 (7 years ago) parent4972cad
13 files changed+426−431
src/components/Blinky.js+1 src/components/MiniBackButton.js+4−1 src/indigo-react/components/Button.js+6−3 src/indigo-react/components/IconButton.js+5−2 src/lib/pluralize.js+8 src/lib/txn.js+1−1 src/lib/useArray.js+6−2 src/lib/useSetState.js+15 src/lib/walletgen.js+9−5 src/store/lib/usePointStore.js+1 src/views/Invite/InviteEmail.js+364−414 src/views/Login/Ledger.js+4−1 src/views/Point.js+2−2
src/components/Blinky.js+1
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
22
33import useInterval from 'lib/useInterval';
44
5+// TODO: make these characters display as the same width
56export const kLoadingCharacter = '▓';
67export const kInterstitialCharacter = '░';
78
src/components/MiniBackButton.js+4−1
@@ -2,7 +2,10 @@ import React from 'react';
22import cn from 'classnames';
33
44import { ReactComponent as Back } from 'assets/back.svg';
5+import { IconButton } from 'indigo-react';
56
67export default ({ className, ...rest }) => (
7− <Back className={cn('black pointer', className)} {...rest} />
8+ <IconButton {...rest}>
9+ <Back className={cn('black', className)} />
10+ </IconButton>
811);
src/indigo-react/components/Button.js+6−3
@@ -7,6 +7,7 @@ import { HelpText } from './Typography';
77
88export default function Button({
99 solid = false,
10+ success = false,
1011 disabled = false,
1112 detail,
1213 className,
@@ -30,9 +31,11 @@ export default function Button({
3031 p4: solid,
3132 },
3233 {
33− 'bg-black': solid && !disabled,
34− 'bg-gray3': solid && disabled,
35− 'bg-transparent': !solid,
34+ 'bg-green3': success && !disabled,
35+ 'bg-green1': success && disabled,
36+ 'bg-black': !success && solid && !disabled,
37+ 'bg-gray3': !success && solid && disabled,
38+ 'bg-transparent': !success && !solid,
3639 },
3740 className
3841 )}
src/indigo-react/components/IconButton.js+5−2
@@ -8,6 +8,7 @@ export default function IconButton({
88 className,
99 onClick,
1010 disabled = false,
11+ secondary = false,
1112 solid = false,
1213 ...rest
1314}) {
@@ -19,8 +20,10 @@ export default function IconButton({
1920 className={cn(
2021 'pointer p2',
2122 {
22− 'bg-black': solid && !disabled,
23− 'bg-gray3': !solid || disabled,
23+ 'bg-black': solid && !secondary && !disabled,
24+ 'bg-gray3': (solid && disabled) || (solid && secondary),
25+ 'bg-gray2': !solid && secondary && disabled,
26+ 'bg-transparent': !solid,
2427 },
2528 {
2629 white: solid,
src/lib/pluralize.jsnew file+8
@@ -0,0 +1,8 @@
1+export default (count, singular, plural) => {
2+ const isPlural = count > 1;
3+ if (!plural) {
4+ return `${count} ${singular}${isPlural ? 's' : ''}`;
5+ }
6+
7+ return isPlural ? plural : singular;
8+};
src/lib/txn.js+1−1
@@ -161,7 +161,7 @@ const sendSignedTransaction = (web3, stx, doubtNonceError, confirmationCb) => {
161161 // if there's a nonce error, but we used the gas tank, it's likely
162162 // that it's because the tank already submitted our transaction.
163163 // we just wait for first confirmation here.
164− console.log('sent and got error', err);
164+ console.error(err);
165165 if (
166166 doubtNonceError &&
167167 (err.message || '').includes("the tx doesn't have the correct nonce.")
src/lib/useArray.js+6−2
@@ -1,13 +1,14 @@
11import { useCallback, useState } from 'react';
2+import { identity } from 'lodash';
23
34/**
45 * Manages an immutable array of items.
56 */
6−export default function useArray(initialItems = [], itemBuilder) {
7+export default function useArray(initialItems = [], itemBuilder = identity) {
78 const [items, _setItems] = useState(initialItems);
89
910 const append = useCallback(
10− () => _setItems(items => [...items, itemBuilder()]),
11+ item => _setItems(items => [...items, itemBuilder(item)]),
1112 [_setItems, itemBuilder]
1213 );
1314
@@ -33,12 +34,15 @@ export default function useArray(initialItems = [], itemBuilder) {
3334 [_setItems]
3435 );
3536
37+ const clear = useCallback(() => _setItems([]), [_setItems]);
38+
3639 return [
3740 items,
3841 {
3942 append,
4043 removeAt,
4144 updateAt,
45+ clear,
4246 },
4347 ];
4448}
src/lib/useSetState.jsnew file+15
@@ -0,0 +1,15 @@
1+import { useCallback, useState } from 'react';
2+
3+const kEmptyObject = {};
4+
5+export default function useSetState() {
6+ const [state, _setState] = useState(kEmptyObject);
7+ const setState = useCallback(
8+ items => _setState(state => ({ ...state, ...items })),
9+ [_setState]
10+ );
11+
12+ const reset = useCallback(() => _setState(kEmptyObject), [_setState]);
13+
14+ return [state, setState, reset];
15+}
src/lib/walletgen.js+9−5
@@ -15,7 +15,7 @@ import {
1515const SEED_LENGTH_BYTES = SEED_ENTROPY_BITS / 8;
1616
1717// returns a promise for a ticket string
18−const makeTicket = point => {
18+export const makeTicket = point => {
1919 const bits =
2020 point < MIN_STAR
2121 ? GALAXY_ENTROPY_BITS
@@ -44,7 +44,7 @@ const makeTicket = point => {
4444};
4545
4646// return a wallet object
47−const generateWallet = async (point, ticket, boot) => {
47+export const generateWallet = async (point, ticket, boot) => {
4848 const config = {
4949 ticket: ticket,
5050 seedSize: SEED_LENGTH_BYTES,
@@ -63,8 +63,12 @@ const generateWallet = async (point, ticket, boot) => {
6363 return wallet;
6464};
6565
66−// returns a promise
67−const generateOwnershipWallet = (ship, ticket) =>
66+export const generateOwnershipWallet = (ship, ticket) =>
6867 kg.generateOwnershipWallet({ ship, ticket });
6968
70−export { makeTicket, generateWallet, generateOwnershipWallet };
69+export const generateTemporaryTicketAndWallet = async point => {
70+ const ticket = await makeTicket(point);
71+ const owner = await generateOwnershipWallet(0, ticket);
72+
73+ return { ticket, owner };
74+};
src/store/lib/usePointStore.js+1
@@ -31,6 +31,7 @@ export default function usePointStore() {
3131 ...birthdays,
3232 ...invites,
3333 ...controlledPoints,
34+ syncInvites,
3435 syncControlledPoints,
3536 syncOwnedPoint,
3637 syncForeignPoint,
src/views/Invite/InviteEmail.js+364−414
@@ -1,14 +1,14 @@
1+import React, { useCallback, useEffect, useState } from 'react';
2+import cn from 'classnames';
13import Maybe, { Just, Nothing } from 'folktale/maybe';
2−import React, { useState } from 'react';
34import * 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';
57
68import * as need from 'lib/need';
79import { hasReceived, sendMail } from 'lib/inviteMail';
810import * as wg from 'lib/walletgen';
911
10−import { H3, Warning } from 'components/old/Base';
11−
1212// for transaction generation and signing
1313import {
1414 signTransaction,
@@ -21,43 +21,84 @@ import {
2121import * as tank from 'lib/tank';
2222import { useLocalRouter } from 'lib/LocalRouter';
2323
24−import View from 'components/View';
2524import MiniBackButton from 'components/MiniBackButton';
26−import useRenderCount from 'lib/useRenderCount';
2725import useInvites from 'lib/useInvites';
2826import { usePointCursor } from 'store/pointCursor';
2927import LoadableButton from 'components/LoadableButton';
3028import useArray from 'lib/useArray';
3129import useForm from 'indigo-react/lib/useForm';
3230import { 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';
38+
39+const kStubMailer = process.env.REACT_APP_STUB_MAILER === 'true';
3340
3441const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
3542const GAS_LIMIT = 350000;
3643const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
3744
3845const STATUS = {
39− INPUT: 'Generate invites',
40− GENERATING: 'Generating invites',
41− CAN_SEND: 'Send invites',
42− FUNDING: 'Funding invites',
43− SENDING: 'Sending invites',
44− DONE: 'Done',
45− FAIL: 'Some failed',
46+ INPUT: 'INPUT',
47+ GENERATING: 'GENERATING',
48+ CAN_SEND: 'CAN_SEND',
49+ FUNDING: 'FUNDING',
50+ SENDING: 'SENDING',
51+ SUCCESS: 'SUCCESS',
52+ FAILURE: 'FAILURE',
4653};
4754
48−const EMAIL_STATUS = {
49− BUSY: '⋯',
50− DONE: '✔',
51− FAIL: '×',
55+const buttonText = (status, count) => {
56+ switch (status) {
57+ case STATUS.INPUT:
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')}`;
63+ case STATUS.FUNDING:
64+ return `Waiting on Funds...`;
65+ case STATUS.SENDING:
66+ return `Sending ${pluralize(count, 'Invite')}...`;
67+ case STATUS.FAILURE:
68+ default:
69+ return 'Error';
70+ }
5271};
5372
73+const kPendingAccessory = '⋯';
74+const kSuccessAccessory = '✔';
75+const kFailureAccessory = '×';
76+
5477// world's simplest uid
5578let id = 0;
5679const buildInputConfig = (extra = {}) =>
5780 buildEmailInputConfig({ name: `email-${id++}`, ...extra });
5881
82+const buildAccessoryFor = (dones, errors) => name => (
83+ <Flex justify="center" align="center" style={{ height: '100%' }}>
84+ {(() => {
85+ if (dones[name]) return kSuccessAccessory;
86+ if (errors[name]) return kFailureAccessory;
87+ return kPendingAccessory;
88+ })()}
89+ </Flex>
90+);
91+
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
5996export default function NewInviteEmail() {
97+ // TODO: resumption after error?
6098 const { pop } = useLocalRouter();
99+ const { contracts, web3, networkType } = useNetwork();
100+ const { wallet, walletType, walletHdPath } = useWallet();
101+ const { syncInvites } = usePointCache();
61102 const { pointCursor } = usePointCursor();
62103 const point = need.pointCursor(pointCursor);
63104
@@ -79,452 +120,361 @@ export default function NewInviteEmail() {
79120 const setHovered = (name, value) => () =>
80121 _setHovered((value = { ...value, [name]: value }));
81122
82− console.log(inputConfigs.length, inputConfigs.map(c => c.name));
83−
123+ // TODO: pass disabled, error info into the useForm hook?
84124 const { inputs, pass } = useForm(inputConfigs);
85125
86− const canAddInvite = inputs.length < maxInvitesToSend;
87−
88− useRenderCount('InviteEmail');
89−
90− return (
91− <Grid gap={12}>
92− <Grid.Item as={Grid} className="mb4" full>
93− <Grid.Item as={Flex} cols={[1, 11]} align="center">
94− <MiniBackButton onClick={() => pop()} />
95− </Grid.Item>
96− <Grid.Item cols={[11, 13]} justifySelf="end">
97− <IconButton onClick={appendInput} disabled={!canAddInvite} solid>
98− +
99− </IconButton>
100− </Grid.Item>
101− </Grid.Item>
102−
103− {/* email inputs */}
104− {inputs.map((input, i) => {
105− const isFirst = i === 0;
106− return (
107− <Grid.Item
108− key={input.name}
109− as={Grid}
110− gap={12}
111− onMouseEnter={setHovered(input.name, true)}
112− onMouseLeave={setHovered(input.name, false)}
113− full>
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>
118− </Grid.Item>
119− )}
120− </Grid.Item>
121− );
122− })}
123−
124− <Grid.Item as={LoadableButton} full disabled={!pass} solid>
125− Generate Invites
126− </Grid.Item>
127− </Grid>
128− );
129−}
130−
131−class InviteEmail extends React.Component {
132− constructor(props) {
133− super(props);
134−
135− this.state = {
136− invitesAvailable: Nothing(),
137− targets: [{ email: '', hasReceived: Nothing(), status: Nothing() }],
138− status: STATUS.INPUT,
139− errors: Nothing(),
140− //
141− fundingMessage: Nothing(),
142− wipInvites: [],
143− };
144−
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);
154− }
155−
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);
161−
162− azimuth.delegatedSending
163− .getTotalUsableInvites(this.contracts, this.point)
164− .then(count => {
165− this.setState({ invitesAvailable: Just(count) });
166− });
167− }
168−
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 };
173− }
174−
175− async generateInvites() {
176− this.setState({ status: STATUS.GENERATING });
126+ const [invites, addInvite, clearInvites] = useSetState({});
127+ const [receipts, addReceipt, clearReceipts] = useSetState({});
128+ const [errors, addError, clearError] = useSetState({});
129+
130+ const [status, setStatus] = useState(STATUS.INPUT);
131+ const [needFunds, setNeedFunds] = useState(null);
132+ const [generalError, setGeneralError] = useState(null);
133+
134+ const allPass = pass && !generalError;
135+
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;
144+ const canAddInvite = canInput && inputs.length < maxInvitesToSend;
145+
146+ const progress = isGenerating
147+ ? Object.keys(invites).length
148+ : isSending
149+ ? Object.keys(receipts).length
150+ : null;
151+ const visualProgress = progress === null ? '-' : `${progress + 1}`;
152+
153+ const accessoryFor = (() => {
154+ if (isGenerating || canSend) return buildAccessoryFor(invites, errors);
155+ if (isSending) return buildAccessoryFor(receipts, errors);
156+ if (isFailed) return buildAccessoryFor({}, errors);
157+ return () => null;
158+ })();
159+
160+ const generateInvites = useCallback(async () => {
161+ const _contracts = contracts.getOrElse(null);
162+ const _web3 = web3.getOrElse(null);
163+ const _wallet = wallet.getOrElse(null);
164+ if (!_contracts || !_web3 || !_wallet) {
165+ // not using need because we want a custom error
166+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
167+ }
177168
178− const web3 = this.web3;
179− const targets = this.state.targets;
169+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
170+ const chainId = await _web3.eth.net.getId();
180171 const planets = await azimuth.delegatedSending.getPlanetsToSend(
181− this.contracts,
182− this.point,
183− targets.length
172+ _contracts,
173+ point,
174+ inputs.length
184175 );
185176
186177 // account for the race condition where invites got used up while we were
187178 // composing our target list
188− if (planets.length < targets.length) {
189− this.addError('Can currently only send ' + planets.length + ' invites!');
190− return;
179+ if (planets.length < inputs.length) {
180+ // resync invites to the cache, since they're out of date
181+ syncInvites();
182+
183+ throw new Error(
184+ `Can currently only send ${planets.length} invites. ` +
185+ `Please remove invites until you are within the limit.`
186+ );
191187 }
192188
193− const nonce = await web3.eth.getTransactionCount(this.address);
194− const chainId = await web3.eth.net.getId();
189+ let errorCount = 0;
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];
194+ try {
195+ const { data: email, name } = inputs[i];
195196
196− // create invite wallets and transactions
197− let invites = []; // { email, ticket, signedTx, rawTx }
198− for (let i = 0; i < targets.length; i++) {
199− console.log('generating email', i, targets[i].email);
200− this.setEmailStatus(i, EMAIL_STATUS.BUSY);
197+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
198+ MIN_PLANET
199+ // we're always giving planets, so generate a ticket of the correct size
200+ );
201201
202− const email = targets[i].email;
203− const wallet = await this.getTemporaryWallet();
202+ const inviteTx = azimuth.delegatedSending.sendPoint(
203+ _contracts,
204+ point,
205+ planets[i],
206+ owner.keys.address
207+ );
208+ const signedTx = await signTransaction({
209+ wallet,
210+ walletType,
211+ walletHdPath,
212+ networkType,
213+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
214+ txn: Just(inviteTx),
215+ gasPrice: GAS_PRICE_GWEI.toString(),
216+ gasLimit: GAS_LIMIT.toString(),
217+ nonce: nonce + i,
218+ chainId,
219+ setStx: () => {},
220+ });
221+ const rawTx = hexify(signedTx.serialize());
204222
205− let inviteTx = azimuth.delegatedSending.sendPoint(
206− this.contracts,
207− this.point,
208− planets[i],
209− wallet.owner
210− );
211− const signedTx = await signTransaction({
212− ...this.props,
213− txn: Just(inviteTx),
214− gasPrice: GAS_PRICE_GWEI.toString(),
215− gasLimit: GAS_LIMIT.toString(),
216− nonce: nonce + i,
217− chainId: chainId,
218− setStx: () => {},
219− });
220− const rawTx = hexify(signedTx.serialize());
221−
222− invites.push({ email, ticket: wallet.ticket, signedTx, rawTx });
223− this.setEmailStatus(i, EMAIL_STATUS.DONE);
223+ addInvite({ [name]: { email, ticket, signedTx, rawTx } });
224+ } catch (error) {
225+ console.error(error);
226+ errorCount++;
227+ addError({ [input.name]: `Wallet Error: ${input.email}` });
228+ }
224229 }
225230
226− this.setState({ wipInvites: invites, status: STATUS.CAN_SEND });
227− }
228−
229− async sendInvites() {
230− const invites = this.state.wipInvites;
231− const web3 = this.web3;
232−
233− //TODO invite status isn't changing/re-rendering... why?
234− this.setState({
235− status: STATUS.FUNDING,
236− invites: invites.map(i => {
237− return { ...i, status: Nothing() };
238− }),
239− });
231+ if (errorCount > 0) {
232+ throw new Error(
233+ `There were ${errorCount} errors while generating wallets.`
234+ );
235+ }
236+ }, [
237+ point,
238+ contracts,
239+ web3,
240+ addInvite,
241+ clearInvites,
242+ syncInvites,
243+ inputs,
244+ networkType,
245+ wallet,
246+ walletType,
247+ walletHdPath,
248+ addError,
249+ ]);
250+
251+ const sendInvites = useCallback(async () => {
252+ const _web3 = web3.getOrElse(null);
253+ const _wallet = wallet.getOrElse(null);
254+ if (!_web3 || !_wallet) {
255+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
256+ }
240257
258+ setStatus(STATUS.FUNDING);
241259 const tankWasUsed = await tank.ensureFundsFor(
242− web3,
243− this.point,
244− this.address,
245− INVITE_COST * invites.length,
246− invites.map(i => i.rawTx),
247− this.askForFunding,
248− () => {
249− this.setState({ fundingMessage: Nothing() });
250− }
260+ _web3,
261+ point,
262+ _wallet.address,
263+ INVITE_COST * inputs.length,
264+ inputs.map(input => invites[input.name].rawTx),
265+ (address, minBalance, balance) =>
266+ setNeedFunds({ address, minBalance, balance }),
267+ () => setNeedFunds(false)
251268 );
252269
253− this.setState({ status: STATUS.SENDING });
270+ setStatus(STATUS.SENDING);
271+ clearReceipts();
254272
255− //TODO nasty loop, probably move into lib... but so many callbacks!
256− let promises = [];
257− for (let i = 0; i < invites.length; i++) {
258− const invite = invites[i];
259−
260− // send transaction. if it fails, inform the user and skip to next
261− let txHash;
273+ let errorCount = 0;
274+ const txAndMailings = inputs.map(async input => {
275+ const invite = invites[input.name];
262276 try {
263− txHash = await sendSignedTransaction(
264− web3,
277+ const txHash = await sendSignedTransaction(
278+ _web3,
265279 Just(invite.signedTx),
266280 tankWasUsed,
267281 () => {}
268282 );
269− } catch (err) {
270− console.error('invite tx sending failed', err);
271− this.addError('Invite transaction not sent for ' + invite.email);
272− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
273− continue;
283+
284+ // TODO: waitForTransactionConfirm never rejects
285+ const didConfirm = await waitForTransactionConfirm(_web3, txHash);
286+ if (!didConfirm) throw new Error();
287+ } catch (error) {
288+ console.error(error);
289+ errorCount++;
290+ addError({
291+ [input.name]: `Transaction Failure for ${invite.email}}`,
292+ });
274293 }
275294
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
279295 try {
296+ if (kStubMailer) {
297+ console.log(`${invite.email} - ${invite.ticket}`);
298+ return;
299+ }
300+
280301 const mailSuccess = await sendMail(
281302 invite.email,
282303 invite.ticket,
283304 invite.rawTx
284305 );
285306 if (!mailSuccess) throw new Error();
286− this.setEmailStatus(i, EMAIL_STATUS.DONE);
287− } catch (e) {
288− console.log('email send failed');
289− //NOTE this assumes that the transaction succeeds, but we don't know
290− // that for a fact yet...
291− this.addError(
292− 'Invite email failed to send for ' +
293− invite.email +
294− '. Please give them this ticket: ' +
295− invite.ticket
296− );
297− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
298− continue;
307+ } catch (error) {
308+ console.error(error);
309+ errorCount++;
310+ addError({
311+ [input.name]: `Mailing Failure for ${invite.email}}`,
312+ });
299313 }
300314
301− // update status on transaction confirm, but don't block on it
302− promises.push(
303− waitForTransactionConfirm(web3, txHash).then(success => {
304− if (success) {
305− this.setEmailStatus(i, EMAIL_STATUS.DONE);
306− } else {
307− console.log('invite tx rejected');
308− this.addError('Invite transaction failed for ' + invite.email);
309− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
310− }
311− return success;
312− })
315+ addReceipt({ [input.name]: true });
316+ });
317+
318+ await Promise.all(txAndMailings);
319+ // if there are any receipt errors, throw a general error
320+ if (errorCount > 0) {
321+ throw new Error(
322+ `There were ${errorCount} errors while sending transactions.`
313323 );
314324 }
315−
316− const txStatus = await Promise.all(promises);
317− if (txStatus.every(e => e)) {
318− this.setState({ status: STATUS.DONE });
319− } else {
320− this.setState({ status: STATUS.FAIL });
325+ }, [
326+ web3,
327+ inputs,
328+ addReceipt,
329+ clearReceipts,
330+ invites,
331+ point,
332+ wallet,
333+ addError,
334+ ]);
335+
336+ const onClickGenerate = useCallback(async () => {
337+ setGeneralError(null);
338+ setStatus(STATUS.GENERATING);
339+ try {
340+ await generateInvites();
341+ setStatus(STATUS.CAN_SEND);
342+ } catch (error) {
343+ console.error(error);
344+ setGeneralError(error);
345+ setStatus(STATUS.FAILURE);
321346 }
322− }
323−
324− setEmailStatus(i, status) {
325− const targets = this.state.targets;
326− targets[i].status = Just(status);
327− this.setState({ targets });
328− }
329−
330− addError(error) {
331− const newError = this.state.errors.matchWith({
332− Nothing: () => [error],
333− Just: errs => {
334− errs.value.push(error);
335− return errs.value;
336− },
337− });
338− this.setState({ errors: Just(newError) });
339− }
340−
341− askForFunding(address, minBalance, balance) {
342− this.setState({
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(
346− balance
347− )}.` +
348− `Waiting...`
349− ),
350− });
351− }
352−
353− handleEmailInput(i, email) {
354− let targets = this.state.targets;
355− targets[i].email = email;
356−
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 });
362− });
363− } else {
364− targets[i].hasReceived = Nothing();
347+ }, [setStatus, setGeneralError, generateInvites]);
348+
349+ const onClickSend = useCallback(async () => {
350+ setGeneralError(null);
351+ setStatus(STATUS.SENDING);
352+ try {
353+ await sendInvites();
354+ setStatus(STATUS.SUCCESS);
355+ } catch (error) {
356+ console.error(error);
357+ setGeneralError(error);
358+ setStatus(STATUS.FAILURE);
365359 }
360+ }, [setStatus, sendInvites, setGeneralError]);
366361
367− this.setState({ targets });
368− }
369−
370− canSend() {
371− const targets = this.state.targets;
372−
373− // may not have duplicate targets
374− if (new Set(targets.map(t => t.email)).size !== targets.length)
375− return false;
376−
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,
383− });
384− if (res) return false;
362+ const onClick = useCallback(() => {
363+ if (canGenerate) {
364+ onClickGenerate();
365+ } else if (canSend) {
366+ onClickSend();
385367 }
368+ }, [canGenerate, canSend, onClickGenerate, onClickSend]);
386369
387− return this.state.invitesAvailable.matchWith({
388− Nothing: () => false,
389− Just: invites => invites.value > 0,
390− });
391− }
392−
393− addTarget() {
394− let targets = this.state.targets;
395− targets.push({ email: '', hasReceived: Nothing(), status: Nothing() });
396− this.setState({ targets });
397− }
398−
399− removeTarget() {
400− let targets = this.state.targets;
401− targets.length = targets.length - 1;
402− this.setState({ targets });
403− }
404−
405− render() {
406− const dif =
407− this.state.status === STATUS.DONE ? this.state.targets.length : null;
408−
409− const error = this.state.errors.matchWith({
410− Nothing: () => null,
411− Just: errors => (
412− <Warning className={'mt-8'}>
413− <H3 style={{ marginTop: 0, paddingTop: 0 }}>
414− {'Something went wrong!'}
415− </H3>
416− {errors.value.map(err => (
417− <p>{err}</p>
418− ))}
419− </Warning>
420− ),
421− });
422−
423− const fundingMessage = this.state.fundingMessage.matchWith({
424− Nothing: () => null,
425− Just: msg => <Warning className={'mt-8'}>{msg.value}</Warning>,
426− });
427−
428− let inputs;
429− if (this.state.status === STATUS.DONE) {
430− //TODO proper clickable router thing
431− inputs = '<- Back to Home';
370+ useEffect(() => {
371+ if (uniq(inputs.map(i => i.data)).length !== inputs.length) {
372+ setGeneralError(new Error(`Duplicate email.`));
432373 } else {
433− const inputDisabled = this.state.status !== STATUS.INPUT;
434−
435− const lessButton = (
436− <Button
437− prop-size={'s narrow'}
438− className={'mt-8'}
439− disabled={inputDisabled || this.state.targets.length === 1}
440− onClick={this.removeTarget}>
441− {'-'}
442− </Button>
443− );
444−
445− const moreButton = (
446− <Button
447− prop-size={'s narrow'}
448− className={'mt-8'}
449− disabled={
450− inputDisabled ||
451− this.state.invitesAvailable.matchWith({
452− Nothing: () => true,
453− Just: inv => inv.value <= this.state.targets.length,
454− })
455− }
456− onClick={this.addTarget}>
457− {'+'}
458− </Button>
459− );
460−
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({
465− Nothing: () => '',
466− Just: status => status.value,
467− });
468− targetInputs.push(
469− <>
470− <Input
471− value={target.email}
472− onChange={value => {
473− this.handleEmailInput(i, value);
474− }}
475− placeholder={'Email address'}
476− disabled={inputDisabled}
477− />
478− {target.hasReceived.matchWith({
479− Nothing: () => null,
480− Just: has =>
481− has.value ? <span>{'×'}</span> : <span>{'°'}</span>,
482− })}
483− {progress}
484− </>
485− );
486− }
374+ setGeneralError(null);
375+ }
376+ }, [inputs]);
487377
488− const submitButton = (
489− <Button
490− prop-size={'xl wide'}
491− className={'mt-8'}
492− disabled={
493− (inputDisabled && this.state.status !== STATUS.CAN_SEND) ||
494− !this.canSend()
495− }
496− onClick={() => {
497− if (this.state.status === STATUS.INPUT) this.generateInvites();
498− if (this.state.status === STATUS.CAN_SEND) this.sendInvites();
499− }}>
500− {this.state.status}
501− </Button>
502− );
378+ return (
379+ <Grid gap={12}>
380+ <Grid.Item as={Grid} full>
381+ <Grid.Item as={Flex} cols={[1, 11]} align="center">
382+ <MiniBackButton onClick={() => pop()} />
383+ </Grid.Item>
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 */}
387+ <IconButton
388+ onClick={() => appendInput()}
389+ disabled={!canAddInvite}
390+ className={cn({ hidden: isDone })}
391+ solid>
392+ +
393+ </IconButton>
394+ </Grid.Item>
395+ </Grid.Item>
503396
504− inputs = (
397+ {isDone && (
505398 <>
506− {lessButton}
507− {moreButton}
508− {targetInputs}
509− {submitButton}
399+ <Grid.Item as={Text} full>
400+ <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
401+ {pluralize(inputs.length, 'has', 'have')} been successfully sent
402+ </Grid.Item>
403+ {inputs.map(input => (
404+ <Grid.Item as={HelpText} key={input.name} full>
405+ {invites[input.name].email}
406+ </Grid.Item>
407+ ))}
510408 </>
511− );
512− }
409+ )}
513410
514− return (
515− <View>
516− {error}
411+ {!isDone && (
412+ <>
413+ {/* email inputs */}
414+ {inputs.map((input, i) => {
415+ const isFirst = i === 0;
416+ return (
417+ <Grid.Item
418+ key={input.name}
419+ as={Grid}
420+ gap={12}
421+ onMouseEnter={setHovered(input.name, true)}
422+ onMouseLeave={setHovered(input.name, false)}
423+ full>
424+ <Grid.Item
425+ as={Input}
426+ cols={[1, 11]}
427+ {...input}
428+ accessory={accessoryFor(input.name)}
429+ disabled={!canInput}
430+ />
431+ {!isFirst &&
432+ (input.focused || hovered[input.name]) &&
433+ canInput && (
434+ <Grid.Item
435+ cols={[11, 13]}
436+ justifySelf="end"
437+ alignSelf="center">
438+ <IconButton
439+ onClick={() => removeInputAt(i)}
440+ solid
441+ secondary>
442+ -
443+ </IconButton>
444+ </Grid.Item>
445+ )}
446+ </Grid.Item>
447+ );
448+ })}
517449
518− {fundingMessage}
450+ <Grid.Item
451+ full
452+ as={LoadableButton}
453+ disabled={!canGenerate && !canSend}
454+ accessory={`${visualProgress}/${inputs.length}`}
455+ onClick={onClick}
456+ success={canSend}
457+ solid>
458+ {buttonText(status, inputs.length)}
459+ </Grid.Item>
519460
520− {inputs}
521− </View>
522− );
523− }
461+ {needFunds && (
462+ <Grid.Item full>
463+ <Highlighted>
464+ Your ownership address {needFunds.address} needs at least{' '}
465+ {fromWei(needFunds.minBalance)}ETH and currently has{' '}
466+ {fromWei(needFunds.balance)}.
467+ </Highlighted>
468+ </Grid.Item>
469+ )}
470+
471+ {generalError && (
472+ <Grid.Item full>
473+ <Highlighted>{generalError.message.toString()}</Highlighted>
474+ </Grid.Item>
475+ )}
476+ </>
477+ )}
478+ </Grid>
479+ );
524480}
525−
526−// export default compose(
527−// withNetwork,
528−// withWallet,
529−// withPointCursor
530−// )(InviteEmail);
src/views/Login/Ledger.js+4−1
@@ -128,7 +128,10 @@ export default function Ledger({ loginCompleted }) {
128128 <ol className="measure-md">
129129 <li className="mt-4">
130130 Install
131− <a target="_blank" href="https://github.com/FiloSottile/mkcert">
131+ <a
132+ target="_blank"
133+ href="https://github.com/FiloSottile/mkcert"
134+ rel="noopener noreferrer">
132135 mkcert
133136 </a>
134137 </li>
src/views/Point.js+2−2
@@ -48,9 +48,9 @@ export default function Point() {
4848 Boot Arvo
4949 </ForwardButton>
5050 </Grid.Item>
51− {/* <Grid.Item full>
51+ <Grid.Item full>
5252 <Actions />
53− </Grid.Item> */}
53+ </Grid.Item>
5454 </Grid>
5555
5656 <FooterButton onClick={goInvite}>