@@ -1,16 +1,45 @@
11import { Just, Nothing } from 'folktale/maybe'
22import React from 'react'
3−import * as ob from 'urbit-ob'
43import * as azimuth from 'azimuth-js'
4+import * as need from '../lib/need'
55
6−import { BRIDGE_ERROR } from '../lib/error'
7−import { sendMail } from '../lib/inviteMail'
8−import { Row, Col, Input, InnerLabel } from '../components/Base'
9−import StatelessTransaction from '../components/StatelessTransaction'
6+import { hasReceived, sendMail } from '../lib/inviteMail'
7+import { Row, Col, Button, Input, H3, Warning } from '../components/Base'
108
119// for wallet generation
1210import * as wg from '../../walletgen/lib/lib'
1311
12+// for transaction generation and signing
13+import {
14+ signTransaction,
15+ sendSignedTransaction,
16+ waitForTransactionConfirm,
17+ hexify,
18+ fromWei,
19+ toWei
20+} from '../lib/txn'
21+import * as tank from '../lib/tank'
22+
23+const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
24+const GAS_LIMIT = 350000;
25+const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
26+
27+const STATUS = {
28+ INPUT: 'Generate invites',
29+ GENERATING: 'Generating invites',
30+ CAN_SEND: 'Send invites',
31+ FUNDING: 'Funding invites',
32+ SENDING: 'Sending invites',
33+ DONE: 'Done',
34+ FAIL: 'Some failed'
35+}
36+
37+const EMAIL_STATUS = {
38+ BUSY: '⋯',
39+ DONE: '✔',
40+ FAIL: '×'
41+}
42+
1443class InvitesSend extends React.Component {
1544
1645 constructor(props) {
@@ -18,47 +47,39 @@ class InvitesSend extends React.Component {
1847
1948 this.state = {
2049 invitesAvailable: Nothing(),
21− fromPool: Nothing(),
22− haveInvited: Nothing(),
23− randomPlanet: Nothing(),
24− inviteWallet: Nothing(),
25− targetEmail: ''
50+ invited: Nothing(),
51+ targets: [{ email: '', hasReceived: Nothing(), status: Nothing() }],
52+ status: STATUS.INPUT,
53+ errors: Nothing(),
54+ //
55+ fundingMessage: Nothing(),
56+ wipInvites: []
2657 }
2758
2859 this.findInvited = this.findInvited.bind(this);
29− this.findRandomPlanet = this.findRandomPlanet.bind(this);
30− this.generateWallet = this.generateWallet.bind(this);
31− this.canGenerate = this.canGenerate.bind(this);
60+ this.generateInvites = this.generateInvites.bind(this);
61+ this.sendInvites = this.sendInvites.bind(this);
62+ this.canSend = this.canSend.bind(this);
63+ this.addError = this.addError.bind(this);
3264 this.handleEmailInput = this.handleEmailInput.bind(this);
33− this.txnConfirmation = this.txnConfirmation.bind(this);
34− this.createUnsignedTxn = this.createUnsignedTxn.bind(this);
65+ this.addTarget = this.addTarget.bind(this);
66+ this.removeTarget = this.removeTarget.bind(this);
67+ this.setEmailStatus = this.setEmailStatus.bind(this);
68+ this.askForFunding = this.askForFunding.bind(this);
3569 }
3670
3771 componentDidMount() {
38− this.point = this.props.pointCursor.matchWith({
39− Just: (pt) => parseInt(pt.value, 10),
40− Nothing: () => {
41− throw BRIDGE_ERROR.MISSING_POINT
42− }
43− });
44− this.contracts = this.props.contracts.matchWith({
45− Just: cs => cs.value,
46− Nothing: _ => {
47− throw BRIDGE_ERROR.MISSING_CONTRACTS
48− }
49− });
72+ this.point = parseInt(need.pointCursor(this.props), 10);
73+ this.address = need.address(this.props);
74+ this.contracts = need.contracts(this.props);
75+ this.web3 = need.web3(this.props);
5076
51− azimuth.delegatedSending.getPool(this.contracts, this.point)
52− .then(pool => {
53− this.setState({fromPool: Just(pool)});
54− azimuth.delegatedSending.invitesInPool(this.contracts, pool)
55− .then(count => {
56− this.setState({invitesAvailable: Just(count)});
57− });
77+ console.log(azimuth.delegatedSending);
78+ azimuth.delegatedSending.getTotalUsableInvites(this.contracts, this.point)
79+ .then(count => {
80+ this.setState({invitesAvailable: Just(count)});
5881 });
5982 this.findInvited();
60− this.findRandomPlanet();
61− this.generateWallet();
6283 }
6384
6485 componentDidUpdate(prevProps) {
@@ -76,127 +97,367 @@ class InvitesSend extends React.Component {
7697 return res;
7798 });
7899 invited = await Promise.all(invited);
79− this.setState({haveInvited: Just(invited)});
100+ const total = invited.length;
101+ const accepted = invited.filter(i => i.active).length;
102+ this.setState({invited: Just({total, accepted})});
80103 }
81104
82− async findRandomPlanet() {
83− let res = Nothing();
84− const kids = await azimuth.azimuth.getUnspawnedChildren(
85− this.contracts,
86− azimuth.azimuth.getPrefix(this.point)
105+ async getTemporaryWallet() {
106+ const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
107+ const owner = await wg.generateOwnershipWallet(0, ticket);
108+ return {ticket, owner: owner.keys.address};
109+ }
110+
111+ async generateInvites() {
112+ this.setState({ status: STATUS.GENERATING });
113+
114+ const web3 = this.web3;
115+ const targets = this.state.targets;
116+ const planets = await azimuth.delegatedSending.getPlanetsToSend(
117+ this.contracts, this.point, targets.length
87118 );
88− if (kids.length > 0) {
89− const i = Math.floor(Math.random() * kids.length);
90− res = Just(Number(kids[i]));
119+
120+ // account for the race condition where invites got used up while we were
121+ // composing our target list
122+ if (planets.length < targets.length) {
123+ this.addError('Can currently only send '+planets.length+' invites!');
124+ return;
91125 }
92− this.setState({randomPlanet: res});
93− return res;
126+
127+ const nonce = await web3.eth.getTransactionCount(this.address);
128+ const chainId = await web3.eth.net.getId();
129+
130+ // create invite wallets and transactions
131+ let invites = []; // { email, ticket, signedTx, rawTx }
132+ for (let i = 0; i < targets.length; i++) {
133+ console.log('generating email', i, targets[i].email);
134+ this.setEmailStatus(i, EMAIL_STATUS.BUSY);
135+
136+ const email = targets[i].email;
137+ const wallet = await this.getTemporaryWallet();
138+
139+ let inviteTx = azimuth.delegatedSending.sendPoint(
140+ this.contracts, this.point, planets[i], wallet.owner
141+ );
142+ const signedTx = await signTransaction({
143+ ...this.props,
144+ txn: Just(inviteTx),
145+ gasPrice: GAS_PRICE_GWEI.toString(),
146+ gasLimit: GAS_LIMIT.toString(),
147+ nonce: nonce + i,
148+ chainId: chainId,
149+ setStx: () => {}
150+ });
151+ const rawTx = hexify(signedTx.serialize());
152+
153+ invites.push({email, ticket: wallet.ticket, signedTx, rawTx });
154+ this.setEmailStatus(i, EMAIL_STATUS.DONE);
155+ }
156+
157+ this.setState({ wipInvites: invites, status: STATUS.CAN_SEND });
94158 }
95159
96− async generateWallet() {
97− const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
98− const wallet = await wg.generateWallet(0, ticket, false);
99− this.setState({inviteWallet:
100− Just({ticket, owner:wallet.ownership.keys.address})
160+ async sendInvites() {
161+ const invites = this.state.wipInvites;
162+ const web3 = this.web3;
163+
164+ //TODO invite status isn't changing/re-rendering... why?
165+ this.setState({
166+ status: STATUS.FUNDING,
167+ invites: invites.map(i => { return {...i, status: Nothing()}; })
101168 });
169+
170+ const tankWasUsed = await tank.ensureFundsFor(
171+ web3, this.point, this.address,
172+ (INVITE_COST * invites.length),
173+ invites.map(i => i.rawTx), this.askForFunding,
174+ () => { this.setState({ fundingMessage: Nothing() }); }
175+ );
176+
177+ this.setState({ status: STATUS.SENDING });
178+
179+ //TODO nasty loop, probably move into lib... but so many callbacks!
180+ let promises = [];
181+ for (let i = 0; i < invites.length; i++) {
182+ const invite = invites[i];
183+
184+ // send transaction. if it fails, inform the user and skip to next
185+ let txHash;
186+ try {
187+ txHash = await sendSignedTransaction(
188+ web3, Just(invite.signedTx), tankWasUsed, ()=>{}
189+ );
190+ } catch(err) {
191+ console.error('invite tx sending failed', err);
192+ this.addError('Invite transaction not sent for ' + invite.email);
193+ this.setEmailStatus(i, EMAIL_STATUS.FAIL);
194+ continue;
195+ };
196+
197+ // ask the email sender to send this. we can do this prior to tx confirm,
198+ // because the sender service waits for confirm & success for us.
199+ // if this fails, inform the user and skip to next
200+ try {
201+ const mailSuccess = await sendMail(
202+ invite.email, invite.ticket, invite.rawTx
203+ );
204+ if (!mailSuccess) throw new Error();
205+ this.setEmailStatus(i, EMAIL_STATUS.DONE);
206+ } catch(e) {
207+ console.log('email send failed');
208+ //NOTE this assumes that the transaction succeeds, but we don't know
209+ // that for a fact yet...
210+ this.addError(
211+ 'Invite email failed to send for ' + invite.email +
212+ '. Please give them this ticket: ' + invite.ticket
213+ );
214+ this.setEmailStatus(i, EMAIL_STATUS.FAIL);
215+ continue;
216+ }
217+
218+ // update status on transaction confirm, but don't block on it
219+ promises.push(
220+ waitForTransactionConfirm(web3, txHash)
221+ .then(success => {
222+ if (success) {
223+ this.setEmailStatus(i, EMAIL_STATUS.DONE);
224+ } else {
225+ console.log('invite tx rejected');
226+ this.addError('Invite transaction failed for ' + invite.email);
227+ this.setEmailStatus(i, EMAIL_STATUS.FAIL);
228+ }
229+ return success;
230+ })
231+ );
232+
233+ }
234+
235+ const txStatus = await Promise.all(promises);
236+ if (txStatus.every(e => e)) {
237+ this.setState({ status: STATUS.DONE });
238+ } else {
239+ this.setState({ status: STATUS.FAIL });
240+ }
102241 }
103242
104− handleEmailInput(email) {
105− //TODO simple .*@.*\..* email validation
106− this.setState({ targetEmail: email });
243+ setEmailStatus(i, status) {
244+ const targets = this.state.targets;
245+ targets[i].status = Just(status);
246+ this.setState({ targets });
107247 }
108248
109− canGenerate() {
110− //TODO and canSend()
111− let res = this.state.invitesAvailable.matchWith({
112− Nothing: () => false,
113− Just: invites => ((invites.value > 0)
114− && Just.hasInstance(this.state.randomPlanet)
115− && Just.hasInstance(this.state.inviteWallet))
249+ addError(error) {
250+ const newError = this.state.errors.matchWith({
251+ Nothing: () => [error],
252+ Just: errs => { errs.value.push(error); return errs.value; }
116253 });
117− return res;
254+ this.setState({ errors: Just(newError) });
118255 }
119256
120− createUnsignedTxn() {
121− const txn = azimuth.delegatedSending.sendPoint(
122− this.contracts,
123− this.point,
124− this.state.randomPlanet.value,
125− this.state.inviteWallet.value.owner
126− );
127− return Just(txn)
257+ askForFunding(address, minBalance, balance) {
258+ this.setState({ fundingMessage: Just(
259+ `Please make sure ${address} has at least ${fromWei(minBalance)} ETH,` +
260+ `we'll continue once that's true. Current balance: ${fromWei(balance)}.` +
261+ `Waiting...`
262+ ) });
128263 }
129264
130− //TODO find a better way of making the user wait for submission success
131− // and email sending, so that we can catch failure cases and can
132− // always fall back to retrying or "please send them this"
133− txnConfirmation(txHash, confirmations) {
134− this.props.setTxnConfirmations(txHash, confirmations);
135− if (confirmations === 1) {
136− sendMail(this.state.targetEmail, this.state.inviteWallet.value.ticket)
137− .then(console.log);
265+ handleEmailInput(i, email) {
266+ let targets = this.state.targets;
267+ targets[i].email = email;
268+
269+ // if it's a valid address, check if it has received an invite before
270+ if (email.match(/.*@.*\...+/)) {
271+ hasReceived(email)
272+ .then(res => {
273+ targets[i].hasReceived = Just(res);
274+ this.setState({ targets });
275+ });
276+ } else {
277+ targets[i].hasReceived = Nothing();
138278 }
279+
280+ this.setState({ targets });
281+ }
282+
283+ canSend() {
284+ const targets = this.state.targets;
285+
286+ // may not have duplicate targets
287+ if ((new Set(targets.map(t => t.email))).size !== targets.length)
288+ return false;
289+
290+ // none may have received invites already
291+ for (let i = 0; i < targets.length; i++) {
292+ const target = targets[i];
293+ const res = target.hasReceived.matchWith({
294+ Nothing: () => true,
295+ Just: has => has.value
296+ });
297+ if (res) return false;
298+ }
299+
300+ return this.state.invitesAvailable.matchWith({
301+ Nothing: () => false,
302+ Just: invites => (invites.value > 0)
303+ });
304+ }
305+
306+ addTarget() {
307+ let targets = this.state.targets;
308+ targets.push({ email: '', hasReceived: Nothing(), status: Nothing() });
309+ this.setState({ targets });
310+ }
311+
312+ removeTarget() {
313+ let targets = this.state.targets;
314+ targets.length = targets.length - 1;
315+ this.setState({ targets });
139316 }
140317
141318 render() {
142− const inviteStatus = this.state.invitesAvailable.matchWith({
319+
320+ const dif = (this.state.status === STATUS.DONE)
321+ ? this.state.targets.length
322+ : null;
323+
324+ const invitesAvailable = this.state.invitesAvailable.matchWith({
143325 Nothing: () => 'Loading...',
144326 Just: (invites) => {
145− let res = `${invites.value} invites available`;
146− return res + this.state.fromPool.matchWith({
147− Nothing: () => '',
148− Just: (pool) => (pool.value === this.point)
149− ? ''
150− : ` (from ${ob.patp(pool.value)})`
151− });
327+ const dia = dif ? `(-${dif})` : '';
328+ return `You currently have ${invites.value}${dia} invitations left.`;
152329 }
153330 });
154331
332+ const invitesSent = this.state.invited.matchWith({
333+ Nothing: () => (<>{'Loading...'}</>),
334+ Just: (invited) => {
335+ const dit = dif ? `(+${dif})` : '';
336+ return (<>
337+ {'Out of the '}
338+ {invited.value.total + dit}
339+ {' invites you sent, '}
340+ {invited.value.accepted}
341+ {' have been accepted.'}
342+ </>);
343+ }
344+ });
155345
156− const inviteList = this.state.haveInvited.matchWith({
157− Nothing: () => (<li>{'Loading...'}</li>),
158− Just: (invited) => invited.value.map(i => {
159− return (<li>
160− <span class="invitee">{ob.patp(i.point)}</span>:
161− <span class="status">{i.active ? 'accepted' : 'pending'}</span>
162− </li>);
163− })
346+ const error = this.state.errors.matchWith({
347+ Nothing: () => null,
348+ Just: (errors) => (<Warning className={'mt-8'}>
349+ <H3 style={{marginTop: 0, paddingTop: 0}}>
350+ { 'Something went wrong!' }
351+ </H3>
352+ { errors.value.map(err => (<p>{err}</p>)) }
353+ </Warning>)
164354 });
165355
356+ const fundingMessage = this.state.fundingMessage.matchWith({
357+ Nothing: () => null,
358+ Just: msg => (<Warning className={'mt-8'}>{msg.value}</Warning>)
359+ });
360+
361+ let inputs;
362+ if (this.state.status === STATUS.DONE) {
363+ //TODO proper clickable router thing
364+ inputs = '<- Back to Home';
365+ } else {
366+
367+ const inputDisabled = (this.state.status !== STATUS.INPUT);
368+
369+ const lessButton = (
370+ <Button
371+ prop-size={'s narrow'}
372+ className={'mt-8'}
373+ disabled={inputDisabled || this.state.targets.length === 1}
374+ onClick={this.removeTarget}
375+ >
376+ { '-' }
377+ </Button>
378+ );
379+
380+ const moreButton = (
381+ <Button
382+ prop-size={'s narrow'}
383+ className={'mt-8'}
384+ disabled={
385+ inputDisabled ||
386+ this.state.invitesAvailable.matchWith({
387+ Nothing: () => true,
388+ Just: (inv) => (inv.value <= this.state.targets.length)
389+ })
390+ }
391+ onClick={this.addTarget}
392+ >
393+ { '+' }
394+ </Button>
395+ );
396+
397+ let targetInputs = [];
398+ for (let i = 0; i < this.state.targets.length; i++) {
399+ const target = this.state.targets[i];
400+ let progress = target.status.matchWith({
401+ Nothing: () => '',
402+ Just: status => status.value
403+ });
404+ targetInputs.push(<>
405+ <Input
406+ value={ target.email }
407+ onChange={ (value) => { this.handleEmailInput(i, value); } }
408+ placeholder={'Email address'}
409+ disabled={ inputDisabled }>
410+ </Input>
411+ {
412+ target.hasReceived.matchWith({
413+ Nothing: () => null,
414+ Just: (has) => has.value ? <span>{'×'}</span> : <span>{'°'}</span>
415+ })
416+ }
417+ { progress }
418+ </>);
419+ }
420+
421+ const submitButton = (
422+ <Button
423+ prop-size={'xl wide'}
424+ className={'mt-8'}
425+ disabled={ (inputDisabled && (this.state.status !== STATUS.CAN_SEND))
426+ || !this.canSend() }
427+ onClick={ () => {
428+ if (this.state.status === STATUS.INPUT)
429+ this.generateInvites();
430+ if (this.state.status === STATUS.CAN_SEND)
431+ this.sendInvites();
432+ }}
433+ >
434+ { this.state.status }
435+ </Button>
436+ );
437+
438+ inputs = (<>
439+ { lessButton }
440+ { moreButton }
441+ { targetInputs }
442+ { submitButton }
443+ </>);
444+ }
445+
166446 return (
167447 <Row>
168448 <Col>
169449
170450 <p>{ 'send invites here, for planets' }</p>
171451
172− <p>{ inviteStatus }</p>
452+ <p>{ invitesAvailable }</p>
173453
174− <ul>{ inviteList }</ul>
454+ <ul>{ invitesSent }</ul>
175455
176− <Input
177− value={ this.state.targetEmail }
178− onChange={ this.handleEmailInput }>
179− <InnerLabel>
180− { 'Recipient email' }
181− </InnerLabel>
182− </Input>
456+ { error }
457+
458+ { fundingMessage }
183459
184− <StatelessTransaction
185− // Upper scope
186− web3={this.props.web3}
187− contracts={this.props.contracts}
188− wallet={this.props.wallet}
189− walletType={this.props.walletType}
190− walletHdPath={this.props.walletHdPath}
191− networkType={this.props.networkType}
192− setTxnConfirmations={this.txnConfirmation}
193− onSent={this.props.setTxnHashCursor}
194− popRoute={this.props.popRoute}
195− pushRoute={this.props.pushRoute}
196− // Other
197− canGenerate={this.canGenerate()}
198− createUnsignedTxn={this.createUnsignedTxn}
199− ref={this.statelessRef} />
460+ { inputs }
200461
201462 </Col>
202463 </Row>