on master help sign in
Merge pull request #221 from urbit/feat/final-form
2bf1af9Fang
package-lock.json+74−1
@@ -2143,6 +2143,14 @@
2143 "@xtuc/long": "4.2.2"
2143 "@xtuc/long": "4.2.2"
2144 }
2144 }
2145 },
2145 },
2146+ "@welldone-software/why-did-you-render": {
2147+ "version": "3.2.3",
2148+ "resolved": "https://registry.npmjs.org/@welldone-software/why-did-you-render/-/why-did-you-render-3.2.3.tgz",
2149+ "integrity": "sha512-dUMkjsVsCfIo+IEmiMyb/FGKVbXrzbGqLvDTCn0ad5drNmD+BGoU/7Z+Nc7ckkV0F1G4vzs1XQrAcIxyPa8Ssw==",
2150+ "requires": {
2151+ "lodash": "^4"
2152+ }
2153+ },
2146 "@xtuc/ieee754": {
2154 "@xtuc/ieee754": {
2147 "version": "1.2.0",
2155 "version": "1.2.0",
2148 "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
2156 "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -2160,7 +2168,7 @@
2160 "dev": true
2168 "dev": true
2161 },
2169 },
2162 "PaperCollateralRenderer": {
2170 "PaperCollateralRenderer": {
2163− "version": "github:urbit/PaperCollateralRenderer#27bd1381529618fb067a35575f8191d04ecfe10b",
2171+ "version": "github:urbit/PaperCollateralRenderer#8478aaf397e12769171b7b14332c47e4457e1d46",
2164 "from": "github:urbit/PaperCollateralRenderer#rc-immediate",
2172 "from": "github:urbit/PaperCollateralRenderer#rc-immediate",
2165 "requires": {
2173 "requires": {
2166 "babel-polyfill": "^6.26.0",
2174 "babel-polyfill": "^6.26.0",
@@ -6918,6 +6926,24 @@
6918 }
6926 }
6919 }
6927 }
6920 },
6928 },
6929+ "final-form": {
6930+ "version": "4.18.4",
6931+ "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.18.4.tgz",
6932+ "integrity": "sha512-UUymL6UykjwO2yUN3EhBdw8ajaa448/CczgXvLcyXwbHRjWbA3Yjdxm6WSHQBx4pLv4iEqkvmPRnQ+xmS9GUcA==",
6933+ "requires": {
6934+ "@babel/runtime": "^7.3.1"
6935+ }
6936+ },
6937+ "final-form-arrays": {
6938+ "version": "3.0.1",
6939+ "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.0.1.tgz",
6940+ "integrity": "sha512-GKXecufCNCjDcz1+3peL21LuuTlApoxCcnpOnmfeJfC3xAlFKGdytYMfifP7W1IEWTGC8twTv3zItESkej8qpg=="
6941+ },
6942+ "final-form-set-field-data": {
6943+ "version": "1.0.2",
6944+ "resolved": "https://registry.npmjs.org/final-form-set-field-data/-/final-form-set-field-data-1.0.2.tgz",
6945+ "integrity": "sha512-gAnENimyQ5GW3OEGca5pbwm4lYshW2orzfBlPUYqzcm7ZxkQrVO8FqCAgEcCM+Rq9U1OU0q+D+UkqETvvDY6jw=="
6946+ },
6921 "finalhandler": {
6947 "finalhandler": {
6922 "version": "1.1.1",
6948 "version": "1.1.1",
6923 "resolved": "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
6949 "resolved": "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
@@ -14511,6 +14537,43 @@
14511 "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-5.1.6.tgz",
14537 "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-5.1.6.tgz",
14512 "integrity": "sha512-X1Y+0jR47ImDVr54Ab6V9eGk0Hnu7fVWGeHQSOXHf/C2pF9c6uy3gef8QUeuUiWlNb0i08InPSE5a/KJzNzw1Q=="
14538 "integrity": "sha512-X1Y+0jR47ImDVr54Ab6V9eGk0Hnu7fVWGeHQSOXHf/C2pF9c6uy3gef8QUeuUiWlNb0i08InPSE5a/KJzNzw1Q=="
14513 },
14539 },
14540+ "react-final-form": {
14541+ "version": "6.3.0",
14542+ "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.3.0.tgz",
14543+ "integrity": "sha512-jijhXR1fFGUBQwNOSqF4MK8XJO7Ynl1p8vcFsnQS0INSkGI52+4IagjUgtHj3w8EviIHPFK/Eflji6FELUl07w==",
14544+ "requires": {
14545+ "@babel/runtime": "^7.4.5",
14546+ "ts-essentials": "^2.0.8"
14547+ },
14548+ "dependencies": {
14549+ "@babel/runtime": {
14550+ "version": "7.5.5",
14551+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz",
14552+ "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==",
14553+ "requires": {
14554+ "regenerator-runtime": "^0.13.2"
14555+ }
14556+ }
14557+ }
14558+ },
14559+ "react-final-form-arrays": {
14560+ "version": "3.1.0",
14561+ "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.0.tgz",
14562+ "integrity": "sha512-eJdAlhTKzlDD/d1wedD592a99eJNGO0e9GzY++RLN99P23cMGKSzCmsiGWLPwpY0H/C/LmNSL4XzWyH/aZembA==",
14563+ "requires": {
14564+ "@babel/runtime": "^7.4.5"
14565+ },
14566+ "dependencies": {
14567+ "@babel/runtime": {
14568+ "version": "7.5.5",
14569+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz",
14570+ "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==",
14571+ "requires": {
14572+ "regenerator-runtime": "^0.13.2"
14573+ }
14574+ }
14575+ }
14576+ },
14514 "react-hot-loader": {
14577 "react-hot-loader": {
14515 "version": "4.12.0",
14578 "version": "4.12.0",
14516 "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.0.tgz",
14579 "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.0.tgz",
@@ -14721,6 +14784,11 @@
14721 "regenerate": "^1.4.0"
14784 "regenerate": "^1.4.0"
14722 }
14785 }
14723 },
14786 },
14787+ "regenerator-runtime": {
14788+ "version": "0.13.3",
14789+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz",
14790+ "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw=="
14791+ },
14724 "regenerator-transform": {
14792 "regenerator-transform": {
14725 "version": "0.14.0",
14793 "version": "0.14.0",
14726 "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.0.tgz",
14794 "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.0.tgz",
@@ -17199,6 +17267,11 @@
17199 "solc": "0.4.24"
17267 "solc": "0.4.24"
17200 }
17268 }
17201 },
17269 },
17270+ "ts-essentials": {
17271+ "version": "2.0.12",
17272+ "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-2.0.12.tgz",
17273+ "integrity": "sha512-3IVX4nI6B5cc31/GFFE+i8ey/N2eA0CZDbo6n0yrz0zDX8ZJ8djmU1p+XRz7G3is0F3bB3pu2pAroFdAWQKU3w=="
17274+ },
17202 "ts-pnp": {
17275 "ts-pnp": {
17203 "version": "1.1.2",
17276 "version": "1.1.2",
17204 "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.1.2.tgz",
17277 "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.1.2.tgz",
package.json+6
@@ -17,6 +17,7 @@
17 "@hot-loader/react-dom": "^16.8.6",
17 "@hot-loader/react-dom": "^16.8.6",
18 "@ledgerhq/hw-app-eth": "^4.61.0",
18 "@ledgerhq/hw-app-eth": "^4.61.0",
19 "@ledgerhq/hw-transport-u2f": "^4.61.0",
19 "@ledgerhq/hw-transport-u2f": "^4.61.0",
20+ "@welldone-software/why-did-you-render": "^3.2.3",
20 "PaperCollateralRenderer": "github:urbit/PaperCollateralRenderer#rc-immediate",
21 "PaperCollateralRenderer": "github:urbit/PaperCollateralRenderer#rc-immediate",
21 "async-retry": "^1.2.3",
22 "async-retry": "^1.2.3",
22 "azimuth-js": "^0.15.1",
23 "azimuth-js": "^0.15.1",
@@ -28,6 +29,9 @@
28 "ethereum-blockies-base64": "^1.0.2",
29 "ethereum-blockies-base64": "^1.0.2",
29 "ethereumjs-tx": "^1.3.7",
30 "ethereumjs-tx": "^1.3.7",
30 "file-saver": "^2.0.0",
31 "file-saver": "^2.0.0",
32+ "final-form": "^4.18.4",
33+ "final-form-arrays": "^3.0.1",
34+ "final-form-set-field-data": "^1.0.2",
31 "folktale": "^2.3.1",
35 "folktale": "^2.3.1",
32 "jszip": "^3.1.5",
36 "jszip": "^3.1.5",
33 "keccak": "^1.4.0",
37 "keccak": "^1.4.0",
@@ -38,6 +42,8 @@
38 "react": "^16.8.6",
42 "react": "^16.8.6",
39 "react-app-rewire-hot-loader": "^2.0.1",
43 "react-app-rewire-hot-loader": "^2.0.1",
40 "react-dom": "^16.8.6",
44 "react-dom": "^16.8.6",
45+ "react-final-form": "^6.3.0",
46+ "react-final-form-arrays": "^3.1.0",
41 "react-hot-loader": "^4.12.0",
47 "react-hot-loader": "^4.12.0",
42 "react-scripts": "3.0.1",
48 "react-scripts": "3.0.1",
43 "react-teleporter": "^1.1.0",
49 "react-teleporter": "^1.1.0",
src/components/Accordion.js+4−2
@@ -2,11 +2,13 @@ import React from 'react';
2import { Grid, AccessoryIcon } from 'indigo-react';
2import { Grid, AccessoryIcon } from 'indigo-react';
3
3
4export default function Accordion({
4export default function Accordion({
5+ className,
5 views,
6 views,
6 options,
7 options,
7 currentTab,
8 currentTab,
8 onTabChange,
9 onTabChange,
9− className,
10+
11+ // Tab props
10 ...rest
12 ...rest
11}) {
13}) {
12 const Tab = views[currentTab];
14 const Tab = views[currentTab];
@@ -37,7 +39,7 @@ export default function Accordion({
37 </AccessoryIcon>
39 </AccessoryIcon>
38 </div>
40 </div>
39 </Grid.Item>
41 </Grid.Item>
40− {isActive && <Grid.Item full as={Tab} />}
42+ {isActive && <Grid.Item full as={Tab} {...rest} />}
41 <Grid.Divider />
43 <Grid.Divider />
42 </React.Fragment>
44 </React.Fragment>
43 );
45 );
src/components/Highlighted.js+14−6
@@ -1,13 +1,21 @@
1import React from 'react';
1import React from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3
3
4−export default function Highlighted({ warning = false, ...rest }) {
4+export default function Highlighted({
5+ as: As = 'span',
6+ className,
7+ warning = false,
8+ ...rest
9+}) {
5 return (
10 return (
6− <span
11+ <As
7− className={cn({
12+ className={cn(
13+ {
8− red3: warning,
14+ red3: warning,
9− green3: !warning,
15+ green3: !warning,
16+ },
17+ className
10− })}
18+ )}
11 {...rest}
19 {...rest}
12 />
20 />
13 );
21 );
src/components/InlineEthereumTransaction.js+127−106
@@ -1,4 +1,4 @@
1−import React, { useEffect } from 'react';
1+import React, { useMemo, useCallback } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3import {
3import {
4 Grid,
4 Grid,
@@ -12,12 +12,14 @@ import {
12} from 'indigo-react';
12} from 'indigo-react';
13import { fromWei } from 'web3-utils';
13import { fromWei } from 'web3-utils';
14
14
15−import { useCheckboxInput } from 'lib/useInputs';
16import { useExploreTxUrl } from 'lib/explorer';
15import { useExploreTxUrl } from 'lib/explorer';
17import { hexify } from 'lib/txn';
16import { hexify } from 'lib/txn';
18
17
19import { GenerateButton, ForwardButton, RestartButton } from './Buttons';
18import { GenerateButton, ForwardButton, RestartButton } from './Buttons';
20import WarningBox from './WarningBox';
19import WarningBox from './WarningBox';
20+import { composeValidator, buildCheckboxValidator } from 'form/validators';
21+import BridgeForm from 'form/BridgeForm';
22+import Condition from 'form/Condition';
21
23
22export default function InlineEthereumTransaction({
24export default function InlineEthereumTransaction({
23 // from useEthereumTransaction.bind
25 // from useEthereumTransaction.bind
@@ -57,29 +59,24 @@ export default function InlineEthereumTransaction({
57 const showSignedTx = signed;
59 const showSignedTx = signed;
58
60
59 const exploreTxUrl = useExploreTxUrl(txHash);
61 const exploreTxUrl = useExploreTxUrl(txHash);
60− const [advancedInput, { data: advancedOpen }] = useCheckboxInput({
61− name: 'advanced',
62− label: 'Advanced Configuration',
63− inverseLabel: 'Cancel Advanced Configuration',
64− disabled: !showConfigureInput,
65− });
66
62
67− const [
63+ const validate = useMemo(
68− viewSignedTransaction,
64+ () =>
69− { data: signedTransactionOpen },
65+ composeValidator({
70− ] = useCheckboxInput({
66+ useAdvanced: buildCheckboxValidator(),
71− name: 'viewsigned',
67+ viewSigned: buildCheckboxValidator(),
72− label: 'View Signed Transaction',
68+ }),
73− inverseLabel: 'Hide Signed Transaction',
69+ []
74− disabled: !showSignedTx,
75− });
70+ );
76
71
77− // reset gas price when closing advanced configuration
72+ const onValues = useCallback(
78− useEffect(() => {
73+ ({ valid, values, form }) => {
79− if (!advancedOpen) {
74+ if (!values.useAdvanced) {
80− resetGasPrice();
75+ resetGasPrice();
81− }
76+ }
77+ },
82− }, [advancedOpen, resetGasPrice]);
78+ [resetGasPrice]
79+ );
83
80
84 const renderPrimaryButton = () => {
81 const renderPrimaryButton = () => {
85 if (error) {
82 if (error) {
@@ -126,94 +123,118 @@ export default function InlineEthereumTransaction({
126
123
127 return (
124 return (
128 <Grid className={cn(className, 'mt1')}>
125 <Grid className={cn(className, 'mt1')}>
126+ <BridgeForm validate={validate} onValues={onValues}>
127+ {({ handleSubmit }) => (
128+ <>
129− {renderPrimaryButton()}
129+ {renderPrimaryButton()}
130
130
131− {error && (
131+ {error && (
132− <Grid.Item full as={ErrorText} className="mt1">
132+ <Grid.Item full as={ErrorText} className="mt1">
133− {error.message}
133+ {error.message}
134− </Grid.Item>
135− )}
136−
137− {needFunds && (
138− <Grid.Item full as={WarningBox} className="mt3">
139− The address {needFunds.address} needs at least{' '}
140− {fromWei(needFunds.minBalance)} ETH and currently has{' '}
141− {fromWei(needFunds.balance)} ETH. Waiting until the account has enough
142− funds.
143− </Grid.Item>
144− )}
145−
146− {showConfigureInput && (
147− <>
148− <Grid.Item full as={ToggleInput} {...advancedInput} />
149− {advancedOpen && (
150− <>
151− <Grid.Item full as={Flex} row justify="between" className="mt2">
152− <Flex.Item as={H5}>Gas Price</Flex.Item>
153− <Flex.Item as={H5}>{gasPrice} Gwei</Flex.Item>
154− </Grid.Item>
155− {/* TODO(shrugs): move to indigo/RangeInput */}
156− <Grid.Item
157− full
158− as="input"
159− type="range"
160− min="1"
161− max="100"
162− value={gasPrice}
163− onChange={e => setGasPrice(parseInt(e.target.value, 10))}
164− />
165− <Grid.Item
166− full
167− as={Flex}
168− row
169− justify="between"
170− className="f6 mt1">
171− <Flex.Item as={Text}>Cheap</Flex.Item>
172− <Flex.Item as={Text}>Fast</Flex.Item>
173 </Grid.Item>
134 </Grid.Item>
135+ )}
136+
137+ {needFunds && (
174− <Grid.Item full as={HelpText} className="mt3">
138+ <Grid.Item full as={WarningBox} className="mt3">
175− Nonce: {nonce}
139+ The address {needFunds.address} needs at least{' '}
176− </Grid.Item>
140+ {fromWei(needFunds.minBalance)} ETH and currently has{' '}
177− <Grid.Item full as={HelpText} className="mt1">
141+ {fromWei(needFunds.balance)} ETH. Waiting until the account has
178− Chain ID: {chainId}
142+ enough funds.
179 </Grid.Item>
143 </Grid.Item>
180− <Grid.Divider className="mt3" />
181− </>
182− )}
183− </>
184− )}
144+ )}
185
145
186− {showSignedTx && (
146+ {showConfigureInput && (
187− <>
147+ <>
148+ <Grid.Item
149+ full
188− <Grid.Item full as={ToggleInput} {...viewSignedTransaction} />
150+ as={ToggleInput}
189− {signedTransactionOpen && (
151+ name="useAdvanced"
190− <Grid.Item full as="code" className="f6 mono gray4 wrap">
152+ label="Advanced Configuration"
191− {hexify(signedTransaction.serialize())}
153+ inverseLabel="Cancel Advanced Configuration"
192− </Grid.Item>
154+ disabled={!showConfigureInput || initializing}
193− )}
155+ />
194− </>
195− )}
196
156
157+ <Condition when="useAdvanced" is={true}>
158+ <Grid.Item
159+ full
160+ as={Flex}
161+ row
162+ justify="between"
163+ className="mt2">
164+ <Flex.Item as={H5}>Gas Price</Flex.Item>
165+ <Flex.Item as={H5}>{gasPrice} Gwei</Flex.Item>
166+ </Grid.Item>
167+ {/* TODO(shrugs): move to indigo/RangeInput */}
168+ <Grid.Item
169+ full
170+ as="input"
171+ type="range"
172+ min="1"
173+ max="100"
174+ value={gasPrice}
175+ onChange={e => setGasPrice(parseInt(e.target.value, 10))}
176+ />
177+ <Grid.Item
178+ full
179+ as={Flex}
180+ row
181+ justify="between"
182+ className="f6 mt1">
183+ <Flex.Item as={Text}>Cheap</Flex.Item>
184+ <Flex.Item as={Text}>Fast</Flex.Item>
185+ </Grid.Item>
186+ <Grid.Item full as={HelpText} className="mt3">
187+ Nonce: {nonce}
188+ </Grid.Item>
189+ <Grid.Item full as={HelpText} className="mt1">
190+ Chain ID: {chainId}
191+ </Grid.Item>
192+ <Grid.Divider className="mt3" />
193+ </Condition>
194+ </>
195+ )}
196+
197+ {showSignedTx && (
198+ <>
199+ <Grid.Item
200+ full
201+ as={ToggleInput}
202+ name="viewSigned"
203+ label="View Signed Transaction"
204+ inverseLabel="Hide Signed Transaction"
205+ disabled={!showSignedTx}
206+ />
207+ <Condition when="viewSigned" is={true}>
208+ <Grid.Item full as="code" className="f6 mono gray4 wrap">
209+ {hexify(signedTransaction.serialize())}
210+ </Grid.Item>
211+ </Condition>
212+ </>
213+ )}
214+
197− {showReceipt && (
215+ {showReceipt && (
198− <>
216+ <>
199− <Grid.Divider />
217+ <Grid.Divider />
200− <Grid.Item full as={Flex} col className="pv4">
218+ <Grid.Item full as={Flex} col className="pv4">
201− <Flex.Item as={Flex} row justify="between">
219+ <Flex.Item as={Flex} row justify="between">
202− <Flex.Item as={H5}>Transaction Hash</Flex.Item>
220+ <Flex.Item as={H5}>Transaction Hash</Flex.Item>
203− <Flex.Item as={LinkButton} href={exploreTxUrl}>
221+ <Flex.Item as={LinkButton} href={exploreTxUrl}>
204− Etherscan↗
222+ Etherscan↗
205− </Flex.Item>
223+ </Flex.Item>
206− </Flex.Item>
224+ </Flex.Item>
207− <Flex.Item as={Flex}>
225+ <Flex.Item as={Flex}>
208− <Flex.Item flex as="code" className="f6 mono gray4 wrap">
226+ <Flex.Item flex as="code" className="f6 mono gray4 wrap">
209− {txHash}
227+ {txHash}
210− </Flex.Item>
228+ </Flex.Item>
211− <Flex.Item flex />
229+ <Flex.Item flex />
212− </Flex.Item>
230+ </Flex.Item>
213− </Grid.Item>
231+ </Grid.Item>
214− <Grid.Divider />
232+ <Grid.Divider />
233+ </>
234+ )}
215− </>
235+ </>
216− )}
236+ )}
237+ </BridgeForm>
217 </Grid>
238 </Grid>
218 );
239 );
219}
240}
src/components/InputSigil.js+9−11
@@ -1,19 +1,18 @@
1import React, { useEffect, useState } from 'react';
1import React, { useEffect, useState } from 'react';
2import { Just } from 'folktale/maybe';
2import { Just } from 'folktale/maybe';
3−import * as ob from 'urbit-ob';
4
3
5import MaybeSigil from './MaybeSigil';
4import MaybeSigil from './MaybeSigil';
6
5
7−const selectColorway = (pass, fail, focused) => {
6+const selectColorway = (valid, error, active) => {
8− if (pass) {
7+ if (valid) {
9 return ['#2AA779', '#FFFFFF'];
8 return ['#2AA779', '#FFFFFF'];
10 }
9 }
11
10
12− if (focused) {
11+ if (active) {
13 return ['#4330FC', '#FFFFFF'];
12 return ['#4330FC', '#FFFFFF'];
14 }
13 }
15
14
16− if (fail) {
15+ if (error) {
17 return ['#F8C134', '#FFFFFF'];
16 return ['#F8C134', '#FFFFFF'];
18 }
17 }
19
18
@@ -24,25 +23,24 @@ export default function InputSigil({
24 className,
23 className,
25 patp,
24 patp,
26 size,
25 size,
27− pass,
26+ valid,
28 error,
27 error,
29− focused,
28+ active,
30 ...rest
29 ...rest
31}) {
30}) {
32 const [lastValidPatp, setLastValidPatp] = useState(patp);
31 const [lastValidPatp, setLastValidPatp] = useState(patp);
33
32
34 useEffect(() => {
33 useEffect(() => {
35− if (ob.isValidPatp(patp)) {
34+ if (valid) {
36 setLastValidPatp(patp);
35 setLastValidPatp(patp);
37 }
36 }
38− }, [patp]);
37+ }, [patp, valid]);
39
38
40 return (
39 return (
41 <MaybeSigil
40 <MaybeSigil
42 patp={Just(lastValidPatp)}
41 patp={Just(lastValidPatp)}
43 size={size}
42 size={size}
44− colors={selectColorway(pass, error, focused)}
43+ colors={selectColorway(valid, error, active)}
45− margin={0}
46 {...rest}
44 {...rest}
47 />
45 />
48 );
46 );
src/components/MaybeSigil.js+1−1
@@ -14,7 +14,7 @@ export default function MaybeSigil({ className, patp, size, ...rest }) {
14 });
14 });
15
15
16 return validPatp ? (
16 return validPatp ? (
17− <Sigil patp={validPatp} size={size} margin={0} {...rest} />
17+ <Sigil patp={validPatp} size={size} {...rest} />
18 ) : (
18 ) : (
19 <AspectRatio aspectRatio={1} />
19 <AspectRatio aspectRatio={1} />
20 );
20 );
src/components/Passport.js+1−2
@@ -75,7 +75,7 @@ function Passport({ point, className, ticket = null, address = Nothing() }) {
75 return (
75 return (
76 <Grid gap={4} className={cn('bg-black r8 p7', className)}>
76 <Grid gap={4} className={cn('bg-black r8 p7', className)}>
77 <Grid.Item cols={[1, 4]}>
77 <Grid.Item cols={[1, 4]}>
78− <MaybeSigil patp={name} size={64} margin={0} />
78+ <MaybeSigil patp={name} size={64} />
79 </Grid.Item>
79 </Grid.Item>
80 <Grid.Item as={Flex} cols={[4, 13]} col justify="between">
80 <Grid.Item as={Flex} cols={[4, 13]} col justify="between">
81 <Flex.Item className="mono white f5">{visualName}</Flex.Item>
81 <Flex.Item className="mono white f5">{visualName}</Flex.Item>
@@ -141,7 +141,6 @@ Passport.Mini = function MiniPassport({ point, className, inverted, ...rest }) {
141 <Sigil
141 <Sigil
142 patp={name}
142 patp={name}
143 size={64}
143 size={64}
144− margin={0}
145 colors={inverted ? INVERTED_COLORWAY : undefined}
144 colors={inverted ? INVERTED_COLORWAY : undefined}
146 />
145 />
147 </Grid.Item>
146 </Grid.Item>
src/components/UploadButton.js+3−1
@@ -6,7 +6,9 @@ export default function UploadButton({ children, onChange, ...rest }) {
6
6
7 return (
7 return (
8 <label htmlFor="file" {...rest}>
8 <label htmlFor="file" {...rest}>
9− <Button accessory="↑">{children}</Button>
9+ <Button accessory="↑" solid>
10+ {children}
11+ </Button>
10 <input
12 <input
11 id="file"
13 id="file"
12 ref={button}
14 ref={button}
src/components/ViewHeader.js+1−1
@@ -3,5 +3,5 @@ import cn from 'classnames';
3import { H5 } from 'indigo-react';
3import { H5 } from 'indigo-react';
4
4
5export default function ViewHeader({ children, className }) {
5export default function ViewHeader({ children, className }) {
6− return <H5 className={cn(className, 'mv4')}>{children}</H5>;
6+ return <H5 className={cn(className, 'mb4')}>{children}</H5>;
7}
7}
src/form/BridgeForm.jsnew file+47
@@ -0,0 +1,47 @@
1+import React, { useCallback } from 'react';
2+import { Form } from 'react-final-form';
3+import arrayMutators from 'final-form-arrays';
4+import { noop } from 'lodash';
5+
6+import ValuesHandler from './ValuesHandler';
7+import ValidationPauser from './ValidationPauser';
8+
9+/**
10+ * BridgeForm adds nice-to-haves for every form in bridge.
11+ * + afterSubmit callback for redirects
12+ * + default onSubmit to noop so react-final-form doesn't throw
13+ * + pauses validation when the form is unmounted
14+ * + `onValues` handler for reactive changes
15+ * + automatically includes array and field data mutators
16+ */
17+export default function BridgeForm({
18+ children,
19+ onValues,
20+ onSubmit = noop,
21+ afterSubmit = noop,
22+ ...rest
23+}) {
24+ const _onSubmit = useCallback(
25+ async (...args) => {
26+ const errors = await onSubmit(...args);
27+ if (errors) {
28+ return errors;
29+ }
30+
31+ afterSubmit();
32+ },
33+ [afterSubmit, onSubmit]
34+ );
35+
36+ return (
37+ <Form mutators={{ ...arrayMutators }} onSubmit={_onSubmit} {...rest}>
38+ {formProps => (
39+ <>
40+ <ValidationPauser />
41+ {children(formProps)}
42+ {onValues && <ValuesHandler {...formProps} onValues={onValues} />}
43+ </>
44+ )}
45+ </Form>
46+ );
47+}
src/form/Condition.jsnew file+11
@@ -0,0 +1,11 @@
1+import { useField } from 'react-final-form';
2+
3+// inspired by: https://codesandbox.io/s/lm4p3m92q
4+// renders childen if the relevant field's value is the test value
5+export default function Condition({ when, is, children }) {
6+ const {
7+ input: { value },
8+ } = useField(when, { subscription: { value: true } });
9+
10+ return value === is ? children : null;
11+}
src/form/FormError.jsnew file+34
@@ -0,0 +1,34 @@
1+import React from 'react';
2+import { useFormState } from 'react-final-form';
3+import { ErrorText } from 'indigo-react';
4+
5+import { WARNING } from './helpers';
6+
7+export default function FormError(props) {
8+ const {
9+ submitError,
10+ submitErrors,
11+ error: validationError,
12+ dirtySinceLastSubmit,
13+ } = useFormState({
14+ subscription: {
15+ submitError: true,
16+ submitErrors: true,
17+ error: true,
18+ dirtySinceLastSubmit: true,
19+ },
20+ });
21+
22+ const showValidationError = !!validationError;
23+
24+ const showSubmitError = !!submitError && !dirtySinceLastSubmit;
25+
26+ const warning = submitErrors && submitErrors[WARNING];
27+ const showWarning = !!warning && !dirtySinceLastSubmit;
28+
29+ return showSubmitError || showValidationError || showWarning ? (
30+ <ErrorText {...props}>
31+ {submitError || validationError || warning}
32+ </ErrorText>
33+ ) : null;
34+}
src/form/Inputs.jsnew file+183
@@ -0,0 +1,183 @@
1+import React from 'react';
2+import { Input, AccessoryIcon } from 'indigo-react';
3+import { useField } from 'react-final-form';
4+
5+import {
6+ convertToNumber,
7+ buildFormatter,
8+ downcase,
9+ ensurePatFormat,
10+} from 'form/formatters';
11+import { DEFAULT_HD_PATH } from 'lib/wallet';
12+import InputSigil from 'components/InputSigil';
13+
14+const PLACEHOLDER_POINT = '~sampel-ponnym';
15+const PLACEHOLDER_HD_PATH = DEFAULT_HD_PATH;
16+const PLACEHOLDER_MNEMONIC =
17+ 'example crew supreme gesture quantum web media hazard theory mercy wing kitten';
18+const PLACEHOLDER_TICKET = '~sampel-ticlyt-migfun-falmel';
19+const PLACEHOLDER_ADDRESS = '0x12345abcdeDB11D175F123F6891AA64F01c24F7d';
20+const PLACEHOLDER_PRIVATE_KEY =
21+ '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3';
22+const PLACEHOLDER_EMAIL = 'email@example.com';
23+
24+const formatPat = buildFormatter([downcase, ensurePatFormat]);
25+
26+export function TicketInput({ name, ...rest }) {
27+ const {
28+ meta: { valid, error, validating, touched, active },
29+ } = useField(name, {
30+ subscription: {
31+ valid: true,
32+ error: true,
33+ validating: true,
34+ touched: true,
35+ active: true,
36+ },
37+ });
38+
39+ return (
40+ <Input
41+ type="text"
42+ name={name}
43+ obscure={value => value.replace(/[^~-]+/g, '••••••')}
44+ placeholder={PLACEHOLDER_TICKET}
45+ autoCapitalize="none"
46+ autoCorrect="off"
47+ accessory={
48+ touched && !active && error ? (
49+ <AccessoryIcon.Failure />
50+ ) : validating ? (
51+ <AccessoryIcon.Pending />
52+ ) : valid ? (
53+ <AccessoryIcon.Success />
54+ ) : null
55+ }
56+ config={{ parse: formatPat }}
57+ mono
58+ {...rest}
59+ />
60+ );
61+}
62+
63+export function MnemonicInput({ ...rest }) {
64+ return (
65+ <Input
66+ type="textarea"
67+ placeholder={PLACEHOLDER_MNEMONIC}
68+ autoCapitalize="none"
69+ autoComplete="off"
70+ mono
71+ {...rest}
72+ />
73+ );
74+}
75+
76+export function HdPathInput({ ...rest }) {
77+ return (
78+ <Input
79+ type="text"
80+ placeholder={PLACEHOLDER_HD_PATH}
81+ autoCapitalize="none"
82+ autoComplete="off"
83+ autoCorrect="off"
84+ {...rest}
85+ />
86+ );
87+}
88+
89+export function PassphraseInput({ ...rest }) {
90+ return (
91+ <Input
92+ type="password"
93+ placeholder="Passphrase"
94+ autoCapitalize="none"
95+ autoCorrect="off"
96+ {...rest}
97+ />
98+ );
99+}
100+
101+export function PointInput({ name, size = 4, ...rest }) {
102+ const {
103+ input: { value },
104+ meta: { active, valid, error },
105+ } = useField(name, {
106+ subscription: { value: true, active: true, valid: true, error: true },
107+ });
108+
109+ return (
110+ <Input
111+ type="text"
112+ label="Point"
113+ name={name}
114+ placeholder={PLACEHOLDER_POINT}
115+ autoCapitalize="none"
116+ autoComplete="off"
117+ autoCorrect="off"
118+ accessory={
119+ value ? (
120+ <InputSigil
121+ patp={value}
122+ size={44}
123+ valid={valid}
124+ error={error}
125+ active={active}
126+ />
127+ ) : null
128+ }
129+ config={{ parse: formatPat }}
130+ mono
131+ {...rest}
132+ />
133+ );
134+}
135+
136+export function HexInput({ ...rest }) {
137+ return (
138+ <Input
139+ type="text"
140+ placeholder={PLACEHOLDER_PRIVATE_KEY}
141+ autoCapitalize="none"
142+ autoComplete="off"
143+ autoCorrect="off"
144+ mono
145+ {...rest}
146+ />
147+ );
148+}
149+
150+export function AddressInput({ ...rest }) {
151+ return (
152+ <Input
153+ type="text"
154+ placeholder={PLACEHOLDER_ADDRESS}
155+ autoCapitalize="none"
156+ autoCorrect="off"
157+ mono
158+ {...rest}
159+ />
160+ );
161+}
162+
163+export function NumberInput({ ...rest }) {
164+ return (
165+ <Input
166+ type="number"
167+ label="Number"
168+ config={{ format: convertToNumber }}
169+ {...rest}
170+ />
171+ );
172+}
173+
174+export function EmailInput({ ...rest }) {
175+ return (
176+ <Input
177+ type="email"
178+ placeholder={PLACEHOLDER_EMAIL}
179+ autoCapitalize="none"
180+ {...rest}
181+ />
182+ );
183+}
src/form/SubmitButton.jsnew file+70
@@ -0,0 +1,70 @@
1+import React from 'react';
2+import cn from 'classnames';
3+import { useFormState } from 'react-final-form';
4+
5+import { ForwardButton } from 'components/Buttons';
6+
7+import { blinkIf } from 'components/Blinky';
8+import { onlyHasWarning } from './helpers';
9+
10+export default function SubmitButton({
11+ as: As = ForwardButton,
12+ className,
13+ children,
14+ handleSubmit,
15+ ...rest
16+}) {
17+ const {
18+ valid,
19+ validating,
20+ submitting,
21+ hasValidationErrors,
22+ hasSubmitErrors,
23+ dirtySinceLastSubmit,
24+ submitErrors,
25+ submitSucceeded,
26+ } = useFormState();
27+
28+ const onlyWarningInSubmitErrors = onlyHasWarning(submitErrors);
29+
30+ // can submit if:
31+ // 1) is valid
32+ // OR has submit errors
33+ // AND
34+ // a) is dirty
35+ // b) OR only has a warning (double conf)
36+ // 2) AND is not validating
37+ // 3) AND has no validation errors
38+ // 4) AND is not actively submitting
39+ // 5) AND submit has not yet succeeded
40+ const canSubmit =
41+ (valid ||
42+ (hasSubmitErrors &&
43+ (dirtySinceLastSubmit || onlyWarningInSubmitErrors))) &&
44+ !validating &&
45+ !hasValidationErrors &&
46+ !submitting &&
47+ !submitSucceeded;
48+
49+ // show the warning action text if
50+ // 1) we have that warning at all
51+ // 2) we can submit or are submitting
52+ const showWarningSubmitText =
53+ onlyWarningInSubmitErrors &&
54+ !dirtySinceLastSubmit &&
55+ (canSubmit || submitting);
56+
57+ return (
58+ <As
59+ className={cn('mt4', className)}
60+ disabled={!canSubmit}
61+ accessory={blinkIf(validating || submitting)}
62+ onClick={handleSubmit}
63+ solid
64+ {...rest}>
65+ {typeof children === 'function'
66+ ? children(showWarningSubmitText)
67+ : children}
68+ </As>
69+ );
70+}
src/form/UploadInput.jsnew file+51
@@ -0,0 +1,51 @@
1+import React, { useCallback } from 'react';
2+import { useField } from 'react-final-form';
3+import UploadButton from 'components/UploadButton';
4+import { AccessoryIcon } from 'indigo-react';
5+
6+export default function UploadInput({ name, label, ...rest }) {
7+ const {
8+ input,
9+ meta: { validating, submitting, touched, active, error },
10+ } = useField(name, { type: 'text' });
11+
12+ const handleUpload = useCallback(
13+ element => {
14+ input.onFocus();
15+
16+ const file = element.files.item(0);
17+ const reader = new FileReader();
18+
19+ reader.onload = e => {
20+ input.onChange({ target: { value: e.target.result } });
21+ };
22+
23+ const failure = _ => {
24+ input.onBlur();
25+ };
26+
27+ reader.onerror = failure;
28+ reader.onabort = failure;
29+
30+ reader.readAsText(file);
31+ },
32+ [input]
33+ );
34+
35+ return (
36+ <UploadButton
37+ onChange={handleUpload}
38+ accessory={
39+ touched && !active && error ? (
40+ <AccessoryIcon.Failure />
41+ ) : validating || submitting ? (
42+ <AccessoryIcon.Pending />
43+ ) : (
44+ undefined
45+ )
46+ }
47+ {...rest}>
48+ {label}
49+ </UploadButton>
50+ );
51+}
src/form/ValidationPauser.jsnew file+27
@@ -0,0 +1,27 @@
1+import { useEffect } from 'react';
2+import { useForm } from 'react-final-form';
3+
4+/**
5+ * pauses validation when it becomes unmounted
6+ *
7+ * NOTE: this is useful because for some reason, final-form will
8+ * re-run validation when a field is unregistered (even post submission !?),
9+ * which react-final-form does on unmount. So when we redirect away from
10+ * a page with a form on it, all of the validation is re-triggered.
11+ *
12+ * Normally, for boring sync-validation-only situations, this is ok, but for
13+ * long-running validators like ours (deriving seeds, checking chain, etc)
14+ * having them re-triggered when a form leaves the page is hilariously bad.
15+ *
16+ * So here we disable validation when unmounting, saving ourselves
17+ * from the footgun.
18+ *
19+ * see: https://github.com/final-form/react-final-form/issues/408
20+ */
21+export default function ValidationPauser() {
22+ const form = useForm();
23+
24+ useEffect(() => () => form.pauseValidation(), [form]);
25+
26+ return null;
27+}
src/form/ValuesHandler.jsnew file+17
@@ -0,0 +1,17 @@
1+import { useEffect } from 'react';
2+import { useForm } from 'react-final-form';
3+
4+/**
5+ * ValuesHandler notifies callback function when form values change.
6+ */
7+export default function ValuesHandler({ valid, validating, values, onValues }) {
8+ const form = useForm();
9+
10+ useEffect(() => {
11+ if (!validating && !form.isValidationPaused()) {
12+ onValues && onValues({ valid, values, form });
13+ }
14+ }, [form, onValues, valid, validating, values]);
15+
16+ return null;
17+}
src/form/formatters.jsnew file+63
@@ -0,0 +1,63 @@
1+import { compose, strSplice } from 'lib/lib';
2+
3+const TICKET_MAX_BYTE_LEN = 32; // tickets can be as large as 32 bytes
4+const PAT_BLOCK_CHAR_LENGTH = 6; // pat{p,q} blocks are 6 characters long
5+
6+export const buildFormatter = (formatters = []) =>
7+ compose(...formatters.reverse());
8+
9+/**
10+ * inserts a ~ or - before every pat block
11+ */
12+export const ensurePatFormat = s => {
13+ // bail if falsy or empty string, nothing to do
14+ if (!s || s.length === 0) {
15+ return s || '';
16+ }
17+
18+ // ensure there's a dash every 6 + 1 characters
19+ const dashAt = i => i * (PAT_BLOCK_CHAR_LENGTH + 1);
20+ const maxBlocks = TICKET_MAX_BYTE_LEN / 2;
21+
22+ // for every index that may need a separator
23+ for (let i = 0; i !== maxBlocks; i++) {
24+ const dashIndex = dashAt(i);
25+
26+ // if the string is too short to have a separator here, we're done
27+ if (s.length - 1 < dashIndex) {
28+ return s;
29+ }
30+
31+ // insert the separator at the correct index if not already there
32+ const sep = i === 0 ? '~' : '-';
33+ if (s.charAt(dashIndex) !== sep) {
34+ s = strSplice(s, dashIndex, sep);
35+ }
36+ }
37+
38+ return s;
39+};
40+
41+export const convertToNumber = s => {
42+ try {
43+ return parseInt(s, 10);
44+ } catch {
45+ return 0;
46+ }
47+};
48+
49+export const downcase = s => {
50+ if (!s) {
51+ return s;
52+ }
53+
54+ return s.toLowerCase();
55+};
56+
57+// const hideAllButLast = s => {
58+// const ll = s[s.length -1];
59+// const bs = fill(s.length - 1, '•')
60+// return `${bs}${ll}`
61+// };
62+//
63+// const hideAll = s => fill(s.length, '•');
src/form/helpers.jsnew file+5
@@ -0,0 +1,5 @@
1+export const WARNING = 'warn';
2+
3+// if the object only has the WARNING key
4+export const onlyHasWarning = obj =>
5+ !!obj && Object.keys(obj).length === 1 && !!obj[WARNING];
src/form/validators.jsnew file+153
@@ -0,0 +1,153 @@
1+import { some } from 'lodash';
2+
3+import {
4+ validateNotEmpty,
5+ validatePatq,
6+ validateMnemonic,
7+ validatePoint,
8+ validateMaximumPatpByteLength,
9+ validateOneOf,
10+ validateHexString,
11+ validateHexLength,
12+ validateEthereumAddress,
13+ validateGreaterThan,
14+ validateEmail,
15+ validateNotNullAddress,
16+} from 'lib/validators';
17+import isPromise from 'lib/isPromise';
18+
19+// iterate over validators, exiting early if there's an error
20+const buildValidator = (validators = [], validate) => value => {
21+ for (const validator of validators) {
22+ try {
23+ const error = validator(value);
24+ if (error) {
25+ return error;
26+ }
27+ } catch (error) {
28+ console.error(error);
29+ return error.message;
30+ }
31+ }
32+
33+ if (validate) {
34+ // the final validate function can optionally return a promise
35+ return validate(value);
36+ }
37+};
38+
39+// maps a validator across an array of values
40+export const buildArrayValidator = validator => values => {
41+ const errorsOrPromises = values.map(validator);
42+ if (some(errorsOrPromises, isPromise)) {
43+ return Promise.all(errorsOrPromises);
44+ }
45+
46+ return errorsOrPromises;
47+};
48+
49+// error object has errors if some of its fields are
50+// 1) an array with any defined values
51+// 2) any defined values
52+export const hasErrors = iter =>
53+ some(iter, v => (Array.isArray(v) ? hasErrors(v) : v !== undefined));
54+
55+export const buildPatqValidator = (validators = []) =>
56+ buildValidator([validateNotEmpty, validatePatq, ...validators]);
57+export const buildMnemonicValidator = () =>
58+ buildValidator([validateNotEmpty, validateMnemonic]);
59+export const buildCheckboxValidator = mustBe =>
60+ buildValidator([
61+ validateOneOf(mustBe !== undefined ? [mustBe] : [true, false]),
62+ ]);
63+export const buildPassphraseValidator = () => buildValidator([]);
64+// TODO: validate hdpath format
65+export const buildHdPathValidator = () => buildValidator([validateNotEmpty]);
66+export const buildPointValidator = (size = 4, validators = []) =>
67+ buildValidator([
68+ validateNotEmpty,
69+ validateMaximumPatpByteLength(size),
70+ validatePoint,
71+ ...validators,
72+ ]);
73+export const buildSelectValidator = options =>
74+ buildValidator([validateOneOf(options.map(option => option.value))]);
75+export const buildHexValidator = length =>
76+ buildValidator([
77+ validateNotEmpty,
78+ validateHexString,
79+ validateHexLength(length),
80+ ]);
81+export const buildUploadValidator = () => buildValidator([validateNotEmpty]);
82+export const buildAddressValidator = () =>
83+ buildValidator([
84+ validateNotEmpty,
85+ validateHexString,
86+ validateHexLength(40),
87+ validateNotNullAddress,
88+ validateEthereumAddress,
89+ ]);
90+export const buildNumberValidator = (min = 0) =>
91+ buildValidator([validateGreaterThan(min)]);
92+export const buildEmailValidator = validate =>
93+ buildValidator([validateNotEmpty, validateEmail], validate);
94+
95+// the form validator is the composition of all of the field validators
96+// plus an additional form validator function
97+export const composeValidator = (
98+ fieldValidators = {},
99+ // the default form validator just returns field-level validations
100+ formValidator = (values, errors) => errors
101+) => {
102+ const names = Object.keys(fieldValidators);
103+
104+ const fieldLevelValidators = names.map(name => value =>
105+ fieldValidators[name](value)
106+ );
107+
108+ // async reduce errors per-field into an errors object
109+ const fieldLevelValidator = values => {
110+ const errorsOrPromises = names.map((name, i) =>
111+ fieldLevelValidators[i](values[name])
112+ );
113+
114+ const reduce = errors =>
115+ names.reduce(
116+ (memo, name, i) => ({
117+ ...memo,
118+ [name]: errors[i],
119+ }),
120+ {}
121+ );
122+
123+ if (some(errorsOrPromises, isPromise)) {
124+ // if any of these results are a promise, await them all then reduce
125+ return Promise.all(errorsOrPromises).then(reduce);
126+ }
127+
128+ // otherwise return immediately
129+ return reduce(errorsOrPromises);
130+ };
131+
132+ // the final-form `validate` function
133+ // NOTE: if we return a Promise to final-form it will toggle the `validating`
134+ // state, which is expected. If our promise resolves immediately, however,
135+ // that means our `validating` state flickers the UI and it looks pretty bad.
136+ // The solution is to conditionally return a promise only when necessary.
137+ return values => {
138+ // ask for field-level errors
139+ const errorsOrPromise = fieldLevelValidator(values);
140+
141+ // pass the current values and their validity to the form-level validator
142+ // that can implement conditional logic and more complex validations
143+ const runFormValidator = errors => formValidator(values, errors);
144+
145+ if (isPromise(errorsOrPromise)) {
146+ // if promise, promise
147+ return errorsOrPromise.then(runFormValidator);
148+ }
149+
150+ // otherwise, it's an errors object
151+ return runFormValidator(errorsOrPromise);
152+ };
153+};
src/index.js+4
@@ -3,4 +3,8 @@ import ReactDOM from 'react-dom';
3
3
4import Bridge from './Bridge';
4import Bridge from './Bridge';
5
5
6+if (process.env.NODE_ENV !== 'production') {
7+ require('@welldone-software/why-did-you-render')(React);
8+}
9+
6ReactDOM.render(<Bridge />, document.getElementById('root'));
10ReactDOM.render(<Bridge />, document.getElementById('root'));
src/indigo-react/components/AccessoryIcon.js+1−1
@@ -4,7 +4,7 @@ import Flex from './Flex';
4
4
5const PENDING_ACCESSORY = '⋯';
5const PENDING_ACCESSORY = '⋯';
6const SUCCESS_ACCESSORY = '✓';
6const SUCCESS_ACCESSORY = '✓';
7−const FAILURE_ACCESSORY = '✗';
7+const FAILURE_ACCESSORY = '!';
8
8
9function AccessoryIcon({ ...props }) {
9function AccessoryIcon({ ...props }) {
10 return (
10 return (
src/indigo-react/components/CheckboxInput.js+11−32
@@ -2,6 +2,7 @@ import React from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3
3
4import Flex from './Flex';
4import Flex from './Flex';
5+import { useField } from 'react-final-form';
5
6
6export default function CheckboxInput({
7export default function CheckboxInput({
7 // visuals
8 // visuals
@@ -9,30 +10,15 @@ export default function CheckboxInput({
9 label,
10 label,
10 className,
11 className,
11
12
12− // callbacks
13− onEnter,
14−
15− // state from hook
16− focused,
17− pass,
18− syncPass,
19− visiblyPassed,
20− error,
21− hintError,
22− data,
23− bind,
24− autoFocus,
25 disabled,
13 disabled,
26− touched,
14+}) {
15+ const {
16+ input,
17+ meta: { submitting, submitSucceeded },
18+ } = useField(name, { type: 'checkbox' });
27
19
28− // ignored
20+ disabled = disabled || submitting || submitSucceeded;
29− initialValue,
30− validators,
31− transformers,
32
21
33− // extra
34− ...rest
35−}) {
36 return (
22 return (
37 <Flex
23 <Flex
38 row
24 row
@@ -48,14 +34,7 @@ export default function CheckboxInput({
48 }),
34 }),
49 }}>
35 }}>
50 {/* we totally hide the checkbox itself */}
36 {/* we totally hide the checkbox itself */}
51− <Flex.Item
52− as="input"
53− {...rest}
54− className={cn('super-hidden')}
37+ <Flex.Item as="input" className="super-hidden" id={name} {...input} />
55− id={name}
56− name={name}
57− {...bind}
58− />
59 {/* and then display a prettier one in its stead */}
38 {/* and then display a prettier one in its stead */}
60 <Flex.Item
39 <Flex.Item
61 flex
40 flex
@@ -67,14 +46,14 @@ export default function CheckboxInput({
67 align="center"
46 align="center"
68 className={cn('b1 p1 mr3', {
47 className={cn('b1 p1 mr3', {
69 'bg-gray1': disabled,
48 'bg-gray1': disabled,
70− 'bg-black white b-black': !disabled && data,
49+ 'bg-black white b-black': !disabled && input.value,
71− 'bg-white black b-black': !disabled && !data,
50+ 'bg-white black b-black': !disabled && !input.value,
72 })}
51 })}
73 style={{
52 style={{
74 height: '14px',
53 height: '14px',
75 width: '14px',
54 width: '14px',
76 }}>
55 }}>
77− {data && '✓'}
56+ {input.value && '✓'}
78 </Flex>
57 </Flex>
79 {label}
58 {label}
80 </Flex.Item>
59 </Flex.Item>
src/indigo-react/components/Input.js+51−47
@@ -1,58 +1,63 @@
1−import React, { useCallback } from 'react';
1+import React from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3−
3+import { useField } from 'react-final-form';
4import Flex from './Flex';
4import Flex from './Flex';
5import { ErrorText } from './Typography';
5import { ErrorText } from './Typography';
6
6
7−export default React.memo(function Input({
7+export default function Input({
8 // visuals
8 // visuals
9 type,
9 type,
10 name,
10 name,
11 label,
11 label,
12 className,
12 className,
13 accessory,
13 accessory,
14+ disabled = false,
14 mono = false,
15 mono = false,
16+ obscure,
15
17
16 // callbacks
18 // callbacks
17 onEnter,
19 onEnter,
18
20
19− // state from hook
21+ // state
20− focused,
22+ config,
21− pass,
22− syncPass,
23− visiblyPassed,
24− error,
25− hintError,
26− data,
27− bind,
28− autoFocus,
29− disabled,
30− touched,
31−
32− // ignored
33− initialValue,
34− validators,
35− transformers,
36
23
37 // extra
24 // extra
38− textarea = false,
39 ...rest
25 ...rest
40}) {
26}) {
41− // NB(shrugs): we disable exhaustive deps because we don't want the callbacks
27+ const {
42− // (whose identity might change between renders) to trigger a re-notify of
28+ input,
43− // state that we already know
29+ meta: {
44− // this also happens to prevent render loops when rendering a map of inputs
30+ active,
31+ error,
32+ submitError,
33+ dirtySinceLastSubmit,
34+ submitting,
35+ submitSucceeded,
36+ touched,
37+ valid,
38+ },
39+ } = useField(name, config);
40+
41+ disabled = disabled || submitting || submitSucceeded;
42+
43+ // choose the base dom component
44+ const BaseComponent = type === 'textarea' ? 'textarea' : 'input';
45
45
46 // notify parent of enter keypress iff not disabled and passing
46 // notify parent of enter keypress iff not disabled and passing
47+ // TODO: integrate this into react-final-form submission
47− const onKeyPress = useCallback(
48+ // const onKeyPress = useCallback(
48− e => !disabled && pass && e.key === 'Enter' && onEnter && onEnter(),
49+ // e => !disabled && valid && e.key === 'Enter' && onEnter && onEnter(),
49− [disabled, pass] // eslint-disable-line react-hooks/exhaustive-deps
50+ // [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps
50− );
51+ // );
52+
53+ const showError = !!error;
54+ const showSubmitError = !!submitError && !dirtySinceLastSubmit;
55+ const indicateError = touched && !active && (showError || showSubmitError);
51
56
52 return (
57 return (
53 <Flex
58 <Flex
54− className={cn(className, 'mb1')}
55 col
59 col
60+ className={cn(className, 'mb1')}
56 style={{
61 style={{
57 ...(disabled && {
62 ...(disabled && {
58 pointerEvents: 'none',
63 pointerEvents: 'none',
@@ -68,37 +73,35 @@ export default React.memo(function Input({
68 htmlFor={name}>
73 htmlFor={name}>
69 {label}
74 {label}
70 </Flex.Item>
75 </Flex.Item>
71− <Flex.Item as={Flex} className="rel" row>
76+ <Flex.Item as={Flex} row className="rel">
72 <Flex.Item
77 <Flex.Item
73− as={type === 'textarea' ? 'textarea' : 'input'}
78+ flex
74− type={type === 'textarea' ? undefined : type}
79+ as={BaseComponent}
75 {...rest}
80 {...rest}
76 // NOTE: 24px = 12px * 2 (from p3 styling)
81 // NOTE: 24px = 12px * 2 (from p3 styling)
77 style={type === 'textarea' ? { minHeight: 'calc(1rem + 24px)' } : {}}
82 style={type === 'textarea' ? { minHeight: 'calc(1rem + 24px)' } : {}}
78 className={cn(
83 className={cn(
79− 'b b1 p3 outline-none',
84+ 'b b1 p3 outline-none bs-none',
80 { mono },
85 { mono },
81 {
86 {
82 'bg-white': !disabled,
87 'bg-white': !disabled,
83 'bg-gray1': disabled,
88 'bg-gray1': disabled,
84 },
89 },
85 {
90 {
86− gray4: !focused && !touched,
91+ gray4: !(active || touched),
87− black: focused || touched,
92+ black: active || touched,
88 },
93 },
89 {
94 {
90− 'b-green3': visiblyPassed,
95+ 'b-green3': valid,
91− 'b-black': focused && !visiblyPassed,
96+ 'b-black': !valid && active,
92− 'b-yellow3': !focused && hintError,
97+ 'b-yellow3': !valid && !active && touched,
93− 'b-gray2': !focused && !hintError && !visiblyPassed,
98+ 'b-gray2': !valid && !active && !touched,
94 }
99 }
95− // TODO: inputClassName ?
96 )}
100 )}
97 id={name}
101 id={name}
98 name={name}
102 name={name}
99− onKeyPress={onKeyPress}
103+ {...input}
100− {...bind}
104+ type={type === 'textarea' ? undefined : type}
101− flex
102 />
105 />
103 {accessory && (
106 {accessory && (
104 <div
107 <div
@@ -114,11 +117,12 @@ export default React.memo(function Input({
114 </div>
117 </div>
115 )}
118 )}
116 </Flex.Item>
119 </Flex.Item>
117− {error && (
120+
121+ {indicateError && (
118 <Flex.Item as={ErrorText} className="mv1">
122 <Flex.Item as={ErrorText} className="mv1">
119− {error}
123+ {error || submitError}
120 </Flex.Item>
124 </Flex.Item>
121 )}
125 )}
122 </Flex>
126 </Flex>
123 );
127 );
124−});
128+}
src/indigo-react/components/SelectInput.js+36−49
@@ -1,69 +1,51 @@
1import React, { useCallback, useRef, useState } from 'react';
1import React, { useCallback, useRef, useState } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3+import { useField } from 'react-final-form';
4+import useOnClickOutside from 'indigo-react/lib/useOnClickOutside';
3
5
4import Flex from './Flex';
6import Flex from './Flex';
5import { ErrorText } from './Typography';
7import { ErrorText } from './Typography';
6−import useOnClickOutside from 'indigo-react/lib/useOnClickOutside';
7import AccessoryIcon from './AccessoryIcon';
8import AccessoryIcon from './AccessoryIcon';
8
9
9// NOTE: if we really care about accessibility, we should pull in a dependency
10// NOTE: if we really care about accessibility, we should pull in a dependency
10export default function SelectInput({
11export default function SelectInput({
11− // visuals
12 name,
12 name,
13 label,
13 label,
14+ placeholder,
14 className,
15 className,
15− accessory,
16 mono = false,
16 mono = false,
17− placeholder,
18−
19− // callbacks
20− onEnter,
21−
22− // state from hook
23− focused,
24− pass,
25− syncPass,
26− visiblyPassed,
27− error,
28− hintError,
29− data,
30− bind,
31− autoFocus,
32− disabled,
33− options,
17+ options = [],
34− touched,
18+ disabled = false,
19+ warning,
20+}) {
21+ const {
22+ input,
23+ meta: { active, error, submitting, submitSucceeded, touched, valid },
24+ } = useField(name, {
25+ type: 'select',
26+ });
35
27
36− // ignored
28+ disabled = disabled || submitting || submitSucceeded;
37− initialValue,
38− validators,
39− transformers,
40
29
41− // extra
42− ...rest
43−}) {
44 const [isOpen, setIsOpen] = useState(false);
30 const [isOpen, setIsOpen] = useState(false);
45 const ref = useRef();
31 const ref = useRef();
46
32
47 // close select on outside clicks
33 // close select on outside clicks
48 useOnClickOutside(ref, useCallback(() => setIsOpen(false), [setIsOpen]));
34 useOnClickOutside(ref, useCallback(() => setIsOpen(false), [setIsOpen]));
49
35
50− const toggleOpen = useCallback(() => setIsOpen(isOpen => !isOpen), [
36+ const toggleOpen = useCallback(() => {
51− setIsOpen,
37+ input.onFocus();
38+ setIsOpen(isOpen => !isOpen);
52− ]);
39+ }, [input]);
53
40
54 const onChange = value => {
41 const onChange = value => {
55− // TODO: provide setValue here?
56 // construct a pseudo event that sets the value correctly
42 // construct a pseudo event that sets the value correctly
57− bind.onChange({ target: { value } });
43+ input.onChange({ target: { value } });
44+ input.onBlur();
58 setIsOpen(false);
45 setIsOpen(false);
59 };
46 };
60
47
61− // redefine accessory because we still want to ignore it from the ..rest above
62− accessory = (
63− <AccessoryIcon className="gray4">{isOpen ? '▲' : '▼'}</AccessoryIcon>
64− );
65−
66− const text = options.find(o => o.value === data).text;
48+ const text = options.find(o => o.value === input.value).text;
67
49
68 return (
50 return (
69 <Flex
51 <Flex
@@ -81,7 +63,7 @@ export default function SelectInput({
81 </Flex.Item>
63 </Flex.Item>
82 <Flex.Item as={Flex} row className="rel clickable" onClick={toggleOpen}>
64 <Flex.Item as={Flex} row className="rel clickable" onClick={toggleOpen}>
83 <Flex.Item
65 <Flex.Item
84− // as="select"
66+ flex
85 className={cn(
67 className={cn(
86 'b b1 p3 outline-none',
68 'b b1 p3 outline-none',
87 { mono },
69 { mono },
@@ -94,16 +76,14 @@ export default function SelectInput({
94 black: !isOpen,
76 black: !isOpen,
95 },
77 },
96 {
78 {
97− 'b-green3': visiblyPassed,
79+ 'b-green3': valid,
98− 'b-black': focused && !visiblyPassed,
80+ 'b-black': !valid && active,
99− 'b-yellow3': !focused && hintError,
81+ 'b-yellow3': !valid && !active && touched && error,
100− 'b-gray3': !focused && !hintError && !visiblyPassed,
82+ 'b-gray2': !valid && !active && !touched && !error,
101 }
83 }
102 )}
84 )}
103 id={name}
85 id={name}
104− name={name}
86+ name={name}>
105− {...bind}
106− flex>
107 {isOpen ? placeholder : text}
87 {isOpen ? placeholder : text}
108 </Flex.Item>
88 </Flex.Item>
109 <div
89 <div
@@ -115,7 +95,7 @@ export default function SelectInput({
115 width: '44px',
95 width: '44px',
116 overflow: 'hidden',
96 overflow: 'hidden',
117 }}>
97 }}>
118− {accessory}
98+ <AccessoryIcon className="gray4">{isOpen ? '▲' : '▼'}</AccessoryIcon>
119 </div>
99 </div>
120 {isOpen && (
100 {isOpen && (
121 <Flex
101 <Flex
@@ -142,7 +122,14 @@ export default function SelectInput({
142 </Flex>
122 </Flex>
143 )}
123 )}
144 </Flex.Item>
124 </Flex.Item>
125+
126+ {warning && (
127+ <Flex.Item as={ErrorText} className="mv1">
128+ {warning}
129+ </Flex.Item>
130+ )}
131+
145− {error && (
132+ {touched && !active && error && (
146 <Flex.Item as={ErrorText} className="mv1">
133 <Flex.Item as={ErrorText} className="mv1">
147 {error}
134 {error}
148 </Flex.Item>
135 </Flex.Item>
src/indigo-react/components/ToggleInput.js+13−23
@@ -3,6 +3,7 @@ import cn from 'classnames';
3
3
4import Flex from './Flex';
4import Flex from './Flex';
5import LinkButton from './LinkButton';
5import LinkButton from './LinkButton';
6+import { useField } from 'react-final-form';
6
7
7export default function ToggleInput({
8export default function ToggleInput({
8 // visuals
9 // visuals
@@ -11,32 +12,21 @@ export default function ToggleInput({
11 inverseLabel,
12 inverseLabel,
12 className,
13 className,
13
14
14− // callbacks
15+ //
15− onEnter,
16−
17− // state from hook
18− focused,
19− pass,
20− syncPass,
21− visiblyPassed,
22− error,
23− hintError,
24− data,
25− bind,
26− autoFocus,
27− disabled,
16+ disabled = false,
28− touched,
29−
30− // ignored
31− initialValue,
32− validators,
33− transformers,
34
17
35 ...rest
18 ...rest
36}) {
19}) {
20+ const {
21+ input,
22+ meta: { submitting, submitSucceeded },
23+ } = useField(name, { type: 'checkbox' });
24+
25+ disabled = disabled || submitting || submitSucceeded;
26+
37 return (
27 return (
38 <Flex
28 <Flex
39− className={cn(className)}
29+ className={className}
40 row
30 row
41 align="center"
31 align="center"
42 style={{
32 style={{
@@ -52,7 +42,7 @@ export default function ToggleInput({
52 className={cn('super-hidden')}
42 className={cn('super-hidden')}
53 id={name}
43 id={name}
54 name={name}
44 name={name}
55− {...bind}
45+ {...input}
56 />
46 />
57 {/* and then display a prettier one in its stead */}
47 {/* and then display a prettier one in its stead */}
58 <Flex.Item
48 <Flex.Item
@@ -63,7 +53,7 @@ export default function ToggleInput({
63 })}
53 })}
64 htmlFor={name}>
54 htmlFor={name}>
65 <LinkButton disabled={disabled} className="f5">
55 <LinkButton disabled={disabled} className="f5">
66− {bind.checked ? inverseLabel : label}
56+ {input.checked ? inverseLabel : label}
67 </LinkButton>
57 </LinkButton>
68 </Flex.Item>
58 </Flex.Item>
69 </Flex>
59 </Flex>
src/indigo-react/index.js−2
@@ -11,5 +11,3 @@ export { default as IconButton } from './components/IconButton';
11export { default as LinkButton } from './components/LinkButton';
11export { default as LinkButton } from './components/LinkButton';
12
12
13export * from './components/Typography';
13export * from './components/Typography';
14−
15−export * from './lib';
src/indigo-react/lib/index.jsdeleted−1
@@ -1 +0,0 @@
1−export { default as useForm } from './useForm';
src/indigo-react/lib/useForm.jsdeleted−264
@@ -1,264 +0,0 @@
1−import { useCallback, useMemo } from 'react';
2−import { keyBy, get, every, some } from 'lodash';
3−
4−import { compose } from 'lib/lib';
5−import { kDefaultValidator } from 'lib/validators';
6−import useSetState from 'lib/useSetState';
7−import useDeepEqualReference from 'lib/useDeepEqualReference';
8−
9−// interface InputConfig {
10−// name: string;
11−// validators: Function[];
12−// transformers: Function[];
13−// initialValue: string;
14−// autoFocus: boolean;
15−// disabled: boolean;
16−// ...extra,
17−// }
18−
19−const defaultsFor = (configs, mapper) =>
20− configs.reduce(
21− (memo, config) => ({ ...memo, [config.name]: mapper(config) }),
22− {}
23− );
24−
25−/**
26− * useForm manages a set of inputs for rendering them in a loop
27− */
28−export default function useForm(inputConfigs = []) {
29− const configs = useDeepEqualReference(inputConfigs);
30−
31− const byName = useMemo(() => keyBy(configs, 'name'), [configs]);
32−
33− // track values
34− const [values, _setValues, clearValues] = useSetState(() =>
35− defaultsFor(configs, config =>
36− config.initialValue === undefined ? '' : config.initialValue
37− )
38− );
39− // ^ NB(shrugs): because we're not syncing the initialValue of _additional_
40− // configs that are added, we won't be able to set an initialValue for a
41− // dynamic form that then triggers validation. for initial configs,
42− // initialValue can be a validated string and everything will be happy,
43− // but for dynamically added input configs, we won't get validation, etc
44− // until there's a traditional state update.
45−
46− // track focused states
47− const [focused, setFocused, clearFocused] = useSetState(() =>
48− defaultsFor(configs, config => config.autoFocus && !config.disabled)
49− );
50− const focuses = useMemo(
51− () => configs.map(config => focused[config.name] || false), //
52− [configs, focused]
53− );
54−
55− // track whether or not input has been focused
56− const [hasBeenFocused, setHasBeenFocused, clearHasBeenFocused] = useSetState(
57− {}
58− );
59−
60− // track whether or not the input has been touched
61− const [hasBeenTouched, setHasBeenTouched, clearHasBeenTouched] = useSetState(
62− {}
63− );
64−
65− // build fn that transforms a value by input name
66− const transform = useCallback(
67− (name, value) => compose(...get(byName, [name, 'transformers'], []))(value),
68− [byName]
69− );
70−
71− // set a value (and transform it)
72− const setValue = useCallback(
73− (name, value) => _setValues({ [name]: transform(name, value) }),
74− [_setValues, transform]
75− );
76−
77− // reset the form
78− const reset = useCallback(() => {
79− clearFocused();
80− clearHasBeenFocused();
81− clearHasBeenTouched();
82− clearValues();
83− }, [clearFocused, clearHasBeenFocused, clearHasBeenTouched, clearValues]);
84−
85− // build fn that validates a value by input name
86− const validate = useCallback(
87− (name, value) =>
88− compose(
89− ...get(byName, [name, 'validators'], []),
90− kDefaultValidator
91− )(value),
92− [byName]
93− );
94−
95− const getValue = useCallback(
96− (name, e) => {
97− if (byName[name].type === 'checkbox') {
98− return e.target.checked;
99− }
100−
101− return e.target.value;
102− },
103− [byName]
104− );
105−
106− // on change, transform and set value
107− const onChange = useCallback(
108− name => e => {
109− setValue(name, getValue(name, e));
110− setHasBeenTouched({ [name]: true });
111− }, //
112− [setValue, getValue, setHasBeenTouched]
113− );
114−
115− // on focus, update focus
116− const onFocus = useCallback(
117− name => e => setFocused({ [name]: true }), //
118− [setFocused]
119− );
120−
121− // on blur, defocus and set has been focused
122− const onBlur = useCallback(
123− name => e => {
124− setFocused({ [name]: false });
125− setHasBeenFocused({ [name]: true });
126− },
127− [setFocused, setHasBeenFocused]
128− );
129−
130− // memo-compute validations of curent values
131− const validations = useMemo(
132− () => configs.map(config => validate(config.name, values[config.name])), //
133− [configs, validate, values]
134− );
135−
136− // memo-compute the set of (perhaps changed by validation) data
137− const datas = useMemo(() => validations.map(v => v.data), [validations]);
138− const syncPasses = useMemo(
139− () => validations.map(v => v.pass, [validations]),
140− [validations]
141− );
142−
143− // the input has errored if it
144− // 1) did not pass validation and has an error text
145− // 2) or has a specific error from parent state
146− // and then we store that in errorTexts[i]
147− // <string | falsy>[]
148− const errorTexts = useMemo(
149− () =>
150− configs.map((config, i) => validations[i].error || config.error || false),
151− [configs, validations]
152− );
153−
154− // we should hint at an error if it
155− // 1) has been focused before
156− // 2) and has an error
157− // boolean[]
158− const hintErrors = useMemo(
159− () =>
160− configs.map(
161− (config, i) => hasBeenFocused[config.name] && !!errorTexts[i]
162− ),
163− [configs, errorTexts, hasBeenFocused]
164− );
165−
166− // we should block on error (with potential reflow) if it
167− // 1) has been touched
168− // 2) and has an error
169− // consumers should use this value to show error text and block on operations
170− // <string | falsy>[]
171− const errors = useMemo(
172− () =>
173− configs.map((config, i) => hasBeenTouched[config.name] && errorTexts[i]),
174− [configs, errorTexts, hasBeenTouched]
175− );
176−
177− // this input has passed if
178− // 1) it has passed local validation
179− // 2) there are no errors
180− const passes = useMemo(
181− () => validations.map((v, i) => v.pass && !errorTexts[i]),
182− [validations, errorTexts]
183− );
184−
185− // visibly tell the user that their input has passed if
186− // 1) they are or have interacted with the input before
187− // 2) and it has passed as defined above
188− const visiblePasses = useMemo(
189− () =>
190− configs.map(
191− (config, i) =>
192− (focused[config.name] || hasBeenFocused[config.name]) && //
193− passes[i]
194− ),
195− [configs, hasBeenFocused, focused, passes]
196− );
197−
198− // generate the list of input states
199− const inputs = useMemo(
200− () =>
201− configs.map(
202− ({ name, error, autoFocus, disabled, initialValue, ...rest }, i) => {
203− const value =
204− datas[i] !== undefined
205− ? datas[i]
206− : initialValue !== undefined
207− ? initialValue
208− : '';
209− return {
210− // Input props
211− name,
212− value: value,
213− data: passes[i] ? datas[i] : undefined,
214− pass: passes[i],
215− syncPass: syncPasses[i],
216− visiblyPassed: visiblePasses[i],
217− error: errors[i],
218− hintError: hintErrors[i],
219− focused: focuses[i],
220− touched: hasBeenTouched[name],
221− autoFocus: autoFocus && !disabled,
222− disabled,
223− ...rest,
224− // DOM Properties for <input />
225− bind: {
226− value,
227− checked: !!datas[i],
228− onChange: onChange(name),
229− onFocus: onFocus(name),
230− onBlur: onBlur(name),
231− autoFocus: autoFocus && !disabled,
232− },
233− };
234− }
235− ),
236− [
237− configs,
238− datas,
239− passes,
240− syncPasses,
241− visiblePasses,
242− errors,
243− hintErrors,
244− focuses,
245− hasBeenTouched,
246− onChange,
247− onFocus,
248− onBlur,
249− ]
250− );
251−
252− // did all of the inputs pass validation?
253− const pass = useMemo(() => every(passes), [passes]);
254− // did any of the inputs error?
255− const error = useMemo(() => some(errors), [errors]);
256−
257− return {
258− inputs,
259− pass,
260− error,
261− setValue,
262− reset,
263− };
264−}
src/indigo-react/lib/useOnClickOutside.js−1
@@ -1,7 +1,6 @@
1import { useEffect } from 'react';
1import { useEffect } from 'react';
2
2
3// via https:// usehooks.com/useOnClickOutside/
3// via https:// usehooks.com/useOnClickOutside/
4−
5export default function useOnClickOutside(ref, handler) {
4export default function useOnClickOutside(ref, handler) {
6 useEffect(() => {
5 useEffect(() => {
7 const listener = event => {
6 const listener = event => {
src/lib/isPromise.jsnew file+6
@@ -0,0 +1,6 @@
1+// an object is a promise if it has a `.then` function
2+// https://github.com/then/is-promise/blob/master/index.js
3+export default obj =>
4+ !!obj &&
5+ (typeof obj === 'object' || typeof obj === 'function') &&
6+ typeof obj.then === 'function';
src/lib/lib.js+3
@@ -16,6 +16,8 @@ const seq = n => Array.from(Array(n), (_, i) => i);
16
16
17const fill = (n, v) => Array.from(Array(n), () => v);
17const fill = (n, v) => Array.from(Array(n), () => v);
18
18
19+const strSplice = (arr, i, val) => `${arr.slice(0, i)}${val}${arr.slice(i)}`;
20+
19const isValidGalaxy = name => {
21const isValidGalaxy = name => {
20 let point;
22 let point;
21 try {
23 try {
@@ -48,6 +50,7 @@ export {
48 defaultTo,
50 defaultTo,
49 seq,
51 seq,
50 fill,
52 fill,
53+ strSplice,
51 isValidGalaxy,
54 isValidGalaxy,
52 randomPatq,
55 randomPatq,
53 patpStringLength,
56 patpStringLength,
src/lib/pluralize.js+1−1
@@ -1,5 +1,5 @@
1export default (count, singular, plural) => {
1export default (count, singular, plural) => {
2− const isPlural = count > 1;
2+ const isPlural = count === 0 || count > 1;
3 if (!plural) {
3 if (!plural) {
4 return `${count} ${singular}${isPlural ? 's' : ''}`;
4 return `${count} ${singular}${isPlural ? 's' : ''}`;
5 }
5 }
src/lib/transformers.jsdeleted−19
@@ -1,19 +0,0 @@
1−// import { fill } from './lib'
2−
3−export const prependSig = s => (s.charAt(0) !== '~' ? `~${s}` : s);
4−
5−export const convertToNumber = s => {
6− try {
7− return parseInt(s, 10);
8− } catch {
9− return 0;
10− }
11−};
12−
13−// const hideAllButLast = s => {
14−// const ll = s[s.length -1];
15−// const bs = fill(s.length - 1, '•')
16−// return `${bs}${ll}`
17−// };
18−//
19−// const hideAll = s => fill(s.length, '•');
src/lib/txn.js+9−3
@@ -129,13 +129,19 @@ const sendSignedTransaction = (web3, stx, doubtNonceError) => {
129 });
129 });
130};
130};
131
131
132−// returns a Promise<bool>, where the bool indicates tx success/failure
132+// returns a Promise<void>, throwing on tx failure
133const waitForTransactionConfirm = (web3, txHash) => {
133const waitForTransactionConfirm = (web3, txHash) => {
134 return retry(async (bail, n) => {
134 return retry(async (bail, n) => {
135 const receipt = await web3.eth.getTransactionReceipt(txHash);
135 const receipt = await web3.eth.getTransactionReceipt(txHash);
136 const confirmed = receipt !== null;
136 const confirmed = receipt !== null;
137+ if (!confirmed) {
138+ throw new Error('Transaction not confirmed.');
139+ }
140+
137− if (confirmed) return receipt.status === true;
141+ const success = receipt.status === true;
138− else throw new Error('retrying');
142+ if (!success) {
143+ return bail(new Error('Transaction failed.'));
144+ }
139 }, RETRY_OPTIONS);
145 }, RETRY_OPTIONS);
140};
146};
141
147
src/lib/useArray.jsdeleted−48
@@ -1,48 +0,0 @@
1−import { useCallback, useState } from 'react';
2−import { identity } from 'lodash';
3−
4−/**
5− * Manages an immutable array of items.
6− */
7−export default function useArray(initialItems = [], itemBuilder = identity) {
8− const [items, _setItems] = useState(initialItems);
9−
10− const append = useCallback(
11− item => _setItems(items => [...items, itemBuilder(item)]),
12− [_setItems, itemBuilder]
13− );
14−
15− const removeAt = useCallback(
16− i =>
17− _setItems(items => {
18− const newItems = [...items.slice(0, i), ...items.slice(i + 1)];
19− return newItems;
20− }),
21− [_setItems]
22− );
23−
24− const updateAt = useCallback(
25− (i, update = {}) =>
26− _setItems(items => [
27− ...items.slice(0, i),
28− {
29− ...items[i],
30− ...update,
31− },
32− ...items.slice(i + 1),
33− ]),
34− [_setItems]
35− );
36−
37− const clear = useCallback(() => _setItems([]), [_setItems]);
38−
39− return [
40− items,
41− {
42− append,
43− removeAt,
44− updateAt,
45− clear,
46− },
47− ];
48−}
src/lib/useConstant.jsnew file+13
@@ -0,0 +1,13 @@
1+import { useRef } from 'react';
2+
3+// inspired by, but simpler version of:
4+// https://github.com/Andarist/use-constant/blob/master/src/index.ts
5+export default function useConstant(fn) {
6+ const ref = useRef();
7+
8+ if (!ref.current) {
9+ ref.current = fn();
10+ }
11+
12+ return ref.current;
13+}
src/lib/useEthereumTransaction.js+19−4
@@ -200,6 +200,7 @@ export default function useEthereumTransaction(
200 }, [estimatedGasPrice]);
200 }, [estimatedGasPrice]);
201
201
202 useEffect(() => {
202 useEffect(() => {
203+ let mounted = true;
203 // if nonce or chainId is undefined, re-fetch on-chain info
204 // if nonce or chainId is undefined, re-fetch on-chain info
204 if (!(nonce === undefined || chainId === undefined)) {
205 if (!(nonce === undefined || chainId === undefined)) {
205 return;
206 return;
@@ -213,16 +214,30 @@ export default function useEthereumTransaction(
213 _web3.eth.net.getId(),
214 _web3.eth.net.getId(),
214 ]);
215 ]);
215
216
217+ if (!mounted) {
218+ return;
219+ }
220+
216 setNonce(nonce);
221 setNonce(nonce);
217 setChainId(chainId);
222 setChainId(chainId);
218 } catch (error) {
223 } catch (error) {
219 setError(error);
224 setError(error);
220 }
225 }
221 })();
226 })();
227+
228+ return () => (mounted = false);
229+ }, [
230+ _wallet,
231+ _web3,
232+ setError,
233+ nonce,
234+ chainId,
222− }, [_wallet, _web3, setError, nonce, chainId, networkType]);
235+ networkType,
236+ estimatedGasPrice,
237+ ]);
223
238
224 useEffect(() => {
239 useEffect(() => {
225− let cancelled = false;
240+ let mounted = true;
226
241
227 if (confirmed) {
242 if (confirmed) {
228 (async () => {
243 (async () => {
@@ -241,7 +256,7 @@ export default function useEthereumTransaction(
241 }
256 }
242 }
257 }
243
258
244− if (cancelled) {
259+ if (!mounted) {
245 return;
260 return;
246 }
261 }
247
262
@@ -249,7 +264,7 @@ export default function useEthereumTransaction(
249 })();
264 })();
250 }
265 }
251
266
252− return () => (cancelled = true);
267+ return () => (mounted = false);
253 }, [confirmed, refetch, completed, setError]);
268 }, [confirmed, refetch, completed, setError]);
254
269
255 const values = useDeepEqualReference({
270 const values = useDeepEqualReference({
src/lib/useImpliedPoint.js+2−2
@@ -1,13 +1,13 @@
1import * as ob from 'urbit-ob';
1import * as ob from 'urbit-ob';
2
2
3−import { prependSig } from './transformers';
3+import { ensurePatFormat } from 'form/formatters';
4
4
5/**
5/**
6 * pull the suggested point from the subdomain
6 * pull the suggested point from the subdomain
7 */
7 */
8export default function useImpliedPoint() {
8export default function useImpliedPoint() {
9 const subdomain = window.location.host.split('.')[0];
9 const subdomain = window.location.host.split('.')[0];
10− const patp = prependSig(subdomain);
10+ const patp = ensurePatFormat(subdomain);
11 const isValid = ob.isValidPatp(patp);
11 const isValid = ob.isValidPatp(patp);
12
12
13 return isValid ? patp : null;
13 return isValid ? patp : null;
src/lib/useImpliedTicket.js+2−2
@@ -1,13 +1,13 @@
1import * as ob from 'urbit-ob';
1import * as ob from 'urbit-ob';
2
2
3−import { prependSig } from './transformers';
3+import { ensurePatFormat } from 'form/formatters';
4
4
5/**
5/**
6 * pull the suggested ticket from the #hash in the url
6 * pull the suggested ticket from the #hash in the url
7 */
7 */
8export default function useImpliedTicket() {
8export default function useImpliedTicket() {
9 const hash = window.location.hash.slice(1);
9 const hash = window.location.hash.slice(1);
10− const ticket = prependSig(hash);
10+ const ticket = ensurePatFormat(hash);
11 const isValid = ob.isValidPatq(ticket);
11 const isValid = ob.isValidPatq(ticket);
12
12
13 return isValid ? ticket : null;
13 return isValid ? ticket : null;
src/lib/useInputs.jsdeleted−277
@@ -1,277 +0,0 @@
1−import React, { useCallback, useMemo } from 'react';
2−import { AccessoryIcon, useForm } from 'indigo-react';
3−import { identity } from 'lodash';
4−
5−import { DEFAULT_HD_PATH } from 'lib/wallet';
6−import {
7− validateNotEmpty,
8− validateMnemonic,
9− validatePoint,
10− validateTicket,
11− validateEmail,
12− validateLength,
13− validateHexString,
14− validateOneOf,
15− validateMaximumPatpByteLength,
16− validateEthereumAddress,
17− validateNotNullAddress,
18− validateGreaterThan,
19−} from 'lib/validators';
20−import { prependSig, convertToNumber } from 'lib/transformers';
21−
22−import InputSigil from 'components/InputSigil';
23−
24−const PLACEHOLDER_POINT = '~sampel-ponnym';
25−const PLACEHOLDER_HD_PATH = DEFAULT_HD_PATH;
26−const PLACEHOLDER_MNEMONIC =
27− 'example crew supreme gesture quantum web media hazard theory mercy wing kitten';
28−const PLACEHOLDER_TICKET = '~sampel-ticlyt-migfun-falmel';
29−const PLACEHOLDER_ADDRESS = '0x12345abcdeDB11D175F123F6891AA64F01c24F7d';
30−const PLACEHOLDER_PRIVATE_KEY =
31− '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3';
32−
33−// pulls out the first input from a useForm() call
34−function useFirstOf({ inputs, setValue, ...rest }, mapper = identity) {
35− // ask the mapper function for any values to overwrite
36− const input = {
37− ...inputs[0],
38− ...mapper(inputs[0]),
39− };
40− // memoize the setValue callback for manually setting the value
41− // in response to some imperative event
42− const _setValue = useCallback(value => setValue(input.name, value), [
43− setValue,
44− input.name,
45− ]);
46−
47− // provide the first input as the first element
48− // _and_ as the second element to facilitate destructuring
49− // and then provide the pass/error/setValue/etc properties at the end
50− return [
51− input,
52− input,
53− {
54− ...rest,
55− setValue: _setValue,
56− },
57− ];
58−}
59−
60−export function usePassphraseInput(props) {
61− return useFirstOf(
62− useForm([
63− {
64− type: 'password',
65− autoComplete: 'off',
66− placeholder: 'Passphrase',
67− ...props,
68− },
69− ])
70− );
71−}
72−
73−export function useHexInput({ length, ...rest }) {
74− return useFirstOf(
75− useForm([
76− {
77− type: 'text', // or password
78− autoComplete: 'off',
79− placeholder: PLACEHOLDER_PRIVATE_KEY,
80− validators: useMemo(
81− () => [
82− validateHexString,
83− validateLength(length + 2),
84− validateNotEmpty,
85− ],
86− [length]
87− ),
88− ...rest,
89− },
90− ])
91− );
92−}
93−
94−const kMnemonicValidators = [validateMnemonic, validateNotEmpty];
95−export function useMnemonicInput(props) {
96− return useFirstOf(
97− useForm([
98− {
99− type: 'textarea',
100− autoComplete: 'off',
101− placeholder: PLACEHOLDER_MNEMONIC,
102− validators: kMnemonicValidators,
103− ...props,
104− },
105− ])
106− );
107−}
108−
109−export function useHdPathInput(props) {
110− return useFirstOf(
111− useForm([
112− {
113− type: 'text',
114− autoComplete: 'off',
115− placeholder: PLACEHOLDER_HD_PATH,
116− ...props,
117− },
118− ])
119− );
120−}
121−
122−const kTicketValidators = [validateTicket, validateNotEmpty];
123−//TODO needs to be fancier, displaying sig and dashes instead of •ing all
124−const kTicketTransformers = [prependSig];
125−export function useTicketInput({ validators = [], deriving = false, ...rest }) {
126− return useFirstOf(
127− useForm([
128− {
129− type: 'password',
130− label: 'Ticket',
131− placeholder: PLACEHOLDER_TICKET,
132− validators: useMemo(() => [...validators, ...kTicketValidators], [
133− validators,
134− ]),
135− transformers: kTicketTransformers,
136− mono: true,
137− ...rest,
138− },
139− ]),
140− ({ error, pass }) => ({
141− accessory: error ? (
142− <AccessoryIcon.Failure />
143− ) : deriving ? (
144− <AccessoryIcon.Pending />
145− ) : pass ? (
146− <AccessoryIcon.Success />
147− ) : null,
148− })
149− );
150−}
151−
152−const kPointTransformers = [prependSig];
153−export function usePointInput({ size = 4, validators = [], ...rest }) {
154− const _validators = useMemo(
155− () => [
156− ...validators,
157− validatePoint,
158− validateMaximumPatpByteLength(size),
159− validateNotEmpty,
160− ],
161− [size, validators]
162− );
163−
164− return useFirstOf(
165− useForm([
166− {
167− type: 'text',
168− label: 'Point',
169− placeholder: PLACEHOLDER_POINT,
170− validators: _validators,
171− transformers: kPointTransformers,
172− mono: true,
173− ...rest,
174− },
175− ]),
176− ({ error, pass, focused, value }) => ({
177− accessory: value ? (
178− <InputSigil
179− patp={value}
180− size={44}
181− margin={8}
182− pass={pass}
183− focused={focused}
184− error={error}
185− />
186− ) : null,
187− })
188− );
189−}
190−
191−export function useGalaxyInput(props) {
192− return usePointInput({
193− label: 'Galaxy Name',
194− size: 1,
195− ...props,
196− });
197−}
198−
199−export function useCheckboxInput({ initialValue, ...rest }) {
200− return useFirstOf(
201− useForm([
202− {
203− type: 'checkbox',
204− ...rest,
205− },
206− ])
207− );
208−}
209−
210−export function useSelectInput({ initialValue, options, ...rest }) {
211− return useFirstOf(
212− useForm([
213− {
214− type: 'select',
215− validators: useMemo(
216− () => [
217− validateOneOf(
218− options.map(option => option.value),
219− validateNotEmpty
220− ),
221− ],
222− [options]
223− ),
224− options,
225− initialValue: initialValue || options[0].value,
226− ...rest,
227− },
228− ])
229− );
230−}
231−
232−const kAddressValidators = [
233− validateEthereumAddress,
234− validateNotNullAddress,
235− validateNotEmpty,
236−];
237−export function useAddressInput({ ...rest }) {
238− return useFirstOf(
239− useForm([
240− {
241− type: 'string',
242− label: 'Ethereum Address',
243− placeholder: PLACEHOLDER_ADDRESS,
244− autoComplete: 'off',
245− validators: kAddressValidators,
246− mono: true,
247− ...rest,
248− },
249− ])
250− );
251−}
252−
253−const kNumberTransformers = [convertToNumber];
254−const kNumberValidators = [validateGreaterThan(0)];
255−export function useNumberInput({ ...rest }) {
256− return useFirstOf(
257− useForm([
258− {
259− type: 'number',
260− label: 'Number',
261− autoComplete: 'off',
262− transformers: kNumberTransformers,
263− validators: kNumberValidators,
264− ...rest,
265− },
266− ])
267− );
268−}
269−
270−const kEmailValidators = [validateEmail, validateNotEmpty];
271−export const buildEmailInputConfig = extra => ({
272− type: 'email',
273− autoComplete: 'off',
274− validators: kEmailValidators,
275− initialValue: '',
276− ...extra,
277−});
src/lib/useMailer.js+18−29
@@ -1,43 +1,29 @@
1−import { useCallback } from 'react';
1+import { useCallback, useRef } from 'react';
2−import { Just, Nothing } from 'folktale/maybe';
3
2
4import { hasReceived, sendMail } from './inviteMail';
3import { hasReceived, sendMail } from './inviteMail';
5−import useSetState from './useSetState';
4+import timeout from './timeout';
6
5
7const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true';
6const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true';
8
7
9−function useHasReceivedCache() {
8+export default function useMailer() {
10− const [cache, addToCache] = useSetState();
9+ const cache = useRef({});
11
10
12 const getHasReceived = useCallback(
11 const getHasReceived = useCallback(
13− email => cache[email] || Nothing(), //
14− [cache]
15− );
16−
17− const syncHasReceivedForEmail = useCallback(
18 async email => {
12 async email => {
19− if (Just.hasInstance(getHasReceived(email))) {
13+ if (!cache.current[email]) {
20− // never update the cache after we know about it
21− return;
22− }
23−
24− if (STUB_MAILER) {
14+ if (STUB_MAILER) {
25− // always allow sending emails when stubbing
15+ await timeout(350); // simulate request
26− return addToCache({ [email]: Just(false) });
16+ cache.current[email] = false;
17+ } else {
18+ cache.current[email] = await hasReceived(email);
19+ }
27 }
20 }
28
21
29− const _hasReceived = await hasReceived(email);
22+ return cache.current[email];
30− addToCache({ [email]: Just(_hasReceived) });
31 },
23 },
32− [getHasReceived, addToCache]
24+ [cache]
33 );
25 );
34
26
35− return { getHasReceived, syncHasReceivedForEmail };
36−}
37−
38−export default function useMailer(emails) {
39− const hasReceivedCache = useHasReceivedCache(emails);
40−
41 // prefix to avoid clobbering sendMail import
27 // prefix to avoid clobbering sendMail import
42 // also throws if return value is false
28 // also throws if return value is false
43 const _sendMail = useCallback(async (email, ticket, sender, rawTx) => {
29 const _sendMail = useCallback(async (email, ticket, sender, rawTx) => {
@@ -46,8 +32,11 @@ export default function useMailer(emails) {
46 return true;
32 return true;
47 }
33 }
48
34
49− return await sendMail(email, ticket, sender, rawTx);
35+ const success = await sendMail(email, ticket, sender, rawTx);
36+ if (!success) {
37+ throw new Error('Failed to send mail');
38+ }
50 }, []);
39 }, []);
51
40
52− return { ...hasReceivedCache, sendMail: _sendMail };
41+ return { getHasReceived, sendMail: _sendMail };
53}
42}
src/lib/useRouter.js+2−2
@@ -53,7 +53,7 @@ export default function useRouter({
53 if (size <= 1) {
53 if (size <= 1) {
54 // if we are at the root, pass this event to our parent
54 // if we are at the root, pass this event to our parent
55 if (oldPopState.current) {
55 if (oldPopState.current) {
56− window.history.back();
56+ oldPopState.current();
57 }
57 }
58 return;
58 return;
59 }
59 }
@@ -108,7 +108,7 @@ export default function useRouter({
108
108
109 // construct new onpopstate handler
109 // construct new onpopstate handler
110 window.onpopstate = e => {
110 window.onpopstate = e => {
111− e.stopImmediatePropagation();
111+ e && e.stopImmediatePropagation();
112 pop();
112 pop();
113 };
113 };
114
114
src/lib/validators.js+56−234
@@ -1,6 +1,6 @@
1import * as bip39 from 'bip39';
1import * as bip39 from 'bip39';
2import * as ob from 'urbit-ob';
2import * as ob from 'urbit-ob';
3−import { identity, includes } from 'lodash';
3+import { includes } from 'lodash';
4
4
5import { isValidAddress, ETH_ZERO_ADDR, ETH_ZERO_ADDR_SHORT } from './wallet';
5import { isValidAddress, ETH_ZERO_ADDR, ETH_ZERO_ADDR_SHORT } from './wallet';
6import patp2dec from './patp2dec';
6import patp2dec from './patp2dec';
@@ -14,261 +14,83 @@ import { MIN_GALAXY, MAX_GALAXY } from './constants';
14// via: https://emailregex.com/
14// via: https://emailregex.com/
15const emailRegExp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
15const emailRegExp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
16
16
17−// is this 64 hex chars?
18−const plain64CharHexValue = /[0-9A-Fa-f]{64}/;
19−
20// is this only hex values with a 0x prefix?
17// is this only hex values with a 0x prefix?
21const isHexString = /0x[0-9A-Fa-f]/;
18const isHexString = /0x[0-9A-Fa-f]/;
22
19
23−// Wraps single validation functions in a controlled and predictable way.
24−export const simpleValidatorWrapper = ({
25− prevMessage,
26− transform = identity,
27− validator,
28− error,
29−}) => {
30− // If a previous validation has already failed, skip this validation and
31− // return the prev message to the next stage in the validation function chain.
32− // Failed validations should drop all the way down the chain and drop out of
33− // the output.
34− if (!prevMessage.pass) {
35− return prevMessage;
36− }
37−
38− // Run the validator and return the result.
39− const pass = validator(prevMessage.data);
40− const data = pass ? transform(prevMessage.data) : prevMessage.data;
41−
42− return newMessage(data, pass, !pass && error);
43−};
44−
45−// Validation message
46−// {
47−// data: ...
48−// pass: ...
49−// error: ...
50−// }
51−// Creates a new validation message in a uniform way.
52−const newMessage = (data, pass, error) => ({
53− // The input data
54− data,
55− // Has the data passed validation?
56− pass,
57− // If data has failed a validator, the error message goes here.
58− error,
59−});
60−
61−// A validator that always passes.
62−export const kDefaultValidator = data => newMessage(data, true, null);
63−
64// Validates a bip39 mnemonic
20// Validates a bip39 mnemonic
65−export const validateMnemonic = m =>
21+export const validateMnemonic = v =>
66− simpleValidatorWrapper({
67− prevMessage: m,
68− validator: d => bip39.validateMnemonic(d),
69− error: 'This is not a valid mnemonic.',
22+ !bip39.validateMnemonic(v) && 'This is not a valid mnemonic.';
70− });
71
23
72// Checks an empty field
24// Checks an empty field
73−export const validateNotEmpty = m =>
25+export const validateNotEmpty = v =>
74− simpleValidatorWrapper({
75− prevMessage: m,
76− validator: d => {
77− try {
78− return d.length > 0;
79− } catch {
80− return false;
81− }
82− },
83− error: 'This field is required.',
26+ (v === undefined || v.length === 0) && 'This field is required.';
84− });
85
27
86// Checks if a patp is a valid galaxy
28// Checks if a patp is a valid galaxy
87−export const validateGalaxy = m =>
29+export const validateGalaxy = v => {
88− simpleValidatorWrapper({
89− prevMessage: m,
90− validator: d => {
91− let point;
92− try {
30+ try {
93− point = patp2dec(d);
31+ const point = patp2dec(v);
94− return point >= MIN_GALAXY && point <= MAX_GALAXY;
32+ const isValidGalaxy = point >= MIN_GALAXY && point <= MAX_GALAXY;
95− } catch (e) {
33+ if (!isValidGalaxy) {
96− return false;
34+ throw new Error();
97− }
35+ }
98− },
36+ } catch {
99− error: 'This is not a valid galaxy.',
37+ return 'This is not a valid galaxy.';
100− });
38+ }
101−
102−export const validatePoint = m =>
103− simpleValidatorWrapper({
104− prevMessage: m,
105− validator: d => {
106− try {
107− return ob.isValidPatp(d);
108− } catch (e) {
109− return false;
110− }
111− },
112− error: 'This is not a valid point.',
113− });
114−
115−export const validateTicket = m =>
116− simpleValidatorWrapper({
117− prevMessage: m,
118− validator: d => {
119− try {
120− return ob.isValidPatq(d);
121− } catch (e) {
122− return false;
123− }
124− },
39+};
125− error: 'This is not a valid ticket.',
126− });
127−
128−export const validateShard = m =>
129− simpleValidatorWrapper({
130− prevMessage: m,
131− validator: d => {
132− try {
133− return ob.isValidPatq(d);
134− } catch (e) {
135− return false;
136− }
137− },
138− error: 'This is not a valid shard.',
139− });
140−
141−export const validateOneOf = (options = []) => m =>
142− simpleValidatorWrapper({
143− prevMessage: m,
144− validator: d => includes(options, d),
145− error: `Is not a valid option.`,
146− });
147−
148−export const validateHexString = m =>
149− simpleValidatorWrapper({
150− prevMessage: m,
151− validator: d => isHexString.test(d),
152− error: 'This is not a valid hex string.',
153− });
154
40
155−// @deprecate
156−export const validateNetworkKey = m =>
41+export const validatePoint = v => {
157− simpleValidatorWrapper({
42+ try {
158− prevMessage: m,
43+ if (!ob.isValidPatp(v)) {
159− validator: d => plain64CharHexValue.test(d),
44+ throw new Error();
45+ }
46+ } catch {
160− error: 'This is not a valid network key.',
47+ return 'This is not a valid point.';
161− });
48+ }
49+};
162
50
163−// @deprecate
164−export const validateNetworkSeed = m =>
51+export const validatePatq = v => {
165− simpleValidatorWrapper({
52+ try {
166− prevMessage: m,
53+ if (!ob.isValidPatq(v)) {
167− validator: d => plain64CharHexValue.test(d),
54+ throw new Error();
55+ }
56+ } catch {
168− error: 'This is not a valid network seed.',
57+ return 'This is not a valid ticket.';
169− });
58+ }
59+};
170
60
171−// Checks if a string is a valid ethereum address
61+export const validateOneOf = (options = []) => v =>
172−export const validateEthereumAddress = m =>
62+ !includes(options, v) && 'Is not a valid option.';
173− simpleValidatorWrapper({
174− prevMessage: m,
175− validator: d => isValidAddress(d),
176− error: 'This is not a valid Ethereum address.',
177− });
178
63
179−export const validateEmail = m =>
64+export const validateHexString = v =>
180− simpleValidatorWrapper({
181− prevMessage: m,
182− validator: d => emailRegExp.test(d),
183− error: 'This is not a valid email address.',
65+ !isHexString.test(v) && 'This is not a valid hex string.';
184− });
185
66
186−export const validateInt = m =>
67+export const validateEthereumAddress = v =>
187− simpleValidatorWrapper({
188− prevMessage: m,
189− transform: d => parseInt(d, 10),
190− validator: d => {
191− try {
192− parseInt(d, 10);
193− return;
194− } catch {
195− return false;
196− }
197− },
198− error: 'This is not a valid number.',
68+ !isValidAddress(v) && 'This is not a valid Ethereum address.';
199− });
200
69
201−export const validateExactly = (value, error) => m =>
70+export const validateEmail = v =>
202− simpleValidatorWrapper({
71+ !emailRegExp.test(v) && 'This is not a valid email address.';
203− prevMessage: m,
204− validator: d => d === value,
205− error,
206− });
207
72
208−export const validateNotAny = (values = []) => m =>
73+export const validateExactly = (value, error) => v => v !== value && error;
209− simpleValidatorWrapper({
210− prevMessage: m,
211− validator: d => !values.includes(d),
212− error: `Cannot be ${m.data}.`,
213− });
214
74
215−export const validateLength = l => m =>
75+export const validateNotAny = (values = []) => v =>
216− simpleValidatorWrapper({
76+ values.includes(v) && `Cannot be ${v}.`;
217− prevMessage: m,
218− validator: d => {
219− try {
220− return d.length === l;
221− } catch {
222− return false;
223− }
224− },
225− error: `Must be exactly ${l} characters.`,
226− });
227
77
228−export const validateMaximumLength = l => m =>
78+export const validateLength = l => v =>
229− simpleValidatorWrapper({
230− prevMessage: m,
231− validator: d => {
232− try {
233− return d.length <= l;
234− } catch {
235− return false;
236− }
237− },
238− error: `Must be ${l} characters or fewer.`,
79+ v.length !== l && `Must be exactly ${l} characters.`;
239− });
240
80
241−export const validateGreaterThan = l => m =>
81+export const validateHexLength = l => v =>
242− simpleValidatorWrapper({
82+ v.length !== l + 2 && `Must be exactly ${l} hex characters.`;
243− prevMessage: m,
244− validator: d => d > l,
245− error: `Must be at least ${l}.`,
246− });
247
83
248−export const validateInSet = (set, error) => m =>
84+export const validateMaximumLength = l => v =>
249− simpleValidatorWrapper({
85+ v.length > l && `Must be ${l} characters or fewer.`;
250− prevMessage: m,
251− validator: d => set.has(d),
252− error,
253− });
254
86
255−export const validateNameInNumberSet = (set, error) => m =>
87+export const validateGreaterThan = l => v =>
256− simpleValidatorWrapper({
88+ !(v > l) && `Must be at least ${l}.`;
257− prevMessage: m,
258− validator: p => {
259− const num = patp2dec(p);
260− return set.has(num);
261− },
262− error,
263− });
264
89
265−export const validatePatpByteLength = byteLength => {
90+export const validateInSet = (set, error) => v => !set.has(v) && error;
266− return validateLength(patpStringLength(byteLength));
267−};
268
91
269−export const validateMaximumPatpByteLength = byteLength => {
92+export const validateMaximumPatpByteLength = byteLength =>
270− return validateMaximumLength(patpStringLength(byteLength));
93+ validateMaximumLength(patpStringLength(byteLength));
271−};
272
94
273export const validateNotNullAddress = validateNotAny([
95export const validateNotNullAddress = validateNotAny([
274 ETH_ZERO_ADDR,
96 ETH_ZERO_ADDR,
src/store/wallet.js+6−1
@@ -33,7 +33,12 @@ const DEFAULT_WALLET_TYPE = WALLET_TYPES.TICKET;
33// JSON keystore files, and Metamask authentication, it wraps an
33// JSON keystore files, and Metamask authentication, it wraps an
34// 'EthereumWallet'.
34// 'EthereumWallet'.
35function _useWallet(initialWallet = Nothing(), initialMnemonic = Nothing()) {
35function _useWallet(initialWallet = Nothing(), initialMnemonic = Nothing()) {
36− const [walletType, _setWalletType] = useState(DEFAULT_WALLET_TYPE);
36+ const [walletType, _setWalletType] = useState(() =>
37+ initialMnemonic.matchWith({
38+ Nothing: () => DEFAULT_WALLET_TYPE,
39+ Just: () => WALLET_TYPES.MNEMONIC,
40+ })
41+ );
37 const [walletHdPath, setWalletHdPath] = useState(DEFAULT_HD_PATH);
42 const [walletHdPath, setWalletHdPath] = useState(DEFAULT_HD_PATH);
38 const [wallet, _setWallet] = useState(initialWallet);
43 const [wallet, _setWallet] = useState(initialWallet);
39 const [urbitWallet, _setUrbitWallet] = useState(Nothing());
44 const [urbitWallet, _setUrbitWallet] = useState(Nothing());
src/style/indigo-proposals.scss+4
@@ -37,6 +37,10 @@
37 user-select: none;
37 user-select: none;
38}
38}
39
39
40+.bs-none {
41+ box-shadow: none !important;
42+}
43+
40.auto-rows-min {
44.auto-rows-min {
41 grid-auto-rows: min-content;
45 grid-auto-rows: min-content;
42}
46}
src/views/Activate.js+7−1
@@ -1,11 +1,12 @@
1import React from 'react';
1import React from 'react';
2
2
3import useRouter from 'lib/useRouter';
3import useRouter from 'lib/useRouter';
4+import { LocalRouterProvider } from 'lib/LocalRouter';
5+import { useSyncKnownPoints } from 'lib/useSyncPoints';
4
6
5import { ActivateFlowProvider } from './Activate/ActivateFlow';
7import { ActivateFlowProvider } from './Activate/ActivateFlow';
6import useActivateFlowState from './Activate/useActivateFlowState';
8import useActivateFlowState from './Activate/useActivateFlowState';
7import ActivateCode from './Activate/ActivateCode';
9import ActivateCode from './Activate/ActivateCode';
8−import { LocalRouterProvider } from 'lib/LocalRouter';
9import ActivatePassport from './Activate/ActivatePassport';
10import ActivatePassport from './Activate/ActivatePassport';
10import Disclaimer from './Disclaimer';
11import Disclaimer from './Disclaimer';
11
12
@@ -29,6 +30,11 @@ export default function Activate() {
29 initialRoutes: [{ key: NAMES.CODE }],
30 initialRoutes: [{ key: NAMES.CODE }],
30 });
31 });
31
32
33+ // when we know the derived point, ensure we have the data to display it
34+ useSyncKnownPoints(
35+ [state.derivedPoint.getOrElse(null)].filter(p => p !== null)
36+ );
37+
32 return (
38 return (
33 <LocalRouterProvider value={router}>
39 <LocalRouterProvider value={router}>
34 <ActivateFlowProvider value={state}>
40 <ActivateFlowProvider value={state}>
src/views/Activate/ActivateCode.js+129−114
@@ -1,30 +1,41 @@
1−import React, { useCallback, useEffect, useState } from 'react';
1+import React, { useCallback, useMemo, useRef } from 'react';
2−import { Just, Nothing } from 'folktale/maybe';
2+import { Just } from 'folktale/maybe';
3import * as azimuth from 'azimuth-js';
3import * as azimuth from 'azimuth-js';
4−import { Grid, Input, H4, ErrorText } from 'indigo-react';
4+import { Grid, H4 } from 'indigo-react';
5+import { FORM_ERROR } from 'final-form';
5
6
6import View from 'components/View';
7import View from 'components/View';
7import { ForwardButton } from 'components/Buttons';
8import { ForwardButton } from 'components/Buttons';
8import Passport from 'components/Passport';
9import Passport from 'components/Passport';
10+import WarningBox from 'components/WarningBox';
11+import FooterButton from 'components/FooterButton';
9
12
13+import { useNetwork } from 'store/network';
10import { useHistory } from 'store/history';
14import { useHistory } from 'store/history';
11
15
12−import { useTicketInput } from 'lib/useInputs';
13import * as need from 'lib/need';
16import * as need from 'lib/need';
14import { ROUTE_NAMES } from 'lib/routeNames';
17import { ROUTE_NAMES } from 'lib/routeNames';
15−import { useSyncKnownPoints } from 'lib/useSyncPoints';
16−import FooterButton from 'components/FooterButton';
17−import { blinkIf } from 'components/Blinky';
18import { DEFAULT_HD_PATH, walletFromMnemonic } from 'lib/wallet';
18import { DEFAULT_HD_PATH, walletFromMnemonic } from 'lib/wallet';
19−import { useNetwork } from 'store/network';
20import { generateWallet } from 'lib/invite';
19import { generateWallet } from 'lib/invite';
21import { generateTemporaryOwnershipWallet } from 'lib/walletgen';
20import { generateTemporaryOwnershipWallet } from 'lib/walletgen';
22−import { useActivateFlow } from './ActivateFlow';
23import { useLocalRouter } from 'lib/LocalRouter';
21import { useLocalRouter } from 'lib/LocalRouter';
24import useImpliedTicket from 'lib/useImpliedTicket';
22import useImpliedTicket from 'lib/useImpliedTicket';
23+import timeout from 'lib/timeout';
25import useHasDisclaimed from 'lib/useHasDisclaimed';
24import useHasDisclaimed from 'lib/useHasDisclaimed';
26import useBreakpoints from 'lib/useBreakpoints';
25import useBreakpoints from 'lib/useBreakpoints';
27−import WarningBox from 'components/WarningBox';
26+
27+import BridgeForm from 'form/BridgeForm';
28+import SubmitButton from 'form/SubmitButton';
29+import { TicketInput } from 'form/Inputs';
30+import {
31+ composeValidator,
32+ buildPatqValidator,
33+ hasErrors,
34+} from 'form/validators';
35+import FormError from 'form/FormError';
36+
37+import { useActivateFlow } from './ActivateFlow';
38+import { WARNING } from 'form/helpers';
28
39
29export default function ActivateCode() {
40export default function ActivateCode() {
30 const history = useHistory();
41 const history = useHistory();
@@ -32,10 +43,9 @@ export default function ActivateCode() {
32 const { contracts } = useNetwork();
43 const { contracts } = useNetwork();
33 const impliedTicket = useImpliedTicket();
44 const impliedTicket = useImpliedTicket();
34 const [hasDisclaimed] = useHasDisclaimed();
45 const [hasDisclaimed] = useHasDisclaimed();
35− const [generalError, setGeneralError] = useState();
46+ const didWarn = useRef(false);
36− const [deriving, setDeriving] = useState(false);
47+
37 const {
48 const {
38− derivedWallet,
39 setDerivedWallet,
49 setDerivedWallet,
40 setInviteWallet,
50 setInviteWallet,
41 derivedPoint,
51 derivedPoint,
@@ -48,129 +58,134 @@ export default function ActivateCode() {
48 // for this screen
58 // for this screen
49 const activationAllowed = useBreakpoints([false, true, true]);
59 const activationAllowed = useBreakpoints([false, true, true]);
50
60
51− const [ticketInput, { pass: validTicket, data: ticket }] = useTicketInput({
52− name: 'ticket',
53− label: 'Activation Code',
54− initialValue: impliedTicket || '',
55− autoFocus: true,
56− disabled: !activationAllowed,
57− });
58−
59 const goToLogin = useCallback(() => history.popAndPush(ROUTE_NAMES.LOGIN), [
61 const goToLogin = useCallback(() => history.popAndPush(ROUTE_NAMES.LOGIN), [
60 history,
62 history,
61 ]);
63 ]);
64+
62 const goToPassport = useCallback(() => {
65 const goToPassport = useCallback(() => {
63 push(names.PASSPORT);
66 push(names.PASSPORT);
67+
64 if (!hasDisclaimed) {
68 if (!hasDisclaimed) {
65 push(names.DISCLAIMER);
69 push(names.DISCLAIMER);
66 }
70 }
67− }, [names, push, hasDisclaimed]);
71+ }, [hasDisclaimed, names.DISCLAIMER, names.PASSPORT, push]);
68
72
69− const pass = derivedWallet.matchWith({
73+ const validateForm = useCallback((values, errors) => {
70− Nothing: () => false,
74+ didWarn.current = false;
71− Just: () => true,
72− });
73
75
74− useEffect(() => {
76+ if (hasErrors(errors)) {
75− if (validTicket) {
77+ return errors;
76− const _contracts = need.contracts(contracts);
78+ }
79+ }, []);
77
80
78− setDeriving(true);
81+ const validate = useMemo(
79− // when the ticket becomes valid, derive the point
82+ () => composeValidator({ ticket: buildPatqValidator() }, validateForm),
83+ [validateForm]
84+ );
85+
86+ // set our state on submission
87+ const onSubmit = useCallback(
80− (async () => {
88+ async values => {
89+ await timeout(16); // allow the ui changes to flush before we lag it out
90+
91+ const _contracts = need.contracts(contracts);
81− const { seed } = await generateTemporaryOwnershipWallet(ticket);
92+ const { seed } = await generateTemporaryOwnershipWallet(values.ticket);
82−
93+
83− //TODO isn't all this accessible in the ownership object?
84− const inviteWallet = walletFromMnemonic(seed, DEFAULT_HD_PATH);
94+ const inviteWallet = walletFromMnemonic(seed, DEFAULT_HD_PATH);
95+
85− const _inviteWallet = need.wallet(inviteWallet);
96+ const _inviteWallet = need.wallet(inviteWallet);
86−
97+
87− const owned = await azimuth.azimuth.getOwnedPoints(
98+ const owned = await azimuth.azimuth.getOwnedPoints(
88− _contracts,
99+ _contracts,
89− _inviteWallet.address
100+ _inviteWallet.address
90− );
101+ );
91− const transferring = await azimuth.azimuth.getTransferringFor(
102+ const transferring = await azimuth.azimuth.getTransferringFor(
92− _contracts,
103+ _contracts,
93− _inviteWallet.address
104+ _inviteWallet.address
94− );
105+ );
95− const incoming = [...owned, ...transferring];
106+ const incoming = [...owned, ...transferring];
96−
107+
97− let realPoint = Nothing();
98− let wallet = Nothing();
99−
100− if (incoming.length > 0) {
108+ if (incoming.length > 0) {
101− const pointNum = parseInt(incoming[0], 10);
102− realPoint = Just(pointNum);
103− wallet = Just(await generateWallet(pointNum));
104−
105− if (incoming.length > 1) {
109+ if (incoming.length > 1 && !didWarn.current) {
106− setGeneralError(
110+ didWarn.current = true;
111+ return {
112+ [WARNING]:
107− 'This invite code has multiple points available.\n' +
113+ 'This invite code has multiple points available. ' +
114+ "Once you've activated this point, " +
108− "Once you've activated this point, activate the next with the same process."
115+ 'activate the next with the same process.',
109− );
110− } else {
111− setGeneralError(false);
112− }
113− } else {
114− setGeneralError(
115− 'Invite code has no claimable point.\n' +
116− 'Check your invite code and try again?'
117− );
116+ };
118 }
117 }
119
118
119+ const point = parseInt(incoming[0], 10);
120+
120− setDerivedPoint(realPoint);
121+ setDerivedPoint(Just(point));
121− setDerivedWallet(wallet);
122 setInviteWallet(inviteWallet);
122 setInviteWallet(inviteWallet);
123− setDeriving(false);
123+ setDerivedWallet(Just(await generateWallet(point)));
124− })();
125− } else {
124+ } else {
126− setGeneralError(false);
125+ return {
126+ [FORM_ERROR]:
127+ 'Invite code has no claimable point.\n' +
128+ 'Check your invite code and try again?',
127− }
129+ };
128− }, [
130+ }
129− validTicket,
131+ },
130− contracts,
131− ticket,
132− setDerivedPoint,
133− setDerivedWallet,
132+ [contracts, setDerivedPoint, setDerivedWallet, setInviteWallet]
134− setInviteWallet,
135− ]);
133+ );
136
134
137− // when we know the derived point, ensure we have the data to display it
135+ const initialValues = useMemo(() => ({ ticket: impliedTicket || '' }), [
138− useSyncKnownPoints([derivedPoint.getOrElse(null)].filter(p => p !== null));
136+ impliedTicket,
137+ ]);
139
138
140 return (
139 return (
141 <View inset>
140 <View inset>
142 <Grid>
141 <Grid>
143− <Grid.Item as={Passport} point={derivedPoint} full />
142+ <Grid.Item full as={Passport} point={derivedPoint} />
144− <Grid.Item as={H4} className="mt3 mb2" full>
143+ <Grid.Item full as={H4} className="mt3 mb2">
145 Activate
144 Activate
146 </Grid.Item>
145 </Grid.Item>
147−
146+ <BridgeForm
148− <Grid.Item as={Input} {...ticketInput} full />
147+ validate={validate}
149−
148+ onSubmit={onSubmit}
150− {generalError && (
151− <Grid.Item full>
152− <ErrorText>{generalError}</ErrorText>
153− </Grid.Item>
154− )}
155−
156− <Grid.Item
157− as={ForwardButton}
158− className="mt4"
159− disabled={!pass || deriving || !activationAllowed}
160− accessory={blinkIf(deriving)}
161− onClick={goToPassport}
149+ afterSubmit={goToPassport}
162− solid
150+ initialValues={initialValues}>
163− full>
151+ {({ validating, submitting, handleSubmit }) => (
152+ <>
153+ <Grid.Item
154+ full
155+ as={TicketInput}
156+ name="ticket"
157+ label="Activation Code"
158+ disabled={!activationAllowed}
159+ />
160+
161+ <Grid.Item full as={FormError} />
162+
163+ <Grid.Item
164+ full
165+ as={SubmitButton}
166+ className="mt4"
167+ handleSubmit={handleSubmit}>
168+ {isWarning =>
169+ validating
164− {deriving && 'Deriving...'}
170+ ? 'Deriving...'
165− {!deriving && 'Go'}
171+ : submitting
172+ ? 'Generating...'
173+ : isWarning
174+ ? 'Continue Activation'
175+ : 'Go'
176+ }
166− </Grid.Item>
177+ </Grid.Item>
167−
178+
168− {!activationAllowed && (
179+ {!activationAllowed && (
169− <Grid.Item full as={WarningBox} className="mt4">
180+ <Grid.Item full as={WarningBox} className="mt4">
170− For your security, please access Bridge on a desktop device.
181+ For your security, please access Bridge on a desktop device.
171− </Grid.Item>
182+ </Grid.Item>
183+ )}
184+ </>
172− )}
185+ )}
186+ </BridgeForm>
173 </Grid>
187 </Grid>
188+
174 <FooterButton as={ForwardButton} onClick={goToLogin}>
189 <FooterButton as={ForwardButton} onClick={goToLogin}>
175 Login
190 Login
176 </FooterButton>
191 </FooterButton>
src/views/Activate/PassportTransfer.js+1−5
@@ -57,11 +57,7 @@ export default function PassportTransfer({ className, resetActivateRouter }) {
57 const [needFunds, setNeedFunds] = useState();
57 const [needFunds, setNeedFunds] = useState();
58
58
59 const goToLogin = useCallback(
59 const goToLogin = useCallback(
60− () =>
61− replaceWith([
62− { key: names.LOGIN },
60+ () => replaceWith([{ key: names.LOGIN }, { key: names.POINT }]),
63− { key: names.POINT },
64− ]),
65 [replaceWith, names]
61 [replaceWith, names]
66 );
62 );
67
63
src/views/Activate/PassportVerify.js+45−22
@@ -1,16 +1,19 @@
1import React, { useCallback, useMemo } from 'react';
1import React, { useCallback, useMemo } from 'react';
2−import { Grid, Input, P } from 'indigo-react';
2+import { Grid, P } from 'indigo-react';
3
3
4import * as need from 'lib/need';
4import * as need from 'lib/need';
5import { useLocalRouter } from 'lib/LocalRouter';
5import { useLocalRouter } from 'lib/LocalRouter';
6−import { useTicketInput } from 'lib/useInputs';
7import { validateExactly } from 'lib/validators';
6import { validateExactly } from 'lib/validators';
8import { isDevelopment } from 'lib/flags';
7import { isDevelopment } from 'lib/flags';
9
8
10−import { ForwardButton } from 'components/Buttons';
9+import SubmitButton from 'form/SubmitButton';
10+import { TicketInput } from 'form/Inputs';
11+import { composeValidator, buildPatqValidator } from 'form/validators';
12+import BridgeForm from 'form/BridgeForm';
11
13
12import { useActivateFlow } from './ActivateFlow';
14import { useActivateFlow } from './ActivateFlow';
13import PassportView from './PassportView';
15import PassportView from './PassportView';
16+import FormError from 'form/FormError';
14
17
15const STUB_VERIFY_TICKET = isDevelopment;
18const STUB_VERIFY_TICKET = isDevelopment;
16
19
@@ -20,17 +23,22 @@ export default function PassportVerify({ className }) {
20 const goToTransfer = useCallback(() => push(names.TRANSFER), [push, names]);
23 const goToTransfer = useCallback(() => push(names.TRANSFER), [push, names]);
21
24
22 const { ticket } = need.wallet(derivedWallet);
25 const { ticket } = need.wallet(derivedWallet);
23− const validators = useMemo(
26+ const validate = useMemo(
27+ () =>
28+ composeValidator({
29+ ticket: buildPatqValidator([
24− () => [validateExactly(ticket, 'Does not match expected master ticket.')],
30+ validateExactly(ticket, 'Does not match expected master ticket.'),
31+ ]),
32+ }),
33+ [ticket]
34+ );
35+
36+ const initialValues = useMemo(
37+ () => ({
38+ ticket: STUB_VERIFY_TICKET ? ticket : undefined,
39+ }),
25 [ticket]
40 [ticket]
26 );
41 );
27− const [ticketInput, { pass }] = useTicketInput({
28− name: 'ticket',
29− label: 'Master Ticket',
30− initialValue: STUB_VERIFY_TICKET ? ticket : undefined,
31− autoFocus: true,
32− validators,
33− });
34
42
35 return (
43 return (
36 <PassportView header="Verify Passport" step={2} className={className}>
44 <PassportView header="Verify Passport" step={2} className={className}>
@@ -40,16 +48,31 @@ export default function PassportVerify({ className }) {
40 should be a folder of image files. One of them is your Master Ticket.
48 should be a folder of image files. One of them is your Master Ticket.
41 Open it and enter the 4 word phrase below (with hyphens).
49 Open it and enter the 4 word phrase below (with hyphens).
42 </Grid.Item>
50 </Grid.Item>
51+ <BridgeForm
52+ validate={validate}
53+ afterSubmit={goToTransfer}
54+ initialValues={initialValues}>
55+ {({ handleSubmit }) => (
56+ <>
43− <Grid.Item full as={Input} {...ticketInput} />
57+ <Grid.Item
58+ full
59+ as={TicketInput}
60+ name="ticket"
61+ label="Master Ticket"
62+ />
63+
64+ <Grid.Item full as={FormError} />
65+
44− <Grid.Item
66+ <Grid.Item
45− full
67+ full
46− className="mt3"
68+ className="mt3"
47− as={ForwardButton}
69+ as={SubmitButton}
48− disabled={!pass}
70+ handleSubmit={handleSubmit}>
49− onClick={goToTransfer}
50− solid>
51− Verify
71+ Verify
52− </Grid.Item>
72+ </Grid.Item>
73+ </>
74+ )}
75+ </BridgeForm>
53 </Grid>
76 </Grid>
54 </PassportView>
77 </PassportView>
55 );
78 );
src/views/Admin/AdminNetworkingKeys.js+101−82
@@ -1,14 +1,6 @@
1−import React, { useCallback, useEffect, useRef, useState } from 'react';
1+import React, { useCallback, useMemo, useRef, useState } from 'react';
2import { Just, Nothing } from 'folktale/maybe';
2import { Just, Nothing } from 'folktale/maybe';
3−import {
4− Grid,
5− Text,
6− H5,
7− Flex,
8− ToggleInput,
9− Input,
10− CheckboxInput,
11−} from 'indigo-react';
3+import { Grid, Text, H5, Flex, ToggleInput, CheckboxInput } from 'indigo-react';
12import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
13import { randomHex } from 'web3-utils';
5import { randomHex } from 'web3-utils';
14
6
@@ -30,7 +22,6 @@ import { formatDotsWithTime } from 'lib/dateFormat';
30import useEthereumTransaction from 'lib/useEthereumTransaction';
22import useEthereumTransaction from 'lib/useEthereumTransaction';
31import { GAS_LIMITS } from 'lib/constants';
23import { GAS_LIMITS } from 'lib/constants';
32import { addHexPrefix } from 'lib/wallet';
24import { addHexPrefix } from 'lib/wallet';
33−import { useHexInput, useCheckboxInput } from 'lib/useInputs';
34import useKeyfileGenerator from 'lib/useKeyfileGenerator';
25import useKeyfileGenerator from 'lib/useKeyfileGenerator';
35
26
36import ViewHeader from 'components/ViewHeader';
27import ViewHeader from 'components/ViewHeader';
@@ -40,6 +31,16 @@ import DownloadKeyfileButton from 'components/DownloadKeyfileButton';
40import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
31import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
41import NoticeBox from 'components/NoticeBox';
32import NoticeBox from 'components/NoticeBox';
42
33
34+import { HexInput } from 'form/Inputs';
35+import {
36+ composeValidator,
37+ buildCheckboxValidator,
38+ buildHexValidator,
39+} from 'form/validators';
40+import BridgeForm from 'form/BridgeForm';
41+import Condition from 'form/Condition';
42+import FormError from 'form/FormError';
43+
43const chainKeyProp = name => d =>
44const chainKeyProp = name => d =>
44 d[name] === CURVE_ZERO_ADDR ? Nothing() : Just(d[name]);
45 d[name] === CURVE_ZERO_ADDR ? Nothing() : Just(d[name]);
45
46
@@ -163,58 +164,50 @@ export default function AdminNetworkingKeys() {
163 keyfileBind,
164 keyfileBind,
164 } = useSetKeys();
165 } = useSetKeys();
165
166
166− const [showNetworkSeedInput, { data: showNetworkSeed }] = useCheckboxInput({
167+ const validateForm = useCallback((values, errors) => {
167− name: 'shownetworkseed',
168+ if (values.useNetworkSeed && errors.networkSeed) {
168− label: 'Use Custom Network Seed',
169+ return errors;
169− inverseLabel: 'Back to Derived Network Seed',
170+ }
170− initialValue: false,
171+ }, []);
171− disabled: inputsLocked,
172+
173+ const validate = useMemo(
174+ () =>
175+ composeValidator(
176+ {
177+ useNetworkSeed: buildCheckboxValidator(),
178+ networkSeed: buildHexValidator(32),
179+ useDiscontinuity: buildCheckboxValidator(),
180+ },
181+ validateForm
182+ ),
183+ [validateForm]
172− });
184+ );
173
185
174− const [
186+ const onValues = useCallback(
175− networkSeedInput,
187+ ({ valid, values, form }) => {
176− { pass: validNetworkSeed, data: networkSeed },
188+ if (valid) {
177− { reset: resetNetworkSeed },
189+ construct(
178− ] = useHexInput({
190+ values.useNetworkSeed ? values.networkSeed : undefined,
179− name: 'networkseed',
191+ values.useDiscontinuity
180− label: 'Network Seed (64 bytes)',
192+ );
181− length: 32, // 64 bytes
193+ } else {
182− disabled: inputsLocked,
194+ unconstruct();
183− });
195+ }
184
196
185− const [
197+ if (!values.useNetworkSeed && values.networkSeed) {
186− discontinuityInput,
198+ form.change('networkSeed', '');
187− { pass: validDiscontinuity, data: isDiscontinuity },
199+ }
188− ] = useCheckboxInput({
200+ },
189− name: 'discontinuity',
201+ [construct, unconstruct]
190− label: 'Trigger New Continuity Era',
191− initialValue: false,
192− disabled: inputsLocked,
193− });
202+ );
194
203
195− useEffect(() => {
204+ const initialValues = useMemo(
196− const nothingOrValidSeed =
205+ () => ({
197− !showNetworkSeed || (showNetworkSeed && validNetworkSeed);
206+ useNetworkSeed: false,
198− if (nothingOrValidSeed && validDiscontinuity) {
207+ useDiscontinuity: false,
199− construct(networkSeed, isDiscontinuity);
208+ }),
200− } else {
201− unconstruct();
202− }
203− }, [
204− construct,
205− unconstruct,
206− isDiscontinuity,
207− networkSeed,
208− showNetworkSeed,
209− validDiscontinuity,
210− validNetworkSeed,
211− ]);
209+ []
212−
210+ );
213− useEffect(() => {
214− if (!showNetworkSeed) {
215− resetNetworkSeed();
216− }
217− }, [resetNetworkSeed, showNetworkSeed]);
218
211
219 const goRelocate = useCallback(() => push(names.RELOCATE), [push, names]);
212 const goRelocate = useCallback(() => push(names.RELOCATE), [push, names]);
220
213
@@ -241,16 +234,16 @@ export default function AdminNetworkingKeys() {
241 </Grid.Item>
234 </Grid.Item>
242 {key.matchWith({
235 {key.matchWith({
243 Nothing: () => (
236 Nothing: () => (
244− <Grid.Item full as="code" className="f5 gray4">
237+ <Grid.Item full as="code" className="f5 mono gray4">
245 Unset
238 Unset
246 </Grid.Item>
239 </Grid.Item>
247 ),
240 ),
248 Just: ({ value: key }) => (
241 Just: ({ value: key }) => (
249 <>
242 <>
250− <Grid.Item full as="code" className="f5 wrap">
243+ <Grid.Item full as="code" className="f5 mono wrap">
251 0x
244 0x
252 </Grid.Item>
245 </Grid.Item>
253− <Grid.Item full as="code" className="f5 wrap">
246+ <Grid.Item full as="code" className="f5 mono wrap">
254 {renderNetworkKey(key)}
247 {renderNetworkKey(key)}
255 </Grid.Item>
248 </Grid.Item>
256 </>
249 </>
@@ -335,30 +328,56 @@ export default function AdminNetworkingKeys() {
335 )}
328 )}
336
329
337 {!completed && (
330 {!completed && (
338− <>
331+ <BridgeForm
339− <Grid.Item full as={ToggleInput} {...showNetworkSeedInput} />
332+ validate={validate}
340− {showNetworkSeed && (
333+ onValues={onValues}
334+ initialValues={initialValues}>
335+ {({ handleSubmit }) => (
341 <>
336 <>
337+ <Grid.Item
338+ full
339+ as={ToggleInput}
340+ name="useNetworkSeed"
341+ label="Use Custom Network Seed"
342+ inverseLabel="Back to Derived Network Seed"
343+ disabled={inputsLocked}
344+ />
345+ <Condition when="useNetworkSeed" is={true}>
342− <Grid.Item full as={NoticeBox} className="mb2">
346+ <Grid.Item full as={NoticeBox} className="mb2">
343− When using a custom network seed, you'll need to download your
347+ When using a custom network seed, you'll need to download
344− Arvo keyfile immediately after this transaction is completed —
348+ your Arvo keyfile immediately after this transaction is
345− Bridge does not store your seed.
349+ Bridge does not store your seed.
346− </Grid.Item>
350+ </Grid.Item>
347− <Grid.Item full as={Input} {...networkSeedInput} />
351+ <Grid.Item
352+ full
353+ as={HexInput}
354+ name="networkSeed"
355+ label="Network Seed (64 bytes)"
356+ disabled={inputsLocked}
357+ />
358+ </Condition>
359+ <Grid.Item
360+ full
361+ as={CheckboxInput}
362+ name="useDiscontinuity"
363+ label="Trigger New Continuity Era"
364+ disabled={inputsLocked}
365+ />
366+
367+ <Grid.Item full as={FormError} />
368+
369+ <Grid.Item
370+ full
371+ as={InlineEthereumTransaction}
372+ {...bind}
373+ label={`${hasKeys ? 'Reset' : 'Set'} Networking Keys`}
374+ onReturn={() => pop()}
375+ />
348 </>
376 </>
349 )}
377 )}
350− <Grid.Item full as={CheckboxInput} {...discontinuityInput} />
378+ </BridgeForm>
351− </>
352 )}
379 )}
353
380
354− <Grid.Item
355− full
356− as={InlineEthereumTransaction}
357− {...bind}
358− label={`${hasKeys ? 'Reset' : 'Set'} Networking Keys`}
359− onReturn={() => pop()}
360− />
361−
362 {isDefaultState && renderDetails()}
381 {isDefaultState && renderDetails()}
363
382
364 {completed && (
383 {completed && (
src/views/Admin/AdminSetProxy.js+95−64
@@ -1,6 +1,6 @@
1−import React, { useCallback, useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3−import { Grid, Text, Input, Flex, ToggleInput } from 'indigo-react';
3+import { Grid, Text, Flex, ToggleInput } from 'indigo-react';
4import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
5
5
6import { useNetwork } from 'store/network';
6import { useNetwork } from 'store/network';
@@ -16,12 +16,19 @@ import * as need from 'lib/need';
16import { useLocalRouter } from 'lib/LocalRouter';
16import { useLocalRouter } from 'lib/LocalRouter';
17import { ETH_ZERO_ADDR, eqAddr, isZeroAddress } from 'lib/wallet';
17import { ETH_ZERO_ADDR, eqAddr, isZeroAddress } from 'lib/wallet';
18import capitalize from 'lib/capitalize';
18import capitalize from 'lib/capitalize';
19−import { useAddressInput, useCheckboxInput } from 'lib/useInputs';
20import useEthereumTransaction from 'lib/useEthereumTransaction';
19import useEthereumTransaction from 'lib/useEthereumTransaction';
21
20
22import ViewHeader from 'components/ViewHeader';
21import ViewHeader from 'components/ViewHeader';
23import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
22import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
24import { GAS_LIMITS } from 'lib/constants';
23import { GAS_LIMITS } from 'lib/constants';
24+import { AddressInput } from 'form/Inputs';
25+import {
26+ composeValidator,
27+ buildCheckboxValidator,
28+ buildAddressValidator,
29+} from 'form/validators';
30+import BridgeForm from 'form/BridgeForm';
31+import FormError from 'form/FormError';
25
32
26const proxyFromDetails = (details, contracts, proxyType) => {
33const proxyFromDetails = (details, contracts, proxyType) => {
27 switch (proxyType) {
34 switch (proxyType) {
@@ -107,41 +114,41 @@ export default function AdminSetProxy() {
107 bind,
114 bind,
108 } = useSetProxy(data.proxyType);
115 } = useSetProxy(data.proxyType);
109
116
110− const [unsetInput, { data: isUnsetting }] = useCheckboxInput({
117+ const validateForm = useCallback((values, errors) => {
111− name: 'unset',
118+ if (!values.unset && errors.address) {
112− label: 'Unset',
119+ return errors;
113− inverseLabel: 'Specify',
114− initialValue: false,
115− disabled: inputsLocked,
116− });
117− const [
118− addressInput,
119− { pass: validAddress, data: address },
120− { reset: resetAddress },
121− ] = useAddressInput({
122− name: 'address',
123− label: `New ${properProxyType} Address`,
124− disabled: inputsLocked || isUnsetting,
125− });
126−
127− useEffect(() => {
128− if (isUnsetting) {
129− unset();
130− resetAddress();
131− } else if (validAddress) {
132− construct(address);
133− } else {
134− unconstruct();
135 }
120 }
136− }, [
121+ }, []);
137− validAddress,
122+
123+ const validate = useMemo(
124+ () =>
125+ composeValidator(
126+ {
127+ unset: buildCheckboxValidator(),
138− address,
128+ address: buildAddressValidator(),
139− construct,
129+ },
140− isUnsetting,
130+ validateForm
131+ ),
132+ [validateForm]
133+ );
134+
135+ const onValues = useCallback(
136+ ({ valid, values, form }) => {
137+ if (valid) {
141− unset,
138+ if (values.unset) {
139+ unset();
140+ form.change('address', '');
141+ } else {
142+ construct(values.address);
143+ }
144+ } else {
142− unconstruct,
145+ unconstruct();
143− resetAddress,
146+ }
144− ]);
147+ },
148+ [construct, unconstruct, unset]
149+ );
150+
151+ const initialValues = useMemo(() => ({ unset: false }), []);
145
152
146 const proxyAddress = proxyFromDetails(_details, _contracts, data.proxyType);
153 const proxyAddress = proxyFromDetails(_details, _contracts, data.proxyType);
147 const isProxySet = !isZeroAddress(proxyAddress);
154 const isProxySet = !isZeroAddress(proxyAddress);
@@ -169,34 +176,58 @@ export default function AdminSetProxy() {
169 {proxyAddressLabel}
176 {proxyAddressLabel}
170 </Grid.Item>
177 </Grid.Item>
171
178
179+ <BridgeForm
180+ validate={validate}
181+ onValues={onValues}
182+ initialValues={initialValues}>
183+ {({ handleSubmit, values }) => (
184+ <>
172− <Grid.Item full as={Flex} row justify="between" align="center">
185+ <Grid.Item full as={Flex} row justify="between" align="center">
173− <Flex.Item
186+ <Flex.Item
174− flex
187+ flex
175− as={Text}
188+ as={Text}
176− className={cn('mono', {
189+ className={cn('mono', {
177− black: !completed && isProxySet,
190+ black: !completed && isProxySet,
178− gray4: !completed && !isProxySet,
191+ gray4: !completed && !isProxySet,
179− green3: completed,
192+ green3: completed,
180− })}>
193+ })}>
181− {isProxySet ? proxyAddress : 'Unset'}
194+ {isProxySet ? proxyAddress : 'Unset'}
182− </Flex.Item>
195+ </Flex.Item>
183− {!completed && isProxySet && (
196+ {!completed && isProxySet && (
197+ <Flex.Item
184− <Flex.Item as={ToggleInput} {...unsetInput} />
198+ as={ToggleInput}
199+ name="unset"
200+ label="Unset"
201+ inverseLabel="Specify"
202+ disabled={inputsLocked}
203+ />
204+ )}
205+ </Grid.Item>
206+
207+ {completed ? (
208+ <Grid.Item full className="mb4" />
209+ ) : (
210+ <Grid.Item
211+ full
212+ as={AddressInput}
213+ className="mv4"
214+ name="address"
215+ label={`New ${properProxyType} Address`}
216+ disabled={inputsLocked || values.unset}
217+ />
218+ )}
219+
220+ <Grid.Item full as={FormError} />
221+
222+ <Grid.Item
223+ full
224+ as={InlineEthereumTransaction}
225+ {...bind}
226+ onReturn={() => pop()}
227+ />
228+ </>
185 )}
229 )}
186− </Grid.Item>
230+ </BridgeForm>
187−
188− {completed ? (
189− <Grid.Item full className="mb4" />
190− ) : (
191− <Grid.Item full as={Input} {...addressInput} className="mv4" />
192− )}
193−
194− <Grid.Item
195− full
196− as={InlineEthereumTransaction}
197− {...bind}
198− onReturn={() => pop()}
199− />
200 </Grid>
231 </Grid>
201 );
232 );
202}
233}
src/views/Admin/AdminTransfer.js+55−34
@@ -1,6 +1,6 @@
1−import React, { useCallback, useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3−import { Grid, Text, Input } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
4import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
5
5
6import { useNetwork } from 'store/network';
6import { useNetwork } from 'store/network';
@@ -9,13 +9,16 @@ import { usePointCache } from 'store/pointCache';
9
9
10import * as need from 'lib/need';
10import * as need from 'lib/need';
11import { useLocalRouter } from 'lib/LocalRouter';
11import { useLocalRouter } from 'lib/LocalRouter';
12−import { useAddressInput } from 'lib/useInputs';
13import useCurrentPointName from 'lib/useCurrentPointName';
12import useCurrentPointName from 'lib/useCurrentPointName';
14import useEthereumTransaction from 'lib/useEthereumTransaction';
13import useEthereumTransaction from 'lib/useEthereumTransaction';
15import { GAS_LIMITS } from 'lib/constants';
14import { GAS_LIMITS } from 'lib/constants';
16
15
17import ViewHeader from 'components/ViewHeader';
16import ViewHeader from 'components/ViewHeader';
18import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
17import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
18+import { AddressInput } from 'form/Inputs';
19+import { composeValidator, buildAddressValidator } from 'form/validators';
20+import BridgeForm from 'form/BridgeForm';
21+import FormError from 'form/FormError';
19
22
20function useTransfer() {
23function useTransfer() {
21 const { contracts } = useNetwork();
24 const { contracts } = useNetwork();
@@ -47,46 +50,64 @@ export default function AdminTransfer() {
47 bind,
50 bind,
48 } = useTransfer();
51 } = useTransfer();
49
52
50− const [addressInput, { pass, data: address }] = useAddressInput({
53+ const validate = useMemo(
51− name: 'address',
54+ () => composeValidator({ address: buildAddressValidator() }),
52− label: `Ethereum Address`,
55+ []
53− disabled: inputsLocked,
54− });
56+ );
55
57
56− useEffect(() => {
58+ const onValues = useCallback(
57− if (pass) {
59+ ({ valid, values }) => {
60+ if (valid) {
58− construct(address);
61+ construct(values.address);
59− } else {
62+ } else {
60− unconstruct();
63+ unconstruct();
61− }
64+ }
65+ },
62− }, [pass, address, construct, unconstruct]);
66+ [construct, unconstruct]
67+ );
63
68
64 return (
69 return (
65 <Grid>
70 <Grid>
66 <Grid.Item full as={ViewHeader}>
71 <Grid.Item full as={ViewHeader}>
67 Transfer Point
72 Transfer Point
68 </Grid.Item>
73 </Grid.Item>
69− <Grid.Item
70− full
71− as={Text}
72− className={cn('f5', {
73− green3: completed,
74− })}>
75− {completed
76− ? `${address} is now the Transfer Proxy for ${name} and can accept the transfer by logging into Bridge themselves. Until they accept your transfer, you will still have ownership over ${name}.`
77− : `Transfer ${name} to a new owner.`}
78− </Grid.Item>
79
74
75+ <BridgeForm validate={validate} onValues={onValues}>
76+ {({ handleSubmit, values }) => (
77+ <>
78+ <Grid.Item
79+ full
80+ as={Text}
81+ className={cn('f5', {
82+ green3: completed,
83+ })}>
84+ {completed
85+ ? `${values.address} is now the Transfer Proxy for ${name} and can accept the transfer by logging into Bridge themselves. Until they accept your transfer, you will still have ownership over ${name}.`
86+ : `Transfer ${name} to a new owner.`}
87+ </Grid.Item>
88+
80− {!completed && (
89+ {!completed && (
90+ <Grid.Item
91+ full
92+ as={AddressInput}
81− <Grid.Item full as={Input} {...addressInput} className="mv4" />
93+ className="mv4"
94+ name="address"
95+ label="Ethereum Address"
96+ disabled={inputsLocked}
97+ />
82− )}
98+ )}
99+
100+ <Grid.Item full as={FormError} />
83
101
84− <Grid.Item
102+ <Grid.Item
85− full
103+ full
86− as={InlineEthereumTransaction}
104+ as={InlineEthereumTransaction}
87− {...bind}
105+ {...bind}
88− onReturn={() => pop()}
106+ onReturn={() => pop()}
89− />
107+ />
108+ </>
109+ )}
110+ </BridgeForm>
90 </Grid>
111 </Grid>
91 );
112 );
92}
113}
src/views/Admin/Reticket/ReticketVerify.js+41−21
@@ -1,12 +1,15 @@
1import React, { useCallback, useMemo } from 'react';
1import React, { useCallback, useMemo } from 'react';
2−import { Input, Text, Grid } from 'indigo-react';
2+import { Text, Grid } from 'indigo-react';
3
3
4−import { useTicketInput } from 'lib/useInputs';
5import { useLocalRouter } from 'lib/LocalRouter';
4import { useLocalRouter } from 'lib/LocalRouter';
6import { validateExactly } from 'lib/validators';
5import { validateExactly } from 'lib/validators';
7import { isDevelopment } from 'lib/flags';
6import { isDevelopment } from 'lib/flags';
8
7
9−import { ForwardButton } from 'components/Buttons';
8+import BridgeForm from 'form/BridgeForm';
9+import { TicketInput } from 'form/Inputs';
10+import { composeValidator, buildPatqValidator } from 'form/validators';
11+import SubmitButton from 'form/SubmitButton';
12+import FormError from 'form/FormError';
10
13
11const STUB_VERIFY_TICKET = isDevelopment;
14const STUB_VERIFY_TICKET = isDevelopment;
12
15
@@ -14,17 +17,22 @@ export default function ReticketVerify({ newWallet }) {
14 const { push, names } = useLocalRouter();
17 const { push, names } = useLocalRouter();
15
18
16 const ticket = newWallet.value.wallet.ticket;
19 const ticket = newWallet.value.wallet.ticket;
17− const validators = useMemo(
20+ const validate = useMemo(
21+ () =>
22+ composeValidator({
23+ ticket: buildPatqValidator([
18− () => [validateExactly(ticket, 'Does not match expected master ticket.')],
24+ validateExactly(ticket, 'Does not match expected master ticket.'),
25+ ]),
26+ }),
27+ [ticket]
28+ );
29+
30+ const initialValues = useMemo(
31+ () => ({
32+ ticket: STUB_VERIFY_TICKET ? ticket : 'undefined',
33+ }),
19 [ticket]
34 [ticket]
20 );
35 );
21− const [ticketInput, { pass }] = useTicketInput({
22− name: 'ticket',
23− label: 'New master ticket',
24− initialValue: STUB_VERIFY_TICKET ? ticket : undefined,
25− autoFocus: true,
26− validators,
27− });
28
36
29 const goExecute = useCallback(() => push(names.EXECUTE), [push, names]);
37 const goExecute = useCallback(() => push(names.EXECUTE), [push, names]);
30
38
@@ -33,15 +41,27 @@ export default function ReticketVerify({ newWallet }) {
33 <Grid.Item full as={Text}>
41 <Grid.Item full as={Text}>
34 Verify New Master Ticket
42 Verify New Master Ticket
35 </Grid.Item>
43 </Grid.Item>
36− <Grid.Item full as={Input} {...ticketInput} />
44+ <BridgeForm
37− <Grid.Item
45+ validate={validate}
38− full
39− as={ForwardButton}
40− solid
41− disabled={!pass}
42− onClick={goExecute}>
46+ afterSubmit={goExecute}
47+ initialValues={initialValues}>
48+ {({ handleSubmit }) => (
49+ <>
50+ <Grid.Item
51+ full
52+ as={TicketInput}
53+ name="ticket"
54+ label="New master ticket"
55+ />
56+
57+ <Grid.Item full as={FormError} />
58+
59+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
43− Verify & Reticket
60+ Verify & Reticket
44− </Grid.Item>
61+ </Grid.Item>
62+ </>
63+ )}
64+ </BridgeForm>
45 </Grid>
65 </Grid>
46 );
66 );
47}
67}
src/views/CreateGalaxy.js+100−94
@@ -1,14 +1,12 @@
1−import React, { useCallback, useEffect, useState } from 'react';
1+import React, { useCallback, useMemo, useState } from 'react';
2−import { Nothing, Just } from 'folktale/maybe';
3import cn from 'classnames';
2import cn from 'classnames';
4−import { Grid, Text, Input } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
5import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
6
5
7import { useNetwork } from 'store/network';
6import { useNetwork } from 'store/network';
8import { usePointCache } from 'store/pointCache';
7import { usePointCache } from 'store/pointCache';
9
8
10import * as need from 'lib/need';
9import * as need from 'lib/need';
11−import { useAddressInput, useGalaxyInput } from 'lib/useInputs';
12import useEthereumTransaction from 'lib/useEthereumTransaction';
10import useEthereumTransaction from 'lib/useEthereumTransaction';
13import { GAS_LIMITS } from 'lib/constants';
11import { GAS_LIMITS } from 'lib/constants';
14import patp2dec from 'lib/patp2dec';
12import patp2dec from 'lib/patp2dec';
@@ -18,6 +16,15 @@ import { useLocalRouter } from 'lib/LocalRouter';
18import ViewHeader from 'components/ViewHeader';
16import ViewHeader from 'components/ViewHeader';
19import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
17import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
20import View from 'components/View';
18import View from 'components/View';
19+import BridgeForm from 'form/BridgeForm';
20+import { PointInput, AddressInput } from 'form/Inputs';
21+import {
22+ composeValidator,
23+ buildPointValidator,
24+ buildAddressValidator,
25+ hasErrors,
26+} from 'form/validators';
27+import FormError from 'form/FormError';
21
28
22function useCreateGalaxy() {
29function useCreateGalaxy() {
23 const { contracts } = useNetwork();
30 const { contracts } = useNetwork();
@@ -45,9 +52,6 @@ export default function CreateGalaxy() {
45 const { contracts } = useNetwork();
52 const { contracts } = useNetwork();
46 const _contracts = need.contracts(contracts);
53 const _contracts = need.contracts(contracts);
47
54
48− const [error, setError] = useState();
49− const [isAvailable, setIsAvailable] = useState(Nothing());
50−
51 const {
55 const {
52 construct,
56 construct,
53 unconstruct,
57 unconstruct,
@@ -56,75 +60,54 @@ export default function CreateGalaxy() {
56 bind,
60 bind,
57 } = useCreateGalaxy();
61 } = useCreateGalaxy();
58
62
59− const [
63+ const validateFormAsync = useCallback(
60− galaxyNameInput,
64+ async values => {
61− { pass: validGalaxyName, syncPass: syncValidGalaxyName, value: galaxyName },
65+ const currentOwner = await azimuth.azimuth.getOwner(
62− // ^ we use value: here so our effect runs onChange
66+ _contracts,
63− ] = useGalaxyInput({
67+ patp2dec(values.galaxyName)
64− name: 'galaxy',
68+ );
65− disabled: inputsLocked,
69+
66− autoFocus: true,
70+ const isAvailable = isZeroAddress(currentOwner);
67− error:
71+ if (!isAvailable) {
68− error ||
69− isAvailable.matchWith({
70− Nothing: () => 'Loading availability...', // TODO: make async loading?
71− Just: p => (p.value ? undefined : 'This galaxy is already owned.'),
72+ return { galaxyName: 'This galaxy is already spawned and owned.' };
72− }),
73+ }
73− });
74−
75− const [ownerInput, { pass: validOwner, data: owner }] = useAddressInput({
76− name: 'owner',
77− label: `Ethereum Address`,
78− disabled: inputsLocked,
79− });
74+ },
80−
75+ [_contracts]
81− useEffect(() => {
76+ );
82− if (validGalaxyName && validOwner) {
77+
83− construct(patp2dec(galaxyName), owner);
78+ const validateForm = useCallback(
84− } else {
79+ (values, errors) => {
85− unconstruct();
80+ if (hasErrors(errors)) {
86− }
81+ return errors;
87− }, [owner, construct, unconstruct, validGalaxyName, validOwner, galaxyName]);
88−
89− useEffect(() => {
90− if (!syncValidGalaxyName || inputsLocked) {
91− return;
92− }
93−
94− setError();
95− setIsAvailable(Nothing());
96−
97− let cancelled = false;
98−
99− (async () => {
100− try {
101− const currentOwner = await azimuth.azimuth.getOwner(
102− _contracts,
103− patp2dec(galaxyName)
104− );
105−
106− const isAvailable = isZeroAddress(currentOwner);
107−
108− if (cancelled) {
109− return;
110− }
111−
112− setIsAvailable(Just(isAvailable));
113− } catch (error) {
114− console.error(error);
115− setError(error.message);
116− setIsAvailable(Just(false));
117 }
82 }
118− })();
119
83
120− return () => (cancelled = true);
84+ return validateFormAsync(values, errors);
85+ },
86+ [validateFormAsync]
87+ );
88+
89+ const validate = useMemo(
90+ () =>
91+ composeValidator(
92+ {
93+ galaxyName: buildPointValidator(1),
94+ owner: buildAddressValidator(),
121− }, [
95+ },
122− _contracts,
96+ validateForm
123− galaxyName,
97+ ),
124− inputsLocked,
98+ [validateForm]
125− setIsAvailable,
99+ );
126− syncValidGalaxyName,
100+
101+ const onValues = useCallback(
102+ ({ valid, values }) => {
103+ if (valid) {
104+ construct(patp2dec(values.galaxyName), values.owner);
105+ } else {
106+ unconstruct();
107+ }
108+ },
109+ [construct, unconstruct]
127− ]);
110+ );
128
111
129 return (
112 return (
130 <View pop={pop} inset>
113 <View pop={pop} inset>
@@ -133,26 +116,49 @@ export default function CreateGalaxy() {
133 Create a Galaxy
116 Create a Galaxy
134 </Grid.Item>
117 </Grid.Item>
135
118
119+ <BridgeForm validate={validate} onValues={onValues}>
120+ {({ handleSubmit, values }) => (
121+ <>
136− {completed && (
122+ {completed && (
137− <Grid.Item
123+ <Grid.Item
138− full
124+ full
139− as={Text}
125+ as={Text}
140− className={cn('f5', {
126+ className={cn('f5', {
141− green3: completed,
127+ green3: completed,
142− })}>
128+ })}>
143− {galaxyName} has been created and can be claimed by {owner}.
129+ {values.galaxyName} has been created and can be claimed by{' '}
130+ {values.owner}.
144− </Grid.Item>
131+ </Grid.Item>
145− )}
132+ )}
146−
133+
134+ <Grid.Item
135+ full
136+ as={PointInput}
147− <Grid.Item full as={Input} {...galaxyNameInput} className="mt4" />
137+ className="mt4"
138+ name="galaxyName"
139+ label="Galaxy Name"
140+ disabled={inputsLocked}
141+ />
142+ <Grid.Item
143+ full
144+ as={AddressInput}
148− <Grid.Item full as={Input} {...ownerInput} className="mb4" />
145+ className="mb4"
149−
146+ name="owner"
147+ label="Ethereum Address"
148+ disabled={inputsLocked}
149+ />
150+
150− <Grid.Item
151+ <Grid.Item full as={FormError} />
152+
153+ <Grid.Item
151− full
154+ full
152− as={InlineEthereumTransaction}
155+ as={InlineEthereumTransaction}
153− {...bind}
156+ {...bind}
154− onReturn={() => pop()}
157+ onReturn={() => pop()}
155− />
158+ />
159+ </>
160+ )}
161+ </BridgeForm>
156 </Grid>
162 </Grid>
157 </View>
163 </View>
158 );
164 );
src/views/Disclaimer.js+30−16
@@ -1,24 +1,28 @@
1−import React, { useCallback } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3import { Grid, H3, B, Text, CheckboxInput } from 'indigo-react';
3import { Grid, H3, B, Text, CheckboxInput } from 'indigo-react';
4
4
5−import { useCheckboxInput } from 'lib/useInputs';
6import { useHistory } from 'store/history';
5import { useHistory } from 'store/history';
7import useHasDisclaimed from 'lib/useHasDisclaimed';
6import useHasDisclaimed from 'lib/useHasDisclaimed';
8
7
9import View from 'components/View';
8import View from 'components/View';
10−import { ForwardButton } from 'components/Buttons';
11import WarningBox from 'components/WarningBox';
9import WarningBox from 'components/WarningBox';
10+import BridgeForm from 'form/BridgeForm';
11+import SubmitButton from 'form/SubmitButton';
12+import { composeValidator, buildCheckboxValidator } from 'form/validators';
12
13
13const TEXT_STYLE = 'f5';
14const TEXT_STYLE = 'f5';
14
15
15export default function ActivateDisclaimer() {
16export default function ActivateDisclaimer() {
16 const { pop } = useHistory();
17 const { pop } = useHistory();
17 const [, setHasDisclaimed] = useHasDisclaimed();
18 const [, setHasDisclaimed] = useHasDisclaimed();
18− const [understoodInput, { data: isUnderstood }] = useCheckboxInput({
19+
19− name: 'checkbox',
20+ const validate = useMemo(
20− label: 'I acknowledge and understand these rights',
21+ () => composeValidator({ checkbox: buildCheckboxValidator(true) }),
21− });
22+ []
23+ );
24+
25+ const initialValues = useMemo(() => ({ checkbox: false }), []);
22
26
23 const goBack = useCallback(async () => {
27 const goBack = useCallback(async () => {
24 setHasDisclaimed(true);
28 setHasDisclaimed(true);
@@ -76,15 +80,25 @@ export default function ActivateDisclaimer() {
76 <Grid.Item full as={WarningBox}>
80 <Grid.Item full as={WarningBox}>
77 Warning: Nobody but you can restore or reset your Master Ticket
81 Warning: Nobody but you can restore or reset your Master Ticket
78 </Grid.Item>
82 </Grid.Item>
83+ <BridgeForm
84+ validate={validate}
85+ afterSubmit={goBack}
86+ initialValues={initialValues}>
87+ {({ handleSubmit }) => (
88+ <>
89+ <Grid.Item
90+ full
79− <Grid.Item as={CheckboxInput} {...understoodInput} full />
91+ as={CheckboxInput}
80− <Grid.Item
92+ name="checkbox"
81− as={ForwardButton}
93+ label="I acknowledge and understand these rights"
82− disabled={!isUnderstood}
94+ />
83− onClick={goBack}
95+
84− solid
96+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
85− full>
86− Continue
97+ Continue
87− </Grid.Item>
98+ </Grid.Item>
99+ </>
100+ )}
101+ </BridgeForm>
88 </Grid>
102 </Grid>
89 </View>
103 </View>
90 );
104 );
src/views/Invite/InviteEmail.js+329−302
@@ -1,20 +1,25 @@
1−import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+import React, {
2+ useCallback,
3+ useEffect,
4+ useRef,
5+ useMemo,
6+ useState,
7+} from 'react';
2import cn from 'classnames';
8import cn from 'classnames';
3import * as ob from 'urbit-ob';
9import * as ob from 'urbit-ob';
4import * as azimuth from 'azimuth-js';
10import * as azimuth from 'azimuth-js';
5import {
11import {
6 Grid,
12 Grid,
7 Flex,
13 Flex,
8− Input,
9 IconButton,
14 IconButton,
10 HelpText,
15 HelpText,
11 Text,
16 Text,
12 ErrorText,
17 ErrorText,
13 AccessoryIcon,
18 AccessoryIcon,
14− useForm,
15} from 'indigo-react';
19} from 'indigo-react';
16import { uniq } from 'lodash';
20import { uniq } from 'lodash';
17import { fromWei, toWei } from 'web3-utils';
21import { fromWei, toWei } from 'web3-utils';
22+import { FieldArray } from 'react-final-form-arrays';
18
23
19import { usePointCursor } from 'store/pointCursor';
24import { usePointCursor } from 'store/pointCursor';
20import { useNetwork } from 'store/network';
25import { useNetwork } from 'store/network';
@@ -29,10 +34,8 @@ import {
29 hexify,
34 hexify,
30} from 'lib/txn';
35} from 'lib/txn';
31import * as tank from 'lib/tank';
36import * as tank from 'lib/tank';
32−import { useSuggestedGasPrice } from 'lib/useSuggestedGasPrice';
33−import useArray from 'lib/useArray';
34−import { buildEmailInputConfig } from 'lib/useInputs';
35import { MIN_PLANET, GAS_LIMITS } from 'lib/constants';
37import { MIN_PLANET, GAS_LIMITS } from 'lib/constants';
38+import { useSuggestedGasPrice } from 'lib/useSuggestedGasPrice';
36import * as need from 'lib/need';
39import * as need from 'lib/need';
37import * as wg from 'lib/walletgen';
40import * as wg from 'lib/walletgen';
38import useSetState from 'lib/useSetState';
41import useSetState from 'lib/useSetState';
@@ -41,6 +44,21 @@ import useMailer from 'lib/useMailer';
41
44
42import LoadableButton from 'components/LoadableButton';
45import LoadableButton from 'components/LoadableButton';
43import Highlighted from 'components/Highlighted';
46import Highlighted from 'components/Highlighted';
47+import BridgeForm from 'form/BridgeForm';
48+import { Field } from 'react-final-form';
49+import { EmailInput } from 'form/Inputs';
50+import {
51+ buildEmailValidator,
52+ composeValidator,
53+ hasErrors,
54+ buildArrayValidator,
55+} from 'form/validators';
56+import { FORM_ERROR } from 'final-form';
57+import SubmitButton from 'form/SubmitButton';
58+import FormError from 'form/FormError';
59+import { WARNING, onlyHasWarning } from 'form/helpers';
60+
61+const INITIAL_VALUES = { emails: [''] };
44
62
45const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
63const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
46const HAS_RECEIVED_TEXT = 'This email has already received an invite.';
64const HAS_RECEIVED_TEXT = 'This email has already received an invite.';
@@ -55,6 +73,8 @@ const STATUS = {
55 FAILURE: 'FAILURE',
73 FAILURE: 'FAILURE',
56};
74};
57
75
76+const nameForEmailField = i => `emails[${i}]`;
77+
58const buttonText = (status, count) => {
78const buttonText = (status, count) => {
59 switch (status) {
79 switch (status) {
60 case STATUS.INPUT:
80 case STATUS.INPUT:
@@ -73,47 +93,29 @@ const buttonText = (status, count) => {
73 }
93 }
74};
94};
75
95
76−// world's simplest uid
77−let id = 0;
78−const buildInputConfig = (extra = {}) =>
79− buildEmailInputConfig({
80− name: `email-${id++}`,
81− placeholder: 'Email Address',
82− ...extra,
83− });
84−
85const buildAccessoryFor = (dones, errors) => name => {
96const buildAccessoryFor = (dones, errors) => name => {
86 if (dones[name]) return <AccessoryIcon.Success />;
97 if (dones[name]) return <AccessoryIcon.Success />;
87 if (errors[name]) return <AccessoryIcon.Failure />;
98 if (errors[name]) return <AccessoryIcon.Failure />;
88 return <AccessoryIcon.Pending />;
99 return <AccessoryIcon.Pending />;
89};
100};
90
101
91−// TODO: test with tank, successful txs
92export default function InviteEmail() {
102export default function InviteEmail() {
93− // TODO: resumption after error?
94 const { contracts, web3, networkType } = useNetwork();
103 const { contracts, web3, networkType } = useNetwork();
95 const { wallet, walletType, walletHdPath } = useWallet();
104 const { wallet, walletType, walletHdPath } = useWallet();
96 const { syncInvites, getInvites } = usePointCache();
105 const { syncInvites, getInvites } = usePointCache();
97 const { pointCursor } = usePointCursor();
106 const { pointCursor } = usePointCursor();
98 const point = need.point(pointCursor);
107 const point = need.point(pointCursor);
99− const { getHasReceived, syncHasReceivedForEmail, sendMail } = useMailer();
108+ const { getHasReceived, sendMail } = useMailer();
100 const { gasPrice } = useSuggestedGasPrice(networkType);
109 const { gasPrice } = useSuggestedGasPrice(networkType);
101
110
111+ const cachedEmails = useRef({});
112+
102 const { availableInvites } = getInvites(point);
113 const { availableInvites } = getInvites(point);
103 const maxInvitesToSend = availableInvites.matchWith({
114 const maxInvitesToSend = availableInvites.matchWith({
104 Nothing: () => 0,
115 Nothing: () => 0,
105 Just: p => p.value,
116 Just: p => p.value,
106 });
117 });
107
118
108− // manage the array of input configs
109− const [
110− inputConfigs,
111− { append: appendInput, removeAt: removeInputAt },
112− ] = useArray(
113− [buildInputConfig({ label: 'Email Address', autoFocus: true })],
114− buildInputConfig
115− );
116−
117 // manage per-input state
119 // manage per-input state
118 const [hovered, setHovered] = useSetState();
120 const [hovered, setHovered] = useSetState();
119 const [invites, addInvite, clearInvites] = useSetState();
121 const [invites, addInvite, clearInvites] = useSetState();
@@ -133,30 +135,38 @@ export default function InviteEmail() {
133 const isFailed = status === STATUS.FAILURE;
135 const isFailed = status === STATUS.FAILURE;
134 const isDone = status === STATUS.SUCCESS;
136 const isDone = status === STATUS.SUCCESS;
135
137
136− // add disabled, error info to input configs
138+ const validateHasReceived = useCallback(
137− const dynamicConfigs = useMemo(
139+ async email => {
138− () =>
139− inputConfigs.map(config => {
140− config.disabled = !canInput;
141− const hasReceivedError = getHasReceived(config.name).matchWith({
140+ const hasReceived = await getHasReceived(email);
142− Nothing: () => null, // loading
141+ if (hasReceived) {
143− Just: p => p.value && HAS_RECEIVED_TEXT,
142+ return HAS_RECEIVED_TEXT;
144− });
145− config.error = hasReceivedError || errors[config.name];
146− return config;
147− }),
143+ }
144+ },
148− [inputConfigs, errors, canInput, getHasReceived]
145+ [getHasReceived]
149 );
146 );
150
147
151− // construct the state of the set of inputs we're rendering below
148+ const validateForm = useCallback((values, errors) => {
152− const { inputs, pass } = useForm(dynamicConfigs);
149+ if (hasErrors(errors)) {
153− // did all of the inputs pass inspection (and there are no general errors)?
150+ return errors;
154− const allPass = pass && !generalError;
151+ }
155− // the form is submittable iff passing and input is allowed
152+
156− const canGenerate = allPass && canInput;
153+ // check for email uniqenesss
157− // additional inputs can be added iff input is allowed and we have enough
154+ const emails = values.emails.filter(d => !!d);
158− // invites to send
155+ if (uniq(emails).length !== emails.length) {
159− const canAddInvite = canInput && inputConfigs.length < maxInvitesToSend;
156+ return { [FORM_ERROR]: 'Duplicate email.' };
157+ }
158+ }, []);
159+
160+ const validate = useMemo(
161+ () =>
162+ composeValidator(
163+ {
164+ emails: buildArrayValidator(buildEmailValidator(validateHasReceived)),
165+ },
166+ validateForm
167+ ),
168+ [validateForm, validateHasReceived]
169+ );
160
170
161 // progress is [0, .length] of invites or receipts, as we're generating them
171 // progress is [0, .length] of invites or receipts, as we're generating them
162 const progress = isGenerating
172 const progress = isGenerating
@@ -174,128 +184,113 @@ export default function InviteEmail() {
174 return () => null;
184 return () => null;
175 })();
185 })();
176
186
177− const generateInvites = useCallback(async () => {
187+ const generateInvites = useCallback(
188+ async values => {
178− const _contracts = contracts.getOrElse(null);
189+ const _contracts = contracts.getOrElse(null);
179− const _web3 = web3.getOrElse(null);
190+ const _web3 = web3.getOrElse(null);
180− const _wallet = wallet.getOrElse(null);
191+ const _wallet = wallet.getOrElse(null);
181− if (!_contracts || !_web3 || !_wallet) {
192+ if (!_contracts || !_web3 || !_wallet) {
182− // not using need because we want a custom error
193+ // not using need because we want a custom error
183− throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
194+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
184− }
185−
186− //TODO want to do this on-input, but that gets weird. see #188
187− let knowAll = true;
188− let alreadyReceived = [];
189− await Promise.all(
190− inputs.map(async input => {
191− const email = input.data;
192− getHasReceived(email).matchWith({
193− Nothing: () => {
194− knowAll = false;
195− },
196− Just: p => {
197− if (p.value) alreadyReceived.push(email);
198− },
199− });
200− })
201− );
202− if (!knowAll) {
203− throw new Error('No word yet from email service...');
204− }
205− if (alreadyReceived.length > 0) {
206− throw new Error(
207− 'The following recipients already own a point: ' +
208− alreadyReceived.join(', ')
209− );
210− }
195+ }
211−
212− const nonce = await _web3.eth.getTransactionCount(_wallet.address);
213− const chainId = await _web3.eth.net.getId();
214− const planets = await azimuth.delegatedSending.getPlanetsToSend(
215− _contracts,
216− point,
217− inputs.length
218− );
219−
220− // account for the race condition where invites got used up while we were
221− // composing our target list
222− if (planets.length < inputs.length) {
223− // resync invites to the cache, since they're out of date
224− syncInvites(point);
225
196
226− throw new Error(
197+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
227− `Can currently only send ${planets.length} invites. ` +
198+ const chainId = await _web3.eth.net.getId();
228− `Please remove invites until you are within the limit.`
199+ const planets = await azimuth.delegatedSending.getPlanetsToSend(
200+ _contracts,
201+ point,
202+ values.emails.length
229 );
203 );
230− }
231
204
232− clearInvites();
205+ // account for the race condition where invites got used up while we were
233− // NB(shrugs) - must be processed in serial because main thread, etc
206+ // composing our target list
234− let errorCount = 0;
207+ if (planets.length < values.emails.length) {
235− for (let i = 0; i < inputs.length; i++) {
208+ // resync invites to the cache, since they're out of date
236− const input = inputs[i];
209+ syncInvites(point);
237− try {
210+
238− const { data: email, name } = inputs[i];
211+ return {
239−
212+ [FORM_ERROR]:
240− const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
213+ `Can currently only send ${planets.length} invites. ` +
241− MIN_PLANET
214+ `Please remove invites until you are within the limit.`,
242− // we're always giving planets, so generate a ticket of the correct size
243− );
215+ };
216+ }
244
217
218+ clearInvites();
219+ // NB(shrugs) - must be processed in serial because main thread, etc
220+ let errorCount = 0;
221+ for (let i = 0; i < values.emails.length; i++) {
222+ const name = nameForEmailField(i);
223+ try {
224+ const email = values.emails[i];
225+ const planet = planets[i];
226+
227+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
228+ MIN_PLANET
229+ // we're always giving planets, so generate a ticket of the correct size
230+ );
231+
245− const inviteTx = azimuth.delegatedSending.sendPoint(
232+ const inviteTx = azimuth.delegatedSending.sendPoint(
246− _contracts,
233+ _contracts,
247− point,
234+ point,
248− planets[i],
235+ planet,
249− owner.keys.address
236+ owner.keys.address
250− );
237+ );
238+
251− const signedTx = await signTransaction({
239+ const signedTx = await signTransaction({
252− wallet: _wallet,
240+ wallet: _wallet,
253− walletType,
241+ walletType,
254− walletHdPath,
242+ walletHdPath,
255− networkType,
243+ networkType,
244+ chainId,
245+ nonce: nonce + i,
256− // TODO: ^ make a useTransactionSigner to encapsulate this logic
246+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
257− txn: inviteTx,
247+ txn: inviteTx,
258− gasPrice: gasPrice.toString(),
248+ gasPrice: gasPrice.toString(),
259− gasLimit: GAS_LIMIT.toString(),
249+ gasLimit: GAS_LIMIT.toString(),
260− nonce: nonce + i,
261− chainId,
262− });
250+ });
251+
263− const rawTx = hexify(signedTx.serialize());
252+ const rawTx = hexify(signedTx.serialize());
264−
253+
265− addInvite({ [name]: { email, ticket, signedTx, rawTx } });
254+ addInvite({ [name]: { email, ticket, signedTx, rawTx } });
266− } catch (error) {
255+ } catch (error) {
267− console.error(error);
256+ console.error(error);
268− errorCount++;
257+ errorCount++;
269− addError({ [input.name]: `Wallet Error: ${input.email}` });
258+ addError({ [name]: 'Error generating invite.' });
259+ }
270 }
260 }
271− }
272
261
273− if (errorCount > 0) {
262+ if (errorCount > 0) {
274− throw new Error(
263+ return {
275− `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
264+ [WARNING]: `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
276− errorCount,
265+ errorCount,
277− 'error'
266+ 'error'
278− )} while generating wallets.`
267+ )} while generating wallets. You can still send the invites that generated correctly.`,
279− );
268+ };
280− }
269+ }
281− }, [
270+ },
271+ [
282− contracts,
272+ contracts,
283− web3,
273+ web3,
284− gasPrice,
285− wallet,
274+ wallet,
286− inputs,
287− point,
275+ point,
288− clearInvites,
276+ clearInvites,
289− getHasReceived,
290− addError,
291− syncInvites,
277+ syncInvites,
292− walletType,
278+ walletType,
293− walletHdPath,
279+ walletHdPath,
294− networkType,
280+ networkType,
281+ gasPrice,
295− addInvite,
282+ addInvite,
283+ addError,
284+ ]
296− ]);
285+ );
297
286
298 const sendInvites = useCallback(async () => {
287 const sendInvites = useCallback(async () => {
288+ // compute all of the names of the fields
289+ const allFieldNames = Object.keys(cachedEmails.current);
290+ // only emails we're sending are the valid ones with invites
291+ const names = allFieldNames.filter(name => !!invites[name]);
292+ const emails = allFieldNames.map(name => cachedEmails.current[name]);
293+
299 const _web3 = web3.getOrElse(null);
294 const _web3 = web3.getOrElse(null);
300 const _wallet = wallet.getOrElse(null);
295 const _wallet = wallet.getOrElse(null);
301 if (!_web3 || !_wallet) {
296 if (!_web3 || !_wallet) {
@@ -307,8 +302,8 @@ export default function InviteEmail() {
307 _web3,
302 _web3,
308 point,
303 point,
309 _wallet.address,
304 _wallet.address,
310− toWei((gasPrice * GAS_LIMIT * inputs.length).toString(), 'gwei'),
305+ toWei((gasPrice * GAS_LIMIT * emails.length).toString(), 'gwei'),
311− inputs.map(input => invites[input.name].rawTx),
306+ names.map(name => invites[name].rawTx),
312 (address, minBalance, balance) =>
307 (address, minBalance, balance) =>
313 setNeedFunds({ address, minBalance, balance }),
308 setNeedFunds({ address, minBalance, balance }),
314 () => setNeedFunds(undefined)
309 () => setNeedFunds(undefined)
@@ -319,8 +314,8 @@ export default function InviteEmail() {
319
314
320 let unsentInvites = [];
315 let unsentInvites = [];
321 let orphanedInvites = [];
316 let orphanedInvites = [];
322− const txAndMailings = inputs.map(async input => {
317+ const txAndMailings = names.map(async name => {
323− const invite = invites[input.name];
318+ const invite = invites[name];
324 try {
319 try {
325 const txHash = await sendSignedTransaction(
320 const txHash = await sendSignedTransaction(
326 _web3,
321 _web3,
@@ -328,9 +323,7 @@ export default function InviteEmail() {
328 tankWasUsed
323 tankWasUsed
329 );
324 );
330
325
331− // TODO: waitForTransactionConfirm never rejects
332− const didConfirm = await waitForTransactionConfirm(_web3, txHash);
326+ await waitForTransactionConfirm(_web3, txHash);
333− if (!didConfirm) throw new Error();
334 } catch (error) {
327 } catch (error) {
335 console.error(error);
328 console.error(error);
336 unsentInvites.push(invite);
329 unsentInvites.push(invite);
@@ -338,19 +331,18 @@ export default function InviteEmail() {
338 }
331 }
339
332
340 try {
333 try {
341− const success = await sendMail(
334+ await sendMail(
342 invite.email,
335 invite.email,
343 invite.ticket,
336 invite.ticket,
344 ob.patp(point),
337 ob.patp(point),
345 invite.rawTx
338 invite.rawTx
346 );
339 );
347− if (!success) throw new Error('Failed to send mail');
348 } catch (error) {
340 } catch (error) {
349 console.error(error);
341 console.error(error);
350 orphanedInvites.push(invite);
342 orphanedInvites.push(invite);
351 }
343 }
352
344
353− addReceipt({ [input.name]: true });
345+ addReceipt({ [name]: true });
354 });
346 });
355
347
356 await Promise.all(txAndMailings);
348 await Promise.all(txAndMailings);
@@ -376,46 +368,48 @@ export default function InviteEmail() {
376 }
368 }
377 }, [
369 }, [
378 web3,
370 web3,
371+ wallet,
372+ point,
379 gasPrice,
373 gasPrice,
380− inputs,
381− addReceipt,
382− clearReceipts,
383 invites,
374 invites,
384− point,
375+ clearReceipts,
385− wallet,
376+ addReceipt,
386 sendMail,
377 sendMail,
387 ]);
378 ]);
388
379
380+ const onSubmit = useCallback(
381+ async values => {
382+ cachedEmails.current = values.emails.reduce((memo, email, i) => {
383+ memo[nameForEmailField(i)] = email;
384+ return memo;
385+ }, {});
386+ setStatus(STATUS.GENERATING);
387+ const errors = await generateInvites(values);
388+ if (hasErrors(errors)) {
389+ if (onlyHasWarning(errors)) {
390+ setStatus(STATUS.CAN_SEND);
391+ }
392+
393+ return errors;
394+ }
395+
396+ setStatus(STATUS.CAN_SEND);
397+ },
398+ [generateInvites]
399+ );
400+
389− const onClick = useCallback(async () => {
401+ const doSend = useCallback(async () => {
390 setGeneralError(null);
402 setGeneralError(null);
391 try {
403 try {
392− if (canGenerate) {
393− setStatus(STATUS.GENERATING);
394− await generateInvites();
395− setStatus(STATUS.CAN_SEND);
396− } else if (canSend) {
397− setStatus(STATUS.SENDING);
404+ setStatus(STATUS.SENDING);
398− await sendInvites();
405+ await sendInvites();
399− setStatus(STATUS.SUCCESS);
406+ setStatus(STATUS.SUCCESS);
400− }
401 } catch (error) {
407 } catch (error) {
402 console.error(error);
408 console.error(error);
403− setGeneralError(error);
409+ setGeneralError(error.message);
404 setStatus(STATUS.FAILURE);
410 setStatus(STATUS.FAILURE);
405 }
411 }
406− }, [canGenerate, canSend, generateInvites, sendInvites]);
412+ }, [sendInvites]);
407−
408− // when inputs update, check to see if any of them are duplicates
409− useEffect(() => {
410− // compute the list of valid emails
411− const emails = inputs.map(i => i.data).filter(d => !!d);
412− if (uniq(emails).length !== emails.length) {
413− setGeneralError(new Error(`Duplicate email.`));
414− } else {
415− setGeneralError(null);
416− setStatus(STATUS.INPUT);
417− }
418− }, [inputs]);
419
413
420 // when we transition to done, sync invites because we just sent some
414 // when we transition to done, sync invites because we just sent some
421 // and therefore know that state has changed
415 // and therefore know that state has changed
@@ -425,109 +419,142 @@ export default function InviteEmail() {
425 }
419 }
426 }, [isDone, syncInvites, point]);
420 }, [isDone, syncInvites, point]);
427
421
428− useEffect(() => {
429− for (const input of inputs) {
430− if (input.pass) {
431− syncHasReceivedForEmail(input.data);
432− }
433− }
434− }, [inputs, syncHasReceivedForEmail]);
435−
436 return (
422 return (
437 <Grid gap={3}>
423 <Grid gap={3}>
424+ <BridgeForm
425+ validate={validate}
426+ onSubmit={onSubmit}
427+ initialValues={INITIAL_VALUES}>
428+ {({ handleSubmit }) => (
429+ <FieldArray name="emails">
430+ {({ fields }) => {
431+ const sentInviteNames = Object.keys(receipts);
432+ return (
433+ <>
438− <Grid.Item full as={Flex} justify="end">
434+ <Grid.Item full as={Flex} justify="end">
439− {/* use hidden class instead of removing component from dom */}
435+ {/* use hidden class instead of removing component from dom */}
440− {/* in order to avoid janky reflow */}
436+ {/* in order to avoid janky reflow */}
441− <IconButton
437+ <IconButton
442− onClick={() => appendInput()}
438+ onClick={() => fields.push('')}
443− disabled={!canAddInvite}
439+ disabled={!canInput || fields.length >= maxInvitesToSend}
444− className={cn({ hidden: isDone })}
440+ className={cn({ hidden: isDone })}
445− solid>
441+ solid>
446− +
442+ +
447− </IconButton>
443+ </IconButton>
448− </Grid.Item>
444+ </Grid.Item>
449−
445+
450− {isDone && (
446+ {isDone ? (
451− <>
447+ <>
452− <Grid.Item as={Text} className="f5" full>
448+ <Grid.Item as={Text} className="f5" full>
449+ <Highlighted>
453− <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
450+ {pluralize(sentInviteNames.length, 'invite')}
451+ </Highlighted>{' '}
454− {pluralize(inputs.length, 'has', 'have')} been successfully sent
452+ {pluralize(sentInviteNames.length, 'has', 'have')} been
453+ successfully sent
455− </Grid.Item>
454+ </Grid.Item>
456− {inputs.map(input => (
455+
456+ {sentInviteNames.map(name => (
457− <Grid.Item as={HelpText} key={input.name} full>
457+ <Grid.Item as={HelpText} key={name} full>
458− {invites[input.name].email}
458+ <Field name={name}>
459+ {({ input: { value } }) => value}
460+ </Field>
459− </Grid.Item>
461+ </Grid.Item>
460− ))}
462+ ))}
461− </>
463+ </>
462− )}
464+ ) : (
463−
464− {!isDone && (
465− <>
465+ <>
466− {/* email inputs */}
466+ {fields.map((name, i) => {
467− {inputs.map((input, i) => {
468− const isFirst = i === 0;
467+ const isFirst = i === 0;
469− return (
468+ return (
470− <Grid.Item
469+ <Grid.Item
471− key={input.name}
470+ key={name}
471+ full
472− as={Grid}
472+ as={Grid}
473− gap={3}
473+ gap={3}
474− onMouseOver={() => setHovered({ [input.name]: true })}
474+ onMouseOver={() => setHovered({ [name]: true })}
475− onMouseLeave={() => setHovered({ [input.name]: false })}
475+ onMouseLeave={() => setHovered({ [name]: false })}>
476− full>
477− <Grid.Item
476+ <Grid.Item
478− as={Input}
479− cols={[1, 11]}
477+ cols={[1, 11]}
480− {...input}
478+ as={EmailInput}
479+ name={name}
480+ label={isFirst ? 'Email Address' : undefined}
481− accessory={accessoryFor(input.name)}
481+ accessory={accessoryFor(name)}
482− />
482+ />
483+ <Field name={name}>
484+ {({ meta: { active } }) => {
485+ return (
486+ <>
483− {!isFirst &&
487+ {!isFirst &&
484− (input.focused || hovered[input.name]) &&
488+ (active || hovered[name]) &&
485− canInput && (
489+ canInput && (
486− <Grid.Item
490+ <Grid.Item
487− cols={[11, 13]}
491+ cols={[11, 13]}
488− justifySelf="end"
492+ justifySelf="end"
489− alignSelf="center">
493+ alignSelf="center">
490− <IconButton
494+ <IconButton
491− onClick={() => removeInputAt(i)}
495+ onClick={() => fields.remove(i)}
492− solid
496+ solid
493− secondary>
497+ secondary>
494− -
498+ -
495− </IconButton>
499+ </IconButton>
500+ </Grid.Item>
501+ )}
502+ </>
503+ );
504+ }}
505+ </Field>
506+ </Grid.Item>
507+ );
508+ })}
509+
510+ {canInput ? (
511+ <Grid.Item
512+ full
513+ as={SubmitButton}
514+ handleSubmit={handleSubmit}
515+ accessory={`${visualProgress}/${fields.length}`}>
516+ {buttonText(status, fields.length)}
517+ </Grid.Item>
518+ ) : (
519+ <Grid.Item
520+ full
521+ as={LoadableButton}
522+ className="mt4"
523+ disabled={!canSend}
524+ accessory={`${visualProgress}/${fields.length}`}
525+ onClick={doSend}
526+ success={canSend}
527+ solid>
528+ {buttonText(status, fields.length)}
529+ </Grid.Item>
530+ )}
531+
532+ {needFunds && (
533+ <Grid.Item full>
534+ <Highlighted warning>
535+ Your ownership address {needFunds.address} needs at
536+ least {fromWei(needFunds.minBalance)} ETH and
537+ currently has {fromWei(needFunds.balance)} ETH.
538+ Waiting until the account has enough funds.
539+ </Highlighted>
540+ </Grid.Item>
541+ )}
542+
543+ <Grid.Item full as={FormError} />
544+
545+ {generalError && (
546+ <Grid.Item full as={ErrorText}>
547+ {generalError}
496− </Grid.Item>
548+ </Grid.Item>
549+ )}
550+ </>
497 )}
551 )}
498− </Grid.Item>
552+ </>
499− );
553+ );
500− })}
554+ }}
501−
555+ </FieldArray>
502− <Grid.Item
556+ )}
503− full
557+ </BridgeForm>
504− as={LoadableButton}
505− disabled={!canGenerate && !canSend}
506− accessory={`${visualProgress}/${inputs.length}`}
507− onClick={onClick}
508− success={canSend}
509− solid>
510− {buttonText(status, inputs.length)}
511− </Grid.Item>
512−
513− {needFunds && (
514− <Grid.Item full>
515− <Highlighted warning>
516− Your ownership address {needFunds.address} needs at least{' '}
517− {fromWei(needFunds.minBalance)} ETH and currently has{' '}
518− {fromWei(needFunds.balance)} ETH. Waiting until the account has
519− enough funds.
520− </Highlighted>
521− </Grid.Item>
522− )}
523−
524− {generalError && (
525− <Grid.Item full>
526− <ErrorText>{generalError.message.toString()}</ErrorText>
527− </Grid.Item>
528− )}
529− </>
530− )}
531 </Grid>
558 </Grid>
532 );
559 );
533}
560}
src/views/IssueChild.js+104−83
@@ -1,7 +1,6 @@
1−import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+import React, { useCallback, useMemo, useState } from 'react';
2−import { Nothing, Just } from 'folktale/maybe';
3import cn from 'classnames';
2import cn from 'classnames';
4−import { Grid, Text, Input } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
5import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
6import * as ob from 'urbit-ob';
5import * as ob from 'urbit-ob';
7
6
@@ -10,18 +9,25 @@ import { usePointCache } from 'store/pointCache';
10import { usePointCursor } from 'store/pointCursor';
9import { usePointCursor } from 'store/pointCursor';
11
10
12import * as need from 'lib/need';
11import * as need from 'lib/need';
13−import { useAddressInput, usePointInput } from 'lib/useInputs';
14import useEthereumTransaction from 'lib/useEthereumTransaction';
12import useEthereumTransaction from 'lib/useEthereumTransaction';
15import { GAS_LIMITS } from 'lib/constants';
13import { GAS_LIMITS } from 'lib/constants';
16import patp2dec from 'lib/patp2dec';
14import patp2dec from 'lib/patp2dec';
17−import useLifecycle from 'lib/useLifecycle';
18−import { validateNameInNumberSet } from 'lib/validators';
19import { getSpawnCandidate } from 'lib/child';
15import { getSpawnCandidate } from 'lib/child';
20import { useLocalRouter } from 'lib/LocalRouter';
16import { useLocalRouter } from 'lib/LocalRouter';
17+import useConstant from 'lib/useConstant';
21
18
22import ViewHeader from 'components/ViewHeader';
19import ViewHeader from 'components/ViewHeader';
23import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
20import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
24import View from 'components/View';
21import View from 'components/View';
22+import { PointInput, AddressInput } from 'form/Inputs';
23+import {
24+ composeValidator,
25+ buildPointValidator,
26+ buildAddressValidator,
27+ hasErrors,
28+} from 'form/validators';
29+import BridgeForm from 'form/BridgeForm';
30+import FormError from 'form/FormError';
25
31
26function useIssueChild() {
32function useIssueChild() {
27 const { contracts } = useNetwork();
33 const { contracts } = useNetwork();
@@ -55,7 +61,11 @@ export default function IssueChild() {
55 const _contracts = need.contracts(contracts);
61 const _contracts = need.contracts(contracts);
56 const _point = parseInt(need.point(pointCursor), 10);
62 const _point = parseInt(need.point(pointCursor), 10);
57
63
58− const [availablePoints, setAvailablePoints] = useState(Nothing());
64+ const availablePointsPromise = useConstant(() =>
65+ azimuth.azimuth
66+ .getUnspawnedChildren(_contracts, _point)
67+ .then(points => new Set(points))
68+ );
59
69
60 const candidates = useMemo(() => {
70 const candidates = useMemo(() => {
61 const getCandidate = () => ob.patp(getSpawnCandidate(_point));
71 const getCandidate = () => ob.patp(getSpawnCandidate(_point));
@@ -72,62 +82,51 @@ export default function IssueChild() {
72 bind,
82 bind,
73 } = useIssueChild();
83 } = useIssueChild();
74
84
75− const validators = useMemo(
85+ const validateFormAsync = useCallback(
76− () => [
86+ async values => {
77− validateNameInNumberSet(
87+ const point = patp2dec(values.name);
78− availablePoints.getOrElse(new Set()),
88+ const hasPoint = (await availablePointsPromise).has(point);
89+
90+ if (!hasPoint) {
79− 'This point cannot be spawned.'
91+ return { point: 'This point cannot be spawned.' };
80− ),
92+ }
81− ],
93+ },
82− [availablePoints]
94+ [availablePointsPromise]
83 );
95 );
84− const [
96+
85− pointNameInput,
97+ const validateForm = useCallback(
86− { pass: validPointName, value: pointName },
98+ (values, errors) => {
87− // ^ we use value: here so our effect runs onChange
99+ if (hasErrors(errors)) {
88− ] = usePointInput({
89− name: 'point',
90− disabled: inputsLocked,
91− autoFocus: true,
92− validators,
93− error: availablePoints.matchWith({
94− Nothing: () => 'Loading availability...',
95− Just: () => undefined,
96− }),
97− });
98−
99− const [ownerInput, { pass: validOwner, data: owner }] = useAddressInput({
100− name: 'owner',
101− label: `Ethereum Address`,
102− disabled: inputsLocked,
103− });
104−
105− useEffect(() => {
106− if (validPointName && validOwner) {
107− construct(patp2dec(pointName), owner);
108− } else {
109− unconstruct();
110− }
111− }, [owner, construct, unconstruct, validPointName, validOwner, pointName]);
112−
113− useLifecycle(() => {
114− let mounted = true;
115−
116− (async () => {
117− const availablePoints = await azimuth.azimuth.getUnspawnedChildren(
118− _contracts,
119− _point
120− );
121−
122− if (!mounted) {
123− return;
100+ return errors;
124 }
101 }
125
102
126− setAvailablePoints(Just(new Set(availablePoints)));
103+ return validateFormAsync(values, errors);
104+ },
105+ [validateFormAsync]
106+ );
107+
108+ const validate = useMemo(
109+ () =>
110+ composeValidator(
111+ {
112+ point: buildPointValidator(4),
113+ owner: buildAddressValidator(),
114+ },
115+ validateForm
116+ ),
117+ [validateForm]
127− })();
118+ );
128
119
129− return () => (mounted = false);
120+ const onValues = useCallback(
121+ ({ valid, values }) => {
122+ if (valid) {
123+ construct(patp2dec(values.point), values.owner);
124+ } else {
125+ unconstruct();
126+ }
127+ },
128+ [construct, unconstruct]
130− });
129+ );
131
130
132 return (
131 return (
133 <View pop={pop} inset>
132 <View pop={pop} inset>
@@ -143,30 +142,52 @@ export default function IssueChild() {
143 </Grid.Item>
142 </Grid.Item>
144 )}
143 )}
145
144
145+ <BridgeForm validate={validate} onValues={onValues}>
146+ {({ handleSubmit, values }) => (
147+ <>
146− {completed && (
148+ {completed && (
147− <Grid.Item
149+ <Grid.Item
148− full
150+ full
149− as={Text}
151+ as={Text}
150− className={cn('f5', {
152+ className={cn('f5', {
151− green3: completed,
153+ green3: completed,
152− })}>
154+ })}>
153− {pointName} has been spawned and can be claimed by {owner}.
155+ {values.point} has been spawned and can be claimed by{' '}
156+ {values.owner}.
154− </Grid.Item>
157+ </Grid.Item>
155− )}
158+ )}
156−
159+
157− {!completed && (
160+ {!completed && (
158− <>
161+ <>
162+ <Grid.Item
163+ full
164+ as={PointInput}
165+ name="point"
166+ disabled={inputsLocked}
159− <Grid.Item full as={Input} {...pointNameInput} className="mt4" />
167+ className="mt4"
168+ />
169+ <Grid.Item
170+ full
171+ as={AddressInput}
160− <Grid.Item full as={Input} {...ownerInput} className="mb4" />
172+ className="mb4"
173+ name="owner"
174+ label="Ethereum Address"
175+ disabled={inputsLocked}
176+ />
161− </>
177+ </>
162− )}
178+ )}
163−
179+
164− <Grid.Item
180+ <Grid.Item full as={FormError} />
181+
182+ <Grid.Item
165− full
183+ full
166− as={InlineEthereumTransaction}
184+ as={InlineEthereumTransaction}
167− {...bind}
185+ {...bind}
168− onReturn={() => pop()}
186+ onReturn={() => pop()}
169− />
187+ />
188+ </>
189+ )}
190+ </BridgeForm>
170 </Grid>
191 </Grid>
171 </View>
192 </View>
172 );
193 );
src/views/Login.js+5−13
@@ -1,5 +1,4 @@
1import React, { useCallback, useState } from 'react';
1import React, { useCallback, useState } from 'react';
2−import { Nothing } from 'folktale/maybe';
3import { H4, Grid } from 'indigo-react';
2import { H4, Grid } from 'indigo-react';
4
3
5import { useHistory } from 'store/history';
4import { useHistory } from 'store/history';
@@ -60,7 +59,7 @@ const walletTypeToViewName = walletType => {
60export default function Login() {
59export default function Login() {
61 // globals
60 // globals
62 const { pop, push, names } = useHistory();
61 const { pop, push, names } = useHistory();
63− const { wallet, walletType } = useWallet();
62+ const { walletType } = useWallet();
64
63
65 // inputs
64 // inputs
66 const [currentTab, setCurrentTab] = useState(
65 const [currentTab, setCurrentTab] = useState(
@@ -72,7 +71,7 @@ export default function Login() {
72 names.ACTIVATE,
71 names.ACTIVATE,
73 ]);
72 ]);
74
73
75− const doContinue = useCallback(() => {
74+ const goHome = useCallback(() => {
76 push(names.POINTS);
75 push(names.POINTS);
77 }, [push, names]);
76 }, [push, names]);
78
77
@@ -88,21 +87,14 @@ export default function Login() {
88 full
87 full
89 as={Tabs}
88 as={Tabs}
90 className="mt1"
89 className="mt1"
90+ // Tabs
91 views={VIEWS}
91 views={VIEWS}
92 options={OPTIONS}
92 options={OPTIONS}
93 currentTab={currentTab}
93 currentTab={currentTab}
94 onTabChange={setCurrentTab}
94 onTabChange={setCurrentTab}
95+ // Tab extra
96+ goHome={goHome}
95 />
97 />
96−
97− <Grid.Item
98− full
99− as={ForwardButton}
100− solid
101− className="mt2"
102− disabled={Nothing.hasInstance(wallet)}
103− onClick={doContinue}>
104− Continue
105− </Grid.Item>
106 </Grid>
98 </Grid>
107
99
108 <Footer>
100 <Footer>
src/views/Login/Advanced.js+2−2
@@ -20,7 +20,7 @@ const OPTIONS = [
20 { text: 'Ethereum Keystore', value: NAMES.KEYSTORE },
20 { text: 'Ethereum Keystore', value: NAMES.KEYSTORE },
21];
21];
22
22
23−export default function Advanced({ loginCompleted, className }) {
23+export default function Advanced({ className, ...rest }) {
24 const [currentTab, setCurrentTab] = useState(undefined);
24 const [currentTab, setCurrentTab] = useState(undefined);
25
25
26 return (
26 return (
@@ -31,7 +31,7 @@ export default function Advanced({ loginCompleted, className }) {
31 currentTab={currentTab}
31 currentTab={currentTab}
32 onTabChange={setCurrentTab}
32 onTabChange={setCurrentTab}
33 //
33 //
34− loginCompleted={loginCompleted}
34+ {...rest}
35 />
35 />
36 );
36 );
37}
37}
src/views/Login/Hardware.js+2−2
@@ -20,7 +20,7 @@ const OPTIONS = [
20 { text: 'Trezor', value: NAMES.TREZOR },
20 { text: 'Trezor', value: NAMES.TREZOR },
21];
21];
22
22
23−export default function Hardware({ loginCompleted, className }) {
23+export default function Hardware({ className, ...rest }) {
24 const [currentTab, setCurrentTab] = useState(undefined);
24 const [currentTab, setCurrentTab] = useState(undefined);
25
25
26 return (
26 return (
@@ -31,7 +31,7 @@ export default function Hardware({ loginCompleted, className }) {
31 currentTab={currentTab}
31 currentTab={currentTab}
32 onTabChange={setCurrentTab}
32 onTabChange={setCurrentTab}
33 //
33 //
34− loginCompleted={loginCompleted}
34+ {...rest}
35 />
35 />
36 );
36 );
37}
37}
src/views/Login/Keystore.js+68−75
@@ -1,70 +1,58 @@
1−import React, { useState } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2−import { Just, Nothing } from 'folktale/maybe';
2+import { Just } from 'folktale/maybe';
3−import { P, Grid, Input, ErrorText } from 'indigo-react';
3+import { P, Grid } from 'indigo-react';
4import * as keythereum from 'keythereum';
4import * as keythereum from 'keythereum';
5
5
6import { useWallet } from 'store/wallet';
6import { useWallet } from 'store/wallet';
7
7
8−import { usePassphraseInput } from 'lib/useInputs';
9−import * as need from 'lib/need';
10import { EthereumWallet, WALLET_TYPES } from 'lib/wallet';
8import { EthereumWallet, WALLET_TYPES } from 'lib/wallet';
11import useLoginView from 'lib/useLoginView';
9import useLoginView from 'lib/useLoginView';
12
10
13−import { ForwardButton } from 'components/Buttons';
11+import { PassphraseInput } from 'form/Inputs';
14−import UploadButton from 'components/UploadButton';
12+import {
15−
13+ composeValidator,
14+ buildPassphraseValidator,
15+ buildUploadValidator,
16+} from 'form/validators';
17+import UploadInput from 'form/UploadInput';
18+import SubmitButton from 'form/SubmitButton';
19+import BridgeForm from 'form/BridgeForm';
20+import { FORM_ERROR } from 'final-form';
21+import FormError from 'form/FormError';
22+
16−export default function Keystore({ className }) {
23+export default function Keystore({ className, goHome }) {
17 useLoginView(WALLET_TYPES.KEYSTORE);
24 useLoginView(WALLET_TYPES.KEYSTORE);
18
25
19 // globals
26 // globals
20 const { setWallet } = useWallet();
27 const { setWallet } = useWallet();
21
28
22− const [error, setError] = useState();
29+ const validate = useMemo(
23− // inputs
30+ () =>
24− // keystore: Maybe<String>
31+ composeValidator({
25− const [keystore, setKeystore] = useState(Nothing());
32+ passphrase: buildPassphraseValidator(),
26− const [passphraseInput, { data: passphrase }] = usePassphraseInput({
33+ keystore: buildUploadValidator(),
27− name: 'password',
34+ }),
28− label: 'Keystore password',
35+ []
29− autoFocus: true,
36+ );
30− });
31−
32− const constructWallet = () => {
33− try {
34− const text = need.keystore(keystore);
35−
36− const json = JSON.parse(text);
37− const privateKey = keythereum.recover(passphrase, json);
38−
39− const newWallet = new EthereumWallet(privateKey);
40− setError();
41− setWallet(Just(newWallet));
42− } catch (err) {
43− setError(
44− "Couldn't decrypt wallet. You may have entered an incorrect password."
45− );
46− setWallet(Nothing());
47− }
48− };
49−
50− const handleKeystoreUpload = element => {
51− const file = element.files.item(0);
52− const reader = new FileReader();
53−
54− reader.onload = e => {
55− const keystore = e.target.result;
56− setKeystore(Just(keystore));
57− };
58−
59− const failure = _ => {
60− setError('There was a problem uploading your Keystore file');
61− };
62−
63− reader.onerror = failure;
64− reader.onabort = failure;
65
37
66− reader.readAsText(file);
38+ const onSubmit = useCallback(
39+ async values => {
40+ try {
41+ const json = JSON.parse(values.keystore);
42+ const privateKey = keythereum.recover(values.passphrase, json);
43+
44+ const wallet = new EthereumWallet(privateKey);
45+ setWallet(Just(wallet));
46+ } catch (error) {
47+ console.error(error);
48+ return {
49+ [FORM_ERROR]:
50+ "Couldn't decrypt wallet. You may have entered an incorrect password.",
51+ };
52+ }
67− };
53+ },
54+ [setWallet]
55+ );
68
56
69 return (
57 return (
70 <Grid className={className}>
58 <Grid className={className}>
@@ -73,27 +61,32 @@ export default function Keystore({ className }) {
73 encrypted with a password, you'll also need to enter that below.
61 encrypted with a password, you'll also need to enter that below.
74 </Grid.Item>
62 </Grid.Item>
75
63
76− <Grid.Item full as={UploadButton} onChange={handleKeystoreUpload}>
64+ <BridgeForm validate={validate} onSubmit={onSubmit} afterSubmit={goHome}>
65+ {({ handleSubmit }) => (
66+ <>
67+ <Grid.Item
68+ full
69+ as={UploadInput}
70+ name="keystore"
77− Upload Keystore file
71+ label="Upload Keystore file"
78− </Grid.Item>
72+ />
79−
73+
80− {error && (
74+ <Grid.Item
81− <Grid.Item full as={ErrorText} className="mt1">
75+ full
82− {error}
76+ as={PassphraseInput}
83− </Grid.Item>
77+ className="mt3"
84− )}
78+ name="passphrase"
85−
79+ label="Passphrase"
80+ />
81+
86− <Grid.Item full as={Input} className="mt3" {...passphraseInput} />
82+ <Grid.Item full as={FormError} />
87−
83+
88− <Grid.Item
84+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
89− full
90− as={ForwardButton}
91− solid
92− className="mt3"
93− disabled={Nothing.hasInstance(keystore)}
94− onClick={constructWallet}>
95− Decrypt
85+ Decrypt
96− </Grid.Item>
86+ </Grid.Item>
87+ </>
88+ )}
89+ </BridgeForm>
97 </Grid>
90 </Grid>
98 );
91 );
99}
92}
src/views/Login/Ledger.js+123−112
@@ -1,15 +1,7 @@
1−import React, { useCallback, useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3−import { Just, Nothing } from 'folktale/maybe';
3+import { Just } from 'folktale/maybe';
4−import {
5− P,
6− Text,
7− Input,
8− Grid,
9− H5,
10− CheckboxInput,
11− SelectInput,
12−} from 'indigo-react';
4+import { P, Text, Grid, H5, CheckboxInput, SelectInput } from 'indigo-react';
13import { times } from 'lodash';
5import { times } from 'lodash';
14import * as bip32 from 'bip32';
6import * as bip32 from 'bip32';
15import Transport from '@ledgerhq/hw-transport-u2f';
7import Transport from '@ledgerhq/hw-transport-u2f';
@@ -18,14 +10,6 @@ import * as secp256k1 from 'secp256k1';
18
10
19import { useWallet } from 'store/wallet';
11import { useWallet } from 'store/wallet';
20
12
21−import { ForwardButton } from 'components/Buttons';
22−
23−import {
24− useCheckboxInput,
25− useHdPathInput,
26− useSelectInput,
27−} from 'lib/useInputs';
28−
29import {
13import {
30 LEDGER_LIVE_PATH,
14 LEDGER_LIVE_PATH,
31 LEDGER_LEGACY_PATH,
15 LEDGER_LEGACY_PATH,
@@ -35,6 +19,18 @@ import {
35import { WALLET_TYPES } from 'lib/wallet';
19import { WALLET_TYPES } from 'lib/wallet';
36import useLoginView from 'lib/useLoginView';
20import useLoginView from 'lib/useLoginView';
37import useBreakpoints from 'lib/useBreakpoints';
21import useBreakpoints from 'lib/useBreakpoints';
22+import {
23+ composeValidator,
24+ buildCheckboxValidator,
25+ buildHdPathValidator,
26+ buildSelectValidator,
27+} from 'form/validators';
28+import BridgeForm from 'form/BridgeForm';
29+import FormError from 'form/FormError';
30+import SubmitButton from 'form/SubmitButton';
31+import Condition from 'form/Condition';
32+import { FORM_ERROR } from 'final-form';
33+import { HdPathInput } from 'form/Inputs';
38
34
39const PATH_OPTIONS = [
35const PATH_OPTIONS = [
40 { text: 'Ledger Live', value: LEDGER_LIVE_PATH },
36 { text: 'Ledger Live', value: LEDGER_LIVE_PATH },
@@ -46,74 +42,68 @@ const ACCOUNT_OPTIONS = times(20, i => ({
46 value: i,
42 value: i,
47}));
43}));
48
44
49−export default function Ledger({ className }) {
45+export default function Ledger({ className, goHome }) {
50 useLoginView(WALLET_TYPES.LEDGER);
46 useLoginView(WALLET_TYPES.LEDGER);
51
47
52 const { setWallet, setWalletHdPath } = useWallet();
48 const { setWallet, setWalletHdPath } = useWallet();
53
49
54− // derivation path input
50+ const validate = useMemo(
55− const [derivationPathInput, { data: basePathPattern }] = useSelectInput({
51+ () =>
52+ composeValidator({
53+ useCustomPath: buildCheckboxValidator(),
56− name: 'derivationpath',
54+ derivationpath: buildSelectValidator(PATH_OPTIONS),
57− label: 'Derivation Path',
58− placeholder: 'Choose path pattern...',
59− options: PATH_OPTIONS,
60− });
61−
62− // account input
63− const [accountInput, { data: accountIndex }] = useSelectInput({
64− name: 'account',
65− label: 'Account',
66− placeholder: 'Choose account...',
67− options: ACCOUNT_OPTIONS,
55+ account: buildSelectValidator(ACCOUNT_OPTIONS),
68− });
56+ hdpath: buildHdPathValidator(),
69−
57+ }),
70− // custom toggle
58+ []
71− const [customPathInput, { data: useCustomPath }] = useCheckboxInput({
72− name: 'customPath',
73− label: 'Custom HD Path',
74− autoComplete: 'off',
75− initialValue: false,
76− });
77−
78− // hd path input
79− const [
80− hdPathInput,
81− { data: hdPath },
82− { setValue: setHdPath },
83− ] = useHdPathInput({
84− name: 'hdpath',
85− label: 'HD Path',
86− initialValue: basePathPattern.replace(/x/g, 0),
87− });
59+ );
88−
60+
89− const pollDevice = useCallback(async () => {
61+ const onSubmit = useCallback(
62+ async values => {
90− const transport = await Transport.create();
63+ const transport = await Transport.create();
91− const eth = new Eth(transport);
64+ const eth = new Eth(transport);
92− const path = chopHdPrefix(hdPath);
65+ const path = chopHdPrefix(values.hdpath);
93−
66+
94− try {
67+ try {
95− const info = await eth.getAddress(path, false, true);
68+ const info = await eth.getAddress(path, false, true);
96− const publicKey = Buffer.from(info.publicKey, 'hex');
69+ const publicKey = Buffer.from(info.publicKey, 'hex');
97− const chainCode = Buffer.from(info.chainCode, 'hex');
70+ const chainCode = Buffer.from(info.chainCode, 'hex');
98− const pub = secp256k1.publicKeyConvert(publicKey, true);
71+ const pub = secp256k1.publicKeyConvert(publicKey, true);
99− const hd = bip32.fromPublicKey(pub, chainCode);
72+ const hd = bip32.fromPublicKey(pub, chainCode);
73+
100− setWallet(Just(hd));
74+ setWallet(Just(hd));
101− setWalletHdPath(addHdPrefix(hdPath));
75+ setWalletHdPath(addHdPrefix(values.hdpath));
102− } catch (error) {
76+ } catch (error) {
103− console.error(error);
77+ return { [FORM_ERROR]: error.message };
78+ }
79+ },
104− setWallet(Nothing());
80+ [setWallet, setWalletHdPath]
81+ );
82+
83+ const onValues = useCallback(({ valid, values, form }) => {
84+ if (!valid) {
85+ return;
105 }
86 }
106− }, [hdPath, setWallet, setWalletHdPath]);
87+
107−
108− // when the base path pattern or the account index changes
88+ // when the base path pattern or the account index changes
109− // update the hd path in our input
89+ // update the hd path in our input
110− useEffect(() => {
111− if (useCustomPath) {
90+ if (!values.useCustomPath) {
112− // updated by useForm
91+ form.change(
113− } else {
92+ 'hdpath',
114− setHdPath(basePathPattern.replace(/x/g, accountIndex));
93+ values.derivationpath.replace(/x/g, values.account)
94+ );
115 }
95 }
116− }, [useCustomPath, setHdPath, basePathPattern, accountIndex]);
96+ }, []);
97+
98+ const initialValues = useMemo(
99+ () => ({
100+ useCustomPath: false,
101+ hdpath: PATH_OPTIONS[0].value.replace(/x/g, ACCOUNT_OPTIONS[0].value),
102+ derivationpath: PATH_OPTIONS[0].value,
103+ account: ACCOUNT_OPTIONS[0].value,
104+ }),
105+ []
106+ );
117
107
118 const full = useBreakpoints([true, true, false]);
108 const full = useBreakpoints([true, true, false]);
119 const half = useBreakpoints([false, false, true]);
109 const half = useBreakpoints([false, false, true]);
@@ -170,37 +160,58 @@ export default function Ledger({ className }) {
170 enable the "contract data" option.
160 enable the "contract data" option.
171 </Grid.Item>
161 </Grid.Item>
172
162
173− {useCustomPath && <Grid.Item full as={Input} {...hdPathInput} />}
163+ <BridgeForm
174−
164+ validate={validate}
175− {!useCustomPath && (
165+ onValues={onValues}
166+ onSubmit={onSubmit}
167+ afterSubmit={goHome}
168+ initialValues={initialValues}>
169+ {({ handleSubmit }) => (
176− <>
170+ <>
177− <Grid.Item
171+ <Condition when="useCustomPath" is={true}>
172+ <Grid.Item full as={HdPathInput} name="hdpath" label="HD Path" />
173+ </Condition>
174+
175+ <Condition when="useCustomPath" is={false}>
176+ <Grid.Item
178− full={full}
177+ full={full}
179− half={half && 1}
178+ half={half && 1}
180− as={SelectInput}
179+ as={SelectInput}
181− className={cn({ pr1: half })}
180+ className={cn({ pr1: half })}
182− {...derivationPathInput}
181+ name="derivationpath"
182+ label="Derivation Path"
183+ placeholder="Choose path pattern..."
184+ options={PATH_OPTIONS}
183− />
185+ />
186+
184− <Grid.Item
187+ <Grid.Item
185− full={full}
188+ full={full}
186− half={half && 2}
189+ half={half && 2}
187− as={SelectInput}
190+ as={SelectInput}
188− className={cn({ pl1: half })}
191+ className={cn({ pl1: half })}
189− {...accountInput}
192+ name="account"
193+ label="Account"
194+ placeholder="Choose account..."
195+ options={ACCOUNT_OPTIONS}
190− />
196+ />
191− </>
197+ </Condition>
192− )}
198+
193−
199+ <Grid.Item
200+ full
194− <Grid.Item full as={CheckboxInput} className="mv3" {...customPathInput} />
201+ as={CheckboxInput}
195−
202+ className="mv3"
203+ name="useCustomPath"
204+ label="Custom HD Path"
205+ />
206+
196− <Grid.Item
207+ <Grid.Item full as={FormError} />
197− full
208+
198− as={ForwardButton}
209+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
199− solid
200− className="mt2"
201− onClick={pollDevice}>
202− Authenticate
210+ Authenticate
203− </Grid.Item>
211+ </Grid.Item>
212+ </>
213+ )}
214+ </BridgeForm>
204 </>
215 </>
205 );
216 );
206
217
src/views/Login/Mnemonic.js+83−57
@@ -1,20 +1,26 @@
1−import React, { useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3import { Just, Nothing } from 'folktale/maybe';
3import { Just, Nothing } from 'folktale/maybe';
4−import { Grid, Input, CheckboxInput } from 'indigo-react';
4+import { Grid, CheckboxInput } from 'indigo-react';
5
5
6import { useWallet } from 'store/wallet';
6import { useWallet } from 'store/wallet';
7
7
8−import {
9− usePassphraseInput,
10− useMnemonicInput,
11− useHdPathInput,
12− useCheckboxInput,
13−} from 'lib/useInputs';
14import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet';
8import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet';
15import useLoginView from 'lib/useLoginView';
9import useLoginView from 'lib/useLoginView';
10+import { MnemonicInput, HdPathInput, PassphraseInput } from 'form/Inputs';
11+import {
12+ composeValidator,
13+ buildMnemonicValidator,
14+ buildCheckboxValidator,
15+ buildPassphraseValidator,
16+ buildHdPathValidator,
17+} from 'form/validators';
18+import BridgeForm from 'form/BridgeForm';
19+import Condition from 'form/Condition';
20+import FormError from 'form/FormError';
21+import SubmitButton from 'form/SubmitButton';
16
22
17−export default function Mnemonic({ className }) {
23+export default function Mnemonic({ className, goHome }) {
18 useLoginView(WALLET_TYPES.MNEMONIC);
24 useLoginView(WALLET_TYPES.MNEMONIC);
19
25
20 const {
26 const {
@@ -24,61 +30,81 @@ export default function Mnemonic({ className }) {
24 setWalletHdPath,
30 setWalletHdPath,
25 } = useWallet();
31 } = useWallet();
26
32
27− const [advancedInput, { data: useAdvanced }] = useCheckboxInput({
33+ const validate = useMemo(
28− name: 'advanced',
34+ () =>
29− label: 'Passphrase & HD Path',
35+ composeValidator({
30− initialValue: false,
36+ useAdvanced: buildCheckboxValidator(),
31− });
37+ mnemonic: buildMnemonicValidator(),
32−
33− const [mnemonicInput, { pass, data: mnemonic }] = useMnemonicInput({
34− name: 'mnemonic',
35− label: 'BIP39 Mnemonic',
36− autoFocus: true,
37− });
38−
39− const [passphraseInput, { data: passphrase }] = usePassphraseInput({
40− name: 'passphrase',
38+ passphrase: buildPassphraseValidator(),
41− label: 'Wallet Passphrase',
39+ hdpath: buildHdPathValidator(),
42− });
40+ }),
43−
41+ []
44− const [hdPathInput, { data: hdPath }] = useHdPathInput({
45− name: 'hdpath',
46− label: 'HD Path',
47− initialValue: walletHdPath,
48− });
42+ );
49
43
50 // when the properties change, re-derive wallet and set global state
44 // when the properties change, re-derive wallet and set global state
51− useEffect(() => {
45+ const onValues = useCallback(
52− if (pass) {
46+ ({ valid, values }) => {
47+ if (valid) {
53− setWalletHdPath(hdPath);
48+ setWalletHdPath(values.hdpath);
54− setAuthMnemonic(Just(mnemonic));
49+ setAuthMnemonic(Just(values.mnemonic));
50+ setWallet(
55− setWallet(walletFromMnemonic(mnemonic, hdPath, passphrase));
51+ walletFromMnemonic(values.mnemonic, values.hdpath, values.passphrase)
52+ );
56− } else {
53+ } else {
57− setAuthMnemonic(Nothing());
54+ setAuthMnemonic(Nothing());
58− setWallet(Nothing());
55+ setWallet(Nothing());
59− }
56+ }
60− }, [
57+ },
61− pass,
58+ [setAuthMnemonic, setWallet, setWalletHdPath]
62− mnemonic,
59+ );
63− passphrase,
60+
64− hdPath,
61+ const initialValues = useMemo(
65− setWallet,
62+ () => ({ hdpath: walletHdPath, useAdvanced: false }),
66− setAuthMnemonic,
63+ [walletHdPath]
67− setWalletHdPath,
68− ]);
64+ );
69
65
70 return (
66 return (
71 <Grid className={cn('mt4', className)}>
67 <Grid className={cn('mt4', className)}>
68+ <BridgeForm
69+ validate={validate}
70+ onValues={onValues}
71+ afterSubmit={goHome}
72+ initialValues={initialValues}>
73+ {({ handleSubmit }) => (
74+ <>
72− <Grid.Item full as={Input} {...mnemonicInput} />
75+ <Grid.Item
76+ full
77+ as={MnemonicInput}
78+ name="mnemonic"
79+ label="BIP39 Mnemonic"
80+ />
81+
82+ <Condition when="useAdvanced" is={true}>
83+ <Grid.Item
84+ full
85+ as={PassphraseInput}
86+ name="passphrase"
87+ label="Passphrase"
88+ />
89+
90+ <Grid.Item full as={HdPathInput} name="hdpath" label="HD Path" />
91+ </Condition>
92+
93+ <Grid.Item
94+ full
95+ as={CheckboxInput}
96+ name="useAdvanced"
97+ label="Passphrase & HD Path"
98+ />
73
99
74− {useAdvanced && (
75− <>
76− <Grid.Item full as={Input} {...passphraseInput} />
100+ <Grid.Item full as={FormError} />
77− <Grid.Item full as={Input} {...hdPathInput} />
78− </>
79− )}
80
101
81− <Grid.Item as={CheckboxInput} {...advancedInput} full />
102+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
103+ Continue
104+ </Grid.Item>
105+ </>
106+ )}
107+ </BridgeForm>
82 </Grid>
108 </Grid>
83 );
109 );
84}
110}
src/views/Login/PrivateKey.js+46−21
@@ -1,38 +1,63 @@
1−import React, { useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import { Just, Nothing } from 'folktale/maybe';
2import { Just, Nothing } from 'folktale/maybe';
3−import { Grid, Input } from 'indigo-react';
3+import { Grid } from 'indigo-react';
4
4
5import { useWallet } from 'store/wallet';
5import { useWallet } from 'store/wallet';
6
6
7−import { useHexInput } from 'lib/useInputs';
8import { EthereumWallet, WALLET_TYPES, stripHexPrefix } from 'lib/wallet';
7import { EthereumWallet, WALLET_TYPES, stripHexPrefix } from 'lib/wallet';
9import useLoginView from 'lib/useLoginView';
8import useLoginView from 'lib/useLoginView';
10
9
10+import FormError from 'form/FormError';
11+import BridgeForm from 'form/BridgeForm';
12+import { HexInput } from 'form/Inputs';
13+import { buildHexValidator, composeValidator } from 'form/validators';
14+
15+import SubmitButton from 'form/SubmitButton';
16+
11−export default function PrivateKey({ className }) {
17+export default function PrivateKey({ className, goHome }) {
12 useLoginView(WALLET_TYPES.PRIVATE_KEY);
18 useLoginView(WALLET_TYPES.PRIVATE_KEY);
13
19
14 const { setWallet } = useWallet();
20 const { setWallet } = useWallet();
15
21
16− const [privateKeyInput, { pass, data: privateKey }] = useHexInput({
22+ const validate = useMemo(
17− length: 64,
23+ () =>
18− name: 'privateKey',
24+ composeValidator({
19− label: 'Private key',
25+ privateKey: buildHexValidator(64),
20− autoFocus: true,
26+ }),
27+ []
21− });
28+ );
22−
29+
23− useEffect(() => {
30+ const onValues = useCallback(
24− if (pass) {
31+ ({ valid, values }) => {
32+ if (valid) {
25− const sec = Buffer.from(stripHexPrefix(privateKey), 'hex');
33+ const sec = Buffer.from(stripHexPrefix(values.privateKey), 'hex');
26− const newWallet = new EthereumWallet(sec);
34+ const newWallet = new EthereumWallet(sec);
27− setWallet(Just(newWallet));
35+ setWallet(Just(newWallet));
28− } else {
36+ } else {
29− setWallet(Nothing());
37+ setWallet(Nothing());
30− }
38+ }
39+ },
31− }, [pass, privateKey, setWallet]);
40+ [setWallet]
41+ );
32
42
33 return (
43 return (
34 <Grid className={className}>
44 <Grid className={className}>
35− <Grid.Item full as={Input} {...privateKeyInput} />
45+ <BridgeForm validate={validate} onValues={onValues} afterSubmit={goHome}>
46+ {({ handleSubmit }) => (
47+ <>
48+ <Grid.Item
49+ full
50+ as={HexInput}
51+ name="privateKey"
52+ label="Private key"
53+ />
54+ <Grid.Item full as={FormError} />
55+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
56+ Continue
57+ </Grid.Item>
58+ </>
59+ )}
60+ </BridgeForm>
36 </Grid>
61 </Grid>
37 );
62 );
38}
63}
src/views/Login/Ticket.js+175−156
@@ -1,187 +1,206 @@
1+import React, { useCallback, useMemo, useRef } from 'react';
1−import { Just, Nothing } from 'folktale/maybe';
2+import { Just } from 'folktale/maybe';
2import cn from 'classnames';
3import cn from 'classnames';
3−import React, { useCallback, useState, useEffect } from 'react';
4import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
5−import * as ob from 'urbit-ob';
6import * as kg from 'urbit-key-generation/dist/index';
5import * as kg from 'urbit-key-generation/dist/index';
7−import { Input, Grid, CheckboxInput } from 'indigo-react';
6+import { Grid, CheckboxInput } from 'indigo-react';
7+import { FORM_ERROR } from 'final-form';
8
8
9import { useNetwork } from 'store/network';
9import { useNetwork } from 'store/network';
10import { useWallet } from 'store/wallet';
10import { useWallet } from 'store/wallet';
11import { usePointCursor } from 'store/pointCursor';
11import { usePointCursor } from 'store/pointCursor';
12
12
13−import {
14− usePointInput,
15− useTicketInput,
16− usePassphraseInput,
17− useCheckboxInput,
18−} from 'lib/useInputs';
19import * as need from 'lib/need';
13import * as need from 'lib/need';
20import { WALLET_TYPES, urbitWalletFromTicket } from 'lib/wallet';
14import { WALLET_TYPES, urbitWalletFromTicket } from 'lib/wallet';
21import useImpliedPoint from 'lib/useImpliedPoint';
15import useImpliedPoint from 'lib/useImpliedPoint';
22import useLoginView from 'lib/useLoginView';
16import useLoginView from 'lib/useLoginView';
23import patp2dec from 'lib/patp2dec';
17import patp2dec from 'lib/patp2dec';
18+import timeout from 'lib/timeout';
24
19
20+import BridgeForm from 'form/BridgeForm';
21+import Condition from 'form/Condition';
22+import { TicketInput, PassphraseInput, PointInput } from 'form/Inputs';
23+import {
24+ composeValidator,
25+ buildCheckboxValidator,
26+ buildPatqValidator,
27+ buildPassphraseValidator,
28+ buildPointValidator,
29+} from 'form/validators';
30+import FormError from 'form/FormError';
31+import SubmitButton from 'form/SubmitButton';
32+import { WARNING } from 'form/helpers';
33+
25−export default function Ticket({ className }) {
34+export default function Ticket({ className, goHome }) {
26 useLoginView(WALLET_TYPES.TICKET);
35 useLoginView(WALLET_TYPES.TICKET);
27
36
28 const { contracts } = useNetwork();
37 const { contracts } = useNetwork();
29 const { setUrbitWallet } = useWallet();
38 const { setUrbitWallet } = useWallet();
30 const { setPointCursor } = usePointCursor();
39 const { setPointCursor } = usePointCursor();
31 const impliedPoint = useImpliedPoint();
40 const impliedPoint = useImpliedPoint();
41+ const didWarn = useRef(false);
32
42
33− // point
43+ const validateForm = useCallback((values, errors) => {
34− const [pointInput, { data: pointName }] = usePointInput({
44+ didWarn.current = false;
35− name: 'point',
36− initialValue: impliedPoint || '',
37− autoFocus: true,
38− });
39−
40− // passphrase
41− const [passphraseInput, { data: passphrase }] = usePassphraseInput({
42− name: 'passphrase',
43− label: 'Wallet Passphrase',
44− });
45−
46− const [hasPassphraseInput, { data: hasPassphrase }] = useCheckboxInput({
47− name: 'has-passphrase',
48− label: 'Passphrase',
49− initialValue: false,
50− });
51−
52− // ticket
53− const [error, setError] = useState();
54− const [deriving, setDeriving] = useState(false);
55− const [ticketInput, { data: ticket, pass: validTicket }] = useTicketInput({
56− name: 'ticket',
57− label: 'Master Ticket',
58− error,
59− deriving,
60− });
61−
62− // shards
63− const [shardsInput, { data: isUsingShards }] = useCheckboxInput({
64− name: 'shards',
65− label: 'Shards',
66− initialValue: false,
67− });
68−
69− const [shard1Input, { data: shard1, pass: shard1Pass }] = useTicketInput({
70− name: 'shard1',
71− label: 'Shard 1',
72− });
73−
74− const [shard2Input, { data: shard2, pass: shard2Pass }] = useTicketInput({
75− name: 'shard2',
76− label: 'Shard 2',
77− });
78−
79− const [shard3Input, { data: shard3, pass: shard3Pass }] = useTicketInput({
80− name: 'shard3',
81− label: 'Shard 3',
82− });
83−
84− const shardsReady = shard1Pass && shard2Pass && shard3Pass;
85−
86− // TODO: maybe want to do this only on-go, because wallet derivation is slow...
87− const deriveWalletFromTicket = useCallback(async () => {
88− // clear states
89− setError();
90− setDeriving(true);
91− setUrbitWallet(Nothing());
92−
93− if (
94− !ticket ||
95− !pointName ||
96− !ob.isValidPatq(ticket) ||
97− !ob.isValidPatp(pointName)
98− ) {
99− setDeriving(false);
100− return;
101− }
102
45
103− const _contracts = need.contracts(contracts);
46+ if (errors.point) {
104− const point = patp2dec(pointName);
47+ return errors;
105− const urbitWallet = await urbitWalletFromTicket(ticket, point, passphrase);
106− const [isOwner, isTransferProxy] = await Promise.all([
107− azimuth.azimuth.isOwner(
108− _contracts,
109− point,
110− urbitWallet.ownership.keys.address
111− ),
112− azimuth.azimuth.isTransferProxy(
113− _contracts,
114− point,
115− urbitWallet.ownership.keys.address
116− ),
117− ]);
118−
119− if (!isOwner && !isTransferProxy) {
120− // notify the user, but allow login regardless
121− setError(
122− 'This ticket is not the owner of or transfer proxy for this point.'
123− );
124 }
48 }
125
49
126− setUrbitWallet(Just(urbitWallet));
50+ if (values.useShards) {
127− setPointCursor(Just(point));
51+ if (errors.shard1 || errors.shard2 || errors.shard3) {
128− setDeriving(false);
52+ return errors;
129− }, [
53+ }
130− pointName,
54+ } else {
131− ticket,
55+ if (errors.ticket) {
132− passphrase,
56+ return errors;
133− contracts,
57+ }
134− setUrbitWallet,
135− setPointCursor,
136− setDeriving,
137− ]);
138−
139− const deriveWalletFromShards = useCallback(async () => {
140− const s1 = shard1 || undefined;
141− const s2 = shard2 || undefined;
142− const s3 = shard3 || undefined;
143−
144− try {
145− const ticket = kg.combine([s1, s2, s3]);
146− const point = patp2dec(pointName);
147− const uhdw = await urbitWalletFromTicket(ticket, point, passphrase);
148− setUrbitWallet(Just(uhdw));
149− } catch (_) {
150− // do nothing
151− }
152− }, [passphrase, pointName, setUrbitWallet, shard1, shard2, shard3]);
153−
154− // derive wallet on change
155− useEffect(() => {
156− if (isUsingShards && shardsReady) {
157− deriveWalletFromShards();
158− } else if (validTicket) {
159− deriveWalletFromTicket();
160 }
58 }
161− }, [
59+ }, []);
162− isUsingShards,
60+
163− validTicket,
61+ const onSubmit = useCallback(
164− shardsReady,
62+ async values => {
165− deriveWalletFromShards,
63+ const ticket = values.useShards
166− deriveWalletFromTicket,
64+ ? kg.combine([values.shard1, values.shard2, values.shard3])
65+ : values.ticket;
66+
67+ try {
68+ const _contracts = need.contracts(contracts);
69+ const point = patp2dec(values.point);
70+
71+ await timeout(16); // allow ui events to flush
72+ const urbitWallet = await urbitWalletFromTicket(
73+ ticket,
74+ point,
75+ values.passphrase
76+ );
77+
78+ const [isOwner, isTransferProxy] = await Promise.all([
79+ azimuth.azimuth.isOwner(
80+ _contracts,
81+ point,
82+ urbitWallet.ownership.keys.address
83+ ),
84+ azimuth.azimuth.isTransferProxy(
85+ _contracts,
86+ point,
87+ urbitWallet.ownership.keys.address
88+ ),
89+ ]);
90+
91+ const noPermissions = !isOwner && !isTransferProxy;
92+ // warn the user
93+ if (noPermissions && !didWarn.current) {
94+ didWarn.current = true;
95+ return {
96+ [WARNING]:
97+ 'This wallet is not the owner or transfer proxy for this point.',
98+ };
99+ }
100+
101+ setUrbitWallet(Just(urbitWallet));
102+ setPointCursor(Just(patp2dec(values.point)));
103+ } catch (error) {
104+ console.error(error);
105+ return {
106+ [FORM_ERROR]: `Unable to derive wallet from ${
107+ values.useShards ? 'shards' : 'ticket'
108+ }.`,
109+ };
110+ }
111+ },
112+ [contracts, setPointCursor, setUrbitWallet]
113+ );
114+
115+ const validate = useMemo(
116+ () =>
117+ composeValidator(
118+ {
119+ usePassphrase: buildCheckboxValidator(),
120+ useShards: buildCheckboxValidator(),
121+ point: buildPointValidator(4),
122+ ticket: buildPatqValidator(),
123+ shard1: buildPatqValidator(),
124+ shard2: buildPatqValidator(),
125+ shard3: buildPatqValidator(),
126+ passphrase: buildPassphraseValidator(),
127+ },
128+ validateForm
129+ ),
130+ [validateForm]
131+ );
132+
133+ const initialValues = useMemo(
134+ () => ({
135+ point: impliedPoint || '',
136+ usePasshrase: false,
137+ useShards: false,
138+ }),
139+ [impliedPoint]
167− ]);
140+ );
168
141
169 return (
142 return (
170 <Grid className={cn('mt4', className)}>
143 <Grid className={cn('mt4', className)}>
171− <Grid.Item full as={Input} {...pointInput} />
144+ <BridgeForm
172−
145+ validate={validate}
173− {!isUsingShards && <Grid.Item full as={Input} {...ticketInput} />}
146+ onSubmit={onSubmit}
174− {isUsingShards && (
147+ afterSubmit={goHome}
148+ initialValues={initialValues}>
149+ {({ handleSubmit, submitting }) => (
175− <>
150+ <>
176− <Grid.Item full as={Input} {...shard1Input} />
151+ <Grid.Item full as={PointInput} name="point" />
177− <Grid.Item full as={Input} {...shard2Input} />
152+
178− <Grid.Item full as={Input} {...shard3Input} />
153+ <Condition when="useShards" is={false}>
154+ <Grid.Item
155+ full
156+ as={TicketInput}
157+ name="ticket"
158+ label="Master Ticket"
159+ />
160+ </Condition>
161+
162+ <Condition when="useShards" is={true}>
163+ <Grid.Item full as={TicketInput} name="shard1" label="Shard 1" />
164+ <Grid.Item full as={TicketInput} name="shard2" label="Shard 2" />
165+ <Grid.Item full as={TicketInput} name="shard3" label="Shard 3" />
166+ </Condition>
167+
168+ <Condition when="usePassphrase" is={true}>
169+ <Grid.Item
170+ full
171+ as={PassphraseInput}
172+ name="passphrase"
173+ label="Wallet Passphrase"
174+ />
175+ </Condition>
176+
177+ <Grid.Item
178+ full
179+ as={CheckboxInput}
180+ name="usePassphrase"
181+ label="Passphrase"
182+ />
183+ <Grid.Item
184+ full
185+ as={CheckboxInput}
186+ name="useShards"
187+ label="Shards"
188+ />
189+
190+ <Grid.Item full as={FormError} />
191+
192+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
193+ {isWarning =>
194+ submitting
195+ ? 'Logging in...'
196+ : isWarning
197+ ? 'Login Anyway'
198+ : 'Continue'
199+ }
200+ </Grid.Item>
179− </>
201+ </>
180− )}
202+ )}
181− {hasPassphrase && <Grid.Item full as={Input} {...passphraseInput} />}
203+ </BridgeForm>
182−
183− <Grid.Item full as={CheckboxInput} {...hasPassphraseInput} />
184− <Grid.Item full as={CheckboxInput} {...shardsInput} />
185 </Grid>
204 </Grid>
186 );
205 );
187}
206}
src/views/Login/Trezor.js+102−80
@@ -1,30 +1,30 @@
1−import React, { useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2−import { Just, Nothing } from 'folktale/maybe';
2+import { Just } from 'folktale/maybe';
3import * as bip32 from 'bip32';
3import * as bip32 from 'bip32';
4import { times } from 'lodash';
4import { times } from 'lodash';
5import TrezorConnect from 'trezor-connect';
5import TrezorConnect from 'trezor-connect';
6import * as secp256k1 from 'secp256k1';
6import * as secp256k1 from 'secp256k1';
7−import {
8− Text,
9− Input,
10− Grid,
11− H5,
12− CheckboxInput,
13− SelectInput,
14−} from 'indigo-react';
7+import { Text, Grid, H5, CheckboxInput, SelectInput } from 'indigo-react';
15
8
16import { useWallet } from 'store/wallet';
9import { useWallet } from 'store/wallet';
17
10
18import { TREZOR_PATH } from 'lib/trezor';
11import { TREZOR_PATH } from 'lib/trezor';
19import { WALLET_TYPES } from 'lib/wallet';
12import { WALLET_TYPES } from 'lib/wallet';
20import useLoginView from 'lib/useLoginView';
13import useLoginView from 'lib/useLoginView';
21−import {
22− useHdPathInput,
23− useCheckboxInput,
24− useSelectInput,
25−} from 'lib/useInputs';
26
14
27−import { ForwardButton } from 'components/Buttons';
15+import {
16+ composeValidator,
17+ buildCheckboxValidator,
18+ buildSelectValidator,
19+ buildHdPathValidator,
20+} from 'form/validators';
21+import BridgeForm from 'form/BridgeForm';
22+import Condition from 'form/Condition';
23+import FormError from 'form/FormError';
24+
25+import SubmitButton from 'form/SubmitButton';
26+import { FORM_ERROR } from 'final-form';
27+import { HdPathInput } from 'form/Inputs';
28
28
29const ACCOUNT_OPTIONS = times(20, i => ({
29const ACCOUNT_OPTIONS = times(20, i => ({
30 text: `Account #${i + 1}`,
30 text: `Account #${i + 1}`,
@@ -32,68 +32,65 @@ const ACCOUNT_OPTIONS = times(20, i => ({
32}));
32}));
33
33
34// see Ledger.js for context — Trezor is basicaly Ledger with less complexity
34// see Ledger.js for context — Trezor is basicaly Ledger with less complexity
35−export default function Trezor({ className }) {
35+export default function Trezor({ className, goHome }) {
36 useLoginView(WALLET_TYPES.TREZOR);
36 useLoginView(WALLET_TYPES.TREZOR);
37
37
38 const { setWallet, setWalletHdPath } = useWallet();
38 const { setWallet, setWalletHdPath } = useWallet();
39
39
40− // custom toggle
40+ const validate = useMemo(
41− const [customPathInput, { data: useCustomPath }] = useCheckboxInput({
41+ () =>
42− name: 'customPath',
42+ composeValidator({
43− label: 'Custom HD Path',
43+ useCustomPath: buildCheckboxValidator(),
44− autoComplete: 'off',
45− initialValue: false,
46− });
47−
48− // account input
49− const [accountInput, { data: accountIndex }] = useSelectInput({
50− name: 'account',
51− label: 'Account',
52− placeholder: 'Choose account...',
53− options: ACCOUNT_OPTIONS,
44+ account: buildSelectValidator(ACCOUNT_OPTIONS),
54− });
45+ hdpath: buildHdPathValidator(),
55−
46+ }),
56− // hd path input
47+ []
57− const [
58− hdPathInput,
59− { data: hdPath },
60− { setValue: setHdPath },
61− ] = useHdPathInput({
62− name: 'hdpath',
63− label: 'HD Path',
64− initialValue: TREZOR_PATH.replace(/x/g, 0),
65− });
48+ );
66−
49+
67− const pollDevice = async () => {
50+ const onSubmit = useCallback(
51+ async values => {
68− TrezorConnect.manifest({
52+ TrezorConnect.manifest({
69− email: 'bridge-trezor@urbit.org',
53+ email: 'bridge-trezor@urbit.org',
70− appUrl: 'https://github.com/urbit/bridge',
54+ appUrl: 'https://github.com/urbit/bridge',
71− });
55+ });
72−
56+
73− const info = await TrezorConnect.getPublicKey({
57+ const { success, payload } = await TrezorConnect.getPublicKey({
74− path: hdPath,
58+ path: values.hdpath,
75− });
59+ });
76−
60+
77− if (info.success === true) {
61+ if (!success) {
78− const payload = info.payload;
62+ return { [FORM_ERROR]: 'Failed to authenticate with your Trezor.' };
63+ }
64+
79 const publicKey = Buffer.from(payload.publicKey, 'hex');
65 const publicKey = Buffer.from(payload.publicKey, 'hex');
80 const chainCode = Buffer.from(payload.chainCode, 'hex');
66 const chainCode = Buffer.from(payload.chainCode, 'hex');
81 const pub = secp256k1.publicKeyConvert(publicKey, true);
67 const pub = secp256k1.publicKeyConvert(publicKey, true);
82 const hd = bip32.fromPublicKey(pub, chainCode);
68 const hd = bip32.fromPublicKey(pub, chainCode);
69+
83 setWallet(Just(hd));
70 setWallet(Just(hd));
84− setWalletHdPath(hdPath);
71+ setWalletHdPath(values.hdPath);
85− } else {
72+ },
86− setWallet(Nothing());
73+ [setWallet, setWalletHdPath]
74+ );
75+
76+ const onValues = useCallback(({ valid, values, form }) => {
77+ if (!valid) {
78+ return;
87 }
79 }
88− };
89
80
90− useEffect(() => {
91− if (useCustomPath) {
81+ if (!values.useCustomPath) {
92− // updated by useForm
93− } else {
94− setHdPath(TREZOR_PATH.replace(/x/g, accountIndex));
82+ form.change('hdpath', TREZOR_PATH.replace(/x/g, values.account));
95 }
83 }
84+ }, []);
85+
86+ const initialValues = useMemo(
87+ () => ({
96− }, [useCustomPath, setHdPath, accountIndex]);
88+ useCustomPath: false,
89+ hdpath: TREZOR_PATH.replace(/x/g, ACCOUNT_OPTIONS[0].value),
90+ account: ACCOUNT_OPTIONS[0].value,
91+ }),
92+ []
93+ );
97
94
98 return (
95 return (
99 <Grid className={className}>
96 <Grid className={className}>
@@ -106,20 +103,45 @@ export default function Trezor({ className }) {
106 derivation path, you may enter it below.
103 derivation path, you may enter it below.
107 </Grid.Item>
104 </Grid.Item>
108
105
109− {useCustomPath && <Grid.Item full as={Input} {...hdPathInput} />}
106+ <BridgeForm
110−
107+ validate={validate}
108+ onValues={onValues}
109+ onSubmit={onSubmit}
110+ afterSubmit={goHome}
111+ initialValues={initialValues}>
112+ {({ handleSubmit }) => (
113+ <>
114+ <Condition when="useCustomPath" is={true}>
115+ <Grid.Item full as={HdPathInput} name="hdpath" label="HD Path" />
116+ </Condition>
117+
118+ <Condition when="useCustomPath" is={false}>
119+ <Grid.Item
120+ full
111− {!useCustomPath && <Grid.Item full as={SelectInput} {...accountInput} />}
121+ as={SelectInput}
112−
122+ name="account"
123+ label="Account"
124+ placeholder="Choose account..."
125+ options={ACCOUNT_OPTIONS}
126+ />
127+ </Condition>
128+
129+ <Grid.Item
130+ full
113− <Grid.Item full as={CheckboxInput} className="mv3" {...customPathInput} />
131+ as={CheckboxInput}
114−
132+ className="mv3"
133+ name="useCustomPath"
134+ label="Custom HD Path"
135+ />
136+
115− <Grid.Item
137+ <Grid.Item full as={FormError} />
116− full
138+
117− as={ForwardButton}
139+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
118− solid
119− className="mt2"
120− onClick={pollDevice}>
121− Authenticate
140+ Authenticate
122− </Grid.Item>
141+ </Grid.Item>
142+ </>
143+ )}
144+ </BridgeForm>
123 </Grid>
145 </Grid>
124 );
146 );
125}
147}
src/views/Party/PartySetPoolSize.js+82−51
@@ -1,6 +1,6 @@
1−import React, { useCallback, useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3−import { Grid, Text, Input } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
4import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
5
5
6import { useNetwork } from 'store/network';
6import { useNetwork } from 'store/network';
@@ -8,18 +8,26 @@ import { usePointCursor } from 'store/pointCursor';
8import { usePointCache } from 'store/pointCache';
8import { usePointCache } from 'store/pointCache';
9
9
10import * as need from 'lib/need';
10import * as need from 'lib/need';
11−import { usePointInput, useNumberInput } from 'lib/useInputs';
12import useEthereumTransaction from 'lib/useEthereumTransaction';
11import useEthereumTransaction from 'lib/useEthereumTransaction';
13import { GAS_LIMITS } from 'lib/constants';
12import { GAS_LIMITS } from 'lib/constants';
14import { useLocalRouter } from 'lib/LocalRouter';
13import { useLocalRouter } from 'lib/LocalRouter';
14+import useCurrentPermissions from 'lib/useCurrentPermissions';
15+import patp2dec from 'lib/patp2dec';
15
16
16import ViewHeader from 'components/ViewHeader';
17import ViewHeader from 'components/ViewHeader';
17import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
18import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
18−import patp2dec from 'lib/patp2dec';
19import View from 'components/View';
19import View from 'components/View';
20−import useCurrentPermissions from 'lib/useCurrentPermissions';
21import WarningBox from 'components/WarningBox';
20import WarningBox from 'components/WarningBox';
22
21
22+import BridgeForm from 'form/BridgeForm';
23+import { PointInput, NumberInput } from 'form/Inputs';
24+import {
25+ composeValidator,
26+ buildNumberValidator,
27+ buildPointValidator,
28+} from 'form/validators';
29+import FormError from 'form/FormError';
30+
23function useSetPoolSize() {
31function useSetPoolSize() {
24 const { contracts } = useNetwork();
32 const { contracts } = useNetwork();
25 const { pointCursor } = usePointCursor();
33 const { pointCursor } = usePointCursor();
@@ -60,31 +68,27 @@ export default function PartySetPoolSize() {
60 bind,
68 bind,
61 } = useSetPoolSize();
69 } = useSetPoolSize();
62
70
63− const [poolOwnerInput, { pass: validPoint, data: poolOwner }] = usePointInput(
71+ const validate = useMemo(
64− {
72+ () =>
65− name: 'point',
73+ composeValidator({
66− label: 'Point',
74+ poolOwner: buildPointValidator(),
67− disabled: inputsLocked,
75+ poolSize: buildNumberValidator(0),
68− }
76+ }),
77+ []
69 );
78 );
70
79
71− const [
80+ const onValues = useCallback(
72− poolSizeInput,
81+ ({ valid, values }) => {
73− { pass: validPoolSize, data: poolSize },
82+ if (valid) {
74− ] = useNumberInput({
75− name: 'poolsize',
76− label: 'Pool Size',
77− initialValue: 5,
78− disabled: inputsLocked,
79− });
80−
81− useEffect(() => {
82− if (validPoint && validPoolSize) {
83− construct(patp2dec(poolOwner), poolSize);
83+ construct(patp2dec(values.poolOwner), values.poolSize);
84− } else {
84+ } else {
85− unconstruct();
85+ unconstruct();
86− }
86+ }
87− }, [poolOwner, construct, validPoint, validPoolSize, poolSize, unconstruct]);
87+ },
88+ [construct, unconstruct]
89+ );
90+
91+ const initialValues = useMemo(() => ({ poolSize: 5 }), []);
88
92
89 return (
93 return (
90 <View pop={pop} inset>
94 <View pop={pop} inset>
@@ -92,6 +96,7 @@ export default function PartySetPoolSize() {
92 <Grid.Item full as={ViewHeader}>
96 <Grid.Item full as={ViewHeader}>
93 Set Pool Size
97 Set Pool Size
94 </Grid.Item>
98 </Grid.Item>
99+
95 {!spawnIsDelegatedSending && (
100 {!spawnIsDelegatedSending && (
96 <Grid.Item full as={WarningBox} className="mb4">
101 <Grid.Item full as={WarningBox} className="mb4">
97 The spawn proxy must be set to{' '}
102 The spawn proxy must be set to{' '}
@@ -101,30 +106,56 @@ export default function PartySetPoolSize() {
101 for invitations to be available.
106 for invitations to be available.
102 </Grid.Item>
107 </Grid.Item>
103 )}
108 )}
104− <Grid.Item
105− full
106− as={Text}
107− className={cn('f5', {
108− green3: completed,
109− })}>
110− {completed
111− ? `${poolSize} invites have been allocated to ${poolOwner}`
112− : `Allocate invites to a child point.`}
113− </Grid.Item>
114−
115− {!completed && (
116− <>
117− <Grid.Item full as={Input} {...poolOwnerInput} className="mt4" />
118− <Grid.Item full as={Input} {...poolSizeInput} className="mb4" />
119− </>
120− )}
121
109
110+ <BridgeForm
111+ validate={validate}
112+ onValues={onValues}
113+ initialValues={initialValues}>
114+ {({ handleSubmit, values }) => (
115+ <>
122− <Grid.Item
116+ <Grid.Item
123− full
117+ full
118+ as={Text}
119+ className={cn('f5', {
120+ green3: completed,
121+ })}>
122+ {completed
123+ ? `${values.poolSize} invites have been allocated to ${values.poolOwner}`
124+ : `Allocate invites to a child point.`}
125+ </Grid.Item>
126+
127+ {!completed && (
128+ <>
129+ <Grid.Item
130+ full
131+ as={PointInput}
132+ className="mt4"
133+ name="poolOwner"
134+ label="Point"
135+ disabled={inputsLocked}
136+ />
137+ <Grid.Item
138+ full
139+ as={NumberInput}
140+ className="mb4"
141+ name="poolSize"
142+ label="Pool Size"
143+ disabled={inputsLocked}
144+ />
145+ </>
146+ )}
147+
148+ <Grid.Item full as={FormError} />
149+
150+ <Grid.Item
151+ full
124− as={InlineEthereumTransaction}
152+ as={InlineEthereumTransaction}
125− {...bind}
153+ {...bind}
126− onReturn={() => pop()}
154+ onReturn={() => pop()}
127− />
155+ />
156+ </>
157+ )}
158+ </BridgeForm>
128 </Grid>
159 </Grid>
129 </View>
160 </View>
130 );
161 );