on f34e8d0 help sign in
bridge/src/views/Invite/InviteEmail.js
531 lines 15 kB history raw
1import Maybe, { Just, Nothing } from 'folktale/maybe';
2import React, { useState } from 'react';
3import * as azimuth from 'azimuth-js';
4import { Grid, Flex, Button, Input, IconButton } from 'indigo-react';
6import * as need from 'lib/need';
7import { hasReceived, sendMail } from 'lib/inviteMail';
8import * as wg from 'lib/walletgen';
10import { H3, Warning } from 'components/old/Base';
12// for transaction generation and signing
13import {
14 signTransaction,
15 sendSignedTransaction,
16 waitForTransactionConfirm,
17 hexify,
18 fromWei,
19 toWei,
20} from 'lib/txn';
21import * as tank from 'lib/tank';
22import { useLocalRouter } from 'lib/LocalRouter';
24import View from 'components/View';
25import MiniBackButton from 'components/MiniBackButton';
26import useRenderCount from 'lib/useRenderCount';
27import useInvites from 'lib/useInvites';
28import { usePointCursor } from 'store/pointCursor';
29import LoadableButton from 'components/LoadableButton';
30import useArray from 'lib/useArray';
31import useForm from 'indigo-react/lib/useForm';
32import { buildEmailInputConfig } from 'components/Inputs';
34const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
35const GAS_LIMIT = 350000;
36const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
38const 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};
48const EMAIL_STATUS = {
49 BUSY: '⋯',
50 DONE: '✔',
51 FAIL: '×',
52};
54// world's simplest uid
55let id = 0;
57const buildInputConfig = (extra = {}) =>
58 buildEmailInputConfig({ name: (id++).toString(), ...extra });
60export default function NewInviteEmail() {
61 const { pop } = useLocalRouter();
62 const { pointCursor } = usePointCursor();
63 const point = need.pointCursor(pointCursor);
65 const { availableInvites } = useInvites(point);
66 const maxInvitesToSend = availableInvites.matchWith({
67 Nothing: () => 0,
68 Just: p => p.value,
69 });
71 const [
72 inputConfigs,
73 { append: appendInput, removeAt: removeInputAt },
74 ] = useArray(
75 [buildInputConfig({ placeholder: 'Email Addresss' })],
76 buildInputConfig
77 );
79 const [hovered, _setHovered] = useState({});
80 const setHovered = (name, value) => () =>
81 _setHovered((value = { ...value, [name]: value }));
83 console.log(inputConfigs.length, inputConfigs.map(c => c.name));
85 const { inputs, pass } = useForm(inputConfigs);
87 const canAddInvite = inputs.length < maxInvitesToSend;
89 useRenderCount('InviteEmail');
91 return (
92 <Grid gap={12}>
93 <Grid.Item as={Grid} className="mb4" full>
94 <Grid.Item as={Flex} cols={[1, 11]} align="center">
95 <MiniBackButton onClick={() => pop()} />
96 </Grid.Item>
97 <Grid.Item cols={[11, 13]} justifySelf="end">
98 <IconButton onClick={appendInput} disabled={!canAddInvite} solid>
99 +
100 </IconButton>
101 </Grid.Item>
102 </Grid.Item>
104 {/* email inputs */}
105 {inputs.map((input, i) => {
106 const isFirst = i === 0;
107 return (
108 <Grid.Item
109 key={input.name}
110 as={Grid}
111 gap={12}
112 onMouseEnter={setHovered(input.name, true)}
113 onMouseLeave={setHovered(input.name, false)}
114 full>
115 <Grid.Item as={Input} cols={[1, 11]} {...input} />
116 {!isFirst && (input.focused || hovered[input.name]) && (
117 <Grid.Item cols={[11, 13]} justifySelf="end" alignSelf="center">
118 <IconButton onClick={() => removeInputAt(i)}>-</IconButton>
119 </Grid.Item>
120 )}
121 </Grid.Item>
122 );
123 })}
125 <Grid.Item as={LoadableButton} full disabled={!pass} solid>
126 Generate Invites
127 </Grid.Item>
128 </Grid>
129 );
132class InviteEmail extends React.Component {
133 constructor(props) {
134 super(props);
136 this.state = {
137 invitesAvailable: Nothing(),
138 targets: [{ email: '', hasReceived: Nothing(), status: Nothing() }],
139 status: STATUS.INPUT,
140 errors: Nothing(),
141 //
142 fundingMessage: Nothing(),
143 wipInvites: [],
144 };
146 this.generateInvites = this.generateInvites.bind(this);
147 this.sendInvites = this.sendInvites.bind(this);
148 this.canSend = this.canSend.bind(this);
149 this.addError = this.addError.bind(this);
150 this.handleEmailInput = this.handleEmailInput.bind(this);
151 this.addTarget = this.addTarget.bind(this);
152 this.removeTarget = this.removeTarget.bind(this);
153 this.setEmailStatus = this.setEmailStatus.bind(this);
154 this.askForFunding = this.askForFunding.bind(this);
155 }
157 componentDidMount() {
158 this.point = need.pointCursor(this.props.pointCursor);
159 this.address = need.addressFromWallet(this.props.wallet);
160 this.contracts = need.contracts(this.props.contracts);
161 this.web3 = need.web3(this.props.web3);
163 azimuth.delegatedSending
164 .getTotalUsableInvites(this.contracts, this.point)
165 .then(count => {
166 this.setState({ invitesAvailable: Just(count) });
167 });
168 }
170 async getTemporaryWallet() {
171 const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
172 const owner = await wg.generateOwnershipWallet(0, ticket);
173 return { ticket, owner: owner.keys.address };
174 }
176 async generateInvites() {
177 this.setState({ status: STATUS.GENERATING });
179 const web3 = this.web3;
180 const targets = this.state.targets;
181 const planets = await azimuth.delegatedSending.getPlanetsToSend(
182 this.contracts,
183 this.point,
184 targets.length
185 );
187 // account for the race condition where invites got used up while we were
188 // composing our target list
189 if (planets.length < targets.length) {
190 this.addError('Can currently only send ' + planets.length + ' invites!');
191 return;
192 }
194 const nonce = await web3.eth.getTransactionCount(this.address);
195 const chainId = await web3.eth.net.getId();
197 // create invite wallets and transactions
198 let invites = []; // { email, ticket, signedTx, rawTx }
199 for (let i = 0; i < targets.length; i++) {
200 console.log('generating email', i, targets[i].email);
201 this.setEmailStatus(i, EMAIL_STATUS.BUSY);
203 const email = targets[i].email;
204 const wallet = await this.getTemporaryWallet();
206 let inviteTx = azimuth.delegatedSending.sendPoint(
207 this.contracts,
208 this.point,
209 planets[i],
210 wallet.owner
211 );
212 const signedTx = await signTransaction({
213 ...this.props,
214 txn: Just(inviteTx),
215 gasPrice: GAS_PRICE_GWEI.toString(),
216 gasLimit: GAS_LIMIT.toString(),
217 nonce: nonce + i,
218 chainId: chainId,
219 setStx: () => {},
220 });
221 const rawTx = hexify(signedTx.serialize());
223 invites.push({ email, ticket: wallet.ticket, signedTx, rawTx });
224 this.setEmailStatus(i, EMAIL_STATUS.DONE);
225 }
227 this.setState({ wipInvites: invites, status: STATUS.CAN_SEND });
228 }
230 async sendInvites() {
231 const invites = this.state.wipInvites;
232 const web3 = this.web3;
234 //TODO invite status isn't changing/re-rendering... why?
235 this.setState({
236 status: STATUS.FUNDING,
237 invites: invites.map(i => {
238 return { ...i, status: Nothing() };
239 }),
240 });
242 const tankWasUsed = await tank.ensureFundsFor(
243 web3,
244 this.point,
245 this.address,
246 INVITE_COST * invites.length,
247 invites.map(i => i.rawTx),
248 this.askForFunding,
249 () => {
250 this.setState({ fundingMessage: Nothing() });
251 }
252 );
254 this.setState({ status: STATUS.SENDING });
256 //TODO nasty loop, probably move into lib... but so many callbacks!
257 let promises = [];
258 for (let i = 0; i < invites.length; i++) {
259 const invite = invites[i];
261 // send transaction. if it fails, inform the user and skip to next
262 let txHash;
263 try {
264 txHash = await sendSignedTransaction(
265 web3,
266 Just(invite.signedTx),
267 tankWasUsed,
268 () => {}
269 );
270 } catch (err) {
271 console.error('invite tx sending failed', err);
272 this.addError('Invite transaction not sent for ' + invite.email);
273 this.setEmailStatus(i, EMAIL_STATUS.FAIL);
274 continue;
275 }
277 // ask the email sender to send this. we can do this prior to tx confirm,
278 // because the sender service waits for confirm & success for us.
279 // if this fails, inform the user and skip to next
280 try {
281 const mailSuccess = await sendMail(
282 invite.email,
283 invite.ticket,
284 invite.rawTx
285 );
286 if (!mailSuccess) throw new Error();
287 this.setEmailStatus(i, EMAIL_STATUS.DONE);
288 } catch (e) {
289 console.log('email send failed');
290 //NOTE this assumes that the transaction succeeds, but we don't know
291 // that for a fact yet...
292 this.addError(
293 'Invite email failed to send for ' +
294 invite.email +
295 '. Please give them this ticket: ' +
296 invite.ticket
297 );
298 this.setEmailStatus(i, EMAIL_STATUS.FAIL);
299 continue;
300 }
302 // update status on transaction confirm, but don't block on it
303 promises.push(
304 waitForTransactionConfirm(web3, txHash).then(success => {
305 if (success) {
306 this.setEmailStatus(i, EMAIL_STATUS.DONE);
307 } else {
308 console.log('invite tx rejected');
309 this.addError('Invite transaction failed for ' + invite.email);
310 this.setEmailStatus(i, EMAIL_STATUS.FAIL);
311 }
312 return success;
313 })
314 );
315 }
317 const txStatus = await Promise.all(promises);
318 if (txStatus.every(e => e)) {
319 this.setState({ status: STATUS.DONE });
320 } else {
321 this.setState({ status: STATUS.FAIL });
322 }
323 }
325 setEmailStatus(i, status) {
326 const targets = this.state.targets;
327 targets[i].status = Just(status);
328 this.setState({ targets });
329 }
331 addError(error) {
332 const newError = this.state.errors.matchWith({
333 Nothing: () => [error],
334 Just: errs => {
335 errs.value.push(error);
336 return errs.value;
337 },
338 });
339 this.setState({ errors: Just(newError) });
340 }
342 askForFunding(address, minBalance, balance) {
343 this.setState({
344 fundingMessage: Just(
345 `Please make sure ${address} has at least ${fromWei(minBalance)} ETH,` +
346 `we'll continue once that's true. Current balance: ${fromWei(
347 balance
348 )}.` +
349 `Waiting...`
350 ),
351 });
352 }
354 handleEmailInput(i, email) {
355 let targets = this.state.targets;
356 targets[i].email = email;
358 // if it's a valid address, check if it has received an invite before
359 if (email.match(/.*@.*\...+/)) {
360 hasReceived(email).then(res => {
361 targets[i].hasReceived = Just(res);
362 this.setState({ targets });
363 });
364 } else {
365 targets[i].hasReceived = Nothing();
366 }
368 this.setState({ targets });
369 }
371 canSend() {
372 const targets = this.state.targets;
374 // may not have duplicate targets
375 if (new Set(targets.map(t => t.email)).size !== targets.length)
376 return false;
378 // none may have received invites already
379 for (let i = 0; i < targets.length; i++) {
380 const target = targets[i];
381 const res = target.hasReceived.matchWith({
382 Nothing: () => true,
383 Just: has => has.value,
384 });
385 if (res) return false;
386 }
388 return this.state.invitesAvailable.matchWith({
389 Nothing: () => false,
390 Just: invites => invites.value > 0,
391 });
392 }
394 addTarget() {
395 let targets = this.state.targets;
396 targets.push({ email: '', hasReceived: Nothing(), status: Nothing() });
397 this.setState({ targets });
398 }
400 removeTarget() {
401 let targets = this.state.targets;
402 targets.length = targets.length - 1;
403 this.setState({ targets });
404 }
406 render() {
407 const dif =
408 this.state.status === STATUS.DONE ? this.state.targets.length : null;
410 const error = this.state.errors.matchWith({
411 Nothing: () => null,
412 Just: errors => (
413 <Warning className={'mt-8'}>
414 <H3 style={{ marginTop: 0, paddingTop: 0 }}>
415 {'Something went wrong!'}
416 </H3>
417 {errors.value.map(err => (
418 <p>{err}</p>
419 ))}
420 </Warning>
421 ),
422 });
424 const fundingMessage = this.state.fundingMessage.matchWith({
425 Nothing: () => null,
426 Just: msg => <Warning className={'mt-8'}>{msg.value}</Warning>,
427 });
429 let inputs;
430 if (this.state.status === STATUS.DONE) {
431 //TODO proper clickable router thing
432 inputs = '<- Back to Home';
433 } else {
434 const inputDisabled = this.state.status !== STATUS.INPUT;
436 const lessButton = (
437 <Button
438 prop-size={'s narrow'}
439 className={'mt-8'}
440 disabled={inputDisabled || this.state.targets.length === 1}
441 onClick={this.removeTarget}>
442 {'-'}
443 </Button>
444 );
446 const moreButton = (
447 <Button
448 prop-size={'s narrow'}
449 className={'mt-8'}
450 disabled={
451 inputDisabled ||
452 this.state.invitesAvailable.matchWith({
453 Nothing: () => true,
454 Just: inv => inv.value <= this.state.targets.length,
455 })
456 }
457 onClick={this.addTarget}>
458 {'+'}
459 </Button>
460 );
462 let targetInputs = [];
463 for (let i = 0; i < this.state.targets.length; i++) {
464 const target = this.state.targets[i];
465 let progress = target.status.matchWith({
466 Nothing: () => '',
467 Just: status => status.value,
468 });
469 targetInputs.push(
470 <>
471 <Input
472 value={target.email}
473 onChange={value => {
474 this.handleEmailInput(i, value);
475 }}
476 placeholder={'Email address'}
477 disabled={inputDisabled}
478 />
479 {target.hasReceived.matchWith({
480 Nothing: () => null,
481 Just: has =>
482 has.value ? <span>{'×'}</span> : <span>{'°'}</span>,
483 })}
484 {progress}
485 </>
486 );
487 }
489 const submitButton = (
490 <Button
491 prop-size={'xl wide'}
492 className={'mt-8'}
493 disabled={
494 (inputDisabled && this.state.status !== STATUS.CAN_SEND) ||
495 !this.canSend()
496 }
497 onClick={() => {
498 if (this.state.status === STATUS.INPUT) this.generateInvites();
499 if (this.state.status === STATUS.CAN_SEND) this.sendInvites();
500 }}>
501 {this.state.status}
502 </Button>
503 );
505 inputs = (
506 <>
507 {lessButton}
508 {moreButton}
509 {targetInputs}
510 {submitButton}
511 </>
512 );
513 }
515 return (
516 <View>
517 {error}
519 {fundingMessage}
521 {inputs}
522 </View>
523 );
524 }
527// export default compose(
528// withNetwork,
529// withWallet,
530// withPointCursor
531// )(InviteEmail);