on master help sign in
checkpoint: nearly finished with hooks rearchitecture, getting revert errors for eth txs
d49f060Matt Condon (shrugs)
src/components/Blinky.js+1
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
2
2
3import useInterval from 'lib/useInterval';
3import useInterval from 'lib/useInterval';
4
4
5+// TODO: make these characters display as the same width
5export const kLoadingCharacter = '▓';
6export const kLoadingCharacter = '▓';
6export const kInterstitialCharacter = '░';
7export const kInterstitialCharacter = '░';
7
8
src/components/MiniBackButton.js+4−1
@@ -2,7 +2,10 @@ import React from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3
3
4import { ReactComponent as Back } from 'assets/back.svg';
4import { ReactComponent as Back } from 'assets/back.svg';
5+import { IconButton } from 'indigo-react';
5
6
6export default ({ className, ...rest }) => (
7export default ({ className, ...rest }) => (
8+ <IconButton {...rest}>
7− <Back className={cn('black pointer', className)} {...rest} />
9+ <Back className={cn('black', className)} />
10+ </IconButton>
8);
11);
src/indigo-react/components/Button.js+6−3
@@ -7,6 +7,7 @@ import { HelpText } from './Typography';
7
7
8export default function Button({
8export default function Button({
9 solid = false,
9 solid = false,
10+ success = false,
10 disabled = false,
11 disabled = false,
11 detail,
12 detail,
12 className,
13 className,
@@ -30,9 +31,11 @@ export default function Button({
30 p4: solid,
31 p4: solid,
31 },
32 },
32 {
33 {
34+ 'bg-green3': success && !disabled,
35+ 'bg-green1': success && disabled,
33− 'bg-black': solid && !disabled,
36+ 'bg-black': !success && solid && !disabled,
34− 'bg-gray3': solid && disabled,
37+ 'bg-gray3': !success && solid && disabled,
35− 'bg-transparent': !solid,
38+ 'bg-transparent': !success && !solid,
36 },
39 },
37 className
40 className
38 )}
41 )}
src/indigo-react/components/IconButton.js+5−2
@@ -8,6 +8,7 @@ export default function IconButton({
8 className,
8 className,
9 onClick,
9 onClick,
10 disabled = false,
10 disabled = false,
11+ secondary = false,
11 solid = false,
12 solid = false,
12 ...rest
13 ...rest
13}) {
14}) {
@@ -19,8 +20,10 @@ export default function IconButton({
19 className={cn(
20 className={cn(
20 'pointer p2',
21 'pointer p2',
21 {
22 {
22− 'bg-black': solid && !disabled,
23+ 'bg-black': solid && !secondary && !disabled,
23− 'bg-gray3': !solid || disabled,
24+ 'bg-gray3': (solid && disabled) || (solid && secondary),
25+ 'bg-gray2': !solid && secondary && disabled,
26+ 'bg-transparent': !solid,
24 },
27 },
25 {
28 {
26 white: solid,
29 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) => {
161 // if there's a nonce error, but we used the gas tank, it's likely
161 // if there's a nonce error, but we used the gas tank, it's likely
162 // that it's because the tank already submitted our transaction.
162 // that it's because the tank already submitted our transaction.
163 // we just wait for first confirmation here.
163 // we just wait for first confirmation here.
164− console.log('sent and got error', err);
164+ console.error(err);
165 if (
165 if (
166 doubtNonceError &&
166 doubtNonceError &&
167 (err.message || '').includes("the tx doesn't have the correct nonce.")
167 (err.message || '').includes("the tx doesn't have the correct nonce.")
src/lib/useArray.js+6−2
@@ -1,13 +1,14 @@
1import { useCallback, useState } from 'react';
1import { useCallback, useState } from 'react';
2+import { identity } from 'lodash';
2
3
3/**
4/**
4 * Manages an immutable array of items.
5 * Manages an immutable array of items.
5 */
6 */
6−export default function useArray(initialItems = [], itemBuilder) {
7+export default function useArray(initialItems = [], itemBuilder = identity) {
7 const [items, _setItems] = useState(initialItems);
8 const [items, _setItems] = useState(initialItems);
8
9
9 const append = useCallback(
10 const append = useCallback(
10− () => _setItems(items => [...items, itemBuilder()]),
11+ item => _setItems(items => [...items, itemBuilder(item)]),
11 [_setItems, itemBuilder]
12 [_setItems, itemBuilder]
12 );
13 );
13
14
@@ -33,12 +34,15 @@ export default function useArray(initialItems = [], itemBuilder) {
33 [_setItems]
34 [_setItems]
34 );
35 );
35
36
37+ const clear = useCallback(() => _setItems([]), [_setItems]);
38+
36 return [
39 return [
37 items,
40 items,
38 {
41 {
39 append,
42 append,
40 removeAt,
43 removeAt,
41 updateAt,
44 updateAt,
45+ clear,
42 },
46 },
43 ];
47 ];
44}
48}
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 {
15const SEED_LENGTH_BYTES = SEED_ENTROPY_BITS / 8;
15const SEED_LENGTH_BYTES = SEED_ENTROPY_BITS / 8;
16
16
17// returns a promise for a ticket string
17// returns a promise for a ticket string
18−const makeTicket = point => {
18+export const makeTicket = point => {
19 const bits =
19 const bits =
20 point < MIN_STAR
20 point < MIN_STAR
21 ? GALAXY_ENTROPY_BITS
21 ? GALAXY_ENTROPY_BITS
@@ -44,7 +44,7 @@ const makeTicket = point => {
44};
44};
45
45
46// return a wallet object
46// return a wallet object
47−const generateWallet = async (point, ticket, boot) => {
47+export const generateWallet = async (point, ticket, boot) => {
48 const config = {
48 const config = {
49 ticket: ticket,
49 ticket: ticket,
50 seedSize: SEED_LENGTH_BYTES,
50 seedSize: SEED_LENGTH_BYTES,
@@ -63,8 +63,12 @@ const generateWallet = async (point, ticket, boot) => {
63 return wallet;
63 return wallet;
64};
64};
65
65
66−// returns a promise
67−const generateOwnershipWallet = (ship, ticket) =>
66+export const generateOwnershipWallet = (ship, ticket) =>
68 kg.generateOwnershipWallet({ ship, ticket });
67 kg.generateOwnershipWallet({ ship, ticket });
69
68
69+export const generateTemporaryTicketAndWallet = async point => {
70+ const ticket = await makeTicket(point);
70−export { makeTicket, generateWallet, generateOwnershipWallet };
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() {
31 ...birthdays,
31 ...birthdays,
32 ...invites,
32 ...invites,
33 ...controlledPoints,
33 ...controlledPoints,
34+ syncInvites,
34 syncControlledPoints,
35 syncControlledPoints,
35 syncOwnedPoint,
36 syncOwnedPoint,
36 syncForeignPoint,
37 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';
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';
5
7
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';
9
11
10−import { H3, Warning } from 'components/old/Base';
11−
12// for transaction generation and signing
12// for transaction generation and signing
13import {
13import {
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';
23
23
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';
38+
39+const kStubMailer = process.env.REACT_APP_STUB_MAILER === 'true';
33
40
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');
37
44
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',
46};
53};
47
54
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')}`;
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+ }
52};
71};
53
72
73+const kPendingAccessory = '⋯';
74+const kSuccessAccessory = '✔';
75+const kFailureAccessory = '×';
76+
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 });
58
81
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
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);
63
104
@@ -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 }));
81
122
82− console.log(inputConfigs.length, inputConfigs.map(c => c.name));
123+ // TODO: pass disabled, error info into the useForm hook?
83−
84 const { inputs, pass } = useForm(inputConfigs);
124 const { inputs, pass } = useForm(inputConfigs);
85
125
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;
86− const canAddInvite = inputs.length < maxInvitesToSend;
144+ const canAddInvite = canInput && inputs.length < maxInvitesToSend;
87−
145+
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+ })();
101− </Grid.Item>
159+
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');
109− as={Grid}
167+ }
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 });
177
168
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
184 );
175 );
185
176
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
181+ syncInvites();
182+
183+ throw new Error(
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.`
186+ );
191 }
187 }
192
188
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];
194+ try {
195+ const { data: email, name } = inputs[i];
195
196
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);
201
201
202− const email = targets[i].email;
202+ const inviteTx = azimuth.delegatedSending.sendPoint(
203− const wallet = await this.getTemporaryWallet();
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());
204
222
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}` });
210− );
228+ }
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);
224 }
229 }
225
230
226− this.setState({ wipInvites: invites, status: STATUS.CAN_SEND });
231+ if (errorCount > 0) {
232+ throw new Error(
233+ `There were ${errorCount} errors while generating wallets.`
234+ );
235+ }
227− }
236+ }, [
228−
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+
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');
234− this.setState({
235− status: STATUS.FUNDING,
236− invites: invites.map(i => {
237− return { ...i, status: Nothing() };
238− }),
239− });
256+ }
240
257
258+ setStatus(STATUS.FUNDING);
241 const tankWasUsed = await tank.ensureFundsFor(
259 const tankWasUsed = await tank.ensureFundsFor(
242− web3,
260+ _web3,
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)
250− }
251 );
268 );
252
269
253− this.setState({ status: STATUS.SENDING });
270+ setStatus(STATUS.SENDING);
271+ clearReceipts();
254
272
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];
259−
260− // send transaction. if it fails, inform the user and skip to next
261− let txHash;
262 try {
276 try {
263− txHash = await sendSignedTransaction(
277+ const txHash = await sendSignedTransaction(
264− web3,
278+ _web3,
265 Just(invite.signedTx),
279 Just(invite.signedTx),
266 tankWasUsed,
280 tankWasUsed,
267 () => {}
281 () => {}
268 );
282 );
283+
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}}`,
292+ });
274 }
293 }
275
294
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
279 try {
295 try {
296+ if (kStubMailer) {
297+ console.log(`${invite.email} - ${invite.ticket}`);
298+ return;
299+ }
300+
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
284 );
305 );
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: ' +
295− invite.ticket
296− );
297− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
298− continue;
299 }
313 }
300
314
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);
310− }
311− return success;
312− })
313 );
323 );
314 }
324 }
315−
325+ }, [
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,
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);
321 }
346 }
322− }
347+ }, [setStatus, setGeneralError, generateInvites]);
323−
348+
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);
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();
365 }
359 }
360+ }, [setStatus, sendInvites, setGeneralError]);
366
361
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();
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;
385 }
367 }
368+ }, [canGenerate, canSend, onClickGenerate, onClickSend]);
386
369
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.`));
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';
432 } else {
373 } else {
433− const inputDisabled = this.state.status !== STATUS.INPUT;
374+ setGeneralError(null);
434−
375+ }
435− const lessButton = (
376+ }, [inputs]);
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− }
487
377
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()} />
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()}
492− disabled={
389+ disabled={!canAddInvite}
493− (inputDisabled && this.state.status !== STATUS.CAN_SEND) ||
390+ className={cn({ hidden: isDone })}
494− !this.canSend()
391+ solid>
495− }
392+ +
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>
499− }}>
500− {this.state.status}
501− </Button>
502− );
503
396
504− inputs = (
397+ {isDone && (
505 <>
398 <>
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}
406+ </Grid.Item>
407+ ))}
510 </>
408 </>
511− );
512− }
409+ )}
513
410
514− return (
411+ {!isDone && (
515− <View>
412+ <>
516− {error}
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+ })}
517
449
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>
519
460
520− {inputs}
461+ {needFunds && (
521− </View>
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>
522− );
479+ );
523− }
524}
480}
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 }) {
128 <ol className="measure-md">
128 <ol className="measure-md">
129 <li className="mt-4">
129 <li className="mt-4">
130 Install
130 Install
131+ <a
132+ target="_blank"
131− <a target="_blank" href="https://github.com/FiloSottile/mkcert">
133+ href="https://github.com/FiloSottile/mkcert"
134+ rel="noopener noreferrer">
132 mkcert
135 mkcert
133 </a>
136 </a>
134 </li>
137 </li>
src/views/Point.js+2−2
@@ -48,9 +48,9 @@ export default function Point() {
48 Boot Arvo
48 Boot Arvo
49 </ForwardButton>
49 </ForwardButton>
50 </Grid.Item>
50 </Grid.Item>
51− {/* <Grid.Item full>
51+ <Grid.Item full>
52 <Actions />
52 <Actions />
53− </Grid.Item> */}
53+ </Grid.Item>
54 </Grid>
54 </Grid>
55
55
56 <FooterButton onClick={goInvite}>
56 <FooterButton onClick={goInvite}>