on master help sign in
flows: implement sending an invite
d808961Fang
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 = {
27 URBIT_ID: Symbol('URBIT_ID'),
27 URBIT_ID: Symbol('URBIT_ID'),
28 RESIDENTS: Symbol('RESIDENTS'),
28 RESIDENTS: Symbol('RESIDENTS'),
29 ADOPT: Symbol('ADOPT'),
29 ADOPT: Symbol('ADOPT'),
30+ //
31+ JUST_INVITE: Symbol('JUST_INVITE'),
30};
32};
src/lib/router.js+4
@@ -19,6 +19,8 @@ import UrbitID from 'views/UrbitID';
19import Residents from 'views/Residents';
19import Residents from 'views/Residents';
20import Adopt from 'views/Adopt';
20import Adopt from 'views/Adopt';
21
21
22+import JustInvite from 'views/flows/JustInvite';
23+
22import { ROUTE_NAMES } from './routeNames';
24import { ROUTE_NAMES } from './routeNames';
23
25
24export const ROUTES = {
26export const ROUTES = {
@@ -42,4 +44,6 @@ export const ROUTES = {
42 [ROUTE_NAMES.URBIT_ID]: UrbitID,
44 [ROUTE_NAMES.URBIT_ID]: UrbitID,
43 [ROUTE_NAMES.RESIDENTS]: Residents,
45 [ROUTE_NAMES.RESIDENTS]: Residents,
44 [ROUTE_NAMES.ADOPT]: Adopt,
46 [ROUTE_NAMES.ADOPT]: Adopt,
47+ //
48+ [ROUTE_NAMES.JUST_INVITE]: JustInvite,
45};
49};
src/lib/useInviter.js+1
@@ -61,6 +61,7 @@ const useInviter = () => {
61 setTxStatus(STATUS.INPUT);
61 setTxStatus(STATUS.INPUT);
62 }, [setProgress, setInvites, setTxStatus]);
62 }, [setProgress, setInvites, setTxStatus]);
63
63
64+ //TODO split this out into smaller callbacks within useInviter
64 const generateInvites = useCallback(
65 const generateInvites = useCallback(
65 async numInvites => {
66 async numInvites => {
66 const _contracts = contracts.getOrElse(null);
67 const _contracts = contracts.getOrElse(null);
src/views/Login.js+48−8
@@ -1,12 +1,16 @@
1import React, { useCallback, useState } from 'react';
1import React, { useCallback, useState } from 'react';
2import { P, H4, H5, Grid, Text, Button, Flex, LinkButton } from 'indigo-react';
2import { P, H4, H5, Grid, Text, Button, Flex, LinkButton } from 'indigo-react';
3+import { Just } from 'folktale/maybe';
4+import ob from 'urbit-ob';
3
5
4import { version } from '../../package.json';
6import { version } from '../../package.json';
5
7
6import { useHistory } from 'store/history';
8import { useHistory } from 'store/history';
7import { useWallet } from 'store/wallet';
9import { useWallet } from 'store/wallet';
10+import { usePointCursor } from 'store/pointCursor';
8
11
9import { WALLET_TYPES } from 'lib/wallet';
12import { WALLET_TYPES } from 'lib/wallet';
13+import { COMMANDS, useFlowCommand } from 'lib/flowCommand';
10
14
11import View from 'components/View';
15import View from 'components/View';
12import Tabs from 'components/Tabs';
16import Tabs from 'components/Tabs';
@@ -43,6 +47,8 @@ export default function Login() {
43 // globals
47 // globals
44 const { pop, push, names } = useHistory();
48 const { pop, push, names } = useHistory();
45 const { walletType } = useWallet();
49 const { walletType } = useWallet();
50+ const { setPointCursor } = usePointCursor();
51+ const flow = useFlowCommand();
46
52
47 // inputs
53 // inputs
48 const [isOther, setisOther] = useState(false);
54 const [isOther, setisOther] = useState(false);
@@ -53,8 +59,33 @@ export default function Login() {
53 ]);
59 ]);
54
60
55 const goHome = useCallback(() => {
61 const goHome = useCallback(() => {
62+ if (!flow) {
56− push(names.POINTS);
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+ }
57− }, [push, names]);
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+ };
58
89
59 return (
90 return (
60 <View inset>
91 <View inset>
@@ -65,8 +96,12 @@ export default function Login() {
65 </Grid.Item>
96 </Grid.Item>
66 <Grid.Item as={Text}>Login</Grid.Item>
97 <Grid.Item as={Text}>Login</Grid.Item>
67 </Grid.Item>
98 </Grid.Item>
68− {isOther && <Grid.Item full as={Other} goHome={goHome} />}
99+ {flow && (
100+ <Grid.Item full as={Text} className="t-center mb4">
101+ {flowDescription(flow)}
102+ </Grid.Item>
103+ )}
69− {!isOther && <Grid.Item full as={Ticket} goHome={goHome} />}
104+ <Grid.Item full as={isOther ? Other : Ticket} goHome={goHome} />
70 {!isOther && (
105 {!isOther && (
71 <>
106 <>
72 <Grid.Item full className="t-center mv4 gray4">
107 <Grid.Item full className="t-center mv4 gray4">
@@ -80,10 +115,15 @@ export default function Login() {
80 onClick={() => setisOther(true)}>
115 onClick={() => setisOther(true)}>
81 Metamask, Mnemonic, Hardware Wallet...
116 Metamask, Mnemonic, Hardware Wallet...
82 </Grid.Item>
117 </Grid.Item>
118+ {!flow && (
119+ <Grid.Item
120+ full
121+ onClick={goToActivate}
83− <Grid.Item full onClick={goToActivate} className="mv10 t-center f6">
122+ className="mv10 t-center f6">
84− <span className="gray4">New Urbit ID? </span>
123+ <span className="gray4">New Urbit ID? </span>
85− <LinkButton>Activate</LinkButton>
124+ <LinkButton>Activate</LinkButton>
86− </Grid.Item>
125+ </Grid.Item>
126+ )}
87 </>
127 </>
88 )}
128 )}
89 {isOther && (
129 {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+}