on master help sign in
Re-implement InvitesSend page
6fd0233Fang
src/bridge/views/InvitesSend.js+285−132
@@ -9,6 +9,7 @@ import { Row, Col, Warning, Button, H1, H3,
9 Input, InnerLabel
9 Input, InnerLabel
10} from '../components/Base'
10} from '../components/Base'
11import StatelessTransaction from '../components/StatelessTransaction'
11import StatelessTransaction from '../components/StatelessTransaction'
12+import { addressFromSecp256k1Public } from '../lib/wallet'
12
13
13// for wallet generation
14// for wallet generation
14import * as wg from '../../walletgen/lib/lib'
15import * as wg from '../../walletgen/lib/lib'
@@ -22,7 +23,25 @@ import {
22 hexify
23 hexify
23} from '../lib/txn'
24} from '../lib/txn'
24import * as tank from '../lib/tank'
25import * as tank from '../lib/tank'
25−import Tx from 'ethereumjs-tx'
26+
27+const GAS_PRICE = 20000000000; // we pay the premium for faster ux
28+const GAS_LIMIT = 500000; //TODO fine-tune
29+const INVITE_COST = (GAS_PRICE * GAS_LIMIT);
30+
31+const 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+}
39+
40+const EMAIL_STATUS = {
41+ BUSY: '⋯',
42+ DONE: '✔',
43+ FAIL: '×'
44+}
26
45
27class InvitesSend extends React.Component {
46class InvitesSend extends React.Component {
28
47
@@ -31,48 +50,57 @@ class InvitesSend extends React.Component {
31
50
32 this.state = {
51 this.state = {
33 invitesAvailable: Nothing(),
52 invitesAvailable: Nothing(),
34− fromPool: Nothing(),
53+ invited: Nothing(),
35− haveInvited: Nothing(),
54+ targets: [{ email: '', hasReceived: Nothing(), status: Nothing() }],
36− randomPlanet: Nothing(),
55+ status: STATUS.INPUT,
37− inviteWallet: Nothing(),
56+ //
38− targetEmail: '',
57+ invites: []
39− targetHasReceived: Nothing()
40 }
58 }
41
59
42 this.findInvited = this.findInvited.bind(this);
60 this.findInvited = this.findInvited.bind(this);
43− this.findRandomPlanet = this.findRandomPlanet.bind(this);
61+ this.generateInvites = this.generateInvites.bind(this);
44− this.generateWallet = this.generateWallet.bind(this);
62+ this.sendInvites = this.sendInvites.bind(this);
45− this.canGenerate = this.canGenerate.bind(this);
63+ this.canSend = this.canSend.bind(this);
46 this.handleEmailInput = this.handleEmailInput.bind(this);
64 this.handleEmailInput = this.handleEmailInput.bind(this);
47− 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);
48− this.beforeSend = this.beforeSend.bind(this);
68+ this.askForFunding = this.askForFunding.bind(this);
49 }
69 }
50
70
51 componentDidMount() {
71 componentDidMount() {
52 this.point = this.props.pointCursor.matchWith({
72 this.point = this.props.pointCursor.matchWith({
53 Just: (pt) => parseInt(pt.value, 10),
73 Just: (pt) => parseInt(pt.value, 10),
54 Nothing: () => {
74 Nothing: () => {
55− throw BRIDGE_ERROR.MISSING_POINT
75+ throw BRIDGE_ERROR.MISSING_POINT;
76+ }
77+ });
78+ //TODO why are we doing this locally, instead of made global on sign-in?
79+ this.address = this.props.wallet.matchWith({
80+ Just: (wal) => addressFromSecp256k1Public(wal.value.publicKey),
81+ Nothing: () => {
82+ throw BRIDGE_ERROR.MISSING_WALLET;
56 }
83 }
57 });
84 });
58 this.contracts = this.props.contracts.matchWith({
85 this.contracts = this.props.contracts.matchWith({
59 Just: cs => cs.value,
86 Just: cs => cs.value,
60− Nothing: _ => {
87+ Nothing: () => {
61− throw BRIDGE_ERROR.MISSING_CONTRACTS
88+ throw BRIDGE_ERROR.MISSING_CONTRACTS;
89+ }
90+ });
91+ this.web3 = this.props.web3.matchWith({
92+ Just: w3 => w3.value,
93+ Nothing: () => {
94+ throw BRIDGE_ERROR.MISSING_WEB3;
62 }
95 }
63 });
96 });
64
97
65− azimuth.delegatedSending.getPool(this.contracts, this.point)
98+ console.log(azimuth.delegatedSending);
99+ azimuth.delegatedSending.getTotalUsableInvites(this.contracts, this.point)
66− .then(pool => {
100+ .then(count => {
67− this.setState({fromPool: Just(pool)});
68− azimuth.delegatedSending.invitesInPool(this.contracts, pool)
69− .then(count => {
70− this.setState({invitesAvailable: Just(count)});
101+ this.setState({invitesAvailable: Just(count)});
71− });
72 });
102 });
73 this.findInvited();
103 this.findInvited();
74− this.findRandomPlanet();
75− this.generateWallet();
76 }
104 }
77
105
78 componentDidUpdate(prevProps) {
106 componentDidUpdate(prevProps) {
@@ -90,155 +118,280 @@ class InvitesSend extends React.Component {
90 return res;
118 return res;
91 });
119 });
92 invited = await Promise.all(invited);
120 invited = await Promise.all(invited);
121+ const total = invited.length;
122+ const accepted = invited.filter(i => i.active).length;
93− this.setState({haveInvited: Just(invited)});
123+ this.setState({invited: Just({total, accepted})});
94 }
124 }
95
125
96− async findRandomPlanet() {
126+ async getTemporaryWallet() {
97− let res = Nothing();
127+ const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
98− const kids = await azimuth.azimuth.getUnspawnedChildren(
128+ const owner = await wg.generateOwnershipWallet(0, ticket);
129+ return {ticket, owner: owner.keys.address};
130+ }
131+
132+ async generateInvites() {
133+ this.setState({ status: STATUS.GENERATING });
134+
135+ const web3 = this.web3;
136+ const targets = this.state.targets;
137+ console.log('getting planets');
138+ const planets = await azimuth.delegatedSending.getPlanetsToSend(
99− this.contracts,
139+ this.contracts, this.point, targets.length
100− azimuth.azimuth.getPrefix(this.point)
101 );
140 );
102− if (kids.length > 0) {
141+ console.log('got planets', planets.length);
103− const i = Math.floor(Math.random() * kids.length);
142+ if (planets.length < targets.length) {
104− res = Just(Number(kids[i]));
143+ //TODO proper display state
144+ throw new Error('can currently only send '+planets.length+' invites...');
145+ }
146+
147+ let invites = []; // { recipient, ticket, signedTx, rawTx }
148+ console.log('generating emails...');
149+ const nonce = await web3.eth.getTransactionCount(this.address);
150+ const chainId = await web3.eth.net.getId();
151+ console.log('chainId', chainId);
152+ for (let i = 0; i < targets.length; i++) {
153+ console.log('generating email', i, targets[i].email);
154+ this.setEmailStatus(i, EMAIL_STATUS.BUSY);
155+
156+ const recipient = targets[i].email;
157+ const wallet = await this.getTemporaryWallet();
158+
159+ let inviteTx = azimuth.delegatedSending.sendPoint(
160+ this.contracts, this.point, planets[i], wallet.owner
161+ );
162+ const signedTx = await signTransaction({
163+ ...this.props,
164+ txn: Just(inviteTx),
165+ gasPrice: '20', //TODO
166+ gasLimit: '600000', //TODO
167+ nonce: nonce + i,
168+ chainId: chainId,
169+ setStx: () => {}
170+ });
171+ const rawTx = '0x'+signedTx.serialize().toString('hex');
172+
173+ invites.push({recipient, ticket: wallet.ticket, signedTx, rawTx });
174+ this.setEmailStatus(i, EMAIL_STATUS.DONE);
105 }
175 }
106− this.setState({randomPlanet: res});
176+
107− return res;
177+ this.setState({ invites, status: STATUS.CAN_SEND });
108 }
178 }
109
179
110− async generateWallet() {
180+ async sendInvites() {
111− const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
181+ const invites = this.state.invites;
112− const wallet = await wg.generateWallet(0, ticket, false);
182+ const web3 = this.web3;
183+
184+ //TODO invite status isn't changing... why?
113− this.setState({inviteWallet:
185+ this.setState({
114− Just({ticket, owner:wallet.ownership.keys.address})
186+ status: STATUS.FUNDING,
187+ invites: invites.map(i => { return {email: i.email, hasReceived: i.hasReceived, status: Nothing()}; })
115 });
188 });
189+
190+ const tankWasUsed = await tank.ensureFundsFor(
191+ web3, this.point, this.address,
192+ (INVITE_COST * invites.length),
193+ invites.map(i => i.rawTx), this.askForFunding
194+ );
195+ this.setState({ message: Nothing() }); //TODO see /lib/tank waitForBalance
196+
197+ this.setState({ status: STATUS.SENDING });
198+
199+ for (let i = 0; i < invites.length; i++) {
200+ const invite = invites[i];
201+ sendSignedTransaction(web3, Just(invite.signedTx), tankWasUsed, ()=>{})
202+ .then(txHash => {
203+ waitForTransactionConfirm(web3, txHash)
204+ .then(async success => {
205+ if (success) {
206+ console.log('tx succeeded, sending mail', i);
207+ success = await sendMail(
208+ invite.recipient, invite.ticket, invite.rawTx
209+ );
210+ if (success) {
211+ this.setEmailStatus(i, EMAIL_STATUS.DONE);
212+ } else {
213+ console.log('email send failed');
214+ //TODO tell user to manually send email
215+ //TODO but this doesn't catch sender-side failures... we may
216+ // just need really good monitoring for that...
217+ this.setEmailStatus(i, EMAIL_STATUS.FAIL);
218+ }
219+ } else {
220+ console.log('invite tx rejected');
221+ //TODO properly inform user?
222+ this.setEmailStatus(i, EMAIL_STATUS.FAIL);
223+ }
224+ });
225+ })
226+ .catch(err => {
227+ console.error('invite tx sending failed', err);
228+ //TODO properly inform user?
229+ this.setEmailStatus(i, EMAIL_STATUS.FAIL);
230+ });
231+ }
232+
233+ this.setState({ status: STATUS.DONE });
234+ }
235+
236+ setEmailStatus(i, status) {
237+ this.state.targets[i].status = Just(status);
238+ this.setState({ targets: this.state.targets });
239+ }
240+
241+ askForFunding(address, minBalance, balance) {
242+ this.setState({ message: Just(
243+ `Please make sure ${address} has at least ${minBalance} wei,` +
244+ `we'll continue once that's true. Current balance: ${balance}. Waiting...`
245+ ) });
116 }
246 }
117
247
118− handleEmailInput(email) {
248+ handleEmailInput(i, email) {
119− let newState = { targetEmail: email };
249+ let targets = this.state.targets;
250+ targets[i].email = email;
251+
120 if (email.match(/.*@.*\...+/)) {
252 if (email.match(/.*@.*\...+/)) {
121 hasReceived(email)
253 hasReceived(email)
122 .then(res => {
254 .then(res => {
123− console.log('hasReceived', res);
255+ targets[i].hasReceived = Just(res);
124− this.setState({ targetHasReceived: Just(res) });
256+ this.setState({ targets });
125 });
257 });
126 } else {
258 } else {
127− newState.targetHasReceived = Nothing();
259+ targets[i].hasReceived = Nothing();
128 }
260 }
129− this.setState(newState);
261+ this.setState({ targets });
130 }
262 }
131
263
132− canGenerate() {
264+ canSend() {
133− //TODO and canSend()
265+ const targets = this.state.targets;
266+ // may not have duplicate targets
267+ if ((new Set(targets.map(t => t.email))).size !== targets.length)
268+ return false;
269+ for (let i = 0; i < targets.length; i++) {
270+ const target = targets[i];
271+ const res = target.hasReceived.matchWith({
272+ Nothing: () => true,
273+ Just: has => has.value
274+ });
275+ if (res) return false;
276+ }
134− let invites = this.state.invitesAvailable.matchWith({
277+ return this.state.invitesAvailable.matchWith({
135 Nothing: () => false,
278 Nothing: () => false,
136 Just: invites => (invites.value > 0)
279 Just: invites => (invites.value > 0)
137 });
280 });
138− let alreadyGotInvited = this.state.targetHasReceived.matchWith({
139− Nothing: () => false,
140− Just: has => has.value
141− });
142− return invites
143− && !alreadyGotInvited
144− && Just.hasInstance(this.state.randomPlanet)
145− && Just.hasInstance(this.state.inviteWallet);
146 }
281 }
147
282
148− createUnsignedTxn() {
283+ addTarget() {
149− const txn = azimuth.delegatedSending.sendPoint(
284+ let targets = this.state.targets;
150− this.contracts,
285+ let i = targets.length;
151− this.point,
286+ targets.push({ email: '', hasReceived: Nothing(), status: Nothing() });
152− this.state.randomPlanet.value,
287+ this.setState({ targets });
153− this.state.inviteWallet.value.owner
154− );
155− return Just(txn)
156 }
288 }
157
289
158− async beforeSend(stx) {
290+ removeTarget() {
159− const rawTx = hexify(stx.serialize());
291+ let targets = this.state.targets;
160− return await sendMail(
292+ targets.length = targets.length - 1;
161− this.state.targetEmail,
293+ this.setState({ targets });
162− this.state.inviteWallet.value.ticket,
163− rawTx
164− );
165 }
294 }
166
295
167 render() {
296 render() {
168− const inviteStatus = this.state.invitesAvailable.matchWith({
297+ const invitesAvailable = this.state.invitesAvailable.matchWith({
169 Nothing: () => 'Loading...',
298 Nothing: () => 'Loading...',
170 Just: (invites) => {
299 Just: (invites) => {
171− let res = `${invites.value} invites available`;
300+ return `You currently have ${invites.value} invitations left.`;
172− return res + this.state.fromPool.matchWith({
173− Nothing: () => '',
174− Just: (pool) => (pool.value === this.point)
175− ? ''
176− : ` (from ${ob.patp(pool.value)})`
177− });
178 }
301 }
179 });
302 });
180
303
181−
182− const inviteList = this.state.haveInvited.matchWith({
304+ const invitesSent = this.state.invited.matchWith({
183− Nothing: () => (<li>{'Loading...'}</li>),
305+ Nothing: () => (<>{'Loading...'}</>),
184− Just: (invited) => invited.value.map(i => {
306+ Just: (invited) => {
185− return (<li>
307+ return (<>
186− <span class="invitee">{ob.patp(i.point)}</span>:
308+ {'Out of the '}
187− <span class="status">{i.active ? 'accepted' : 'pending'}</span>
309+ {invited.value.total}
188− </li>);
310+ {' invites you sent, '}
189− })
311+ {invited.value.accepted}
190− });
312+ {' have been accepted.'}
191−
192− const recipientWarning = this.state.targetHasReceived.matchWith({
193− Nothing: () => null,
194− Just: hasReceived => {
195− if (!hasReceived.value) return null;
196− return (
197− <Warning>
198− <h3 className={'mb-2'}>{'Warning'}</h3>
199− { 'That email address has already received an invite!' }
200− </Warning>
201− );
313+ </>);
202 }
314 }
203 });
315 });
204
316
317+ //TODO don't render inputs at all if STATUS.DONE
318+
319+ const inputDisabled = (this.state.status !== STATUS.INPUT);
320+
321+ let targetInputs = [];
322+ for (let i = 0; i < this.state.targets.length; i++) {
323+ const target = this.state.targets[i];
324+ let progress = target.status.matchWith({
325+ Nothing: () => '',
326+ Just: status => status.value
327+ });
328+ targetInputs.push(<>
329+ <Input
330+ value={ target.email }
331+ onChange={ (value) => { this.handleEmailInput(i, value); } }
332+ placeholder={'Email address'}
333+ disabled={ inputDisabled }>
334+ </Input>
335+ {
336+ target.hasReceived.matchWith({
337+ Nothing: () => null,
338+ Just: (has) => has.value ? <span>{'×'}</span> : <span>{'°'}</span>
339+ })
340+ }
341+ { progress }
342+ </>);
343+ }
344+
205 return (
345 return (
206 <Row>
346 <Row>
207 <Col>
347 <Col>
208
348
209 <p>{ 'send invites here, for planets' }</p>
349 <p>{ 'send invites here, for planets' }</p>
210
350
211− <p>{ inviteStatus }</p>
351+ <p>{ invitesAvailable }</p>
212−
352+
213− <ul>{ inviteList }</ul>
353+ <ul>{ invitesSent }</ul>
214−
354+
215− <Input
355+ <Button
356+ prop-size={'s narrow'}
357+ className={'mt-8'}
358+ disabled={inputDisabled || this.state.targets.length === 1}
359+ onClick={this.removeTarget}
360+ >
361+ { '-' }
362+ </Button>
363+
364+ <Button
365+ prop-size={'s narrow'}
366+ className={'mt-8'}
367+ disabled={
368+ inputDisabled ||
369+ this.state.invitesAvailable.matchWith({
370+ Nothing: () => true,
216− value={ this.state.targetEmail }
371+ Just: (inv) => (inv.value <= this.state.targets.length)
217− onChange={ this.handleEmailInput }>
372+ })
218− <InnerLabel>
373+ }
219− { 'Recipient email' }
374+ onClick={this.addTarget}
220− </InnerLabel>
375+ >
221− </Input>
376+ { '+' }
222−
377+ </Button>
223− { recipientWarning }
378+
224−
379+ { targetInputs }
225− <StatelessTransaction
380+
226− // Upper scope
381+ <Button
227− web3={this.props.web3}
382+ prop-size={'xl wide'}
228− contracts={this.props.contracts}
383+ className={'mt-8'}
229− wallet={this.props.wallet}
384+ disabled={ (inputDisabled && (this.state.status !== STATUS.CAN_SEND))
230− walletType={this.props.walletType}
385+ || !this.canSend() }
231− walletHdPath={this.props.walletHdPath}
386+ onClick={ () => {
232− networkType={this.props.networkType}
387+ if (this.state.status === STATUS.INPUT)
233− beforeSend={this.beforeSend}
388+ this.generateInvites();
234− onSent={this.props.setTxnHashCursor}
389+ if (this.state.status === STATUS.CAN_SEND)
235− setTxnConfirmations={this.props.setTxnConfirmations}
390+ this.sendInvites();
236− popRoute={this.props.popRoute}
391+ }}
237− pushRoute={this.props.pushRoute}
392+ >
238− // Other
393+ { this.state.status }
239− canGenerate={this.canGenerate()}
394+ </Button>
240− createUnsignedTxn={this.createUnsignedTxn}
241− ref={this.statelessRef} />
242
395
243 </Col>
396 </Col>
244 </Row>
397 </Row>