on master help sign in
Merge pull request #221 from urbit/feat/final-form

The Final Form PR

commit2bf1af96d16d9922ff729beb323f64255ce4c27a authorFang <Fang-@users.noreply.github.com> dateThu 15 Aug 2019, 11:11 (7 years ago) parent776dd3bb019e78
69 files changed+2883−2580
package-lock.json+74−1 package.json+6 src/components/Accordion.js+4−2 src/components/Highlighted.js+14−6 src/components/InlineEthereumTransaction.js+127−106 src/components/InputSigil.js+9−11 src/components/MaybeSigil.js+1−1 src/components/Passport.js+1−2 src/components/UploadButton.js+3−1 src/components/ViewHeader.js+1−1 src/form/BridgeForm.js+47 src/form/Condition.js+11 src/form/FormError.js+34 src/form/Inputs.js+183 src/form/SubmitButton.js+70 src/form/UploadInput.js+51 src/form/ValidationPauser.js+27 src/form/ValuesHandler.js+17 src/form/formatters.js+63 src/form/helpers.js+5 src/form/validators.js+153 src/index.js+4 src/indigo-react/components/AccessoryIcon.js+1−1 src/indigo-react/components/CheckboxInput.js+11−32 src/indigo-react/components/Input.js+51−47 src/indigo-react/components/SelectInput.js+36−49 src/indigo-react/components/ToggleInput.js+13−23 src/indigo-react/index.js−2 src/indigo-react/lib/index.js−1 src/indigo-react/lib/useForm.js−264 src/indigo-react/lib/useOnClickOutside.js−1 src/lib/isPromise.js+6 src/lib/lib.js+3 src/lib/pluralize.js+1−1 src/lib/transformers.js−19 src/lib/txn.js+9−3 src/lib/useArray.js−48 src/lib/useConstant.js+13 src/lib/useEthereumTransaction.js+19−4 src/lib/useImpliedPoint.js+2−2 src/lib/useImpliedTicket.js+2−2 src/lib/useInputs.js−277 src/lib/useMailer.js+18−29 src/lib/useRouter.js+2−2 src/lib/validators.js+56−234 src/store/wallet.js+6−1 src/style/indigo-proposals.scss+4 src/views/Activate.js+7−1 src/views/Activate/ActivateCode.js+129−114 src/views/Activate/PassportTransfer.js+1−5 src/views/Activate/PassportVerify.js+45−22 src/views/Admin/AdminNetworkingKeys.js+101−82 src/views/Admin/AdminSetProxy.js+95−64 src/views/Admin/AdminTransfer.js+55−34 src/views/Admin/Reticket/ReticketVerify.js+41−21 src/views/CreateGalaxy.js+100−94 src/views/Disclaimer.js+30−16 src/views/Invite/InviteEmail.js+329−302 src/views/IssueChild.js+104−83 src/views/Login.js+5−13 src/views/Login/Advanced.js+2−2 src/views/Login/Hardware.js+2−2 src/views/Login/Keystore.js+68−75 src/views/Login/Ledger.js+123−112 src/views/Login/Mnemonic.js+83−57 src/views/Login/PrivateKey.js+46−21 src/views/Login/Ticket.js+175−156 src/views/Login/Trezor.js+102−80 src/views/Party/PartySetPoolSize.js+82−51
package-lock.json+74−1
@@ -2143,6 +2143,14 @@
21432143 "@xtuc/long": "4.2.2"
21442144 }
21452145 },
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+ },
21462154 "@xtuc/ieee754": {
21472155 "version": "1.2.0",
21482156 "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -2160,7 +2168,7 @@
21602168 "dev": true
21612169 },
21622170 "PaperCollateralRenderer": {
2163− "version": "github:urbit/PaperCollateralRenderer#27bd1381529618fb067a35575f8191d04ecfe10b",
2171+ "version": "github:urbit/PaperCollateralRenderer#8478aaf397e12769171b7b14332c47e4457e1d46",
21642172 "from": "github:urbit/PaperCollateralRenderer#rc-immediate",
21652173 "requires": {
21662174 "babel-polyfill": "^6.26.0",
@@ -6918,6 +6926,24 @@
69186926 }
69196927 }
69206928 },
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+ },
69216947 "finalhandler": {
69226948 "version": "1.1.1",
69236949 "resolved": "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
@@ -14511,6 +14537,43 @@
1451114537 "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-5.1.6.tgz",
1451214538 "integrity": "sha512-X1Y+0jR47ImDVr54Ab6V9eGk0Hnu7fVWGeHQSOXHf/C2pF9c6uy3gef8QUeuUiWlNb0i08InPSE5a/KJzNzw1Q=="
1451314539 },
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+ },
1451414577 "react-hot-loader": {
1451514578 "version": "4.12.0",
1451614579 "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.0.tgz",
@@ -14721,6 +14784,11 @@
1472114784 "regenerate": "^1.4.0"
1472214785 }
1472314786 },
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+ },
1472414792 "regenerator-transform": {
1472514793 "version": "0.14.0",
1472614794 "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.0.tgz",
@@ -17199,6 +17267,11 @@
1719917267 "solc": "0.4.24"
1720017268 }
1720117269 },
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+ },
1720217275 "ts-pnp": {
1720317276 "version": "1.1.2",
1720417277 "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.1.2.tgz",
package.json+6
@@ -17,6 +17,7 @@
1717 "@hot-loader/react-dom": "^16.8.6",
1818 "@ledgerhq/hw-app-eth": "^4.61.0",
1919 "@ledgerhq/hw-transport-u2f": "^4.61.0",
20+ "@welldone-software/why-did-you-render": "^3.2.3",
2021 "PaperCollateralRenderer": "github:urbit/PaperCollateralRenderer#rc-immediate",
2122 "async-retry": "^1.2.3",
2223 "azimuth-js": "^0.15.1",
@@ -28,6 +29,9 @@
2829 "ethereum-blockies-base64": "^1.0.2",
2930 "ethereumjs-tx": "^1.3.7",
3031 "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",
3135 "folktale": "^2.3.1",
3236 "jszip": "^3.1.5",
3337 "keccak": "^1.4.0",
@@ -38,6 +42,8 @@
3842 "react": "^16.8.6",
3943 "react-app-rewire-hot-loader": "^2.0.1",
4044 "react-dom": "^16.8.6",
45+ "react-final-form": "^6.3.0",
46+ "react-final-form-arrays": "^3.1.0",
4147 "react-hot-loader": "^4.12.0",
4248 "react-scripts": "3.0.1",
4349 "react-teleporter": "^1.1.0",
src/components/Accordion.js+4−2
@@ -2,11 +2,13 @@ import React from 'react';
22import { Grid, AccessoryIcon } from 'indigo-react';
33
44export default function Accordion({
5+ className,
56 views,
67 options,
78 currentTab,
89 onTabChange,
9− className,
10+
11+ // Tab props
1012 ...rest
1113}) {
1214 const Tab = views[currentTab];
@@ -37,7 +39,7 @@ export default function Accordion({
3739 </AccessoryIcon>
3840 </div>
3941 </Grid.Item>
40− {isActive && <Grid.Item full as={Tab} />}
42+ {isActive && <Grid.Item full as={Tab} {...rest} />}
4143 <Grid.Divider />
4244 </React.Fragment>
4345 );
src/components/Highlighted.js+14−6
@@ -1,13 +1,21 @@
11import React from 'react';
22import cn from 'classnames';
33
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+}) {
510 return (
6− <span
7− className={cn({
8− red3: warning,
9− green3: !warning,
10− })}
11+ <As
12+ className={cn(
13+ {
14+ red3: warning,
15+ green3: !warning,
16+ },
17+ className
18+ )}
1119 {...rest}
1220 />
1321 );
src/components/InlineEthereumTransaction.js+127−106
@@ -1,4 +1,4 @@
1−import React, { useEffect } from 'react';
1+import React, { useMemo, useCallback } from 'react';
22import cn from 'classnames';
33import {
44 Grid,
@@ -12,12 +12,14 @@ import {
1212} from 'indigo-react';
1313import { fromWei } from 'web3-utils';
1414
15−import { useCheckboxInput } from 'lib/useInputs';
1615import { useExploreTxUrl } from 'lib/explorer';
1716import { hexify } from 'lib/txn';
1817
1918import { GenerateButton, ForwardButton, RestartButton } from './Buttons';
2019import WarningBox from './WarningBox';
20+import { composeValidator, buildCheckboxValidator } from 'form/validators';
21+import BridgeForm from 'form/BridgeForm';
22+import Condition from 'form/Condition';
2123
2224export default function InlineEthereumTransaction({
2325 // from useEthereumTransaction.bind
@@ -57,29 +59,24 @@ export default function InlineEthereumTransaction({
5759 const showSignedTx = signed;
5860
5961 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− });
6662
67− const [
68− viewSignedTransaction,
69− { data: signedTransactionOpen },
70− ] = useCheckboxInput({
71− name: 'viewsigned',
72− label: 'View Signed Transaction',
73− inverseLabel: 'Hide Signed Transaction',
74− disabled: !showSignedTx,
75− });
63+ const validate = useMemo(
64+ () =>
65+ composeValidator({
66+ useAdvanced: buildCheckboxValidator(),
67+ viewSigned: buildCheckboxValidator(),
68+ }),
69+ []
70+ );
7671
77− // reset gas price when closing advanced configuration
78− useEffect(() => {
79− if (!advancedOpen) {
80− resetGasPrice();
81− }
82− }, [advancedOpen, resetGasPrice]);
72+ const onValues = useCallback(
73+ ({ valid, values, form }) => {
74+ if (!values.useAdvanced) {
75+ resetGasPrice();
76+ }
77+ },
78+ [resetGasPrice]
79+ );
8380
8481 const renderPrimaryButton = () => {
8582 if (error) {
@@ -126,94 +123,118 @@ export default function InlineEthereumTransaction({
126123
127124 return (
128125 <Grid className={cn(className, 'mt1')}>
129− {renderPrimaryButton()}
126+ <BridgeForm validate={validate} onValues={onValues}>
127+ {({ handleSubmit }) => (
128+ <>
129+ {renderPrimaryButton()}
130130
131− {error && (
132− <Grid.Item full as={ErrorText} className="mt1">
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>
131+ {error && (
132+ <Grid.Item full as={ErrorText} className="mt1">
133+ {error.message}
173134 </Grid.Item>
174− <Grid.Item full as={HelpText} className="mt3">
175− Nonce: {nonce}
176− </Grid.Item>
177− <Grid.Item full as={HelpText} className="mt1">
178− Chain ID: {chainId}
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
142+ enough funds.
179143 </Grid.Item>
180− <Grid.Divider className="mt3" />
181− </>
182− )}
183− </>
184− )}
144+ )}
185145
186− {showSignedTx && (
187− <>
188− <Grid.Item full as={ToggleInput} {...viewSignedTransaction} />
189− {signedTransactionOpen && (
190− <Grid.Item full as="code" className="f6 mono gray4 wrap">
191− {hexify(signedTransaction.serialize())}
192− </Grid.Item>
193− )}
194− </>
195− )}
146+ {showConfigureInput && (
147+ <>
148+ <Grid.Item
149+ full
150+ as={ToggleInput}
151+ name="useAdvanced"
152+ label="Advanced Configuration"
153+ inverseLabel="Cancel Advanced Configuration"
154+ disabled={!showConfigureInput || initializing}
155+ />
196156
197− {showReceipt && (
198− <>
199− <Grid.Divider />
200− <Grid.Item full as={Flex} col className="pv4">
201− <Flex.Item as={Flex} row justify="between">
202− <Flex.Item as={H5}>Transaction Hash</Flex.Item>
203− <Flex.Item as={LinkButton} href={exploreTxUrl}>
204− Etherscan↗
205− </Flex.Item>
206− </Flex.Item>
207− <Flex.Item as={Flex}>
208− <Flex.Item flex as="code" className="f6 mono gray4 wrap">
209− {txHash}
210− </Flex.Item>
211− <Flex.Item flex />
212− </Flex.Item>
213− </Grid.Item>
214− <Grid.Divider />
215− </>
216− )}
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+
215+ {showReceipt && (
216+ <>
217+ <Grid.Divider />
218+ <Grid.Item full as={Flex} col className="pv4">
219+ <Flex.Item as={Flex} row justify="between">
220+ <Flex.Item as={H5}>Transaction Hash</Flex.Item>
221+ <Flex.Item as={LinkButton} href={exploreTxUrl}>
222+ Etherscan↗
223+ </Flex.Item>
224+ </Flex.Item>
225+ <Flex.Item as={Flex}>
226+ <Flex.Item flex as="code" className="f6 mono gray4 wrap">
227+ {txHash}
228+ </Flex.Item>
229+ <Flex.Item flex />
230+ </Flex.Item>
231+ </Grid.Item>
232+ <Grid.Divider />
233+ </>
234+ )}
235+ </>
236+ )}
237+ </BridgeForm>
217238 </Grid>
218239 );
219240}
src/components/InputSigil.js+9−11
@@ -1,19 +1,18 @@
11import React, { useEffect, useState } from 'react';
22import { Just } from 'folktale/maybe';
3−import * as ob from 'urbit-ob';
43
54import MaybeSigil from './MaybeSigil';
65
7−const selectColorway = (pass, fail, focused) => {
8− if (pass) {
6+const selectColorway = (valid, error, active) => {
7+ if (valid) {
98 return ['#2AA779', '#FFFFFF'];
109 }
1110
12− if (focused) {
11+ if (active) {
1312 return ['#4330FC', '#FFFFFF'];
1413 }
1514
16− if (fail) {
15+ if (error) {
1716 return ['#F8C134', '#FFFFFF'];
1817 }
1918
@@ -24,25 +23,24 @@ export default function InputSigil({
2423 className,
2524 patp,
2625 size,
27− pass,
26+ valid,
2827 error,
29− focused,
28+ active,
3029 ...rest
3130}) {
3231 const [lastValidPatp, setLastValidPatp] = useState(patp);
3332
3433 useEffect(() => {
35− if (ob.isValidPatp(patp)) {
34+ if (valid) {
3635 setLastValidPatp(patp);
3736 }
38− }, [patp]);
37+ }, [patp, valid]);
3938
4039 return (
4140 <MaybeSigil
4241 patp={Just(lastValidPatp)}
4342 size={size}
44− colors={selectColorway(pass, error, focused)}
45− margin={0}
43+ colors={selectColorway(valid, error, active)}
4644 {...rest}
4745 />
4846 );
src/components/MaybeSigil.js+1−1
@@ -14,7 +14,7 @@ export default function MaybeSigil({ className, patp, size, ...rest }) {
1414 });
1515
1616 return validPatp ? (
17− <Sigil patp={validPatp} size={size} margin={0} {...rest} />
17+ <Sigil patp={validPatp} size={size} {...rest} />
1818 ) : (
1919 <AspectRatio aspectRatio={1} />
2020 );
src/components/Passport.js+1−2
@@ -75,7 +75,7 @@ function Passport({ point, className, ticket = null, address = Nothing() }) {
7575 return (
7676 <Grid gap={4} className={cn('bg-black r8 p7', className)}>
7777 <Grid.Item cols={[1, 4]}>
78− <MaybeSigil patp={name} size={64} margin={0} />
78+ <MaybeSigil patp={name} size={64} />
7979 </Grid.Item>
8080 <Grid.Item as={Flex} cols={[4, 13]} col justify="between">
8181 <Flex.Item className="mono white f5">{visualName}</Flex.Item>
@@ -141,7 +141,6 @@ Passport.Mini = function MiniPassport({ point, className, inverted, ...rest }) {
141141 <Sigil
142142 patp={name}
143143 size={64}
144− margin={0}
145144 colors={inverted ? INVERTED_COLORWAY : undefined}
146145 />
147146 </Grid.Item>
src/components/UploadButton.js+3−1
@@ -6,7 +6,9 @@ export default function UploadButton({ children, onChange, ...rest }) {
66
77 return (
88 <label htmlFor="file" {...rest}>
9− <Button accessory="↑">{children}</Button>
9+ <Button accessory="↑" solid>
10+ {children}
11+ </Button>
1012 <input
1113 id="file"
1214 ref={button}
src/components/ViewHeader.js+1−1
@@ -3,5 +3,5 @@ import cn from 'classnames';
33import { H5 } from 'indigo-react';
44
55export default function ViewHeader({ children, className }) {
6− return <H5 className={cn(className, 'mv4')}>{children}</H5>;
6+ return <H5 className={cn(className, 'mb4')}>{children}</H5>;
77}
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';
33
44import Bridge from './Bridge';
55
6+if (process.env.NODE_ENV !== 'production') {
7+ require('@welldone-software/why-did-you-render')(React);
8+}
9+
610ReactDOM.render(<Bridge />, document.getElementById('root'));
src/indigo-react/components/AccessoryIcon.js+1−1
@@ -4,7 +4,7 @@ import Flex from './Flex';
44
55const PENDING_ACCESSORY = '⋯';
66const SUCCESS_ACCESSORY = '✓';
7−const FAILURE_ACCESSORY = '✗';
7+const FAILURE_ACCESSORY = '!';
88
99function AccessoryIcon({ ...props }) {
1010 return (
src/indigo-react/components/CheckboxInput.js+11−32
@@ -2,6 +2,7 @@ import React from 'react';
22import cn from 'classnames';
33
44import Flex from './Flex';
5+import { useField } from 'react-final-form';
56
67export default function CheckboxInput({
78 // visuals
@@ -9,30 +10,15 @@ export default function CheckboxInput({
910 label,
1011 className,
1112
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,
2513 disabled,
26− touched,
14+}) {
15+ const {
16+ input,
17+ meta: { submitting, submitSucceeded },
18+ } = useField(name, { type: 'checkbox' });
2719
28− // ignored
29− initialValue,
30− validators,
31− transformers,
20+ disabled = disabled || submitting || submitSucceeded;
3221
33− // extra
34− ...rest
35−}) {
3622 return (
3723 <Flex
3824 row
@@ -48,14 +34,7 @@ export default function CheckboxInput({
4834 }),
4935 }}>
5036 {/* we totally hide the checkbox itself */}
51− <Flex.Item
52− as="input"
53− {...rest}
54− className={cn('super-hidden')}
55− id={name}
56− name={name}
57− {...bind}
58− />
37+ <Flex.Item as="input" className="super-hidden" id={name} {...input} />
5938 {/* and then display a prettier one in its stead */}
6039 <Flex.Item
6140 flex
@@ -67,14 +46,14 @@ export default function CheckboxInput({
6746 align="center"
6847 className={cn('b1 p1 mr3', {
6948 'bg-gray1': disabled,
70− 'bg-black white b-black': !disabled && data,
71− 'bg-white black b-black': !disabled && !data,
49+ 'bg-black white b-black': !disabled && input.value,
50+ 'bg-white black b-black': !disabled && !input.value,
7251 })}
7352 style={{
7453 height: '14px',
7554 width: '14px',
7655 }}>
77− {data && '✓'}
56+ {input.value && '✓'}
7857 </Flex>
7958 {label}
8059 </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';
22import cn from 'classnames';
3−
3+import { useField } from 'react-final-form';
44import Flex from './Flex';
55import { ErrorText } from './Typography';
66
7−export default React.memo(function Input({
7+export default function Input({
88 // visuals
99 type,
1010 name,
1111 label,
1212 className,
1313 accessory,
14+ disabled = false,
1415 mono = false,
16+ obscure,
1517
1618 // callbacks
1719 onEnter,
1820
19− // state from hook
20− focused,
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,
21+ // state
22+ config,
3623
3724 // extra
38− textarea = false,
3925 ...rest
4026}) {
41− // NB(shrugs): we disable exhaustive deps because we don't want the callbacks
42− // (whose identity might change between renders) to trigger a re-notify of
43− // state that we already know
44− // this also happens to prevent render loops when rendering a map of inputs
27+ const {
28+ input,
29+ meta: {
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';
4545
4646 // notify parent of enter keypress iff not disabled and passing
47− const onKeyPress = useCallback(
48− e => !disabled && pass && e.key === 'Enter' && onEnter && onEnter(),
49− [disabled, pass] // eslint-disable-line react-hooks/exhaustive-deps
50− );
47+ // TODO: integrate this into react-final-form submission
48+ // const onKeyPress = useCallback(
49+ // e => !disabled && valid && e.key === 'Enter' && onEnter && onEnter(),
50+ // [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps
51+ // );
52+
53+ const showError = !!error;
54+ const showSubmitError = !!submitError && !dirtySinceLastSubmit;
55+ const indicateError = touched && !active && (showError || showSubmitError);
5156
5257 return (
5358 <Flex
54− className={cn(className, 'mb1')}
5559 col
60+ className={cn(className, 'mb1')}
5661 style={{
5762 ...(disabled && {
5863 pointerEvents: 'none',
@@ -68,37 +73,35 @@ export default React.memo(function Input({
6873 htmlFor={name}>
6974 {label}
7075 </Flex.Item>
71− <Flex.Item as={Flex} className="rel" row>
76+ <Flex.Item as={Flex} row className="rel">
7277 <Flex.Item
73− as={type === 'textarea' ? 'textarea' : 'input'}
74− type={type === 'textarea' ? undefined : type}
78+ flex
79+ as={BaseComponent}
7580 {...rest}
7681 // NOTE: 24px = 12px * 2 (from p3 styling)
7782 style={type === 'textarea' ? { minHeight: 'calc(1rem + 24px)' } : {}}
7883 className={cn(
79− 'b b1 p3 outline-none',
84+ 'b b1 p3 outline-none bs-none',
8085 { mono },
8186 {
8287 'bg-white': !disabled,
8388 'bg-gray1': disabled,
8489 },
8590 {
86− gray4: !focused && !touched,
87− black: focused || touched,
91+ gray4: !(active || touched),
92+ black: active || touched,
8893 },
8994 {
90− 'b-green3': visiblyPassed,
91− 'b-black': focused && !visiblyPassed,
92− 'b-yellow3': !focused && hintError,
93− 'b-gray2': !focused && !hintError && !visiblyPassed,
95+ 'b-green3': valid,
96+ 'b-black': !valid && active,
97+ 'b-yellow3': !valid && !active && touched,
98+ 'b-gray2': !valid && !active && !touched,
9499 }
95− // TODO: inputClassName ?
96100 )}
97101 id={name}
98102 name={name}
99− onKeyPress={onKeyPress}
100− {...bind}
101− flex
103+ {...input}
104+ type={type === 'textarea' ? undefined : type}
102105 />
103106 {accessory && (
104107 <div
@@ -114,11 +117,12 @@ export default React.memo(function Input({
114117 </div>
115118 )}
116119 </Flex.Item>
117− {error && (
120+
121+ {indicateError && (
118122 <Flex.Item as={ErrorText} className="mv1">
119− {error}
123+ {error || submitError}
120124 </Flex.Item>
121125 )}
122126 </Flex>
123127 );
124−});
128+}
src/indigo-react/components/SelectInput.js+36−49
@@ -1,69 +1,51 @@
11import React, { useCallback, useRef, useState } from 'react';
22import cn from 'classnames';
3+import { useField } from 'react-final-form';
4+import useOnClickOutside from 'indigo-react/lib/useOnClickOutside';
35
46import Flex from './Flex';
57import { ErrorText } from './Typography';
6−import useOnClickOutside from 'indigo-react/lib/useOnClickOutside';
78import AccessoryIcon from './AccessoryIcon';
89
910// NOTE: if we really care about accessibility, we should pull in a dependency
1011export default function SelectInput({
11− // visuals
1212 name,
1313 label,
14+ placeholder,
1415 className,
15− accessory,
1616 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,
34− touched,
17+ options = [],
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+ });
3527
36− // ignored
37− initialValue,
38− validators,
39− transformers,
28+ disabled = disabled || submitting || submitSucceeded;
4029
41− // extra
42− ...rest
43−}) {
4430 const [isOpen, setIsOpen] = useState(false);
4531 const ref = useRef();
4632
4733 // close select on outside clicks
4834 useOnClickOutside(ref, useCallback(() => setIsOpen(false), [setIsOpen]));
4935
50− const toggleOpen = useCallback(() => setIsOpen(isOpen => !isOpen), [
51− setIsOpen,
52− ]);
36+ const toggleOpen = useCallback(() => {
37+ input.onFocus();
38+ setIsOpen(isOpen => !isOpen);
39+ }, [input]);
5340
5441 const onChange = value => {
55− // TODO: provide setValue here?
5642 // construct a pseudo event that sets the value correctly
57− bind.onChange({ target: { value } });
43+ input.onChange({ target: { value } });
44+ input.onBlur();
5845 setIsOpen(false);
5946 };
6047
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;
6749
6850 return (
6951 <Flex
@@ -81,7 +63,7 @@ export default function SelectInput({
8163 </Flex.Item>
8264 <Flex.Item as={Flex} row className="rel clickable" onClick={toggleOpen}>
8365 <Flex.Item
84− // as="select"
66+ flex
8567 className={cn(
8668 'b b1 p3 outline-none',
8769 { mono },
@@ -94,16 +76,14 @@ export default function SelectInput({
9476 black: !isOpen,
9577 },
9678 {
97− 'b-green3': visiblyPassed,
98− 'b-black': focused && !visiblyPassed,
99− 'b-yellow3': !focused && hintError,
100− 'b-gray3': !focused && !hintError && !visiblyPassed,
79+ 'b-green3': valid,
80+ 'b-black': !valid && active,
81+ 'b-yellow3': !valid && !active && touched && error,
82+ 'b-gray2': !valid && !active && !touched && !error,
10183 }
10284 )}
10385 id={name}
104− name={name}
105− {...bind}
106− flex>
86+ name={name}>
10787 {isOpen ? placeholder : text}
10888 </Flex.Item>
10989 <div
@@ -115,7 +95,7 @@ export default function SelectInput({
11595 width: '44px',
11696 overflow: 'hidden',
11797 }}>
118− {accessory}
98+ <AccessoryIcon className="gray4">{isOpen ? '▲' : '▼'}</AccessoryIcon>
11999 </div>
120100 {isOpen && (
121101 <Flex
@@ -142,7 +122,14 @@ export default function SelectInput({
142122 </Flex>
143123 )}
144124 </Flex.Item>
145− {error && (
125+
126+ {warning && (
127+ <Flex.Item as={ErrorText} className="mv1">
128+ {warning}
129+ </Flex.Item>
130+ )}
131+
132+ {touched && !active && error && (
146133 <Flex.Item as={ErrorText} className="mv1">
147134 {error}
148135 </Flex.Item>
src/indigo-react/components/ToggleInput.js+13−23
@@ -3,6 +3,7 @@ import cn from 'classnames';
33
44import Flex from './Flex';
55import LinkButton from './LinkButton';
6+import { useField } from 'react-final-form';
67
78export default function ToggleInput({
89 // visuals
@@ -11,32 +12,21 @@ export default function ToggleInput({
1112 inverseLabel,
1213 className,
1314
14− // callbacks
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,
28− touched,
29−
30− // ignored
31− initialValue,
32− validators,
33− transformers,
15+ //
16+ disabled = false,
3417
3518 ...rest
3619}) {
20+ const {
21+ input,
22+ meta: { submitting, submitSucceeded },
23+ } = useField(name, { type: 'checkbox' });
24+
25+ disabled = disabled || submitting || submitSucceeded;
26+
3727 return (
3828 <Flex
39− className={cn(className)}
29+ className={className}
4030 row
4131 align="center"
4232 style={{
@@ -52,7 +42,7 @@ export default function ToggleInput({
5242 className={cn('super-hidden')}
5343 id={name}
5444 name={name}
55− {...bind}
45+ {...input}
5646 />
5747 {/* and then display a prettier one in its stead */}
5848 <Flex.Item
@@ -63,7 +53,7 @@ export default function ToggleInput({
6353 })}
6454 htmlFor={name}>
6555 <LinkButton disabled={disabled} className="f5">
66− {bind.checked ? inverseLabel : label}
56+ {input.checked ? inverseLabel : label}
6757 </LinkButton>
6858 </Flex.Item>
6959 </Flex>
src/indigo-react/index.js−2
@@ -11,5 +11,3 @@ export { default as IconButton } from './components/IconButton';
1111export { default as LinkButton } from './components/LinkButton';
1212
1313export * 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 @@
11import { useEffect } from 'react';
22
33// via https:// usehooks.com/useOnClickOutside/
4−
54export default function useOnClickOutside(ref, handler) {
65 useEffect(() => {
76 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);
1616
1717const fill = (n, v) => Array.from(Array(n), () => v);
1818
19+const strSplice = (arr, i, val) => `${arr.slice(0, i)}${val}${arr.slice(i)}`;
20+
1921const isValidGalaxy = name => {
2022 let point;
2123 try {
@@ -48,6 +50,7 @@ export {
4850 defaultTo,
4951 seq,
5052 fill,
53+ strSplice,
5154 isValidGalaxy,
5255 randomPatq,
5356 patpStringLength,
src/lib/pluralize.js+1−1
@@ -1,5 +1,5 @@
11export default (count, singular, plural) => {
2− const isPlural = count > 1;
2+ const isPlural = count === 0 || count > 1;
33 if (!plural) {
44 return `${count} ${singular}${isPlural ? 's' : ''}`;
55 }
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) => {
129129 });
130130};
131131
132−// returns a Promise<bool>, where the bool indicates tx success/failure
132+// returns a Promise<void>, throwing on tx failure
133133const waitForTransactionConfirm = (web3, txHash) => {
134134 return retry(async (bail, n) => {
135135 const receipt = await web3.eth.getTransactionReceipt(txHash);
136136 const confirmed = receipt !== null;
137− if (confirmed) return receipt.status === true;
138− else throw new Error('retrying');
137+ if (!confirmed) {
138+ throw new Error('Transaction not confirmed.');
139+ }
140+
141+ const success = receipt.status === true;
142+ if (!success) {
143+ return bail(new Error('Transaction failed.'));
144+ }
139145 }, RETRY_OPTIONS);
140146};
141147
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(
200200 }, [estimatedGasPrice]);
201201
202202 useEffect(() => {
203+ let mounted = true;
203204 // if nonce or chainId is undefined, re-fetch on-chain info
204205 if (!(nonce === undefined || chainId === undefined)) {
205206 return;
@@ -213,16 +214,30 @@ export default function useEthereumTransaction(
213214 _web3.eth.net.getId(),
214215 ]);
215216
217+ if (!mounted) {
218+ return;
219+ }
220+
216221 setNonce(nonce);
217222 setChainId(chainId);
218223 } catch (error) {
219224 setError(error);
220225 }
221226 })();
222− }, [_wallet, _web3, setError, nonce, chainId, networkType]);
227+
228+ return () => (mounted = false);
229+ }, [
230+ _wallet,
231+ _web3,
232+ setError,
233+ nonce,
234+ chainId,
235+ networkType,
236+ estimatedGasPrice,
237+ ]);
223238
224239 useEffect(() => {
225− let cancelled = false;
240+ let mounted = true;
226241
227242 if (confirmed) {
228243 (async () => {
@@ -241,7 +256,7 @@ export default function useEthereumTransaction(
241256 }
242257 }
243258
244− if (cancelled) {
259+ if (!mounted) {
245260 return;
246261 }
247262
@@ -249,7 +264,7 @@ export default function useEthereumTransaction(
249264 })();
250265 }
251266
252− return () => (cancelled = true);
267+ return () => (mounted = false);
253268 }, [confirmed, refetch, completed, setError]);
254269
255270 const values = useDeepEqualReference({
src/lib/useImpliedPoint.js+2−2
@@ -1,13 +1,13 @@
11import * as ob from 'urbit-ob';
22
3−import { prependSig } from './transformers';
3+import { ensurePatFormat } from 'form/formatters';
44
55/**
66 * pull the suggested point from the subdomain
77 */
88export default function useImpliedPoint() {
99 const subdomain = window.location.host.split('.')[0];
10− const patp = prependSig(subdomain);
10+ const patp = ensurePatFormat(subdomain);
1111 const isValid = ob.isValidPatp(patp);
1212
1313 return isValid ? patp : null;
src/lib/useImpliedTicket.js+2−2
@@ -1,13 +1,13 @@
11import * as ob from 'urbit-ob';
22
3−import { prependSig } from './transformers';
3+import { ensurePatFormat } from 'form/formatters';
44
55/**
66 * pull the suggested ticket from the #hash in the url
77 */
88export default function useImpliedTicket() {
99 const hash = window.location.hash.slice(1);
10− const ticket = prependSig(hash);
10+ const ticket = ensurePatFormat(hash);
1111 const isValid = ob.isValidPatq(ticket);
1212
1313 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';
2−import { Just, Nothing } from 'folktale/maybe';
1+import { useCallback, useRef } from 'react';
32
43import { hasReceived, sendMail } from './inviteMail';
5−import useSetState from './useSetState';
4+import timeout from './timeout';
65
76const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true';
87
9−function useHasReceivedCache() {
10− const [cache, addToCache] = useSetState();
8+export default function useMailer() {
9+ const cache = useRef({});
1110
1211 const getHasReceived = useCallback(
13− email => cache[email] || Nothing(), //
14− [cache]
15− );
16−
17− const syncHasReceivedForEmail = useCallback(
1812 async email => {
19− if (Just.hasInstance(getHasReceived(email))) {
20− // never update the cache after we know about it
21− return;
22− }
23−
24− if (STUB_MAILER) {
25− // always allow sending emails when stubbing
26− return addToCache({ [email]: Just(false) });
13+ if (!cache.current[email]) {
14+ if (STUB_MAILER) {
15+ await timeout(350); // simulate request
16+ cache.current[email] = false;
17+ } else {
18+ cache.current[email] = await hasReceived(email);
19+ }
2720 }
2821
29− const _hasReceived = await hasReceived(email);
30− addToCache({ [email]: Just(_hasReceived) });
22+ return cache.current[email];
3123 },
32− [getHasReceived, addToCache]
24+ [cache]
3325 );
3426
35− return { getHasReceived, syncHasReceivedForEmail };
36−}
37−
38−export default function useMailer(emails) {
39− const hasReceivedCache = useHasReceivedCache(emails);
40−
4127 // prefix to avoid clobbering sendMail import
4228 // also throws if return value is false
4329 const _sendMail = useCallback(async (email, ticket, sender, rawTx) => {
@@ -46,8 +32,11 @@ export default function useMailer(emails) {
4632 return true;
4733 }
4834
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+ }
5039 }, []);
5140
52− return { ...hasReceivedCache, sendMail: _sendMail };
41+ return { getHasReceived, sendMail: _sendMail };
5342}
src/lib/useRouter.js+2−2
@@ -53,7 +53,7 @@ export default function useRouter({
5353 if (size <= 1) {
5454 // if we are at the root, pass this event to our parent
5555 if (oldPopState.current) {
56− window.history.back();
56+ oldPopState.current();
5757 }
5858 return;
5959 }
@@ -108,7 +108,7 @@ export default function useRouter({
108108
109109 // construct new onpopstate handler
110110 window.onpopstate = e => {
111− e.stopImmediatePropagation();
111+ e && e.stopImmediatePropagation();
112112 pop();
113113 };
114114
src/lib/validators.js+56−234
@@ -1,6 +1,6 @@
11import * as bip39 from 'bip39';
22import * as ob from 'urbit-ob';
3−import { identity, includes } from 'lodash';
3+import { includes } from 'lodash';
44
55import { isValidAddress, ETH_ZERO_ADDR, ETH_ZERO_ADDR_SHORT } from './wallet';
66import patp2dec from './patp2dec';
@@ -14,261 +14,83 @@ import { MIN_GALAXY, MAX_GALAXY } from './constants';
1414// via: https://emailregex.com/
1515const 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,}))$/;
1616
17−// is this 64 hex chars?
18−const plain64CharHexValue = /[0-9A-Fa-f]{64}/;
19−
2017// is this only hex values with a 0x prefix?
2118const isHexString = /0x[0-9A-Fa-f]/;
2219
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−
6420// Validates a bip39 mnemonic
65−export const validateMnemonic = m =>
66− simpleValidatorWrapper({
67− prevMessage: m,
68− validator: d => bip39.validateMnemonic(d),
69− error: 'This is not a valid mnemonic.',
70− });
21+export const validateMnemonic = v =>
22+ !bip39.validateMnemonic(v) && 'This is not a valid mnemonic.';
7123
7224// Checks an empty field
73−export const validateNotEmpty = m =>
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.',
84− });
25+export const validateNotEmpty = v =>
26+ (v === undefined || v.length === 0) && 'This field is required.';
8527
8628// Checks if a patp is a valid galaxy
87−export const validateGalaxy = m =>
88− simpleValidatorWrapper({
89− prevMessage: m,
90− validator: d => {
91− let point;
92− try {
93− point = patp2dec(d);
94− return point >= MIN_GALAXY && point <= MAX_GALAXY;
95− } catch (e) {
96− return false;
97− }
98− },
99− error: 'This is not a valid galaxy.',
100− });
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− },
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− });
29+export const validateGalaxy = v => {
30+ try {
31+ const point = patp2dec(v);
32+ const isValidGalaxy = point >= MIN_GALAXY && point <= MAX_GALAXY;
33+ if (!isValidGalaxy) {
34+ throw new Error();
35+ }
36+ } catch {
37+ return 'This is not a valid galaxy.';
38+ }
39+};
15440
155−// @deprecate
156−export const validateNetworkKey = m =>
157− simpleValidatorWrapper({
158− prevMessage: m,
159− validator: d => plain64CharHexValue.test(d),
160− error: 'This is not a valid network key.',
161− });
41+export const validatePoint = v => {
42+ try {
43+ if (!ob.isValidPatp(v)) {
44+ throw new Error();
45+ }
46+ } catch {
47+ return 'This is not a valid point.';
48+ }
49+};
16250
163−// @deprecate
164−export const validateNetworkSeed = m =>
165− simpleValidatorWrapper({
166− prevMessage: m,
167− validator: d => plain64CharHexValue.test(d),
168− error: 'This is not a valid network seed.',
169− });
51+export const validatePatq = v => {
52+ try {
53+ if (!ob.isValidPatq(v)) {
54+ throw new Error();
55+ }
56+ } catch {
57+ return 'This is not a valid ticket.';
58+ }
59+};
17060
171−// Checks if a string is a valid ethereum address
172−export const validateEthereumAddress = m =>
173− simpleValidatorWrapper({
174− prevMessage: m,
175− validator: d => isValidAddress(d),
176− error: 'This is not a valid Ethereum address.',
177− });
61+export const validateOneOf = (options = []) => v =>
62+ !includes(options, v) && 'Is not a valid option.';
17863
179−export const validateEmail = m =>
180− simpleValidatorWrapper({
181− prevMessage: m,
182− validator: d => emailRegExp.test(d),
183− error: 'This is not a valid email address.',
184− });
64+export const validateHexString = v =>
65+ !isHexString.test(v) && 'This is not a valid hex string.';
18566
186−export const validateInt = m =>
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.',
199− });
67+export const validateEthereumAddress = v =>
68+ !isValidAddress(v) && 'This is not a valid Ethereum address.';
20069
201−export const validateExactly = (value, error) => m =>
202− simpleValidatorWrapper({
203− prevMessage: m,
204− validator: d => d === value,
205− error,
206− });
70+export const validateEmail = v =>
71+ !emailRegExp.test(v) && 'This is not a valid email address.';
20772
208−export const validateNotAny = (values = []) => m =>
209− simpleValidatorWrapper({
210− prevMessage: m,
211− validator: d => !values.includes(d),
212− error: `Cannot be ${m.data}.`,
213− });
73+export const validateExactly = (value, error) => v => v !== value && error;
21474
215−export const validateLength = l => m =>
216− simpleValidatorWrapper({
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− });
75+export const validateNotAny = (values = []) => v =>
76+ values.includes(v) && `Cannot be ${v}.`;
22777
228−export const validateMaximumLength = l => m =>
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.`,
239− });
78+export const validateLength = l => v =>
79+ v.length !== l && `Must be exactly ${l} characters.`;
24080
241−export const validateGreaterThan = l => m =>
242− simpleValidatorWrapper({
243− prevMessage: m,
244− validator: d => d > l,
245− error: `Must be at least ${l}.`,
246− });
81+export const validateHexLength = l => v =>
82+ v.length !== l + 2 && `Must be exactly ${l} hex characters.`;
24783
248−export const validateInSet = (set, error) => m =>
249− simpleValidatorWrapper({
250− prevMessage: m,
251− validator: d => set.has(d),
252− error,
253− });
84+export const validateMaximumLength = l => v =>
85+ v.length > l && `Must be ${l} characters or fewer.`;
25486
255−export const validateNameInNumberSet = (set, error) => m =>
256− simpleValidatorWrapper({
257− prevMessage: m,
258− validator: p => {
259− const num = patp2dec(p);
260− return set.has(num);
261− },
262− error,
263− });
87+export const validateGreaterThan = l => v =>
88+ !(v > l) && `Must be at least ${l}.`;
26489
265−export const validatePatpByteLength = byteLength => {
266− return validateLength(patpStringLength(byteLength));
267−};
90+export const validateInSet = (set, error) => v => !set.has(v) && error;
26891
269−export const validateMaximumPatpByteLength = byteLength => {
270− return validateMaximumLength(patpStringLength(byteLength));
271−};
92+export const validateMaximumPatpByteLength = byteLength =>
93+ validateMaximumLength(patpStringLength(byteLength));
27294
27395export const validateNotNullAddress = validateNotAny([
27496 ETH_ZERO_ADDR,
src/store/wallet.js+6−1
@@ -33,7 +33,12 @@ const DEFAULT_WALLET_TYPE = WALLET_TYPES.TICKET;
3333// JSON keystore files, and Metamask authentication, it wraps an
3434// 'EthereumWallet'.
3535function _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+ );
3742 const [walletHdPath, setWalletHdPath] = useState(DEFAULT_HD_PATH);
3843 const [wallet, _setWallet] = useState(initialWallet);
3944 const [urbitWallet, _setUrbitWallet] = useState(Nothing());
src/style/indigo-proposals.scss+4
@@ -37,6 +37,10 @@
3737 user-select: none;
3838}
3939
40+.bs-none {
41+ box-shadow: none !important;
42+}
43+
4044.auto-rows-min {
4145 grid-auto-rows: min-content;
4246}
src/views/Activate.js+7−1
@@ -1,11 +1,12 @@
11import React from 'react';
22
33import useRouter from 'lib/useRouter';
4+import { LocalRouterProvider } from 'lib/LocalRouter';
5+import { useSyncKnownPoints } from 'lib/useSyncPoints';
46
57import { ActivateFlowProvider } from './Activate/ActivateFlow';
68import useActivateFlowState from './Activate/useActivateFlowState';
79import ActivateCode from './Activate/ActivateCode';
8−import { LocalRouterProvider } from 'lib/LocalRouter';
910import ActivatePassport from './Activate/ActivatePassport';
1011import Disclaimer from './Disclaimer';
1112
@@ -29,6 +30,11 @@ export default function Activate() {
2930 initialRoutes: [{ key: NAMES.CODE }],
3031 });
3132
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+
3238 return (
3339 <LocalRouterProvider value={router}>
3440 <ActivateFlowProvider value={state}>
src/views/Activate/ActivateCode.js+129−114
@@ -1,30 +1,41 @@
1−import React, { useCallback, useEffect, useState } from 'react';
2−import { Just, Nothing } from 'folktale/maybe';
1+import React, { useCallback, useMemo, useRef } from 'react';
2+import { Just } from 'folktale/maybe';
33import * 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';
56
67import View from 'components/View';
78import { ForwardButton } from 'components/Buttons';
89import Passport from 'components/Passport';
10+import WarningBox from 'components/WarningBox';
11+import FooterButton from 'components/FooterButton';
912
13+import { useNetwork } from 'store/network';
1014import { useHistory } from 'store/history';
1115
12−import { useTicketInput } from 'lib/useInputs';
1316import * as need from 'lib/need';
1417import { ROUTE_NAMES } from 'lib/routeNames';
15−import { useSyncKnownPoints } from 'lib/useSyncPoints';
16−import FooterButton from 'components/FooterButton';
17−import { blinkIf } from 'components/Blinky';
1818import { DEFAULT_HD_PATH, walletFromMnemonic } from 'lib/wallet';
19−import { useNetwork } from 'store/network';
2019import { generateWallet } from 'lib/invite';
2120import { generateTemporaryOwnershipWallet } from 'lib/walletgen';
22−import { useActivateFlow } from './ActivateFlow';
2321import { useLocalRouter } from 'lib/LocalRouter';
2422import useImpliedTicket from 'lib/useImpliedTicket';
23+import timeout from 'lib/timeout';
2524import useHasDisclaimed from 'lib/useHasDisclaimed';
2625import 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';
2839
2940export default function ActivateCode() {
3041 const history = useHistory();
@@ -32,10 +43,9 @@ export default function ActivateCode() {
3243 const { contracts } = useNetwork();
3344 const impliedTicket = useImpliedTicket();
3445 const [hasDisclaimed] = useHasDisclaimed();
35− const [generalError, setGeneralError] = useState();
36− const [deriving, setDeriving] = useState(false);
46+ const didWarn = useRef(false);
47+
3748 const {
38− derivedWallet,
3949 setDerivedWallet,
4050 setInviteWallet,
4151 derivedPoint,
@@ -48,129 +58,134 @@ export default function ActivateCode() {
4858 // for this screen
4959 const activationAllowed = useBreakpoints([false, true, true]);
5060
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−
5961 const goToLogin = useCallback(() => history.popAndPush(ROUTE_NAMES.LOGIN), [
6062 history,
6163 ]);
64+
6265 const goToPassport = useCallback(() => {
6366 push(names.PASSPORT);
67+
6468 if (!hasDisclaimed) {
6569 push(names.DISCLAIMER);
6670 }
67− }, [names, push, hasDisclaimed]);
71+ }, [hasDisclaimed, names.DISCLAIMER, names.PASSPORT, push]);
6872
69− const pass = derivedWallet.matchWith({
70− Nothing: () => false,
71− Just: () => true,
72− });
73+ const validateForm = useCallback((values, errors) => {
74+ didWarn.current = false;
7375
74− useEffect(() => {
75− if (validTicket) {
76− const _contracts = need.contracts(contracts);
76+ if (hasErrors(errors)) {
77+ return errors;
78+ }
79+ }, []);
7780
78− setDeriving(true);
79− // when the ticket becomes valid, derive the point
80− (async () => {
81− const { seed } = await generateTemporaryOwnershipWallet(ticket);
82−
83− //TODO isn't all this accessible in the ownership object?
84− const inviteWallet = walletFromMnemonic(seed, DEFAULT_HD_PATH);
85− const _inviteWallet = need.wallet(inviteWallet);
86−
87− const owned = await azimuth.azimuth.getOwnedPoints(
88− _contracts,
89− _inviteWallet.address
90− );
91− const transferring = await azimuth.azimuth.getTransferringFor(
92− _contracts,
93− _inviteWallet.address
94− );
95− const incoming = [...owned, ...transferring];
96−
97− let realPoint = Nothing();
98− let wallet = Nothing();
99−
100− 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) {
106− setGeneralError(
107− 'This invite code has multiple points available.\n' +
108− "Once you've activated this point, 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− );
81+ const validate = useMemo(
82+ () => composeValidator({ ticket: buildPatqValidator() }, validateForm),
83+ [validateForm]
84+ );
85+
86+ // set our state on submission
87+ const onSubmit = useCallback(
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);
92+ const { seed } = await generateTemporaryOwnershipWallet(values.ticket);
93+
94+ const inviteWallet = walletFromMnemonic(seed, DEFAULT_HD_PATH);
95+
96+ const _inviteWallet = need.wallet(inviteWallet);
97+
98+ const owned = await azimuth.azimuth.getOwnedPoints(
99+ _contracts,
100+ _inviteWallet.address
101+ );
102+ const transferring = await azimuth.azimuth.getTransferringFor(
103+ _contracts,
104+ _inviteWallet.address
105+ );
106+ const incoming = [...owned, ...transferring];
107+
108+ if (incoming.length > 0) {
109+ if (incoming.length > 1 && !didWarn.current) {
110+ didWarn.current = true;
111+ return {
112+ [WARNING]:
113+ 'This invite code has multiple points available. ' +
114+ "Once you've activated this point, " +
115+ 'activate the next with the same process.',
116+ };
118117 }
119118
120− setDerivedPoint(realPoint);
121− setDerivedWallet(wallet);
119+ const point = parseInt(incoming[0], 10);
120+
121+ setDerivedPoint(Just(point));
122122 setInviteWallet(inviteWallet);
123− setDeriving(false);
124− })();
125− } else {
126− setGeneralError(false);
127− }
128− }, [
129− validTicket,
130− contracts,
131− ticket,
132− setDerivedPoint,
133− setDerivedWallet,
134− setInviteWallet,
135− ]);
123+ setDerivedWallet(Just(await generateWallet(point)));
124+ } else {
125+ return {
126+ [FORM_ERROR]:
127+ 'Invite code has no claimable point.\n' +
128+ 'Check your invite code and try again?',
129+ };
130+ }
131+ },
132+ [contracts, setDerivedPoint, setDerivedWallet, setInviteWallet]
133+ );
136134
137− // when we know the derived point, ensure we have the data to display it
138− useSyncKnownPoints([derivedPoint.getOrElse(null)].filter(p => p !== null));
135+ const initialValues = useMemo(() => ({ ticket: impliedTicket || '' }), [
136+ impliedTicket,
137+ ]);
139138
140139 return (
141140 <View inset>
142141 <Grid>
143− <Grid.Item as={Passport} point={derivedPoint} full />
144− <Grid.Item as={H4} className="mt3 mb2" full>
142+ <Grid.Item full as={Passport} point={derivedPoint} />
143+ <Grid.Item full as={H4} className="mt3 mb2">
145144 Activate
146145 </Grid.Item>
147−
148− <Grid.Item as={Input} {...ticketInput} full />
149−
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}
162− solid
163− full>
164− {deriving && 'Deriving...'}
165− {!deriving && 'Go'}
166− </Grid.Item>
167−
168− {!activationAllowed && (
169− <Grid.Item full as={WarningBox} className="mt4">
170− For your security, please access Bridge on a desktop device.
171− </Grid.Item>
172− )}
146+ <BridgeForm
147+ validate={validate}
148+ onSubmit={onSubmit}
149+ afterSubmit={goToPassport}
150+ initialValues={initialValues}>
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
170+ ? 'Deriving...'
171+ : submitting
172+ ? 'Generating...'
173+ : isWarning
174+ ? 'Continue Activation'
175+ : 'Go'
176+ }
177+ </Grid.Item>
178+
179+ {!activationAllowed && (
180+ <Grid.Item full as={WarningBox} className="mt4">
181+ For your security, please access Bridge on a desktop device.
182+ </Grid.Item>
183+ )}
184+ </>
185+ )}
186+ </BridgeForm>
173187 </Grid>
188+
174189 <FooterButton as={ForwardButton} onClick={goToLogin}>
175190 Login
176191 </FooterButton>
src/views/Activate/PassportTransfer.js+1−5
@@ -57,11 +57,7 @@ export default function PassportTransfer({ className, resetActivateRouter }) {
5757 const [needFunds, setNeedFunds] = useState();
5858
5959 const goToLogin = useCallback(
60− () =>
61− replaceWith([
62− { key: names.LOGIN },
63− { key: names.POINT },
64− ]),
60+ () => replaceWith([{ key: names.LOGIN }, { key: names.POINT }]),
6561 [replaceWith, names]
6662 );
6763
src/views/Activate/PassportVerify.js+45−22
@@ -1,16 +1,19 @@
11import React, { useCallback, useMemo } from 'react';
2−import { Grid, Input, P } from 'indigo-react';
2+import { Grid, P } from 'indigo-react';
33
44import * as need from 'lib/need';
55import { useLocalRouter } from 'lib/LocalRouter';
6−import { useTicketInput } from 'lib/useInputs';
76import { validateExactly } from 'lib/validators';
87import { isDevelopment } from 'lib/flags';
98
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';
1113
1214import { useActivateFlow } from './ActivateFlow';
1315import PassportView from './PassportView';
16+import FormError from 'form/FormError';
1417
1518const STUB_VERIFY_TICKET = isDevelopment;
1619
@@ -20,17 +23,22 @@ export default function PassportVerify({ className }) {
2023 const goToTransfer = useCallback(() => push(names.TRANSFER), [push, names]);
2124
2225 const { ticket } = need.wallet(derivedWallet);
23− const validators = useMemo(
24− () => [validateExactly(ticket, 'Does not match expected master ticket.')],
26+ const validate = useMemo(
27+ () =>
28+ composeValidator({
29+ ticket: buildPatqValidator([
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+ }),
2540 [ticket]
2641 );
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− });
3442
3543 return (
3644 <PassportView header="Verify Passport" step={2} className={className}>
@@ -40,16 +48,31 @@ export default function PassportVerify({ className }) {
4048 should be a folder of image files. One of them is your Master Ticket.
4149 Open it and enter the 4 word phrase below (with hyphens).
4250 </Grid.Item>
43− <Grid.Item full as={Input} {...ticketInput} />
44− <Grid.Item
45− full
46− className="mt3"
47− as={ForwardButton}
48− disabled={!pass}
49− onClick={goToTransfer}
50− solid>
51− Verify
52− </Grid.Item>
51+ <BridgeForm
52+ validate={validate}
53+ afterSubmit={goToTransfer}
54+ initialValues={initialValues}>
55+ {({ handleSubmit }) => (
56+ <>
57+ <Grid.Item
58+ full
59+ as={TicketInput}
60+ name="ticket"
61+ label="Master Ticket"
62+ />
63+
64+ <Grid.Item full as={FormError} />
65+
66+ <Grid.Item
67+ full
68+ className="mt3"
69+ as={SubmitButton}
70+ handleSubmit={handleSubmit}>
71+ Verify
72+ </Grid.Item>
73+ </>
74+ )}
75+ </BridgeForm>
5376 </Grid>
5477 </PassportView>
5578 );
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';
22import { 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';
124import * as azimuth from 'azimuth-js';
135import { randomHex } from 'web3-utils';
146
@@ -30,7 +22,6 @@ import { formatDotsWithTime } from 'lib/dateFormat';
3022import useEthereumTransaction from 'lib/useEthereumTransaction';
3123import { GAS_LIMITS } from 'lib/constants';
3224import { addHexPrefix } from 'lib/wallet';
33−import { useHexInput, useCheckboxInput } from 'lib/useInputs';
3425import useKeyfileGenerator from 'lib/useKeyfileGenerator';
3526
3627import ViewHeader from 'components/ViewHeader';
@@ -40,6 +31,16 @@ import DownloadKeyfileButton from 'components/DownloadKeyfileButton';
4031import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
4132import NoticeBox from 'components/NoticeBox';
4233
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+
4344const chainKeyProp = name => d =>
4445 d[name] === CURVE_ZERO_ADDR ? Nothing() : Just(d[name]);
4546
@@ -163,58 +164,50 @@ export default function AdminNetworkingKeys() {
163164 keyfileBind,
164165 } = useSetKeys();
165166
166− const [showNetworkSeedInput, { data: showNetworkSeed }] = useCheckboxInput({
167− name: 'shownetworkseed',
168− label: 'Use Custom Network Seed',
169− inverseLabel: 'Back to Derived Network Seed',
170− initialValue: false,
171− disabled: inputsLocked,
172− });
167+ const validateForm = useCallback((values, errors) => {
168+ if (values.useNetworkSeed && errors.networkSeed) {
169+ return errors;
170+ }
171+ }, []);
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]
184+ );
173185
174− const [
175− networkSeedInput,
176− { pass: validNetworkSeed, data: networkSeed },
177− { reset: resetNetworkSeed },
178− ] = useHexInput({
179− name: 'networkseed',
180− label: 'Network Seed (64 bytes)',
181− length: 32, // 64 bytes
182− disabled: inputsLocked,
183− });
186+ const onValues = useCallback(
187+ ({ valid, values, form }) => {
188+ if (valid) {
189+ construct(
190+ values.useNetworkSeed ? values.networkSeed : undefined,
191+ values.useDiscontinuity
192+ );
193+ } else {
194+ unconstruct();
195+ }
184196
185− const [
186− discontinuityInput,
187− { pass: validDiscontinuity, data: isDiscontinuity },
188− ] = useCheckboxInput({
189− name: 'discontinuity',
190− label: 'Trigger New Continuity Era',
191− initialValue: false,
192− disabled: inputsLocked,
193− });
197+ if (!values.useNetworkSeed && values.networkSeed) {
198+ form.change('networkSeed', '');
199+ }
200+ },
201+ [construct, unconstruct]
202+ );
194203
195− useEffect(() => {
196− const nothingOrValidSeed =
197− !showNetworkSeed || (showNetworkSeed && validNetworkSeed);
198− if (nothingOrValidSeed && validDiscontinuity) {
199− construct(networkSeed, isDiscontinuity);
200− } else {
201− unconstruct();
202− }
203− }, [
204− construct,
205− unconstruct,
206− isDiscontinuity,
207− networkSeed,
208− showNetworkSeed,
209− validDiscontinuity,
210− validNetworkSeed,
211− ]);
212−
213− useEffect(() => {
214− if (!showNetworkSeed) {
215− resetNetworkSeed();
216− }
217− }, [resetNetworkSeed, showNetworkSeed]);
204+ const initialValues = useMemo(
205+ () => ({
206+ useNetworkSeed: false,
207+ useDiscontinuity: false,
208+ }),
209+ []
210+ );
218211
219212 const goRelocate = useCallback(() => push(names.RELOCATE), [push, names]);
220213
@@ -241,16 +234,16 @@ export default function AdminNetworkingKeys() {
241234 </Grid.Item>
242235 {key.matchWith({
243236 Nothing: () => (
244− <Grid.Item full as="code" className="f5 gray4">
237+ <Grid.Item full as="code" className="f5 mono gray4">
245238 Unset
246239 </Grid.Item>
247240 ),
248241 Just: ({ value: key }) => (
249242 <>
250− <Grid.Item full as="code" className="f5 wrap">
243+ <Grid.Item full as="code" className="f5 mono wrap">
251244 0x
252245 </Grid.Item>
253− <Grid.Item full as="code" className="f5 wrap">
246+ <Grid.Item full as="code" className="f5 mono wrap">
254247 {renderNetworkKey(key)}
255248 </Grid.Item>
256249 </>
@@ -335,30 +328,56 @@ export default function AdminNetworkingKeys() {
335328 )}
336329
337330 {!completed && (
338− <>
339− <Grid.Item full as={ToggleInput} {...showNetworkSeedInput} />
340− {showNetworkSeed && (
331+ <BridgeForm
332+ validate={validate}
333+ onValues={onValues}
334+ initialValues={initialValues}>
335+ {({ handleSubmit }) => (
341336 <>
342− <Grid.Item full as={NoticeBox} className="mb2">
343− When using a custom network seed, you'll need to download your
344− Arvo keyfile immediately after this transaction is completed —
345− Bridge does not store your seed.
346− </Grid.Item>
347− <Grid.Item full as={Input} {...networkSeedInput} />
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}>
346+ <Grid.Item full as={NoticeBox} className="mb2">
347+ When using a custom network seed, you'll need to download
348+ your Arvo keyfile immediately after this transaction is
349+ Bridge does not store your seed.
350+ </Grid.Item>
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+ />
348376 </>
349377 )}
350− <Grid.Item full as={CheckboxInput} {...discontinuityInput} />
351− </>
378+ </BridgeForm>
352379 )}
353380
354− <Grid.Item
355− full
356− as={InlineEthereumTransaction}
357− {...bind}
358− label={`${hasKeys ? 'Reset' : 'Set'} Networking Keys`}
359− onReturn={() => pop()}
360− />
361−
362381 {isDefaultState && renderDetails()}
363382
364383 {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';
22import cn from 'classnames';
3−import { Grid, Text, Input, Flex, ToggleInput } from 'indigo-react';
3+import { Grid, Text, Flex, ToggleInput } from 'indigo-react';
44import * as azimuth from 'azimuth-js';
55
66import { useNetwork } from 'store/network';
@@ -16,12 +16,19 @@ import * as need from 'lib/need';
1616import { useLocalRouter } from 'lib/LocalRouter';
1717import { ETH_ZERO_ADDR, eqAddr, isZeroAddress } from 'lib/wallet';
1818import capitalize from 'lib/capitalize';
19−import { useAddressInput, useCheckboxInput } from 'lib/useInputs';
2019import useEthereumTransaction from 'lib/useEthereumTransaction';
2120
2221import ViewHeader from 'components/ViewHeader';
2322import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
2423import { 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';
2532
2633const proxyFromDetails = (details, contracts, proxyType) => {
2734 switch (proxyType) {
@@ -107,41 +114,41 @@ export default function AdminSetProxy() {
107114 bind,
108115 } = useSetProxy(data.proxyType);
109116
110− const [unsetInput, { data: isUnsetting }] = useCheckboxInput({
111− name: 'unset',
112− label: 'Unset',
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();
117+ const validateForm = useCallback((values, errors) => {
118+ if (!values.unset && errors.address) {
119+ return errors;
135120 }
136− }, [
137− validAddress,
138− address,
139− construct,
140− isUnsetting,
141− unset,
142− unconstruct,
143− resetAddress,
144− ]);
121+ }, []);
122+
123+ const validate = useMemo(
124+ () =>
125+ composeValidator(
126+ {
127+ unset: buildCheckboxValidator(),
128+ address: buildAddressValidator(),
129+ },
130+ validateForm
131+ ),
132+ [validateForm]
133+ );
134+
135+ const onValues = useCallback(
136+ ({ valid, values, form }) => {
137+ if (valid) {
138+ if (values.unset) {
139+ unset();
140+ form.change('address', '');
141+ } else {
142+ construct(values.address);
143+ }
144+ } else {
145+ unconstruct();
146+ }
147+ },
148+ [construct, unconstruct, unset]
149+ );
150+
151+ const initialValues = useMemo(() => ({ unset: false }), []);
145152
146153 const proxyAddress = proxyFromDetails(_details, _contracts, data.proxyType);
147154 const isProxySet = !isZeroAddress(proxyAddress);
@@ -169,34 +176,58 @@ export default function AdminSetProxy() {
169176 {proxyAddressLabel}
170177 </Grid.Item>
171178
172− <Grid.Item full as={Flex} row justify="between" align="center">
173− <Flex.Item
174− flex
175− as={Text}
176− className={cn('mono', {
177− black: !completed && isProxySet,
178− gray4: !completed && !isProxySet,
179− green3: completed,
180− })}>
181− {isProxySet ? proxyAddress : 'Unset'}
182− </Flex.Item>
183− {!completed && isProxySet && (
184− <Flex.Item as={ToggleInput} {...unsetInput} />
179+ <BridgeForm
180+ validate={validate}
181+ onValues={onValues}
182+ initialValues={initialValues}>
183+ {({ handleSubmit, values }) => (
184+ <>
185+ <Grid.Item full as={Flex} row justify="between" align="center">
186+ <Flex.Item
187+ flex
188+ as={Text}
189+ className={cn('mono', {
190+ black: !completed && isProxySet,
191+ gray4: !completed && !isProxySet,
192+ green3: completed,
193+ })}>
194+ {isProxySet ? proxyAddress : 'Unset'}
195+ </Flex.Item>
196+ {!completed && isProxySet && (
197+ <Flex.Item
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+ </>
185229 )}
186− </Grid.Item>
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− />
230+ </BridgeForm>
200231 </Grid>
201232 );
202233}
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';
22import cn from 'classnames';
3−import { Grid, Text, Input } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
44import * as azimuth from 'azimuth-js';
55
66import { useNetwork } from 'store/network';
@@ -9,13 +9,16 @@ import { usePointCache } from 'store/pointCache';
99
1010import * as need from 'lib/need';
1111import { useLocalRouter } from 'lib/LocalRouter';
12−import { useAddressInput } from 'lib/useInputs';
1312import useCurrentPointName from 'lib/useCurrentPointName';
1413import useEthereumTransaction from 'lib/useEthereumTransaction';
1514import { GAS_LIMITS } from 'lib/constants';
1615
1716import ViewHeader from 'components/ViewHeader';
1817import 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';
1922
2023function useTransfer() {
2124 const { contracts } = useNetwork();
@@ -47,46 +50,64 @@ export default function AdminTransfer() {
4750 bind,
4851 } = useTransfer();
4952
50− const [addressInput, { pass, data: address }] = useAddressInput({
51− name: 'address',
52− label: `Ethereum Address`,
53− disabled: inputsLocked,
54− });
53+ const validate = useMemo(
54+ () => composeValidator({ address: buildAddressValidator() }),
55+ []
56+ );
5557
56− useEffect(() => {
57− if (pass) {
58− construct(address);
59− } else {
60− unconstruct();
61− }
62− }, [pass, address, construct, unconstruct]);
58+ const onValues = useCallback(
59+ ({ valid, values }) => {
60+ if (valid) {
61+ construct(values.address);
62+ } else {
63+ unconstruct();
64+ }
65+ },
66+ [construct, unconstruct]
67+ );
6368
6469 return (
6570 <Grid>
6671 <Grid.Item full as={ViewHeader}>
6772 Transfer Point
6873 </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>
7974
80− {!completed && (
81− <Grid.Item full as={Input} {...addressInput} className="mv4" />
82− )}
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+
89+ {!completed && (
90+ <Grid.Item
91+ full
92+ as={AddressInput}
93+ className="mv4"
94+ name="address"
95+ label="Ethereum Address"
96+ disabled={inputsLocked}
97+ />
98+ )}
99+
100+ <Grid.Item full as={FormError} />
83101
84− <Grid.Item
85− full
86− as={InlineEthereumTransaction}
87− {...bind}
88− onReturn={() => pop()}
89− />
102+ <Grid.Item
103+ full
104+ as={InlineEthereumTransaction}
105+ {...bind}
106+ onReturn={() => pop()}
107+ />
108+ </>
109+ )}
110+ </BridgeForm>
90111 </Grid>
91112 );
92113}
src/views/Admin/Reticket/ReticketVerify.js+41−21
@@ -1,12 +1,15 @@
11import React, { useCallback, useMemo } from 'react';
2−import { Input, Text, Grid } from 'indigo-react';
2+import { Text, Grid } from 'indigo-react';
33
4−import { useTicketInput } from 'lib/useInputs';
54import { useLocalRouter } from 'lib/LocalRouter';
65import { validateExactly } from 'lib/validators';
76import { isDevelopment } from 'lib/flags';
87
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';
1013
1114const STUB_VERIFY_TICKET = isDevelopment;
1215
@@ -14,17 +17,22 @@ export default function ReticketVerify({ newWallet }) {
1417 const { push, names } = useLocalRouter();
1518
1619 const ticket = newWallet.value.wallet.ticket;
17− const validators = useMemo(
18− () => [validateExactly(ticket, 'Does not match expected master ticket.')],
20+ const validate = useMemo(
21+ () =>
22+ composeValidator({
23+ ticket: buildPatqValidator([
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+ }),
1934 [ticket]
2035 );
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− });
2836
2937 const goExecute = useCallback(() => push(names.EXECUTE), [push, names]);
3038
@@ -33,15 +41,27 @@ export default function ReticketVerify({ newWallet }) {
3341 <Grid.Item full as={Text}>
3442 Verify New Master Ticket
3543 </Grid.Item>
36− <Grid.Item full as={Input} {...ticketInput} />
37− <Grid.Item
38− full
39− as={ForwardButton}
40− solid
41− disabled={!pass}
42− onClick={goExecute}>
43− Verify & Reticket
44− </Grid.Item>
44+ <BridgeForm
45+ validate={validate}
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}>
60+ Verify & Reticket
61+ </Grid.Item>
62+ </>
63+ )}
64+ </BridgeForm>
4565 </Grid>
4666 );
4767}
src/views/CreateGalaxy.js+100−94
@@ -1,14 +1,12 @@
1−import React, { useCallback, useEffect, useState } from 'react';
2−import { Nothing, Just } from 'folktale/maybe';
1+import React, { useCallback, useMemo, useState } from 'react';
32import cn from 'classnames';
4−import { Grid, Text, Input } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
54import * as azimuth from 'azimuth-js';
65
76import { useNetwork } from 'store/network';
87import { usePointCache } from 'store/pointCache';
98
109import * as need from 'lib/need';
11−import { useAddressInput, useGalaxyInput } from 'lib/useInputs';
1210import useEthereumTransaction from 'lib/useEthereumTransaction';
1311import { GAS_LIMITS } from 'lib/constants';
1412import patp2dec from 'lib/patp2dec';
@@ -18,6 +16,15 @@ import { useLocalRouter } from 'lib/LocalRouter';
1816import ViewHeader from 'components/ViewHeader';
1917import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
2018import 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';
2128
2229function useCreateGalaxy() {
2330 const { contracts } = useNetwork();
@@ -45,9 +52,6 @@ export default function CreateGalaxy() {
4552 const { contracts } = useNetwork();
4653 const _contracts = need.contracts(contracts);
4754
48− const [error, setError] = useState();
49− const [isAvailable, setIsAvailable] = useState(Nothing());
50−
5155 const {
5256 construct,
5357 unconstruct,
@@ -56,75 +60,54 @@ export default function CreateGalaxy() {
5660 bind,
5761 } = useCreateGalaxy();
5862
59− const [
60− galaxyNameInput,
61− { pass: validGalaxyName, syncPass: syncValidGalaxyName, value: galaxyName },
62− // ^ we use value: here so our effect runs onChange
63− ] = useGalaxyInput({
64− name: 'galaxy',
65− disabled: inputsLocked,
66− autoFocus: true,
67− error:
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− }),
73− });
74−
75− const [ownerInput, { pass: validOwner, data: owner }] = useAddressInput({
76− name: 'owner',
77− label: `Ethereum Address`,
78− disabled: inputsLocked,
79− });
80−
81− useEffect(() => {
82− if (validGalaxyName && validOwner) {
83− construct(patp2dec(galaxyName), owner);
84− } else {
85− unconstruct();
86− }
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));
63+ const validateFormAsync = useCallback(
64+ async values => {
65+ const currentOwner = await azimuth.azimuth.getOwner(
66+ _contracts,
67+ patp2dec(values.galaxyName)
68+ );
69+
70+ const isAvailable = isZeroAddress(currentOwner);
71+ if (!isAvailable) {
72+ return { galaxyName: 'This galaxy is already spawned and owned.' };
73+ }
74+ },
75+ [_contracts]
76+ );
77+
78+ const validateForm = useCallback(
79+ (values, errors) => {
80+ if (hasErrors(errors)) {
81+ return errors;
11782 }
118− })();
11983
120− return () => (cancelled = true);
121− }, [
122− _contracts,
123− galaxyName,
124− inputsLocked,
125− setIsAvailable,
126− syncValidGalaxyName,
127− ]);
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(),
95+ },
96+ validateForm
97+ ),
98+ [validateForm]
99+ );
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]
110+ );
128111
129112 return (
130113 <View pop={pop} inset>
@@ -133,26 +116,49 @@ export default function CreateGalaxy() {
133116 Create a Galaxy
134117 </Grid.Item>
135118
136− {completed && (
137− <Grid.Item
138− full
139− as={Text}
140− className={cn('f5', {
141− green3: completed,
142− })}>
143− {galaxyName} has been created and can be claimed by {owner}.
144− </Grid.Item>
145− )}
146−
147− <Grid.Item full as={Input} {...galaxyNameInput} className="mt4" />
148− <Grid.Item full as={Input} {...ownerInput} className="mb4" />
149−
150− <Grid.Item
151− full
152− as={InlineEthereumTransaction}
153− {...bind}
154− onReturn={() => pop()}
155− />
119+ <BridgeForm validate={validate} onValues={onValues}>
120+ {({ handleSubmit, values }) => (
121+ <>
122+ {completed && (
123+ <Grid.Item
124+ full
125+ as={Text}
126+ className={cn('f5', {
127+ green3: completed,
128+ })}>
129+ {values.galaxyName} has been created and can be claimed by{' '}
130+ {values.owner}.
131+ </Grid.Item>
132+ )}
133+
134+ <Grid.Item
135+ full
136+ as={PointInput}
137+ className="mt4"
138+ name="galaxyName"
139+ label="Galaxy Name"
140+ disabled={inputsLocked}
141+ />
142+ <Grid.Item
143+ full
144+ as={AddressInput}
145+ className="mb4"
146+ name="owner"
147+ label="Ethereum Address"
148+ disabled={inputsLocked}
149+ />
150+
151+ <Grid.Item full as={FormError} />
152+
153+ <Grid.Item
154+ full
155+ as={InlineEthereumTransaction}
156+ {...bind}
157+ onReturn={() => pop()}
158+ />
159+ </>
160+ )}
161+ </BridgeForm>
156162 </Grid>
157163 </View>
158164 );
src/views/Disclaimer.js+30−16
@@ -1,24 +1,28 @@
1−import React, { useCallback } from 'react';
1+import React, { useCallback, useMemo } from 'react';
22import cn from 'classnames';
33import { Grid, H3, B, Text, CheckboxInput } from 'indigo-react';
44
5−import { useCheckboxInput } from 'lib/useInputs';
65import { useHistory } from 'store/history';
76import useHasDisclaimed from 'lib/useHasDisclaimed';
87
98import View from 'components/View';
10−import { ForwardButton } from 'components/Buttons';
119import WarningBox from 'components/WarningBox';
10+import BridgeForm from 'form/BridgeForm';
11+import SubmitButton from 'form/SubmitButton';
12+import { composeValidator, buildCheckboxValidator } from 'form/validators';
1213
1314const TEXT_STYLE = 'f5';
1415
1516export default function ActivateDisclaimer() {
1617 const { pop } = useHistory();
1718 const [, setHasDisclaimed] = useHasDisclaimed();
18− const [understoodInput, { data: isUnderstood }] = useCheckboxInput({
19− name: 'checkbox',
20− label: 'I acknowledge and understand these rights',
21− });
19+
20+ const validate = useMemo(
21+ () => composeValidator({ checkbox: buildCheckboxValidator(true) }),
22+ []
23+ );
24+
25+ const initialValues = useMemo(() => ({ checkbox: false }), []);
2226
2327 const goBack = useCallback(async () => {
2428 setHasDisclaimed(true);
@@ -76,15 +80,25 @@ export default function ActivateDisclaimer() {
7680 <Grid.Item full as={WarningBox}>
7781 Warning: Nobody but you can restore or reset your Master Ticket
7882 </Grid.Item>
79− <Grid.Item as={CheckboxInput} {...understoodInput} full />
80− <Grid.Item
81− as={ForwardButton}
82− disabled={!isUnderstood}
83− onClick={goBack}
84− solid
85− full>
86− Continue
87− </Grid.Item>
83+ <BridgeForm
84+ validate={validate}
85+ afterSubmit={goBack}
86+ initialValues={initialValues}>
87+ {({ handleSubmit }) => (
88+ <>
89+ <Grid.Item
90+ full
91+ as={CheckboxInput}
92+ name="checkbox"
93+ label="I acknowledge and understand these rights"
94+ />
95+
96+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
97+ Continue
98+ </Grid.Item>
99+ </>
100+ )}
101+ </BridgeForm>
88102 </Grid>
89103 </View>
90104 );
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';
28import cn from 'classnames';
39import * as ob from 'urbit-ob';
410import * as azimuth from 'azimuth-js';
511import {
612 Grid,
713 Flex,
8− Input,
914 IconButton,
1015 HelpText,
1116 Text,
1217 ErrorText,
1318 AccessoryIcon,
14− useForm,
1519} from 'indigo-react';
1620import { uniq } from 'lodash';
1721import { fromWei, toWei } from 'web3-utils';
22+import { FieldArray } from 'react-final-form-arrays';
1823
1924import { usePointCursor } from 'store/pointCursor';
2025import { useNetwork } from 'store/network';
@@ -29,10 +34,8 @@ import {
2934 hexify,
3035} from 'lib/txn';
3136import * as tank from 'lib/tank';
32−import { useSuggestedGasPrice } from 'lib/useSuggestedGasPrice';
33−import useArray from 'lib/useArray';
34−import { buildEmailInputConfig } from 'lib/useInputs';
3537import { MIN_PLANET, GAS_LIMITS } from 'lib/constants';
38+import { useSuggestedGasPrice } from 'lib/useSuggestedGasPrice';
3639import * as need from 'lib/need';
3740import * as wg from 'lib/walletgen';
3841import useSetState from 'lib/useSetState';
@@ -41,6 +44,21 @@ import useMailer from 'lib/useMailer';
4144
4245import LoadableButton from 'components/LoadableButton';
4346import 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: [''] };
4462
4563const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
4664const HAS_RECEIVED_TEXT = 'This email has already received an invite.';
@@ -55,6 +73,8 @@ const STATUS = {
5573 FAILURE: 'FAILURE',
5674};
5775
76+const nameForEmailField = i => `emails[${i}]`;
77+
5878const buttonText = (status, count) => {
5979 switch (status) {
6080 case STATUS.INPUT:
@@ -73,47 +93,29 @@ const buttonText = (status, count) => {
7393 }
7494};
7595
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−
8596const buildAccessoryFor = (dones, errors) => name => {
8697 if (dones[name]) return <AccessoryIcon.Success />;
8798 if (errors[name]) return <AccessoryIcon.Failure />;
8899 return <AccessoryIcon.Pending />;
89100};
90101
91−// TODO: test with tank, successful txs
92102export default function InviteEmail() {
93− // TODO: resumption after error?
94103 const { contracts, web3, networkType } = useNetwork();
95104 const { wallet, walletType, walletHdPath } = useWallet();
96105 const { syncInvites, getInvites } = usePointCache();
97106 const { pointCursor } = usePointCursor();
98107 const point = need.point(pointCursor);
99− const { getHasReceived, syncHasReceivedForEmail, sendMail } = useMailer();
108+ const { getHasReceived, sendMail } = useMailer();
100109 const { gasPrice } = useSuggestedGasPrice(networkType);
101110
111+ const cachedEmails = useRef({});
112+
102113 const { availableInvites } = getInvites(point);
103114 const maxInvitesToSend = availableInvites.matchWith({
104115 Nothing: () => 0,
105116 Just: p => p.value,
106117 });
107118
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−
117119 // manage per-input state
118120 const [hovered, setHovered] = useSetState();
119121 const [invites, addInvite, clearInvites] = useSetState();
@@ -133,30 +135,38 @@ export default function InviteEmail() {
133135 const isFailed = status === STATUS.FAILURE;
134136 const isDone = status === STATUS.SUCCESS;
135137
136− // add disabled, error info to input configs
137− const dynamicConfigs = useMemo(
138− () =>
139− inputConfigs.map(config => {
140− config.disabled = !canInput;
141− const hasReceivedError = getHasReceived(config.name).matchWith({
142− Nothing: () => null, // loading
143− Just: p => p.value && HAS_RECEIVED_TEXT,
144− });
145− config.error = hasReceivedError || errors[config.name];
146− return config;
147− }),
148− [inputConfigs, errors, canInput, getHasReceived]
138+ const validateHasReceived = useCallback(
139+ async email => {
140+ const hasReceived = await getHasReceived(email);
141+ if (hasReceived) {
142+ return HAS_RECEIVED_TEXT;
143+ }
144+ },
145+ [getHasReceived]
149146 );
150147
151− // construct the state of the set of inputs we're rendering below
152− const { inputs, pass } = useForm(dynamicConfigs);
153− // did all of the inputs pass inspection (and there are no general errors)?
154− const allPass = pass && !generalError;
155− // the form is submittable iff passing and input is allowed
156− const canGenerate = allPass && canInput;
157− // additional inputs can be added iff input is allowed and we have enough
158− // invites to send
159− const canAddInvite = canInput && inputConfigs.length < maxInvitesToSend;
148+ const validateForm = useCallback((values, errors) => {
149+ if (hasErrors(errors)) {
150+ return errors;
151+ }
152+
153+ // check for email uniqenesss
154+ const emails = values.emails.filter(d => !!d);
155+ if (uniq(emails).length !== emails.length) {
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+ );
160170
161171 // progress is [0, .length] of invites or receipts, as we're generating them
162172 const progress = isGenerating
@@ -174,128 +184,113 @@ export default function InviteEmail() {
174184 return () => null;
175185 })();
176186
177− const generateInvites = useCallback(async () => {
178− const _contracts = contracts.getOrElse(null);
179− const _web3 = web3.getOrElse(null);
180− const _wallet = wallet.getOrElse(null);
181− if (!_contracts || !_web3 || !_wallet) {
182− // not using need because we want a custom error
183− 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− }
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);
187+ const generateInvites = useCallback(
188+ async values => {
189+ const _contracts = contracts.getOrElse(null);
190+ const _web3 = web3.getOrElse(null);
191+ const _wallet = wallet.getOrElse(null);
192+ if (!_contracts || !_web3 || !_wallet) {
193+ // not using need because we want a custom error
194+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
195+ }
225196
226− throw new Error(
227− `Can currently only send ${planets.length} invites. ` +
228− `Please remove invites until you are within the limit.`
197+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
198+ const chainId = await _web3.eth.net.getId();
199+ const planets = await azimuth.delegatedSending.getPlanetsToSend(
200+ _contracts,
201+ point,
202+ values.emails.length
229203 );
230− }
231204
232− clearInvites();
233− // NB(shrugs) - must be processed in serial because main thread, etc
234− let errorCount = 0;
235− for (let i = 0; i < inputs.length; i++) {
236− const input = inputs[i];
237− try {
238− const { data: email, name } = inputs[i];
239−
240− const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
241− MIN_PLANET
242− // we're always giving planets, so generate a ticket of the correct size
243− );
205+ // account for the race condition where invites got used up while we were
206+ // composing our target list
207+ if (planets.length < values.emails.length) {
208+ // resync invites to the cache, since they're out of date
209+ syncInvites(point);
210+
211+ return {
212+ [FORM_ERROR]:
213+ `Can currently only send ${planets.length} invites. ` +
214+ `Please remove invites until you are within the limit.`,
215+ };
216+ }
244217
245− const inviteTx = azimuth.delegatedSending.sendPoint(
246− _contracts,
247− point,
248− planets[i],
249− owner.keys.address
250− );
251− const signedTx = await signTransaction({
252− wallet: _wallet,
253− walletType,
254− walletHdPath,
255− networkType,
256− // TODO: ^ make a useTransactionSigner to encapsulate this logic
257− txn: inviteTx,
258− gasPrice: gasPrice.toString(),
259− gasLimit: GAS_LIMIT.toString(),
260− nonce: nonce + i,
261− chainId,
262− });
263− const rawTx = hexify(signedTx.serialize());
264−
265− addInvite({ [name]: { email, ticket, signedTx, rawTx } });
266− } catch (error) {
267− console.error(error);
268− errorCount++;
269− addError({ [input.name]: `Wallet Error: ${input.email}` });
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+
232+ const inviteTx = azimuth.delegatedSending.sendPoint(
233+ _contracts,
234+ point,
235+ planet,
236+ owner.keys.address
237+ );
238+
239+ const signedTx = await signTransaction({
240+ wallet: _wallet,
241+ walletType,
242+ walletHdPath,
243+ networkType,
244+ chainId,
245+ nonce: nonce + i,
246+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
247+ txn: inviteTx,
248+ gasPrice: gasPrice.toString(),
249+ gasLimit: GAS_LIMIT.toString(),
250+ });
251+
252+ const rawTx = hexify(signedTx.serialize());
253+
254+ addInvite({ [name]: { email, ticket, signedTx, rawTx } });
255+ } catch (error) {
256+ console.error(error);
257+ errorCount++;
258+ addError({ [name]: 'Error generating invite.' });
259+ }
270260 }
271− }
272261
273− if (errorCount > 0) {
274− throw new Error(
275− `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
276− errorCount,
277− 'error'
278− )} while generating wallets.`
279− );
280− }
281− }, [
282− contracts,
283− web3,
284− gasPrice,
285− wallet,
286− inputs,
287− point,
288− clearInvites,
289− getHasReceived,
290− addError,
291− syncInvites,
292− walletType,
293− walletHdPath,
294− networkType,
295− addInvite,
296− ]);
262+ if (errorCount > 0) {
263+ return {
264+ [WARNING]: `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
265+ errorCount,
266+ 'error'
267+ )} while generating wallets. You can still send the invites that generated correctly.`,
268+ };
269+ }
270+ },
271+ [
272+ contracts,
273+ web3,
274+ wallet,
275+ point,
276+ clearInvites,
277+ syncInvites,
278+ walletType,
279+ walletHdPath,
280+ networkType,
281+ gasPrice,
282+ addInvite,
283+ addError,
284+ ]
285+ );
297286
298287 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+
299294 const _web3 = web3.getOrElse(null);
300295 const _wallet = wallet.getOrElse(null);
301296 if (!_web3 || !_wallet) {
@@ -307,8 +302,8 @@ export default function InviteEmail() {
307302 _web3,
308303 point,
309304 _wallet.address,
310− toWei((gasPrice * GAS_LIMIT * inputs.length).toString(), 'gwei'),
311− inputs.map(input => invites[input.name].rawTx),
305+ toWei((gasPrice * GAS_LIMIT * emails.length).toString(), 'gwei'),
306+ names.map(name => invites[name].rawTx),
312307 (address, minBalance, balance) =>
313308 setNeedFunds({ address, minBalance, balance }),
314309 () => setNeedFunds(undefined)
@@ -319,8 +314,8 @@ export default function InviteEmail() {
319314
320315 let unsentInvites = [];
321316 let orphanedInvites = [];
322− const txAndMailings = inputs.map(async input => {
323− const invite = invites[input.name];
317+ const txAndMailings = names.map(async name => {
318+ const invite = invites[name];
324319 try {
325320 const txHash = await sendSignedTransaction(
326321 _web3,
@@ -328,9 +323,7 @@ export default function InviteEmail() {
328323 tankWasUsed
329324 );
330325
331− // TODO: waitForTransactionConfirm never rejects
332− const didConfirm = await waitForTransactionConfirm(_web3, txHash);
333− if (!didConfirm) throw new Error();
326+ await waitForTransactionConfirm(_web3, txHash);
334327 } catch (error) {
335328 console.error(error);
336329 unsentInvites.push(invite);
@@ -338,19 +331,18 @@ export default function InviteEmail() {
338331 }
339332
340333 try {
341− const success = await sendMail(
334+ await sendMail(
342335 invite.email,
343336 invite.ticket,
344337 ob.patp(point),
345338 invite.rawTx
346339 );
347− if (!success) throw new Error('Failed to send mail');
348340 } catch (error) {
349341 console.error(error);
350342 orphanedInvites.push(invite);
351343 }
352344
353− addReceipt({ [input.name]: true });
345+ addReceipt({ [name]: true });
354346 });
355347
356348 await Promise.all(txAndMailings);
@@ -376,46 +368,48 @@ export default function InviteEmail() {
376368 }
377369 }, [
378370 web3,
371+ wallet,
372+ point,
379373 gasPrice,
380− inputs,
381− addReceipt,
382− clearReceipts,
383374 invites,
384− point,
385− wallet,
375+ clearReceipts,
376+ addReceipt,
386377 sendMail,
387378 ]);
388379
389− const onClick = useCallback(async () => {
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+
401+ const doSend = useCallback(async () => {
390402 setGeneralError(null);
391403 try {
392− if (canGenerate) {
393− setStatus(STATUS.GENERATING);
394− await generateInvites();
395− setStatus(STATUS.CAN_SEND);
396− } else if (canSend) {
397− setStatus(STATUS.SENDING);
398− await sendInvites();
399− setStatus(STATUS.SUCCESS);
400− }
404+ setStatus(STATUS.SENDING);
405+ await sendInvites();
406+ setStatus(STATUS.SUCCESS);
401407 } catch (error) {
402408 console.error(error);
403− setGeneralError(error);
409+ setGeneralError(error.message);
404410 setStatus(STATUS.FAILURE);
405411 }
406− }, [canGenerate, canSend, generateInvites, 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]);
412+ }, [sendInvites]);
419413
420414 // when we transition to done, sync invites because we just sent some
421415 // and therefore know that state has changed
@@ -425,109 +419,142 @@ export default function InviteEmail() {
425419 }
426420 }, [isDone, syncInvites, point]);
427421
428− useEffect(() => {
429− for (const input of inputs) {
430− if (input.pass) {
431− syncHasReceivedForEmail(input.data);
432− }
433− }
434− }, [inputs, syncHasReceivedForEmail]);
435−
436422 return (
437423 <Grid gap={3}>
438− <Grid.Item full as={Flex} justify="end">
439− {/* use hidden class instead of removing component from dom */}
440− {/* in order to avoid janky reflow */}
441− <IconButton
442− onClick={() => appendInput()}
443− disabled={!canAddInvite}
444− className={cn({ hidden: isDone })}
445− solid>
446− +
447− </IconButton>
448− </Grid.Item>
449−
450− {isDone && (
451− <>
452− <Grid.Item as={Text} className="f5" full>
453− <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
454− {pluralize(inputs.length, 'has', 'have')} been successfully sent
455− </Grid.Item>
456− {inputs.map(input => (
457− <Grid.Item as={HelpText} key={input.name} full>
458− {invites[input.name].email}
459− </Grid.Item>
460− ))}
461− </>
462− )}
463−
464− {!isDone && (
465− <>
466− {/* email inputs */}
467− {inputs.map((input, i) => {
468− const isFirst = i === 0;
469− return (
470− <Grid.Item
471− key={input.name}
472− as={Grid}
473− gap={3}
474− onMouseOver={() => setHovered({ [input.name]: true })}
475− onMouseLeave={() => setHovered({ [input.name]: false })}
476− full>
477− <Grid.Item
478− as={Input}
479− cols={[1, 11]}
480− {...input}
481− accessory={accessoryFor(input.name)}
482− />
483− {!isFirst &&
484− (input.focused || hovered[input.name]) &&
485− canInput && (
486− <Grid.Item
487− cols={[11, 13]}
488− justifySelf="end"
489− alignSelf="center">
490− <IconButton
491− onClick={() => removeInputAt(i)}
492− solid
493− secondary>
494− -
495− </IconButton>
496− </Grid.Item>
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+ <>
434+ <Grid.Item full as={Flex} justify="end">
435+ {/* use hidden class instead of removing component from dom */}
436+ {/* in order to avoid janky reflow */}
437+ <IconButton
438+ onClick={() => fields.push('')}
439+ disabled={!canInput || fields.length >= maxInvitesToSend}
440+ className={cn({ hidden: isDone })}
441+ solid>
442+ +
443+ </IconButton>
444+ </Grid.Item>
445+
446+ {isDone ? (
447+ <>
448+ <Grid.Item as={Text} className="f5" full>
449+ <Highlighted>
450+ {pluralize(sentInviteNames.length, 'invite')}
451+ </Highlighted>{' '}
452+ {pluralize(sentInviteNames.length, 'has', 'have')} been
453+ successfully sent
454+ </Grid.Item>
455+
456+ {sentInviteNames.map(name => (
457+ <Grid.Item as={HelpText} key={name} full>
458+ <Field name={name}>
459+ {({ input: { value } }) => value}
460+ </Field>
461+ </Grid.Item>
462+ ))}
463+ </>
464+ ) : (
465+ <>
466+ {fields.map((name, i) => {
467+ const isFirst = i === 0;
468+ return (
469+ <Grid.Item
470+ key={name}
471+ full
472+ as={Grid}
473+ gap={3}
474+ onMouseOver={() => setHovered({ [name]: true })}
475+ onMouseLeave={() => setHovered({ [name]: false })}>
476+ <Grid.Item
477+ cols={[1, 11]}
478+ as={EmailInput}
479+ name={name}
480+ label={isFirst ? 'Email Address' : undefined}
481+ accessory={accessoryFor(name)}
482+ />
483+ <Field name={name}>
484+ {({ meta: { active } }) => {
485+ return (
486+ <>
487+ {!isFirst &&
488+ (active || hovered[name]) &&
489+ canInput && (
490+ <Grid.Item
491+ cols={[11, 13]}
492+ justifySelf="end"
493+ alignSelf="center">
494+ <IconButton
495+ onClick={() => fields.remove(i)}
496+ solid
497+ secondary>
498+ -
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}
548+ </Grid.Item>
549+ )}
550+ </>
497551 )}
498− </Grid.Item>
499− );
500− })}
501−
502− <Grid.Item
503− full
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− )}
552+ </>
553+ );
554+ }}
555+ </FieldArray>
556+ )}
557+ </BridgeForm>
531558 </Grid>
532559 );
533560}
src/views/IssueChild.js+104−83
@@ -1,7 +1,6 @@
1−import React, { useCallback, useEffect, useMemo, useState } from 'react';
2−import { Nothing, Just } from 'folktale/maybe';
1+import React, { useCallback, useMemo, useState } from 'react';
32import cn from 'classnames';
4−import { Grid, Text, Input } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
54import * as azimuth from 'azimuth-js';
65import * as ob from 'urbit-ob';
76
@@ -10,18 +9,25 @@ import { usePointCache } from 'store/pointCache';
109import { usePointCursor } from 'store/pointCursor';
1110
1211import * as need from 'lib/need';
13−import { useAddressInput, usePointInput } from 'lib/useInputs';
1412import useEthereumTransaction from 'lib/useEthereumTransaction';
1513import { GAS_LIMITS } from 'lib/constants';
1614import patp2dec from 'lib/patp2dec';
17−import useLifecycle from 'lib/useLifecycle';
18−import { validateNameInNumberSet } from 'lib/validators';
1915import { getSpawnCandidate } from 'lib/child';
2016import { useLocalRouter } from 'lib/LocalRouter';
17+import useConstant from 'lib/useConstant';
2118
2219import ViewHeader from 'components/ViewHeader';
2320import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
2421import 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';
2531
2632function useIssueChild() {
2733 const { contracts } = useNetwork();
@@ -55,7 +61,11 @@ export default function IssueChild() {
5561 const _contracts = need.contracts(contracts);
5662 const _point = parseInt(need.point(pointCursor), 10);
5763
58− const [availablePoints, setAvailablePoints] = useState(Nothing());
64+ const availablePointsPromise = useConstant(() =>
65+ azimuth.azimuth
66+ .getUnspawnedChildren(_contracts, _point)
67+ .then(points => new Set(points))
68+ );
5969
6070 const candidates = useMemo(() => {
6171 const getCandidate = () => ob.patp(getSpawnCandidate(_point));
@@ -72,62 +82,51 @@ export default function IssueChild() {
7282 bind,
7383 } = useIssueChild();
7484
75− const validators = useMemo(
76− () => [
77− validateNameInNumberSet(
78− availablePoints.getOrElse(new Set()),
79− 'This point cannot be spawned.'
80− ),
81− ],
82− [availablePoints]
85+ const validateFormAsync = useCallback(
86+ async values => {
87+ const point = patp2dec(values.name);
88+ const hasPoint = (await availablePointsPromise).has(point);
89+
90+ if (!hasPoint) {
91+ return { point: 'This point cannot be spawned.' };
92+ }
93+ },
94+ [availablePointsPromise]
8395 );
84− const [
85− pointNameInput,
86− { pass: validPointName, value: pointName },
87− // ^ we use value: here so our effect runs onChange
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;
96+
97+ const validateForm = useCallback(
98+ (values, errors) => {
99+ if (hasErrors(errors)) {
100+ return errors;
124101 }
125102
126− setAvailablePoints(Just(new Set(availablePoints)));
127− })();
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]
118+ );
128119
129− return () => (mounted = false);
130− });
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]
129+ );
131130
132131 return (
133132 <View pop={pop} inset>
@@ -143,30 +142,52 @@ export default function IssueChild() {
143142 </Grid.Item>
144143 )}
145144
146− {completed && (
147− <Grid.Item
148− full
149− as={Text}
150− className={cn('f5', {
151− green3: completed,
152− })}>
153− {pointName} has been spawned and can be claimed by {owner}.
154− </Grid.Item>
155− )}
156−
157− {!completed && (
158− <>
159− <Grid.Item full as={Input} {...pointNameInput} className="mt4" />
160− <Grid.Item full as={Input} {...ownerInput} className="mb4" />
161− </>
162− )}
163−
164− <Grid.Item
165− full
166− as={InlineEthereumTransaction}
167− {...bind}
168− onReturn={() => pop()}
169− />
145+ <BridgeForm validate={validate} onValues={onValues}>
146+ {({ handleSubmit, values }) => (
147+ <>
148+ {completed && (
149+ <Grid.Item
150+ full
151+ as={Text}
152+ className={cn('f5', {
153+ green3: completed,
154+ })}>
155+ {values.point} has been spawned and can be claimed by{' '}
156+ {values.owner}.
157+ </Grid.Item>
158+ )}
159+
160+ {!completed && (
161+ <>
162+ <Grid.Item
163+ full
164+ as={PointInput}
165+ name="point"
166+ disabled={inputsLocked}
167+ className="mt4"
168+ />
169+ <Grid.Item
170+ full
171+ as={AddressInput}
172+ className="mb4"
173+ name="owner"
174+ label="Ethereum Address"
175+ disabled={inputsLocked}
176+ />
177+ </>
178+ )}
179+
180+ <Grid.Item full as={FormError} />
181+
182+ <Grid.Item
183+ full
184+ as={InlineEthereumTransaction}
185+ {...bind}
186+ onReturn={() => pop()}
187+ />
188+ </>
189+ )}
190+ </BridgeForm>
170191 </Grid>
171192 </View>
172193 );
src/views/Login.js+5−13
@@ -1,5 +1,4 @@
11import React, { useCallback, useState } from 'react';
2−import { Nothing } from 'folktale/maybe';
32import { H4, Grid } from 'indigo-react';
43
54import { useHistory } from 'store/history';
@@ -60,7 +59,7 @@ const walletTypeToViewName = walletType => {
6059export default function Login() {
6160 // globals
6261 const { pop, push, names } = useHistory();
63− const { wallet, walletType } = useWallet();
62+ const { walletType } = useWallet();
6463
6564 // inputs
6665 const [currentTab, setCurrentTab] = useState(
@@ -72,7 +71,7 @@ export default function Login() {
7271 names.ACTIVATE,
7372 ]);
7473
75− const doContinue = useCallback(() => {
74+ const goHome = useCallback(() => {
7675 push(names.POINTS);
7776 }, [push, names]);
7877
@@ -88,21 +87,14 @@ export default function Login() {
8887 full
8988 as={Tabs}
9089 className="mt1"
90+ // Tabs
9191 views={VIEWS}
9292 options={OPTIONS}
9393 currentTab={currentTab}
9494 onTabChange={setCurrentTab}
95+ // Tab extra
96+ goHome={goHome}
9597 />
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>
10698 </Grid>
10799
108100 <Footer>
src/views/Login/Advanced.js+2−2
@@ -20,7 +20,7 @@ const OPTIONS = [
2020 { text: 'Ethereum Keystore', value: NAMES.KEYSTORE },
2121];
2222
23−export default function Advanced({ loginCompleted, className }) {
23+export default function Advanced({ className, ...rest }) {
2424 const [currentTab, setCurrentTab] = useState(undefined);
2525
2626 return (
@@ -31,7 +31,7 @@ export default function Advanced({ loginCompleted, className }) {
3131 currentTab={currentTab}
3232 onTabChange={setCurrentTab}
3333 //
34− loginCompleted={loginCompleted}
34+ {...rest}
3535 />
3636 );
3737}
src/views/Login/Hardware.js+2−2
@@ -20,7 +20,7 @@ const OPTIONS = [
2020 { text: 'Trezor', value: NAMES.TREZOR },
2121];
2222
23−export default function Hardware({ loginCompleted, className }) {
23+export default function Hardware({ className, ...rest }) {
2424 const [currentTab, setCurrentTab] = useState(undefined);
2525
2626 return (
@@ -31,7 +31,7 @@ export default function Hardware({ loginCompleted, className }) {
3131 currentTab={currentTab}
3232 onTabChange={setCurrentTab}
3333 //
34− loginCompleted={loginCompleted}
34+ {...rest}
3535 />
3636 );
3737}
src/views/Login/Keystore.js+68−75
@@ -1,70 +1,58 @@
1−import React, { useState } from 'react';
2−import { Just, Nothing } from 'folktale/maybe';
3−import { P, Grid, Input, ErrorText } from 'indigo-react';
1+import React, { useCallback, useMemo } from 'react';
2+import { Just } from 'folktale/maybe';
3+import { P, Grid } from 'indigo-react';
44import * as keythereum from 'keythereum';
55
66import { useWallet } from 'store/wallet';
77
8−import { usePassphraseInput } from 'lib/useInputs';
9−import * as need from 'lib/need';
108import { EthereumWallet, WALLET_TYPES } from 'lib/wallet';
119import useLoginView from 'lib/useLoginView';
1210
13−import { ForwardButton } from 'components/Buttons';
14−import UploadButton from 'components/UploadButton';
15−
16−export default function Keystore({ className }) {
11+import { PassphraseInput } from 'form/Inputs';
12+import {
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+
23+export default function Keystore({ className, goHome }) {
1724 useLoginView(WALLET_TYPES.KEYSTORE);
1825
1926 // globals
2027 const { setWallet } = useWallet();
2128
22− const [error, setError] = useState();
23− // inputs
24− // keystore: Maybe<String>
25− const [keystore, setKeystore] = useState(Nothing());
26− const [passphraseInput, { data: passphrase }] = usePassphraseInput({
27− name: 'password',
28− label: 'Keystore password',
29− autoFocus: true,
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;
29+ const validate = useMemo(
30+ () =>
31+ composeValidator({
32+ passphrase: buildPassphraseValidator(),
33+ keystore: buildUploadValidator(),
34+ }),
35+ []
36+ );
6537
66− reader.readAsText(file);
67− };
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+ }
53+ },
54+ [setWallet]
55+ );
6856
6957 return (
7058 <Grid className={className}>
@@ -73,27 +61,32 @@ export default function Keystore({ className }) {
7361 encrypted with a password, you'll also need to enter that below.
7462 </Grid.Item>
7563
76− <Grid.Item full as={UploadButton} onChange={handleKeystoreUpload}>
77− Upload Keystore file
78− </Grid.Item>
79−
80− {error && (
81− <Grid.Item full as={ErrorText} className="mt1">
82− {error}
83− </Grid.Item>
84− )}
85−
86− <Grid.Item full as={Input} className="mt3" {...passphraseInput} />
87−
88− <Grid.Item
89− full
90− as={ForwardButton}
91− solid
92− className="mt3"
93− disabled={Nothing.hasInstance(keystore)}
94− onClick={constructWallet}>
95− Decrypt
96− </Grid.Item>
64+ <BridgeForm validate={validate} onSubmit={onSubmit} afterSubmit={goHome}>
65+ {({ handleSubmit }) => (
66+ <>
67+ <Grid.Item
68+ full
69+ as={UploadInput}
70+ name="keystore"
71+ label="Upload Keystore file"
72+ />
73+
74+ <Grid.Item
75+ full
76+ as={PassphraseInput}
77+ className="mt3"
78+ name="passphrase"
79+ label="Passphrase"
80+ />
81+
82+ <Grid.Item full as={FormError} />
83+
84+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
85+ Decrypt
86+ </Grid.Item>
87+ </>
88+ )}
89+ </BridgeForm>
9790 </Grid>
9891 );
9992}
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';
22import cn from 'classnames';
3−import { Just, Nothing } from 'folktale/maybe';
4−import {
5− P,
6− Text,
7− Input,
8− Grid,
9− H5,
10− CheckboxInput,
11− SelectInput,
12−} from 'indigo-react';
3+import { Just } from 'folktale/maybe';
4+import { P, Text, Grid, H5, CheckboxInput, SelectInput } from 'indigo-react';
135import { times } from 'lodash';
146import * as bip32 from 'bip32';
157import Transport from '@ledgerhq/hw-transport-u2f';
@@ -18,14 +10,6 @@ import * as secp256k1 from 'secp256k1';
1810
1911import { useWallet } from 'store/wallet';
2012
21−import { ForwardButton } from 'components/Buttons';
22−
23−import {
24− useCheckboxInput,
25− useHdPathInput,
26− useSelectInput,
27−} from 'lib/useInputs';
28−
2913import {
3014 LEDGER_LIVE_PATH,
3115 LEDGER_LEGACY_PATH,
@@ -35,6 +19,18 @@ import {
3519import { WALLET_TYPES } from 'lib/wallet';
3620import useLoginView from 'lib/useLoginView';
3721import 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';
3834
3935const PATH_OPTIONS = [
4036 { text: 'Ledger Live', value: LEDGER_LIVE_PATH },
@@ -46,74 +42,68 @@ const ACCOUNT_OPTIONS = times(20, i => ({
4642 value: i,
4743}));
4844
49−export default function Ledger({ className }) {
45+export default function Ledger({ className, goHome }) {
5046 useLoginView(WALLET_TYPES.LEDGER);
5147
5248 const { setWallet, setWalletHdPath } = useWallet();
5349
54− // derivation path input
55− const [derivationPathInput, { data: basePathPattern }] = useSelectInput({
56− name: 'derivationpath',
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,
68− });
69−
70− // custom toggle
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− });
88−
89− const pollDevice = useCallback(async () => {
90− const transport = await Transport.create();
91− const eth = new Eth(transport);
92− const path = chopHdPrefix(hdPath);
93−
94− try {
95− const info = await eth.getAddress(path, false, true);
96− const publicKey = Buffer.from(info.publicKey, 'hex');
97− const chainCode = Buffer.from(info.chainCode, 'hex');
98− const pub = secp256k1.publicKeyConvert(publicKey, true);
99− const hd = bip32.fromPublicKey(pub, chainCode);
100− setWallet(Just(hd));
101− setWalletHdPath(addHdPrefix(hdPath));
102− } catch (error) {
103− console.error(error);
104− setWallet(Nothing());
50+ const validate = useMemo(
51+ () =>
52+ composeValidator({
53+ useCustomPath: buildCheckboxValidator(),
54+ derivationpath: buildSelectValidator(PATH_OPTIONS),
55+ account: buildSelectValidator(ACCOUNT_OPTIONS),
56+ hdpath: buildHdPathValidator(),
57+ }),
58+ []
59+ );
60+
61+ const onSubmit = useCallback(
62+ async values => {
63+ const transport = await Transport.create();
64+ const eth = new Eth(transport);
65+ const path = chopHdPrefix(values.hdpath);
66+
67+ try {
68+ const info = await eth.getAddress(path, false, true);
69+ const publicKey = Buffer.from(info.publicKey, 'hex');
70+ const chainCode = Buffer.from(info.chainCode, 'hex');
71+ const pub = secp256k1.publicKeyConvert(publicKey, true);
72+ const hd = bip32.fromPublicKey(pub, chainCode);
73+
74+ setWallet(Just(hd));
75+ setWalletHdPath(addHdPrefix(values.hdpath));
76+ } catch (error) {
77+ return { [FORM_ERROR]: error.message };
78+ }
79+ },
80+ [setWallet, setWalletHdPath]
81+ );
82+
83+ const onValues = useCallback(({ valid, values, form }) => {
84+ if (!valid) {
85+ return;
10586 }
106− }, [hdPath, setWallet, setWalletHdPath]);
107−
108− // when the base path pattern or the account index changes
109− // update the hd path in our input
110− useEffect(() => {
111− if (useCustomPath) {
112− // updated by useForm
113− } else {
114− setHdPath(basePathPattern.replace(/x/g, accountIndex));
87+
88+ // when the base path pattern or the account index changes
89+ // update the hd path in our input
90+ if (!values.useCustomPath) {
91+ form.change(
92+ 'hdpath',
93+ values.derivationpath.replace(/x/g, values.account)
94+ );
11595 }
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+ );
117107
118108 const full = useBreakpoints([true, true, false]);
119109 const half = useBreakpoints([false, false, true]);
@@ -170,37 +160,58 @@ export default function Ledger({ className }) {
170160 enable the "contract data" option.
171161 </Grid.Item>
172162
173− {useCustomPath && <Grid.Item full as={Input} {...hdPathInput} />}
174−
175− {!useCustomPath && (
176− <>
177− <Grid.Item
178− full={full}
179− half={half && 1}
180− as={SelectInput}
181− className={cn({ pr1: half })}
182− {...derivationPathInput}
183− />
184− <Grid.Item
185− full={full}
186− half={half && 2}
187− as={SelectInput}
188− className={cn({ pl1: half })}
189− {...accountInput}
190− />
191− </>
192− )}
193−
194− <Grid.Item full as={CheckboxInput} className="mv3" {...customPathInput} />
195−
196− <Grid.Item
197− full
198− as={ForwardButton}
199− solid
200− className="mt2"
201− onClick={pollDevice}>
202− Authenticate
203− </Grid.Item>
163+ <BridgeForm
164+ validate={validate}
165+ onValues={onValues}
166+ onSubmit={onSubmit}
167+ afterSubmit={goHome}
168+ initialValues={initialValues}>
169+ {({ handleSubmit }) => (
170+ <>
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
177+ full={full}
178+ half={half && 1}
179+ as={SelectInput}
180+ className={cn({ pr1: half })}
181+ name="derivationpath"
182+ label="Derivation Path"
183+ placeholder="Choose path pattern..."
184+ options={PATH_OPTIONS}
185+ />
186+
187+ <Grid.Item
188+ full={full}
189+ half={half && 2}
190+ as={SelectInput}
191+ className={cn({ pl1: half })}
192+ name="account"
193+ label="Account"
194+ placeholder="Choose account..."
195+ options={ACCOUNT_OPTIONS}
196+ />
197+ </Condition>
198+
199+ <Grid.Item
200+ full
201+ as={CheckboxInput}
202+ className="mv3"
203+ name="useCustomPath"
204+ label="Custom HD Path"
205+ />
206+
207+ <Grid.Item full as={FormError} />
208+
209+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
210+ Authenticate
211+ </Grid.Item>
212+ </>
213+ )}
214+ </BridgeForm>
204215 </>
205216 );
206217
src/views/Login/Mnemonic.js+83−57
@@ -1,20 +1,26 @@
1−import React, { useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
22import cn from 'classnames';
33import { Just, Nothing } from 'folktale/maybe';
4−import { Grid, Input, CheckboxInput } from 'indigo-react';
4+import { Grid, CheckboxInput } from 'indigo-react';
55
66import { useWallet } from 'store/wallet';
77
8−import {
9− usePassphraseInput,
10− useMnemonicInput,
11− useHdPathInput,
12− useCheckboxInput,
13−} from 'lib/useInputs';
148import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet';
159import 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';
1622
17−export default function Mnemonic({ className }) {
23+export default function Mnemonic({ className, goHome }) {
1824 useLoginView(WALLET_TYPES.MNEMONIC);
1925
2026 const {
@@ -24,61 +30,81 @@ export default function Mnemonic({ className }) {
2430 setWalletHdPath,
2531 } = useWallet();
2632
27− const [advancedInput, { data: useAdvanced }] = useCheckboxInput({
28− name: 'advanced',
29− label: 'Passphrase & HD Path',
30− initialValue: false,
31− });
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',
41− label: 'Wallet Passphrase',
42− });
43−
44− const [hdPathInput, { data: hdPath }] = useHdPathInput({
45− name: 'hdpath',
46− label: 'HD Path',
47− initialValue: walletHdPath,
48− });
33+ const validate = useMemo(
34+ () =>
35+ composeValidator({
36+ useAdvanced: buildCheckboxValidator(),
37+ mnemonic: buildMnemonicValidator(),
38+ passphrase: buildPassphraseValidator(),
39+ hdpath: buildHdPathValidator(),
40+ }),
41+ []
42+ );
4943
5044 // when the properties change, re-derive wallet and set global state
51− useEffect(() => {
52− if (pass) {
53− setWalletHdPath(hdPath);
54− setAuthMnemonic(Just(mnemonic));
55− setWallet(walletFromMnemonic(mnemonic, hdPath, passphrase));
56− } else {
57− setAuthMnemonic(Nothing());
58− setWallet(Nothing());
59− }
60− }, [
61− pass,
62− mnemonic,
63− passphrase,
64− hdPath,
65− setWallet,
66− setAuthMnemonic,
67− setWalletHdPath,
68− ]);
45+ const onValues = useCallback(
46+ ({ valid, values }) => {
47+ if (valid) {
48+ setWalletHdPath(values.hdpath);
49+ setAuthMnemonic(Just(values.mnemonic));
50+ setWallet(
51+ walletFromMnemonic(values.mnemonic, values.hdpath, values.passphrase)
52+ );
53+ } else {
54+ setAuthMnemonic(Nothing());
55+ setWallet(Nothing());
56+ }
57+ },
58+ [setAuthMnemonic, setWallet, setWalletHdPath]
59+ );
60+
61+ const initialValues = useMemo(
62+ () => ({ hdpath: walletHdPath, useAdvanced: false }),
63+ [walletHdPath]
64+ );
6965
7066 return (
7167 <Grid className={cn('mt4', className)}>
72− <Grid.Item full as={Input} {...mnemonicInput} />
68+ <BridgeForm
69+ validate={validate}
70+ onValues={onValues}
71+ afterSubmit={goHome}
72+ initialValues={initialValues}>
73+ {({ handleSubmit }) => (
74+ <>
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+ />
7399
74− {useAdvanced && (
75− <>
76− <Grid.Item full as={Input} {...passphraseInput} />
77− <Grid.Item full as={Input} {...hdPathInput} />
78− </>
79− )}
100+ <Grid.Item full as={FormError} />
80101
81− <Grid.Item as={CheckboxInput} {...advancedInput} full />
102+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
103+ Continue
104+ </Grid.Item>
105+ </>
106+ )}
107+ </BridgeForm>
82108 </Grid>
83109 );
84110}
src/views/Login/PrivateKey.js+46−21
@@ -1,38 +1,63 @@
1−import React, { useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
22import { Just, Nothing } from 'folktale/maybe';
3−import { Grid, Input } from 'indigo-react';
3+import { Grid } from 'indigo-react';
44
55import { useWallet } from 'store/wallet';
66
7−import { useHexInput } from 'lib/useInputs';
87import { EthereumWallet, WALLET_TYPES, stripHexPrefix } from 'lib/wallet';
98import useLoginView from 'lib/useLoginView';
109
11−export default function PrivateKey({ className }) {
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+
17+export default function PrivateKey({ className, goHome }) {
1218 useLoginView(WALLET_TYPES.PRIVATE_KEY);
1319
1420 const { setWallet } = useWallet();
1521
16− const [privateKeyInput, { pass, data: privateKey }] = useHexInput({
17− length: 64,
18− name: 'privateKey',
19− label: 'Private key',
20− autoFocus: true,
21− });
22−
23− useEffect(() => {
24− if (pass) {
25− const sec = Buffer.from(stripHexPrefix(privateKey), 'hex');
26− const newWallet = new EthereumWallet(sec);
27− setWallet(Just(newWallet));
28− } else {
29− setWallet(Nothing());
30− }
31− }, [pass, privateKey, setWallet]);
22+ const validate = useMemo(
23+ () =>
24+ composeValidator({
25+ privateKey: buildHexValidator(64),
26+ }),
27+ []
28+ );
29+
30+ const onValues = useCallback(
31+ ({ valid, values }) => {
32+ if (valid) {
33+ const sec = Buffer.from(stripHexPrefix(values.privateKey), 'hex');
34+ const newWallet = new EthereumWallet(sec);
35+ setWallet(Just(newWallet));
36+ } else {
37+ setWallet(Nothing());
38+ }
39+ },
40+ [setWallet]
41+ );
3242
3343 return (
3444 <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>
3661 </Grid>
3762 );
3863}
src/views/Login/Ticket.js+175−156
@@ -1,187 +1,206 @@
1−import { Just, Nothing } from 'folktale/maybe';
1+import React, { useCallback, useMemo, useRef } from 'react';
2+import { Just } from 'folktale/maybe';
23import cn from 'classnames';
3−import React, { useCallback, useState, useEffect } from 'react';
44import * as azimuth from 'azimuth-js';
5−import * as ob from 'urbit-ob';
65import * 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';
88
99import { useNetwork } from 'store/network';
1010import { useWallet } from 'store/wallet';
1111import { usePointCursor } from 'store/pointCursor';
1212
13−import {
14− usePointInput,
15− useTicketInput,
16− usePassphraseInput,
17− useCheckboxInput,
18−} from 'lib/useInputs';
1913import * as need from 'lib/need';
2014import { WALLET_TYPES, urbitWalletFromTicket } from 'lib/wallet';
2115import useImpliedPoint from 'lib/useImpliedPoint';
2216import useLoginView from 'lib/useLoginView';
2317import patp2dec from 'lib/patp2dec';
18+import timeout from 'lib/timeout';
2419
25−export default function Ticket({ className }) {
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+
34+export default function Ticket({ className, goHome }) {
2635 useLoginView(WALLET_TYPES.TICKET);
2736
2837 const { contracts } = useNetwork();
2938 const { setUrbitWallet } = useWallet();
3039 const { setPointCursor } = usePointCursor();
3140 const impliedPoint = useImpliedPoint();
41+ const didWarn = useRef(false);
3242
33− // point
34− const [pointInput, { data: pointName }] = usePointInput({
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− }
43+ const validateForm = useCallback((values, errors) => {
44+ didWarn.current = false;
10245
103− const _contracts = need.contracts(contracts);
104− const point = patp2dec(pointName);
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− );
46+ if (errors.point) {
47+ return errors;
12448 }
12549
126− setUrbitWallet(Just(urbitWallet));
127− setPointCursor(Just(point));
128− setDeriving(false);
129− }, [
130− pointName,
131− ticket,
132− passphrase,
133− contracts,
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();
50+ if (values.useShards) {
51+ if (errors.shard1 || errors.shard2 || errors.shard3) {
52+ return errors;
53+ }
54+ } else {
55+ if (errors.ticket) {
56+ return errors;
57+ }
16058 }
161− }, [
162− isUsingShards,
163− validTicket,
164− shardsReady,
165− deriveWalletFromShards,
166− deriveWalletFromTicket,
167− ]);
59+ }, []);
60+
61+ const onSubmit = useCallback(
62+ async values => {
63+ const ticket = values.useShards
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]
140+ );
168141
169142 return (
170143 <Grid className={cn('mt4', className)}>
171− <Grid.Item full as={Input} {...pointInput} />
172−
173− {!isUsingShards && <Grid.Item full as={Input} {...ticketInput} />}
174− {isUsingShards && (
175− <>
176− <Grid.Item full as={Input} {...shard1Input} />
177− <Grid.Item full as={Input} {...shard2Input} />
178− <Grid.Item full as={Input} {...shard3Input} />
179− </>
180− )}
181− {hasPassphrase && <Grid.Item full as={Input} {...passphraseInput} />}
182−
183− <Grid.Item full as={CheckboxInput} {...hasPassphraseInput} />
184− <Grid.Item full as={CheckboxInput} {...shardsInput} />
144+ <BridgeForm
145+ validate={validate}
146+ onSubmit={onSubmit}
147+ afterSubmit={goHome}
148+ initialValues={initialValues}>
149+ {({ handleSubmit, submitting }) => (
150+ <>
151+ <Grid.Item full as={PointInput} name="point" />
152+
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>
201+ </>
202+ )}
203+ </BridgeForm>
185204 </Grid>
186205 );
187206}
src/views/Login/Trezor.js+102−80
@@ -1,30 +1,30 @@
1−import React, { useEffect } from 'react';
2−import { Just, Nothing } from 'folktale/maybe';
1+import React, { useCallback, useMemo } from 'react';
2+import { Just } from 'folktale/maybe';
33import * as bip32 from 'bip32';
44import { times } from 'lodash';
55import TrezorConnect from 'trezor-connect';
66import * 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';
158
169import { useWallet } from 'store/wallet';
1710
1811import { TREZOR_PATH } from 'lib/trezor';
1912import { WALLET_TYPES } from 'lib/wallet';
2013import useLoginView from 'lib/useLoginView';
21−import {
22− useHdPathInput,
23− useCheckboxInput,
24− useSelectInput,
25−} from 'lib/useInputs';
2614
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';
2828
2929const ACCOUNT_OPTIONS = times(20, i => ({
3030 text: `Account #${i + 1}`,
@@ -32,68 +32,65 @@ const ACCOUNT_OPTIONS = times(20, i => ({
3232}));
3333
3434// 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 }) {
3636 useLoginView(WALLET_TYPES.TREZOR);
3737
3838 const { setWallet, setWalletHdPath } = useWallet();
3939
40− // custom toggle
41− const [customPathInput, { data: useCustomPath }] = useCheckboxInput({
42− name: 'customPath',
43− label: 'Custom HD Path',
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,
54− });
55−
56− // hd path input
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− });
66−
67− const pollDevice = async () => {
68− TrezorConnect.manifest({
69− email: 'bridge-trezor@urbit.org',
70− appUrl: 'https://github.com/urbit/bridge',
71− });
72−
73− const info = await TrezorConnect.getPublicKey({
74− path: hdPath,
75− });
76−
77− if (info.success === true) {
78− const payload = info.payload;
40+ const validate = useMemo(
41+ () =>
42+ composeValidator({
43+ useCustomPath: buildCheckboxValidator(),
44+ account: buildSelectValidator(ACCOUNT_OPTIONS),
45+ hdpath: buildHdPathValidator(),
46+ }),
47+ []
48+ );
49+
50+ const onSubmit = useCallback(
51+ async values => {
52+ TrezorConnect.manifest({
53+ email: 'bridge-trezor@urbit.org',
54+ appUrl: 'https://github.com/urbit/bridge',
55+ });
56+
57+ const { success, payload } = await TrezorConnect.getPublicKey({
58+ path: values.hdpath,
59+ });
60+
61+ if (!success) {
62+ return { [FORM_ERROR]: 'Failed to authenticate with your Trezor.' };
63+ }
64+
7965 const publicKey = Buffer.from(payload.publicKey, 'hex');
8066 const chainCode = Buffer.from(payload.chainCode, 'hex');
8167 const pub = secp256k1.publicKeyConvert(publicKey, true);
8268 const hd = bip32.fromPublicKey(pub, chainCode);
69+
8370 setWallet(Just(hd));
84− setWalletHdPath(hdPath);
85− } else {
86− setWallet(Nothing());
71+ setWalletHdPath(values.hdPath);
72+ },
73+ [setWallet, setWalletHdPath]
74+ );
75+
76+ const onValues = useCallback(({ valid, values, form }) => {
77+ if (!valid) {
78+ return;
8779 }
88− };
8980
90− useEffect(() => {
91− if (useCustomPath) {
92− // updated by useForm
93− } else {
94− setHdPath(TREZOR_PATH.replace(/x/g, accountIndex));
81+ if (!values.useCustomPath) {
82+ form.change('hdpath', TREZOR_PATH.replace(/x/g, values.account));
9583 }
96− }, [useCustomPath, setHdPath, accountIndex]);
84+ }, []);
85+
86+ const initialValues = useMemo(
87+ () => ({
88+ useCustomPath: false,
89+ hdpath: TREZOR_PATH.replace(/x/g, ACCOUNT_OPTIONS[0].value),
90+ account: ACCOUNT_OPTIONS[0].value,
91+ }),
92+ []
93+ );
9794
9895 return (
9996 <Grid className={className}>
@@ -106,20 +103,45 @@ export default function Trezor({ className }) {
106103 derivation path, you may enter it below.
107104 </Grid.Item>
108105
109− {useCustomPath && <Grid.Item full as={Input} {...hdPathInput} />}
110−
111− {!useCustomPath && <Grid.Item full as={SelectInput} {...accountInput} />}
112−
113− <Grid.Item full as={CheckboxInput} className="mv3" {...customPathInput} />
114−
115− <Grid.Item
116− full
117− as={ForwardButton}
118− solid
119− className="mt2"
120− onClick={pollDevice}>
121− Authenticate
122− </Grid.Item>
106+ <BridgeForm
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
121+ as={SelectInput}
122+ name="account"
123+ label="Account"
124+ placeholder="Choose account..."
125+ options={ACCOUNT_OPTIONS}
126+ />
127+ </Condition>
128+
129+ <Grid.Item
130+ full
131+ as={CheckboxInput}
132+ className="mv3"
133+ name="useCustomPath"
134+ label="Custom HD Path"
135+ />
136+
137+ <Grid.Item full as={FormError} />
138+
139+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
140+ Authenticate
141+ </Grid.Item>
142+ </>
143+ )}
144+ </BridgeForm>
123145 </Grid>
124146 );
125147}
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';
22import cn from 'classnames';
3−import { Grid, Text, Input } from 'indigo-react';
3+import { Grid, Text } from 'indigo-react';
44import * as azimuth from 'azimuth-js';
55
66import { useNetwork } from 'store/network';
@@ -8,18 +8,26 @@ import { usePointCursor } from 'store/pointCursor';
88import { usePointCache } from 'store/pointCache';
99
1010import * as need from 'lib/need';
11−import { usePointInput, useNumberInput } from 'lib/useInputs';
1211import useEthereumTransaction from 'lib/useEthereumTransaction';
1312import { GAS_LIMITS } from 'lib/constants';
1413import { useLocalRouter } from 'lib/LocalRouter';
14+import useCurrentPermissions from 'lib/useCurrentPermissions';
15+import patp2dec from 'lib/patp2dec';
1516
1617import ViewHeader from 'components/ViewHeader';
1718import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
18−import patp2dec from 'lib/patp2dec';
1919import View from 'components/View';
20−import useCurrentPermissions from 'lib/useCurrentPermissions';
2120import WarningBox from 'components/WarningBox';
2221
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+
2331function useSetPoolSize() {
2432 const { contracts } = useNetwork();
2533 const { pointCursor } = usePointCursor();
@@ -60,31 +68,27 @@ export default function PartySetPoolSize() {
6068 bind,
6169 } = useSetPoolSize();
6270
63− const [poolOwnerInput, { pass: validPoint, data: poolOwner }] = usePointInput(
64− {
65− name: 'point',
66− label: 'Point',
67− disabled: inputsLocked,
68− }
71+ const validate = useMemo(
72+ () =>
73+ composeValidator({
74+ poolOwner: buildPointValidator(),
75+ poolSize: buildNumberValidator(0),
76+ }),
77+ []
6978 );
7079
71− const [
72− poolSizeInput,
73− { pass: validPoolSize, data: poolSize },
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);
84− } else {
85− unconstruct();
86− }
87− }, [poolOwner, construct, validPoint, validPoolSize, poolSize, unconstruct]);
80+ const onValues = useCallback(
81+ ({ valid, values }) => {
82+ if (valid) {
83+ construct(patp2dec(values.poolOwner), values.poolSize);
84+ } else {
85+ unconstruct();
86+ }
87+ },
88+ [construct, unconstruct]
89+ );
90+
91+ const initialValues = useMemo(() => ({ poolSize: 5 }), []);
8892
8993 return (
9094 <View pop={pop} inset>
@@ -92,6 +96,7 @@ export default function PartySetPoolSize() {
9296 <Grid.Item full as={ViewHeader}>
9397 Set Pool Size
9498 </Grid.Item>
99+
95100 {!spawnIsDelegatedSending && (
96101 <Grid.Item full as={WarningBox} className="mb4">
97102 The spawn proxy must be set to{' '}
@@ -101,30 +106,56 @@ export default function PartySetPoolSize() {
101106 for invitations to be available.
102107 </Grid.Item>
103108 )}
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− )}
121109
122− <Grid.Item
123− full
124− as={InlineEthereumTransaction}
125− {...bind}
126− onReturn={() => pop()}
127− />
110+ <BridgeForm
111+ validate={validate}
112+ onValues={onValues}
113+ initialValues={initialValues}>
114+ {({ handleSubmit, values }) => (
115+ <>
116+ <Grid.Item
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
152+ as={InlineEthereumTransaction}
153+ {...bind}
154+ onReturn={() => pop()}
155+ />
156+ </>
157+ )}
158+ </BridgeForm>
128159 </Grid>
129160 </View>
130161 );