on master help sign in
Re-implement InvitesSend page

To be functionally closer to the new design. Still some TODOs, still not pretty at all.

commit6fd0233baea94ad1f0fc93df85998f23c58bed05 authorFang <git@fang.io> dateWed 5 Jun 2019, 17:03 (7 years ago) parente9c2016
1 file changed+285−132
src/bridge/views/InvitesSend.js+285−132
src/bridge/views/InvitesSend.js+285−132
@@ -9,6 +9,7 @@ import { Row, Col, Warning, Button, H1, H3,
99 Input, InnerLabel
1010} from '../components/Base'
1111import StatelessTransaction from '../components/StatelessTransaction'
12+import { addressFromSecp256k1Public } from '../lib/wallet'
1213
1314// for wallet generation
1415import * as wg from '../../walletgen/lib/lib'
@@ -22,7 +23,25 @@ import {
2223 hexify
2324} from '../lib/txn'
2425import * 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+}
2645
2746class InvitesSend extends React.Component {
2847
@@ -31,48 +50,57 @@ class InvitesSend extends React.Component {
3150
3251 this.state = {
3352 invitesAvailable: Nothing(),
34− fromPool: Nothing(),
35− haveInvited: Nothing(),
36− randomPlanet: Nothing(),
37− inviteWallet: Nothing(),
38− targetEmail: '',
39− targetHasReceived: Nothing()
53+ invited: Nothing(),
54+ targets: [{ email: '', hasReceived: Nothing(), status: Nothing() }],
55+ status: STATUS.INPUT,
56+ //
57+ invites: []
4058 }
4159
4260 this.findInvited = this.findInvited.bind(this);
43− this.findRandomPlanet = this.findRandomPlanet.bind(this);
44− this.generateWallet = this.generateWallet.bind(this);
45− this.canGenerate = this.canGenerate.bind(this);
61+ this.generateInvites = this.generateInvites.bind(this);
62+ this.sendInvites = this.sendInvites.bind(this);
63+ this.canSend = this.canSend.bind(this);
4664 this.handleEmailInput = this.handleEmailInput.bind(this);
47− this.createUnsignedTxn = this.createUnsignedTxn.bind(this);
48− this.beforeSend = this.beforeSend.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);
4969 }
5070
5171 componentDidMount() {
5272 this.point = this.props.pointCursor.matchWith({
5373 Just: (pt) => parseInt(pt.value, 10),
5474 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;
5683 }
5784 });
5885 this.contracts = this.props.contracts.matchWith({
5986 Just: cs => cs.value,
60− Nothing: _ => {
61− throw BRIDGE_ERROR.MISSING_CONTRACTS
87+ Nothing: () => {
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;
6295 }
6396 });
6497
65− azimuth.delegatedSending.getPool(this.contracts, this.point)
66− .then(pool => {
67− this.setState({fromPool: Just(pool)});
68− azimuth.delegatedSending.invitesInPool(this.contracts, pool)
69− .then(count => {
70− this.setState({invitesAvailable: Just(count)});
71− });
98+ console.log(azimuth.delegatedSending);
99+ azimuth.delegatedSending.getTotalUsableInvites(this.contracts, this.point)
100+ .then(count => {
101+ this.setState({invitesAvailable: Just(count)});
72102 });
73103 this.findInvited();
74− this.findRandomPlanet();
75− this.generateWallet();
76104 }
77105
78106 componentDidUpdate(prevProps) {
@@ -90,155 +118,280 @@ class InvitesSend extends React.Component {
90118 return res;
91119 });
92120 invited = await Promise.all(invited);
93− this.setState({haveInvited: Just(invited)});
121+ const total = invited.length;
122+ const accepted = invited.filter(i => i.active).length;
123+ this.setState({invited: Just({total, accepted})});
94124 }
95125
96− async findRandomPlanet() {
97− let res = Nothing();
98− const kids = await azimuth.azimuth.getUnspawnedChildren(
99− this.contracts,
100− azimuth.azimuth.getPrefix(this.point)
126+ async getTemporaryWallet() {
127+ const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
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(
139+ this.contracts, this.point, targets.length
101140 );
102− if (kids.length > 0) {
103− const i = Math.floor(Math.random() * kids.length);
104− res = Just(Number(kids[i]));
141+ console.log('got planets', planets.length);
142+ if (planets.length < targets.length) {
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);
105175 }
106− this.setState({randomPlanet: res});
107− return res;
176+
177+ this.setState({ invites, status: STATUS.CAN_SEND });
108178 }
109179
110− async generateWallet() {
111− const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
112− const wallet = await wg.generateWallet(0, ticket, false);
113− this.setState({inviteWallet:
114− Just({ticket, owner:wallet.ownership.keys.address})
180+ async sendInvites() {
181+ const invites = this.state.invites;
182+ const web3 = this.web3;
183+
184+ //TODO invite status isn't changing... why?
185+ this.setState({
186+ status: STATUS.FUNDING,
187+ invites: invites.map(i => { return {email: i.email, hasReceived: i.hasReceived, status: Nothing()}; })
115188 });
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+ ) });
116246 }
117247
118− handleEmailInput(email) {
119− let newState = { targetEmail: email };
248+ handleEmailInput(i, email) {
249+ let targets = this.state.targets;
250+ targets[i].email = email;
251+
120252 if (email.match(/.*@.*\...+/)) {
121253 hasReceived(email)
122254 .then(res => {
123− console.log('hasReceived', res);
124− this.setState({ targetHasReceived: Just(res) });
255+ targets[i].hasReceived = Just(res);
256+ this.setState({ targets });
125257 });
126258 } else {
127− newState.targetHasReceived = Nothing();
259+ targets[i].hasReceived = Nothing();
128260 }
129− this.setState(newState);
261+ this.setState({ targets });
130262 }
131263
132− canGenerate() {
133− //TODO and canSend()
134− let invites = this.state.invitesAvailable.matchWith({
264+ 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+ }
277+ return this.state.invitesAvailable.matchWith({
135278 Nothing: () => false,
136279 Just: invites => (invites.value > 0)
137280 });
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);
146281 }
147282
148− createUnsignedTxn() {
149− const txn = azimuth.delegatedSending.sendPoint(
150− this.contracts,
151− this.point,
152− this.state.randomPlanet.value,
153− this.state.inviteWallet.value.owner
154− );
155− return Just(txn)
283+ addTarget() {
284+ let targets = this.state.targets;
285+ let i = targets.length;
286+ targets.push({ email: '', hasReceived: Nothing(), status: Nothing() });
287+ this.setState({ targets });
156288 }
157289
158− async beforeSend(stx) {
159− const rawTx = hexify(stx.serialize());
160− return await sendMail(
161− this.state.targetEmail,
162− this.state.inviteWallet.value.ticket,
163− rawTx
164− );
290+ removeTarget() {
291+ let targets = this.state.targets;
292+ targets.length = targets.length - 1;
293+ this.setState({ targets });
165294 }
166295
167296 render() {
168− const inviteStatus = this.state.invitesAvailable.matchWith({
297+ const invitesAvailable = this.state.invitesAvailable.matchWith({
169298 Nothing: () => 'Loading...',
170299 Just: (invites) => {
171− let res = `${invites.value} invites available`;
172− return res + this.state.fromPool.matchWith({
173− Nothing: () => '',
174− Just: (pool) => (pool.value === this.point)
175− ? ''
176− : ` (from ${ob.patp(pool.value)})`
177− });
300+ return `You currently have ${invites.value} invitations left.`;
178301 }
179302 });
180303
181−
182− const inviteList = this.state.haveInvited.matchWith({
183− Nothing: () => (<li>{'Loading...'}</li>),
184− Just: (invited) => invited.value.map(i => {
185− return (<li>
186− <span class="invitee">{ob.patp(i.point)}</span>:
187− <span class="status">{i.active ? 'accepted' : 'pending'}</span>
188− </li>);
189− })
190− });
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− );
304+ const invitesSent = this.state.invited.matchWith({
305+ Nothing: () => (<>{'Loading...'}</>),
306+ Just: (invited) => {
307+ return (<>
308+ {'Out of the '}
309+ {invited.value.total}
310+ {' invites you sent, '}
311+ {invited.value.accepted}
312+ {' have been accepted.'}
313+ </>);
202314 }
203315 });
204316
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+
205345 return (
206346 <Row>
207347 <Col>
208348
209349 <p>{ 'send invites here, for planets' }</p>
210350
211− <p>{ inviteStatus }</p>
212−
213− <ul>{ inviteList }</ul>
214−
215− <Input
216− value={ this.state.targetEmail }
217− onChange={ this.handleEmailInput }>
218− <InnerLabel>
219− { 'Recipient email' }
220− </InnerLabel>
221− </Input>
222−
223− { recipientWarning }
224−
225− <StatelessTransaction
226− // Upper scope
227− web3={this.props.web3}
228− contracts={this.props.contracts}
229− wallet={this.props.wallet}
230− walletType={this.props.walletType}
231− walletHdPath={this.props.walletHdPath}
232− networkType={this.props.networkType}
233− beforeSend={this.beforeSend}
234− onSent={this.props.setTxnHashCursor}
235− setTxnConfirmations={this.props.setTxnConfirmations}
236− popRoute={this.props.popRoute}
237− pushRoute={this.props.pushRoute}
238− // Other
239− canGenerate={this.canGenerate()}
240− createUnsignedTxn={this.createUnsignedTxn}
241− ref={this.statelessRef} />
351+ <p>{ invitesAvailable }</p>
352+
353+ <ul>{ invitesSent }</ul>
354+
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,
371+ Just: (inv) => (inv.value <= this.state.targets.length)
372+ })
373+ }
374+ onClick={this.addTarget}
375+ >
376+ { '+' }
377+ </Button>
378+
379+ { targetInputs }
380+
381+ <Button
382+ prop-size={'xl wide'}
383+ className={'mt-8'}
384+ disabled={ (inputDisabled && (this.state.status !== STATUS.CAN_SEND))
385+ || !this.canSend() }
386+ onClick={ () => {
387+ if (this.state.status === STATUS.INPUT)
388+ this.generateInvites();
389+ if (this.state.status === STATUS.CAN_SEND)
390+ this.sendInvites();
391+ }}
392+ >
393+ { this.state.status }
394+ </Button>
242395
243396 </Col>
244397 </Row>