on master help sign in
flows: implement sending an invite

Introduces "flows" that can be selected by writing to window.flow. Supports passing in success and failure callbacks. Implements such a flow for sending an invite containing a specific planet to send. Borrows lots of logic from useInviter. Should be refactored to share it. Might be worth trying to refactor JustInvite and PassportTransfer to share their "render progress screen" logic.

commitd8089610bcf24bb311b9db5d6d39c503d3367075 authorFang <git@fang.io> dateThu 20 Aug 2020, 01:36 (6 years ago) parent14ac3f1
6 files changed+363−8
src/lib/flowCommand.js+49 src/lib/routeNames.js+2 src/lib/router.js+4 src/lib/useInviter.js+1 src/views/Login.js+48−8 src/views/flows/JustInvite.js+259
src/lib/flowCommand.jsnew file+49
@@ -0,0 +1,49 @@
1+import ob from 'urbit-ob';
2+
3+import { ensurePatFormat } from 'form/formatters';
4+
5+// looks into the window context to find a "flow command", an object indicating
6+// if bridge is to be used for one specific flow.
7+// expects to find objects of the following shape, varying by kind:
8+//
9+// { kind: 'invite',
10+// as: '~ship', // who to send the invite as
11+// ship: '~ship', // what planet to send as the invite
12+// email: 'ex@amp.le' // email to send the invite code to
13+// }
14+//
15+// you may optionally include .success() and .failure(reason) callbacks.
16+
17+const COMMANDS = {
18+ INVITE: 'invite',
19+};
20+
21+const useFlowCommand = () => {
22+ let flow = window.flow;
23+
24+ if (typeof flow === 'object') {
25+ flow.success = flow.success || (() => {});
26+ flow.failure = flow.failure || (() => {});
27+ switch (flow.kind) {
28+ case COMMANDS.INVITE:
29+ flow.as = ensurePatFormat(flow.as);
30+ flow.ship = ensurePatFormat(flow.ship);
31+ if (!ob.isValidPatp(flow.as) || !ob.isValidPatp(flow.ship)) {
32+ console.log('invalid inviting flow arguments', flow.as, flow.ship);
33+ flow = null;
34+ }
35+ break;
36+ //
37+ default:
38+ console.log('unrecognized kind of flow:', flow.kind);
39+ flow = null;
40+ break;
41+ }
42+ } else {
43+ flow = null;
44+ }
45+
46+ return flow;
47+};
48+
49+export { COMMANDS, useFlowCommand };
src/lib/routeNames.js+2
@@ -27,4 +27,6 @@ export const ROUTE_NAMES = {
2727 URBIT_ID: Symbol('URBIT_ID'),
2828 RESIDENTS: Symbol('RESIDENTS'),
2929 ADOPT: Symbol('ADOPT'),
30+ //
31+ JUST_INVITE: Symbol('JUST_INVITE'),
3032};
src/lib/router.js+4
@@ -19,6 +19,8 @@ import UrbitID from 'views/UrbitID';
1919import Residents from 'views/Residents';
2020import Adopt from 'views/Adopt';
2121
22+import JustInvite from 'views/flows/JustInvite';
23+
2224import { ROUTE_NAMES } from './routeNames';
2325
2426export const ROUTES = {
@@ -42,4 +44,6 @@ export const ROUTES = {
4244 [ROUTE_NAMES.URBIT_ID]: UrbitID,
4345 [ROUTE_NAMES.RESIDENTS]: Residents,
4446 [ROUTE_NAMES.ADOPT]: Adopt,
47+ //
48+ [ROUTE_NAMES.JUST_INVITE]: JustInvite,
4549};
src/lib/useInviter.js+1
@@ -61,6 +61,7 @@ const useInviter = () => {
6161 setTxStatus(STATUS.INPUT);
6262 }, [setProgress, setInvites, setTxStatus]);
6363
64+ //TODO split this out into smaller callbacks within useInviter
6465 const generateInvites = useCallback(
6566 async numInvites => {
6667 const _contracts = contracts.getOrElse(null);
src/views/Login.js+48−8
@@ -1,12 +1,16 @@
11import React, { useCallback, useState } from 'react';
22import { P, H4, H5, Grid, Text, Button, Flex, LinkButton } from 'indigo-react';
3+import { Just } from 'folktale/maybe';
4+import ob from 'urbit-ob';
35
46import { version } from '../../package.json';
57
68import { useHistory } from 'store/history';
79import { useWallet } from 'store/wallet';
10+import { usePointCursor } from 'store/pointCursor';
811
912import { WALLET_TYPES } from 'lib/wallet';
13+import { COMMANDS, useFlowCommand } from 'lib/flowCommand';
1014
1115import View from 'components/View';
1216import Tabs from 'components/Tabs';
@@ -43,6 +47,8 @@ export default function Login() {
4347 // globals
4448 const { pop, push, names } = useHistory();
4549 const { walletType } = useWallet();
50+ const { setPointCursor } = usePointCursor();
51+ const flow = useFlowCommand();
4652
4753 // inputs
4854 const [isOther, setisOther] = useState(false);
@@ -53,8 +59,33 @@ export default function Login() {
5359 ]);
5460
5561 const goHome = useCallback(() => {
56− push(names.POINTS);
57− }, [push, names]);
62+ if (!flow) {
63+ push(names.POINTS);
64+ } else {
65+ switch (flow.kind) {
66+ case COMMANDS.INVITE:
67+ setPointCursor(Just(ob.patp2dec(flow.as)));
68+ push(names.JUST_INVITE);
69+ break;
70+ //
71+ default:
72+ throw new Error('unimplemented flow ' + flow.kind);
73+ break;
74+ }
75+ }
76+ }, [flow, push, names]);
77+
78+ const flowDescription = command => {
79+ switch (command.kind) {
80+ case COMMANDS.INVITE:
81+ return (
82+ <>
83+ To send an invite, please sign in as <b>{command.as}</b>.
84+ </>
85+ );
86+ break;
87+ }
88+ };
5889
5990 return (
6091 <View inset>
@@ -65,8 +96,12 @@ export default function Login() {
6596 </Grid.Item>
6697 <Grid.Item as={Text}>Login</Grid.Item>
6798 </Grid.Item>
68− {isOther && <Grid.Item full as={Other} goHome={goHome} />}
69− {!isOther && <Grid.Item full as={Ticket} goHome={goHome} />}
99+ {flow && (
100+ <Grid.Item full as={Text} className="t-center mb4">
101+ {flowDescription(flow)}
102+ </Grid.Item>
103+ )}
104+ <Grid.Item full as={isOther ? Other : Ticket} goHome={goHome} />
70105 {!isOther && (
71106 <>
72107 <Grid.Item full className="t-center mv4 gray4">
@@ -80,10 +115,15 @@ export default function Login() {
80115 onClick={() => setisOther(true)}>
81116 Metamask, Mnemonic, Hardware Wallet...
82117 </Grid.Item>
83− <Grid.Item full onClick={goToActivate} className="mv10 t-center f6">
84− <span className="gray4">New Urbit ID? </span>
85− <LinkButton>Activate</LinkButton>
86− </Grid.Item>
118+ {!flow && (
119+ <Grid.Item
120+ full
121+ onClick={goToActivate}
122+ className="mv10 t-center f6">
123+ <span className="gray4">New Urbit ID? </span>
124+ <LinkButton>Activate</LinkButton>
125+ </Grid.Item>
126+ )}
87127 </>
88128 )}
89129 {isOther && (
src/views/flows/JustInvite.jsnew file+259
@@ -0,0 +1,259 @@
1+//NOTE repurposed from /views/Activate/PassportTransfer,
2+// with a lot of logic from /lib/useInviter
3+
4+//TODO display "you may now _close this window_" cta after calling a flow cb
5+//TODO make sure we also call failure cb if we get closed prematurely
6+
7+import React, { useState, useCallback } from 'react';
8+import { Grid, Text, ErrorText, H4 } from 'indigo-react';
9+import cn from 'classnames';
10+import ob from 'urbit-ob';
11+import { toBN } from 'web3-utils';
12+
13+import { useNetwork } from 'store/network';
14+import { useWallet } from 'store/wallet';
15+
16+import * as wg from 'lib/walletgen';
17+import * as tank from 'lib/tank';
18+import { GAS_LIMITS } from 'lib/constants';
19+import {
20+ signTransaction,
21+ sendSignedTransaction,
22+ waitForTransactionConfirm,
23+ hexify,
24+} from 'lib/txn';
25+import useGasPrice from 'lib/useGasPrice';
26+import useLifecycle from 'lib/useLifecycle';
27+import useBlockWindowClose from 'lib/useBlockWindowClose';
28+import { useFlowCommand } from 'lib/flowCommand';
29+import { hasReceived, sendMail } from 'lib/inviteMail';
30+import { safeToWei } from 'lib/lib';
31+
32+import LoadingBar from 'components/LoadingBar';
33+import { RestartButton } from 'components/Buttons';
34+import NeedFundsNotice from 'components/NeedFundsNotice';
35+
36+import * as az from 'azimuth-js';
37+
38+const labelForProgress = progress => {
39+ if (progress <= 0) {
40+ return 'Checking sanity...';
41+ } else if (progress <= 0.2) {
42+ return 'Generating invite...';
43+ } else if (progress <= 0.3) {
44+ return 'Building transaction...';
45+ } else if (progress <= 0.4) {
46+ return 'Signing transaction...';
47+ } else if (progress <= 0.5) {
48+ return 'Funding transaction...';
49+ } else if (progress <= 0.7) {
50+ return 'Sending transaction...';
51+ } else if (progress <= 0.9) {
52+ return 'Sending invite email...';
53+ } else {
54+ return 'Done';
55+ }
56+};
57+
58+export default function JustInvite({ className, resetActivateRouter }) {
59+ const { wallet, walletType, walletHdPath, authToken } = useWallet();
60+ const flow = useFlowCommand();
61+ const { web3, contracts, networkType } = useNetwork();
62+ const { gasPrice } = useGasPrice();
63+ const [generalError, setGeneralError] = useState();
64+ const [progress, setProgress] = useState(0);
65+ const [needFunds, setNeedFunds] = useState(null);
66+
67+ useBlockWindowClose();
68+
69+ // useEffect(() => {
70+ // switch (type) {
71+ // case 'progress':
72+ // return setProcesss(state);
73+ // case 'askFunding':
74+ // return setNeedFunds(value);
75+ // case 'gotFunding':
76+ // return setNeedFunds(false);
77+ // default:
78+ // console.error(`Unknown update: ${type}`);
79+ // }
80+ // }, [progress]);
81+
82+ const sendInvite = useCallback(async () => {
83+ setGeneralError(false);
84+
85+ try {
86+ const _contracts = contracts.getOrElse(null);
87+ const _web3 = web3.getOrElse(null);
88+ const _wallet = wallet.getOrElse(null);
89+ const _authToken = authToken.getOrElse(null);
90+ if (!_contracts || !_web3 || !_wallet || !_authToken) {
91+ // not using need because we want a custom error
92+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
93+ }
94+
95+ const _as = ob.patp2dec(flow.as);
96+ const _ship = ob.patp2dec(flow.ship);
97+ const _email = flow.email;
98+
99+ console.log('xx checking email');
100+ // check if email not invited before
101+ if (await hasReceived(_email)) {
102+ throw new Error(
103+ `This email (${_email}) has already received an invite.`
104+ );
105+ }
106+ setProgress(0.1);
107+
108+ console.log('xx checking owner', _wallet.address, _as);
109+ // check if logged in with flow.as's owner
110+ //TODO we could move this check to the login screen
111+ if (!(await az.azimuth.isOwner(_contracts, _as, _wallet.address))) {
112+ throw new Error(`You are not logged in as ${flow.as}'s owner.'`);
113+ }
114+ setProgress(0.2);
115+
116+ // generate invite code + address
117+ const { ticket, owner } = await wg.generateTemporaryDeterministicWallet(
118+ _ship,
119+ _authToken
120+ );
121+ setProgress(0.3);
122+
123+ // generate invite tx
124+ const inviteTx = az.delegatedSending.sendPoint(
125+ _contracts,
126+ _as,
127+ _ship,
128+ owner.keys.address
129+ );
130+ setProgress(0.4);
131+
132+ // sign invite tx
133+ const signedTx = await signTransaction({
134+ wallet: _wallet,
135+ walletType,
136+ walletHdPath,
137+ networkType,
138+ chainId: await _web3.eth.net.getId(),
139+ nonce: await _web3.eth.getTransactionCount(_wallet.address),
140+ txn: inviteTx,
141+ gasPrice: gasPrice.toFixed(), // expects string gwei
142+ gasLimit: GAS_LIMITS.GIFT_PLANET.toFixed(),
143+ });
144+ const rawTx = hexify(signedTx.serialize());
145+ setProgress(0.5);
146+
147+ // ensure funds
148+ const totalCost = toBN(GAS_LIMITS.GIFT_PLANET).mul(toBN(gasPrice));
149+ const tankWasUsed = await tank.ensureFundsFor(
150+ _web3,
151+ _as,
152+ _wallet.address,
153+ walletType,
154+ safeToWei(totalCost, 'gwei'),
155+ [rawTx],
156+ (address, minBalance, balance) =>
157+ setNeedFunds({ address, minBalance, balance }),
158+ () => setNeedFunds(undefined)
159+ );
160+ setProgress(0.7);
161+
162+ // send and await transaction
163+ const txHash = await sendSignedTransaction(_web3, signedTx, tankWasUsed);
164+ await waitForTransactionConfirm(_web3, txHash);
165+ setProgress(0.9);
166+
167+ // send invite email
168+ //NOTE we could chuck a "pls join group-name" message in here
169+ await sendMail(_email, ticket, flow.as, '', rawTx);
170+ setProgress(1);
171+
172+ flow.success();
173+ } catch (error) {
174+ // some generic error
175+ setGeneralError(error);
176+ flow.failure(error);
177+ }
178+ }, [
179+ wallet,
180+ web3,
181+ contracts,
182+ networkType,
183+ authToken,
184+ flow,
185+ gasPrice,
186+ walletHdPath,
187+ walletType,
188+ ]);
189+
190+ useLifecycle(() => {
191+ sendInvite();
192+ });
193+
194+ const goToRestart = useCallback(() => {
195+ sendInvite();
196+ }, [sendInvite]);
197+
198+ const renderAdditionalInfo = () => {
199+ if (generalError) {
200+ console.log(generalError);
201+ let msg =
202+ typeof generalError === 'string'
203+ ? generalError
204+ : generalError.message || 'Something went wrong!';
205+ return (
206+ <>
207+ <Grid.Item full className="mt8">
208+ <ErrorText>{msg}</ErrorText>
209+ </Grid.Item>
210+ <Grid.Item
211+ full
212+ className="mt3"
213+ as={RestartButton}
214+ solid
215+ onClick={goToRestart}>
216+ Restart
217+ </Grid.Item>
218+ </>
219+ );
220+ }
221+
222+ if (needFunds) {
223+ return (
224+ <Grid.Item full as={NeedFundsNotice} className="mt8" {...needFunds} />
225+ );
226+ }
227+
228+ // if (progress < 1.0) {
229+ // return (
230+ // <Grid.Item full as={WarningBox} className="mt8">
231+ // Never give your Master Ticket to anyone
232+ // </Grid.Item>
233+ // );
234+ // }
235+
236+ return null;
237+ };
238+
239+ return (
240+ <Grid className={cn(className, 'auto-rows-min')}>
241+ <Grid.Item full as={H4}>
242+ {labelForProgress(progress)}
243+ </Grid.Item>
244+ <Grid>
245+ <Grid.Item full as={Grid} className="mt3" gap={3}>
246+ <Grid.Item full as={LoadingBar} progress={progress} />
247+ <Grid.Item full>
248+ <Text className="f5 green4">
249+ This process can take up to 5 minutes to complete. Don't close
250+ this page until the process is complete.
251+ </Text>
252+ </Grid.Item>
253+ </Grid.Item>
254+
255+ {renderAdditionalInfo()}
256+ </Grid>
257+ </Grid>
258+ );
259+}