on e366ef3 help sign in
bridge/src/views/InvitesSend.js
502 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 { Row, Col, 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';
27const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
28const GAS_LIMIT = 350000;
29const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
31const STATUS = {
32 INPUT: 'Generate invites',
33 GENERATING: 'Generating invites',
34 CAN_SEND: 'Send invites',
35 FUNDING: 'Funding invites',
36 SENDING: 'Sending invites',
37 DONE: 'Done',
38 FAIL: 'Some failed',
39};
41const EMAIL_STATUS = {
42 BUSY: '⋯',
43 DONE: '✔',
44 FAIL: '×',
45};
47class InvitesSend extends React.Component {
48 constructor(props) {
49 super(props);
51 this.state = {
52 invitesAvailable: Nothing(),
53 invited: Nothing(),
54 targets: [{ email: '', hasReceived: Nothing(), status: Nothing() }],
55 status: STATUS.INPUT,
56 errors: Nothing(),
57 //
58 fundingMessage: Nothing(),
59 wipInvites: [],
60 };
62 this.findInvited = this.findInvited.bind(this);
63 this.generateInvites = this.generateInvites.bind(this);
64 this.sendInvites = this.sendInvites.bind(this);
65 this.canSend = this.canSend.bind(this);
66 this.addError = this.addError.bind(this);
67 this.handleEmailInput = this.handleEmailInput.bind(this);
68 this.addTarget = this.addTarget.bind(this);
69 this.removeTarget = this.removeTarget.bind(this);
70 this.setEmailStatus = this.setEmailStatus.bind(this);
71 this.askForFunding = this.askForFunding.bind(this);
72 }
74 componentDidMount() {
75 this.point = parseInt(need.pointCursor(this.props), 10);
76 this.address = need.address(this.props);
77 this.contracts = need.contracts(this.props);
78 this.web3 = need.web3(this.props);
80 console.log(azimuth.delegatedSending);
81 azimuth.delegatedSending
82 .getTotalUsableInvites(this.contracts, this.point)
83 .then(count => {
84 this.setState({ invitesAvailable: Just(count) });
85 });
86 this.findInvited();
87 }
89 componentDidUpdate(prevProps) {
90 //
91 }
93 async findInvited() {
94 let invited = await azimuth.delegatedSending.getInvited(
95 this.contracts,
96 this.point
97 );
98 invited = invited.map(async point => {
99 const active = await azimuth.azimuth.isActive(this.contracts, point);
100 const res = { point: Number(point), active: active };
101 return res;
102 });
103 invited = await Promise.all(invited);
104 const total = invited.length;
105 const accepted = invited.filter(i => i.active).length;
106 this.setState({ invited: Just({ total, accepted }) });
107 }
109 async getTemporaryWallet() {
110 const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
111 const owner = await wg.generateOwnershipWallet(0, ticket);
112 return { ticket, owner: owner.keys.address };
113 }
115 async generateInvites() {
116 this.setState({ status: STATUS.GENERATING });
118 const web3 = this.web3;
119 const targets = this.state.targets;
120 const planets = await azimuth.delegatedSending.getPlanetsToSend(
121 this.contracts,
122 this.point,
123 targets.length
124 );
126 // account for the race condition where invites got used up while we were
127 // composing our target list
128 if (planets.length < targets.length) {
129 this.addError('Can currently only send ' + planets.length + ' invites!');
130 return;
131 }
133 const nonce = await web3.eth.getTransactionCount(this.address);
134 const chainId = await web3.eth.net.getId();
136 // create invite wallets and transactions
137 let invites = []; // { email, ticket, signedTx, rawTx }
138 for (let i = 0; i < targets.length; i++) {
139 console.log('generating email', i, targets[i].email);
140 this.setEmailStatus(i, EMAIL_STATUS.BUSY);
142 const email = targets[i].email;
143 const wallet = await this.getTemporaryWallet();
145 let inviteTx = azimuth.delegatedSending.sendPoint(
146 this.contracts,
147 this.point,
148 planets[i],
149 wallet.owner
150 );
151 const signedTx = await signTransaction({
152 ...this.props,
153 txn: Just(inviteTx),
154 gasPrice: GAS_PRICE_GWEI.toString(),
155 gasLimit: GAS_LIMIT.toString(),
156 nonce: nonce + i,
157 chainId: chainId,
158 setStx: () => {},
159 });
160 const rawTx = hexify(signedTx.serialize());
162 invites.push({ email, ticket: wallet.ticket, signedTx, rawTx });
163 this.setEmailStatus(i, EMAIL_STATUS.DONE);
164 }
166 this.setState({ wipInvites: invites, status: STATUS.CAN_SEND });
167 }
169 async sendInvites() {
170 const invites = this.state.wipInvites;
171 const web3 = this.web3;
173 //TODO invite status isn't changing/re-rendering... why?
174 this.setState({
175 status: STATUS.FUNDING,
176 invites: invites.map(i => {
177 return { ...i, status: Nothing() };
178 }),
179 });
181 const tankWasUsed = await tank.ensureFundsFor(
182 web3,
183 this.point,
184 this.address,
185 INVITE_COST * invites.length,
186 invites.map(i => i.rawTx),
187 this.askForFunding,
188 () => {
189 this.setState({ fundingMessage: Nothing() });
190 }
191 );
193 this.setState({ status: STATUS.SENDING });
195 //TODO nasty loop, probably move into lib... but so many callbacks!
196 let promises = [];
197 for (let i = 0; i < invites.length; i++) {
198 const invite = invites[i];
200 // send transaction. if it fails, inform the user and skip to next
201 let txHash;
202 try {
203 txHash = await sendSignedTransaction(
204 web3,
205 Just(invite.signedTx),
206 tankWasUsed,
207 () => {}
208 );
209 } catch (err) {
210 console.error('invite tx sending failed', err);
211 this.addError('Invite transaction not sent for ' + invite.email);
212 this.setEmailStatus(i, EMAIL_STATUS.FAIL);
213 continue;
214 }
216 // ask the email sender to send this. we can do this prior to tx confirm,
217 // because the sender service waits for confirm & success for us.
218 // if this fails, inform the user and skip to next
219 try {
220 const mailSuccess = await sendMail(
221 invite.email,
222 invite.ticket,
223 invite.rawTx
224 );
225 if (!mailSuccess) throw new Error();
226 this.setEmailStatus(i, EMAIL_STATUS.DONE);
227 } catch (e) {
228 console.log('email send failed');
229 //NOTE this assumes that the transaction succeeds, but we don't know
230 // that for a fact yet...
231 this.addError(
232 'Invite email failed to send for ' +
233 invite.email +
234 '. Please give them this ticket: ' +
235 invite.ticket
236 );
237 this.setEmailStatus(i, EMAIL_STATUS.FAIL);
238 continue;
239 }
241 // update status on transaction confirm, but don't block on it
242 promises.push(
243 waitForTransactionConfirm(web3, txHash).then(success => {
244 if (success) {
245 this.setEmailStatus(i, EMAIL_STATUS.DONE);
246 } else {
247 console.log('invite tx rejected');
248 this.addError('Invite transaction failed for ' + invite.email);
249 this.setEmailStatus(i, EMAIL_STATUS.FAIL);
250 }
251 return success;
252 })
253 );
254 }
256 const txStatus = await Promise.all(promises);
257 if (txStatus.every(e => e)) {
258 this.setState({ status: STATUS.DONE });
259 } else {
260 this.setState({ status: STATUS.FAIL });
261 }
262 }
264 setEmailStatus(i, status) {
265 const targets = this.state.targets;
266 targets[i].status = Just(status);
267 this.setState({ targets });
268 }
270 addError(error) {
271 const newError = this.state.errors.matchWith({
272 Nothing: () => [error],
273 Just: errs => {
274 errs.value.push(error);
275 return errs.value;
276 },
277 });
278 this.setState({ errors: Just(newError) });
279 }
281 askForFunding(address, minBalance, balance) {
282 this.setState({
283 fundingMessage: Just(
284 `Please make sure ${address} has at least ${fromWei(minBalance)} ETH,` +
285 `we'll continue once that's true. Current balance: ${fromWei(
286 balance
287 )}.` +
288 `Waiting...`
289 ),
290 });
291 }
293 handleEmailInput(i, email) {
294 let targets = this.state.targets;
295 targets[i].email = email;
297 // if it's a valid address, check if it has received an invite before
298 if (email.match(/.*@.*\...+/)) {
299 hasReceived(email).then(res => {
300 targets[i].hasReceived = Just(res);
301 this.setState({ targets });
302 });
303 } else {
304 targets[i].hasReceived = Nothing();
305 }
307 this.setState({ targets });
308 }
310 canSend() {
311 const targets = this.state.targets;
313 // may not have duplicate targets
314 if (new Set(targets.map(t => t.email)).size !== targets.length)
315 return false;
317 // none may have received invites already
318 for (let i = 0; i < targets.length; i++) {
319 const target = targets[i];
320 const res = target.hasReceived.matchWith({
321 Nothing: () => true,
322 Just: has => has.value,
323 });
324 if (res) return false;
325 }
327 return this.state.invitesAvailable.matchWith({
328 Nothing: () => false,
329 Just: invites => invites.value > 0,
330 });
331 }
333 addTarget() {
334 let targets = this.state.targets;
335 targets.push({ email: '', hasReceived: Nothing(), status: Nothing() });
336 this.setState({ targets });
337 }
339 removeTarget() {
340 let targets = this.state.targets;
341 targets.length = targets.length - 1;
342 this.setState({ targets });
343 }
345 render() {
346 const dif =
347 this.state.status === STATUS.DONE ? this.state.targets.length : null;
349 const invitesAvailable = this.state.invitesAvailable.matchWith({
350 Nothing: () => 'Loading...',
351 Just: invites => {
352 const dia = dif ? `(-${dif})` : '';
353 return `You currently have ${invites.value}${dia} invitations left.`;
354 },
355 });
357 const invitesSent = this.state.invited.matchWith({
358 Nothing: () => <>{'Loading...'}</>,
359 Just: invited => {
360 const dit = dif ? `(+${dif})` : '';
361 return (
362 <>
363 {'Out of the '}
364 {invited.value.total + dit}
365 {' invites you sent, '}
366 {invited.value.accepted}
367 {' have been accepted.'}
368 </>
369 );
370 },
371 });
373 const error = this.state.errors.matchWith({
374 Nothing: () => null,
375 Just: errors => (
376 <Warning className={'mt-8'}>
377 <H3 style={{ marginTop: 0, paddingTop: 0 }}>
378 {'Something went wrong!'}
379 </H3>
380 {errors.value.map(err => (
381 <p>{err}</p>
382 ))}
383 </Warning>
384 ),
385 });
387 const fundingMessage = this.state.fundingMessage.matchWith({
388 Nothing: () => null,
389 Just: msg => <Warning className={'mt-8'}>{msg.value}</Warning>,
390 });
392 let inputs;
393 if (this.state.status === STATUS.DONE) {
394 //TODO proper clickable router thing
395 inputs = '<- Back to Home';
396 } else {
397 const inputDisabled = this.state.status !== STATUS.INPUT;
399 const lessButton = (
400 <Button
401 prop-size={'s narrow'}
402 className={'mt-8'}
403 disabled={inputDisabled || this.state.targets.length === 1}
404 onClick={this.removeTarget}>
405 {'-'}
406 </Button>
407 );
409 const moreButton = (
410 <Button
411 prop-size={'s narrow'}
412 className={'mt-8'}
413 disabled={
414 inputDisabled ||
415 this.state.invitesAvailable.matchWith({
416 Nothing: () => true,
417 Just: inv => inv.value <= this.state.targets.length,
418 })
419 }
420 onClick={this.addTarget}>
421 {'+'}
422 </Button>
423 );
425 let targetInputs = [];
426 for (let i = 0; i < this.state.targets.length; i++) {
427 const target = this.state.targets[i];
428 let progress = target.status.matchWith({
429 Nothing: () => '',
430 Just: status => status.value,
431 });
432 targetInputs.push(
433 <>
434 <Input
435 value={target.email}
436 onChange={value => {
437 this.handleEmailInput(i, value);
438 }}
439 placeholder={'Email address'}
440 disabled={inputDisabled}
441 />
442 {target.hasReceived.matchWith({
443 Nothing: () => null,
444 Just: has =>
445 has.value ? <span>{'×'}</span> : <span>{'°'}</span>,
446 })}
447 {progress}
448 </>
449 );
450 }
452 const submitButton = (
453 <Button
454 prop-size={'xl wide'}
455 className={'mt-8'}
456 disabled={
457 (inputDisabled && this.state.status !== STATUS.CAN_SEND) ||
458 !this.canSend()
459 }
460 onClick={() => {
461 if (this.state.status === STATUS.INPUT) this.generateInvites();
462 if (this.state.status === STATUS.CAN_SEND) this.sendInvites();
463 }}>
464 {this.state.status}
465 </Button>
466 );
468 inputs = (
469 <>
470 {lessButton}
471 {moreButton}
472 {targetInputs}
473 {submitButton}
474 </>
475 );
476 }
478 return (
479 <Row>
480 <Col>
481 <p>{'send invites here, for planets'}</p>
483 <p>{invitesAvailable}</p>
485 <ul>{invitesSent}</ul>
487 {error}
489 {fundingMessage}
491 {inputs}
492 </Col>
493 </Row>
494 );
495 }
498export default compose(
499 withNetwork,
500 withWallet,
501 withPointCursor
502)(InvitesSend);