on dd12472 help sign in
bridge/src/views/InvitesSend.js
501 lines 14 kB history raw
1import { Just, Nothing } from 'folktale/maybe';
2import React from 'react';
3import * as azimuth from 'azimuth-js';
4import * as need from '../lib/need';
6import { hasReceived, sendMail } from '../lib/inviteMail';
7import { Button, Input, H3, Warning } from '../components/old/Base';
9// for wallet generation
10import * as wg from '../_walletgen/lib/lib';
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 { withNetwork } from '../store/network';
23import { compose } from '../lib/lib';
24import { withWallet } from '../store/wallet';
25import { withPointCursor } from '../store/pointCursor';
26import View from 'components/View';
28const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
29const GAS_LIMIT = 350000;
30const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
32const 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',
40};
42const EMAIL_STATUS = {
43 BUSY: '⋯',
44 DONE: '✔',
45 FAIL: '×',
46};
48class InvitesSend extends React.Component {
49 constructor(props) {
50 super(props);
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 };
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 }
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);
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 }
90 componentDidUpdate(prevProps) {
91 //
92 }
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 }) });
108 }
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 }
116 async generateInvites() {
117 this.setState({ status: STATUS.GENERATING });
119 const web3 = this.web3;
120 const targets = this.state.targets;
121 const planets = await azimuth.delegatedSending.getPlanetsToSend(
122 this.contracts,
123 this.point,
124 targets.length
125 );
127 // account for the race condition where invites got used up while we were
128 // composing our target list
129 if (planets.length < targets.length) {
130 this.addError('Can currently only send ' + planets.length + ' invites!');
131 return;
132 }
134 const nonce = await web3.eth.getTransactionCount(this.address);
135 const chainId = await web3.eth.net.getId();
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);
143 const email = targets[i].email;
144 const wallet = await this.getTemporaryWallet();
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());
163 invites.push({ email, ticket: wallet.ticket, signedTx, rawTx });
164 this.setEmailStatus(i, EMAIL_STATUS.DONE);
165 }
167 this.setState({ wipInvites: invites, status: STATUS.CAN_SEND });
168 }
170 async sendInvites() {
171 const invites = this.state.wipInvites;
172 const web3 = this.web3;
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 });
182 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 }
192 );
194 this.setState({ status: STATUS.SENDING });
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];
201 // send transaction. if it fails, inform the user and skip to next
202 let txHash;
203 try {
204 txHash = await sendSignedTransaction(
205 web3,
206 Just(invite.signedTx),
207 tankWasUsed,
208 () => {}
209 );
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;
215 }
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
220 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;
240 }
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 })
254 );
255 }
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 });
262 }
263 }
265 setEmailStatus(i, status) {
266 const targets = this.state.targets;
267 targets[i].status = Just(status);
268 this.setState({ targets });
269 }
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 }
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 }
294 handleEmailInput(i, email) {
295 let targets = this.state.targets;
296 targets[i].email = email;
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 });
304 } else {
305 targets[i].hasReceived = Nothing();
306 }
308 this.setState({ targets });
309 }
311 canSend() {
312 const targets = this.state.targets;
314 // may not have duplicate targets
315 if (new Set(targets.map(t => t.email)).size !== targets.length)
316 return false;
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;
326 }
328 return this.state.invitesAvailable.matchWith({
329 Nothing: () => false,
330 Just: invites => invites.value > 0,
331 });
332 }
334 addTarget() {
335 let targets = this.state.targets;
336 targets.push({ email: '', hasReceived: Nothing(), status: Nothing() });
337 this.setState({ targets });
338 }
340 removeTarget() {
341 let targets = this.state.targets;
342 targets.length = targets.length - 1;
343 this.setState({ targets });
344 }
346 render() {
347 const dif =
348 this.state.status === STATUS.DONE ? this.state.targets.length : null;
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 });
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 });
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>
383 ))}
384 </Warning>
385 ),
386 });
388 const fundingMessage = this.state.fundingMessage.matchWith({
389 Nothing: () => null,
390 Just: msg => <Warning className={'mt-8'}>{msg.value}</Warning>,
391 });
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;
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 );
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 );
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 }
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 );
469 inputs = (
470 <>
471 {lessButton}
472 {moreButton}
473 {targetInputs}
474 {submitButton}
475 </>
476 );
477 }
479 return (
480 <View>
481 <p>{'send invites here, for planets'}</p>
483 <p>{invitesAvailable}</p>
485 <ul>{invitesSent}</ul>
487 {error}
489 {fundingMessage}
491 {inputs}
492 </View>
493 );
494 }
497export default compose(
498 withNetwork,
499 withWallet,
500 withPointCursor
501)(InvitesSend);