on master help sign in
checkpoint: 90% done with inviteemail.js
commit21d1c95dff63a002b774ee2f1a5d460f7b19ced8 authorMatt Condon (shrugs) <mattgcondon@gmail.com> dateMon 5 Aug 2019, 23:46 (7 years ago) parentfdeb086
21 files changed+710−1073
package-lock.json+24−1 package.json+2 src/components/ViewHeader.js+1−1 src/form/BridgeForm.js+2−1 src/form/Inputs.js+36−7 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/useArray.js−48 src/lib/useConstant.js+13 src/lib/useInputs.js−152 src/lib/useMailer.js+16−29 src/views/Admin/AdminNetworkingKeys.js+3 src/views/Admin/AdminSetProxy.js+10−7 src/views/Admin/AdminTransfer.js+58−34 src/views/Admin/Reticket/ReticketVerify.js+4 src/views/CreateGalaxy.js+84−93 src/views/Invite/InviteEmail.js+287−299 src/views/IssueChild.js+91−84 src/views/Party/PartySetPoolSize.js+79−49
package-lock.json+24−1
@@ -2168,7 +2168,7 @@
21682168 "dev": true
21692169 },
21702170 "PaperCollateralRenderer": {
2171− "version": "github:urbit/PaperCollateralRenderer#27bd1381529618fb067a35575f8191d04ecfe10b",
2171+ "version": "github:urbit/PaperCollateralRenderer#8478aaf397e12769171b7b14332c47e4457e1d46",
21722172 "from": "github:urbit/PaperCollateralRenderer#rc-immediate",
21732173 "requires": {
21742174 "babel-polyfill": "^6.26.0",
@@ -6934,6 +6934,11 @@
69346934 "@babel/runtime": "^7.3.1"
69356935 }
69366936 },
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+ },
69376942 "final-form-set-field-data": {
69386943 "version": "1.0.2",
69396944 "resolved": "https://registry.npmjs.org/final-form-set-field-data/-/final-form-set-field-data-1.0.2.tgz",
@@ -14551,6 +14556,24 @@
1455114556 }
1455214557 }
1455314558 },
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+ },
1455414577 "react-hot-loader": {
1455514578 "version": "4.12.0",
1455614579 "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.0.tgz",
package.json+2
@@ -30,6 +30,7 @@
3030 "ethereumjs-tx": "^1.3.7",
3131 "file-saver": "^2.0.0",
3232 "final-form": "^4.18.2",
33+ "final-form-arrays": "^3.0.1",
3334 "final-form-set-field-data": "^1.0.2",
3435 "folktale": "^2.3.1",
3536 "jszip": "^3.1.5",
@@ -42,6 +43,7 @@
4243 "react-app-rewire-hot-loader": "^2.0.1",
4344 "react-dom": "^16.8.6",
4445 "react-final-form": "^6.3.0",
46+ "react-final-form-arrays": "^3.1.0",
4547 "react-hot-loader": "^4.12.0",
4648 "react-scripts": "3.0.1",
4749 "react-teleporter": "^1.1.0",
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.js+2−1
@@ -1,13 +1,14 @@
11import React from 'react';
22import { Form } from 'react-final-form';
33import setFieldData from 'final-form-set-field-data';
4+import arrayMutators from 'final-form-arrays';
45
56import ValuesHandler from './ValuesHandler';
67import ValidationPauser from './ValidationPauser';
78
89export default function BridgeForm({ children, onValues, ...rest }) {
910 return (
10− <Form mutators={{ setFieldData }} {...rest}>
11+ <Form mutators={{ setFieldData, ...arrayMutators }} {...rest}>
1112 {formProps => (
1213 <>
1314 <ValidationPauser />
src/form/Inputs.js+36−7
@@ -14,9 +14,11 @@ import {
1414 validateHexString,
1515 validateHexLength,
1616 validateEthereumAddress,
17+ validateGreaterThan,
18+ validateEmail,
1719} from 'lib/validators';
1820import { compose } from 'lib/lib';
19−import { prependSig } from 'lib/transformers';
21+import { prependSig, convertToNumber } from 'lib/transformers';
2022import { DEFAULT_HD_PATH } from 'lib/wallet';
2123import InputSigil from 'components/InputSigil';
2224
@@ -28,6 +30,7 @@ const PLACEHOLDER_TICKET = '~sampel-ticlyt-migfun-falmel';
2830const PLACEHOLDER_ADDRESS = '0x12345abcdeDB11D175F123F6891AA64F01c24F7d';
2931const PLACEHOLDER_PRIVATE_KEY =
3032 '0x12345abcdee6beb2f323fab48b432925c9785808d33a6ca6d7ba00b45e9370c3';
33+const PLACEHOLDER_EMAIL = 'Email Address';
3134
3235const buildValidator = (
3336 validators = [],
@@ -54,12 +57,11 @@ export const buildCheckboxValidator = mustBe =>
5457export const buildPassphraseValidator = () => buildValidator([]);
5558// TODO: validate hdpath format
5659export const buildHdPathValidator = () => buildValidator([validateNotEmpty]);
57−export const buildPointValidator = (size = 4) =>
58− buildValidator([
59− validatePoint,
60− validateMaximumPatpByteLength(size),
61− validateNotEmpty,
62− ]);
60+export const buildPointValidator = (size = 4, validate) =>
61+ buildValidator(
62+ [validatePoint, validateMaximumPatpByteLength(size), validateNotEmpty],
63+ validate
64+ );
6365export const buildSelectValidator = options =>
6466 buildValidator([validateOneOf(options.map(option => option.value))]);
6567export const buildHexValidator = length =>
@@ -76,6 +78,10 @@ export const buildAddressValidator = () =>
7678 validateHexString,
7779 validateNotEmpty,
7880 ]);
81+export const buildNumberValidator = (min = 0) =>
82+ buildValidator([validateGreaterThan(min)]);
83+export const buildEmailValidator = () =>
84+ buildValidator([validateEmail, validateNotEmpty]);
7985
8086// the default form validator just returns field-level validations
8187const kDefaultFormValidator = (values, errors) => errors;
@@ -227,3 +233,26 @@ export function AddressInput({ ...rest }) {
227233 />
228234 );
229235}
236+
237+export function NumberInput({ ...rest }) {
238+ return (
239+ <Input
240+ type="number"
241+ label="Number"
242+ autoComplete="off"
243+ config={{ format: convertToNumber }}
244+ {...rest}
245+ />
246+ );
247+}
248+
249+export function EmailInput({ ...rest }) {
250+ return (
251+ <Input
252+ type="email"
253+ placeholder={PLACEHOLDER_EMAIL}
254+ autoComplete="off"
255+ {...rest}
256+ />
257+ );
258+}
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/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/useInputs.jsdeleted−152
@@ -1,152 +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−const kPointTransformers = [prependSig];
61−export function usePointInput({ size = 4, validators = [], ...rest }) {
62− const _validators = useMemo(
63− () => [
64− ...validators,
65− validatePoint,
66− validateMaximumPatpByteLength(size),
67− validateNotEmpty,
68− ],
69− [size, validators]
70− );
71−
72− return useFirstOf(
73− useForm([
74− {
75− type: 'text',
76− label: 'Point',
77− placeholder: PLACEHOLDER_POINT,
78− validators: _validators,
79− transformers: kPointTransformers,
80− mono: true,
81− ...rest,
82− },
83− ]),
84− ({ error, pass, focused, value }) => ({
85− accessory: value ? (
86− <InputSigil
87− patp={value}
88− size={44}
89− margin={8}
90− pass={pass}
91− focused={focused}
92− error={error}
93− />
94− ) : null,
95− })
96− );
97−}
98−
99−export function useGalaxyInput(props) {
100− return usePointInput({
101− label: 'Galaxy Name',
102− size: 1,
103− ...props,
104− });
105−}
106−
107−const kAddressValidators = [
108− validateEthereumAddress,
109− validateNotNullAddress,
110− validateNotEmpty,
111−];
112−export function useAddressInput({ ...rest }) {
113− return useFirstOf(
114− useForm([
115− {
116− type: 'string',
117− label: 'Ethereum Address',
118− placeholder: PLACEHOLDER_ADDRESS,
119− autoComplete: 'off',
120− validators: kAddressValidators,
121− mono: true,
122− ...rest,
123− },
124− ])
125− );
126−}
127−
128−const kNumberTransformers = [convertToNumber];
129−const kNumberValidators = [validateGreaterThan(0)];
130−export function useNumberInput({ ...rest }) {
131− return useFirstOf(
132− useForm([
133− {
134− type: 'number',
135− label: 'Number',
136− autoComplete: 'off',
137− transformers: kNumberTransformers,
138− validators: kNumberValidators,
139− ...rest,
140− },
141− ])
142− );
143−}
144−
145−const kEmailValidators = [validateEmail, validateNotEmpty];
146−export const buildEmailInputConfig = extra => ({
147− type: 'email',
148− autoComplete: 'off',
149− validators: kEmailValidators,
150− initialValue: '',
151− ...extra,
152−});
src/lib/useMailer.js+16−29
@@ -1,43 +1,27 @@
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';
64
75const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true';
86
9−function useHasReceivedCache() {
10− const [cache, addToCache] = useSetState();
7+export default function useMailer(emails) {
8+ const cache = useRef({});
119
1210 const getHasReceived = useCallback(
13− email => cache[email] || Nothing(), //
14− [cache]
15− );
16−
17− const syncHasReceivedForEmail = useCallback(
1811 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) });
12+ if (!cache.current[email]) {
13+ if (STUB_MAILER) {
14+ cache.current[email] = false;
15+ } else {
16+ cache.current[email] = await hasReceived(email);
17+ }
2718 }
2819
29− const _hasReceived = await hasReceived(email);
30− addToCache({ [email]: Just(_hasReceived) });
20+ return cache.current[email];
3121 },
32− [getHasReceived, addToCache]
22+ [cache]
3323 );
3424
35− return { getHasReceived, syncHasReceivedForEmail };
36−}
37−
38−export default function useMailer(emails) {
39− const hasReceivedCache = useHasReceivedCache(emails);
40−
4125 // prefix to avoid clobbering sendMail import
4226 // also throws if return value is false
4327 const _sendMail = useCallback(async (email, ticket, sender, rawTx) => {
@@ -46,8 +30,11 @@ export default function useMailer(emails) {
4630 return true;
4731 }
4832
49− return await sendMail(email, ticket, sender, rawTx);
33+ const success = await sendMail(email, ticket, sender, rawTx);
34+ if (!success) {
35+ throw new Error('Failed to send mail');
36+ }
5037 }, []);
5138
52− return { ...hasReceivedCache, sendMail: _sendMail };
39+ return { getHasReceived, sendMail: _sendMail };
5340}
src/views/Admin/AdminNetworkingKeys.js+3
@@ -39,6 +39,7 @@ import {
3939} from 'form/Inputs';
4040import BridgeForm from 'form/BridgeForm';
4141import Condition from 'form/Condition';
42+import FormError from 'form/FormError';
4243
4344const chainKeyProp = name => d =>
4445 d[name] === CURVE_ZERO_ADDR ? Nothing() : Just(d[name]);
@@ -365,6 +366,8 @@ export default function AdminNetworkingKeys() {
365366 </BridgeForm>
366367 )}
367368
369+ <Grid.Item full as={FormError} />
370+
368371 <Grid.Item
369372 full
370373 as={InlineEthereumTransaction}
src/views/Admin/AdminSetProxy.js+10−7
@@ -28,6 +28,7 @@ import {
2828 AddressInput,
2929} from 'form/Inputs';
3030import BridgeForm from 'form/BridgeForm';
31+import FormError from 'form/FormError';
3132
3233const proxyFromDetails = (details, contracts, proxyType) => {
3334 switch (proxyType) {
@@ -218,14 +219,16 @@ export default function AdminSetProxy() {
218219 )}
219220 </>
220221 )}
221− </BridgeForm>
222222
223− <Grid.Item
224− full
225− as={InlineEthereumTransaction}
226− {...bind}
227− onReturn={() => pop()}
228− />
223+ <Grid.Item full as={FormError} />
224+
225+ <Grid.Item
226+ full
227+ as={InlineEthereumTransaction}
228+ {...bind}
229+ onReturn={() => pop()}
230+ />
231+ </BridgeForm>
229232 </Grid>
230233 );
231234}
src/views/Admin/AdminTransfer.js+58−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,19 @@ 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 {
19+ AddressInput,
20+ composeValidator,
21+ buildAddressValidator,
22+} from 'form/Inputs';
23+import BridgeForm from 'form/BridgeForm';
24+import FormError from 'form/FormError';
1925
2026function useTransfer() {
2127 const { contracts } = useNetwork();
@@ -47,46 +53,64 @@ export default function AdminTransfer() {
4753 bind,
4854 } = useTransfer();
4955
50− const [addressInput, { pass, data: address }] = useAddressInput({
51− name: 'address',
52− label: `Ethereum Address`,
53− disabled: inputsLocked,
54− });
56+ const validate = useMemo(
57+ () => composeValidator({ address: buildAddressValidator() }),
58+ []
59+ );
5560
56− useEffect(() => {
57− if (pass) {
58− construct(address);
59− } else {
60− unconstruct();
61− }
62− }, [pass, address, construct, unconstruct]);
61+ const onValues = useCallback(
62+ ({ valid, values }) => {
63+ if (valid) {
64+ construct(values.address);
65+ } else {
66+ unconstruct();
67+ }
68+ },
69+ [construct, unconstruct]
70+ );
6371
6472 return (
6573 <Grid>
6674 <Grid.Item full as={ViewHeader}>
6775 Transfer Point
6876 </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 Multipass themselves. Until they accept your transfer, you will still have ownership over ${name}.`
77− : `Transfer ${name} to a new owner.`}
78− </Grid.Item>
7977
80− {!completed && (
81− <Grid.Item full as={Input} {...addressInput} className="mv4" />
82− )}
78+ <BridgeForm validate={validate} onSubmit={() => {}} onValues={onValues}>
79+ {({ handleSubmit, values }) => (
80+ <>
81+ <Grid.Item
82+ full
83+ as={Text}
84+ className={cn('f5', {
85+ green3: completed,
86+ })}>
87+ {completed
88+ ? `${values.address} is now the Transfer Proxy for ${name} and can accept the transfer by logging into Multipass themselves. Until they accept your transfer, you will still have ownership over ${name}.`
89+ : `Transfer ${name} to a new owner.`}
90+ </Grid.Item>
91+
92+ {!completed && (
93+ <Grid.Item
94+ full
95+ as={AddressInput}
96+ className="mv4"
97+ name="address"
98+ label="Ethereum Address"
99+ disabled={inputsLocked}
100+ />
101+ )}
102+
103+ <Grid.Item full as={FormError} />
83104
84− <Grid.Item
85− full
86− as={InlineEthereumTransaction}
87− {...bind}
88− onReturn={() => pop()}
89− />
105+ <Grid.Item
106+ full
107+ as={InlineEthereumTransaction}
108+ {...bind}
109+ onReturn={() => pop()}
110+ />
111+ </>
112+ )}
113+ </BridgeForm>
90114 </Grid>
91115 );
92116}
src/views/Admin/Reticket/ReticketVerify.js+4
@@ -12,6 +12,7 @@ import {
1212 buildTicketValidator,
1313} from 'form/Inputs';
1414import SubmitButton from 'form/SubmitButton';
15+import FormError from 'form/FormError';
1516
1617const STUB_VERIFY_TICKET = isDevelopment;
1718
@@ -50,6 +51,9 @@ export default function ReticketVerify({ newWallet }) {
5051 name="ticket"
5152 label="New master ticket"
5253 />
54+
55+ <Grid.Item full as={FormError} />
56+
5357 <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
5458 Verify & Reticket
5559 </Grid.Item>
src/views/CreateGalaxy.js+84−93
@@ -1,5 +1,4 @@
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';
43import { Grid, Text, Input } from 'indigo-react';
54import * as azimuth from 'azimuth-js';
@@ -8,7 +7,6 @@ import { 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,14 @@ 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 {
21+ PointInput,
22+ composeValidator,
23+ buildPointValidator,
24+ buildAddressValidator,
25+} from 'form/Inputs';
26+import FormError from 'form/FormError';
2127
2228function useCreateGalaxy() {
2329 const { contracts } = useNetwork();
@@ -45,9 +51,6 @@ export default function CreateGalaxy() {
4551 const { contracts } = useNetwork();
4652 const _contracts = need.contracts(contracts);
4753
48− const [error, setError] = useState();
49− const [isAvailable, setIsAvailable] = useState(Nothing());
50−
5154 const {
5255 construct,
5356 unconstruct,
@@ -56,75 +59,40 @@ export default function CreateGalaxy() {
5659 bind,
5760 } = useCreateGalaxy();
5861
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));
62+ const validateGalaxy = useCallback(
63+ async galaxyName => {
64+ const currentOwner = await azimuth.azimuth.getOwner(
65+ _contracts,
66+ patp2dec(galaxyName)
67+ );
68+
69+ const isAvailable = isZeroAddress(currentOwner);
70+ if (!isAvailable) {
71+ return 'This galaxy is already spawned and owned.';
11772 }
118− })();
73+ },
74+ [_contracts]
75+ );
11976
120− return () => (cancelled = true);
121− }, [
122− _contracts,
123− galaxyName,
124− inputsLocked,
125− setIsAvailable,
126− syncValidGalaxyName,
127− ]);
77+ const validate = useMemo(
78+ () =>
79+ composeValidator({
80+ galaxyName: buildPointValidator(1, validateGalaxy),
81+ owner: buildAddressValidator(),
82+ }),
83+ [validateGalaxy]
84+ );
85+
86+ const onValues = useCallback(
87+ ({ valid, values }) => {
88+ if (valid) {
89+ construct(patp2dec(values.galaxyName), values.owner);
90+ } else {
91+ unconstruct();
92+ }
93+ },
94+ [construct, unconstruct]
95+ );
12896
12997 return (
13098 <View pop={pop} inset>
@@ -133,26 +101,49 @@ export default function CreateGalaxy() {
133101 Create a Galaxy
134102 </Grid.Item>
135103
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− />
104+ <BridgeForm validate={validate} onSubmit={() => {}} onValues={onValues}>
105+ {({ handleSubmit, values }) => (
106+ <>
107+ {completed && (
108+ <Grid.Item
109+ full
110+ as={Text}
111+ className={cn('f5', {
112+ green3: completed,
113+ })}>
114+ {values.galaxyName} has been created and can be claimed by{' '}
115+ {values.owner}.
116+ </Grid.Item>
117+ )}
118+
119+ <Grid.Item
120+ full
121+ as={PointInput}
122+ className="mt4"
123+ name="galaxyName"
124+ label="Galaxy Name"
125+ disabled={inputsLocked}
126+ />
127+ <Grid.Item
128+ full
129+ as={Input}
130+ className="mb4"
131+ name="owner"
132+ label="Ethereum Address"
133+ disabled={inputsLocked}
134+ />
135+
136+ <Grid.Item full as={FormError} />
137+
138+ <Grid.Item
139+ full
140+ as={InlineEthereumTransaction}
141+ {...bind}
142+ onReturn={() => pop()}
143+ />
144+ </>
145+ )}
146+ </BridgeForm>
156147 </Grid>
157148 </View>
158149 );
src/views/Invite/InviteEmail.js+287−299
@@ -1,20 +1,19 @@
1−import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+import React, { useCallback, useEffect, useRef, useState } from 'react';
22import cn from 'classnames';
33import * as ob from 'urbit-ob';
44import * as azimuth from 'azimuth-js';
55import {
66 Grid,
77 Flex,
8− Input,
98 IconButton,
109 HelpText,
1110 Text,
1211 ErrorText,
1312 AccessoryIcon,
14− useForm,
1513} from 'indigo-react';
1614import { uniq } from 'lodash';
1715import { fromWei, toWei } from 'web3-utils';
16+import { FieldArray } from 'react-final-form-arrays';
1817
1918import { usePointCursor } from 'store/pointCursor';
2019import { useNetwork } from 'store/network';
@@ -29,8 +28,6 @@ import {
2928 hexify,
3029} from 'lib/txn';
3130import * as tank from 'lib/tank';
32−import useArray from 'lib/useArray';
33−import { buildEmailInputConfig } from 'lib/useInputs';
3431import { MIN_PLANET, GAS_LIMITS, DEFAULT_GAS_PRICE_GWEI } from 'lib/constants';
3532import * as need from 'lib/need';
3633import * as wg from 'lib/walletgen';
@@ -40,6 +37,14 @@ import useMailer from 'lib/useMailer';
4037
4138import LoadableButton from 'components/LoadableButton';
4239import Highlighted from 'components/Highlighted';
40+import BridgeForm from 'form/BridgeForm';
41+import { Field } from 'react-final-form';
42+import { EmailInput, buildEmailValidator } from 'form/Inputs';
43+import { FORM_ERROR } from 'final-form';
44+import SubmitButton from 'form/SubmitButton';
45+import FormError from 'form/FormError';
46+
47+const INITIAL_VALUES = { emails: [''] };
4348
4449const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
4550const INVITE_COST = toWei(
@@ -76,21 +81,14 @@ const buttonText = (status, count) => {
7681 }
7782};
7883
79−// world's simplest uid
80−let id = 0;
81−const buildInputConfig = (extra = {}) =>
82− buildEmailInputConfig({
83− name: `email-${id++}`,
84− placeholder: 'Email Address',
85− ...extra,
86− });
87−
8884const buildAccessoryFor = (dones, errors) => name => {
8985 if (dones[name]) return <AccessoryIcon.Success />;
9086 if (errors[name]) return <AccessoryIcon.Failure />;
9187 return <AccessoryIcon.Pending />;
9288};
9389
90+const emailFormatValidator = buildEmailValidator();
91+
9492// TODO: test with tank, successful txs
9593export default function InviteEmail() {
9694 // TODO: resumption after error?
@@ -99,7 +97,9 @@ export default function InviteEmail() {
9997 const { syncInvites, getInvites } = usePointCache();
10098 const { pointCursor } = usePointCursor();
10199 const point = need.point(pointCursor);
102− const { getHasReceived, syncHasReceivedForEmail, sendMail } = useMailer();
100+ const { getHasReceived, sendMail } = useMailer();
101+
102+ const cachedEmails = useRef([]);
103103
104104 const { availableInvites } = getInvites(point);
105105 const maxInvitesToSend = availableInvites.matchWith({
@@ -107,15 +107,6 @@ export default function InviteEmail() {
107107 Just: p => p.value,
108108 });
109109
110− // manage the array of input configs
111− const [
112− inputConfigs,
113− { append: appendInput, removeAt: removeInputAt },
114− ] = useArray(
115− [buildInputConfig({ label: 'Email Address', autoFocus: true })],
116− buildInputConfig
117− );
118−
119110 // manage per-input state
120111 const [hovered, setHovered] = useSetState();
121112 const [invites, addInvite, clearInvites] = useSetState();
@@ -135,30 +126,40 @@ export default function InviteEmail() {
135126 const isFailed = status === STATUS.FAILURE;
136127 const isDone = status === STATUS.SUCCESS;
137128
138− // add disabled, error info to input configs
139− const dynamicConfigs = useMemo(
140− () =>
141− inputConfigs.map(config => {
142− config.disabled = !canInput;
143− const hasReceivedError = getHasReceived(config.name).matchWith({
144− Nothing: () => null, // loading
145− Just: p => p.value && HAS_RECEIVED_TEXT,
146− });
147− config.error = hasReceivedError || errors[config.name];
148− return config;
149− }),
150− [inputConfigs, errors, canInput, getHasReceived]
151− );
129+ const validate = useCallback(
130+ async values => {
131+ // compute the list of valid emails
132+ const emails = values.emails.filter(d => !!d);
133+ if (uniq(emails).length !== emails.length) {
134+ return { [FORM_ERROR]: 'Duplicate email.' };
135+ }
152136
153− // construct the state of the set of inputs we're rendering below
154− const { inputs, pass } = useForm(dynamicConfigs);
155− // did all of the inputs pass inspection (and there are no general errors)?
156− const allPass = pass && !generalError;
157− // the form is submittable iff passing and input is allowed
158− const canGenerate = allPass && canInput;
159− // additional inputs can be added iff input is allowed and we have enough
160− // invites to send
161− const canAddInvite = canInput && inputConfigs.length < maxInvitesToSend;
137+ let errors = {};
138+ for (let i = 0; i < values.emails.length; i++) {
139+ const name = `emails[${i}]`;
140+ const email = values.emails[i];
141+
142+ // check individual email validity
143+ // TODO: validate each email individually using similar pattern from form/Inputs
144+ // and then reutrn that
145+ const error = await emailFormatValidator(email);
146+ if (error) {
147+ errors[name] = error;
148+ continue;
149+ }
150+
151+ const hasReceived = await getHasReceived(email);
152+ if (hasReceived) {
153+ errors[name] = HAS_RECEIVED_TEXT;
154+ continue;
155+ }
156+ }
157+
158+ console.log(errors);
159+ return errors;
160+ },
161+ [getHasReceived]
162+ );
162163
163164 // progress is [0, .length] of invites or receipts, as we're generating them
164165 const progress = isGenerating
@@ -176,125 +177,102 @@ export default function InviteEmail() {
176177 return () => null;
177178 })();
178179
179− const generateInvites = useCallback(async () => {
180− const _contracts = contracts.getOrElse(null);
181− const _web3 = web3.getOrElse(null);
182− const _wallet = wallet.getOrElse(null);
183− if (!_contracts || !_web3 || !_wallet) {
184− // not using need because we want a custom error
185− throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
186− }
187−
188− //TODO want to do this on-input, but that gets weird. see #188
189− let knowAll = true;
190− let alreadyReceived = [];
191− await Promise.all(
192− inputs.map(async input => {
193− const email = input.data;
194− getHasReceived(email).matchWith({
195− Nothing: () => {
196− knowAll = false;
197− },
198− Just: p => {
199− if (p.value) alreadyReceived.push(email);
200− },
201− });
202− })
203− );
204− if (!knowAll) {
205− throw new Error('No word yet from email service...');
206− }
207− if (alreadyReceived.length > 0) {
208− throw new Error(
209− 'The following recipients already own a point: ' +
210− alreadyReceived.join(', ')
211− );
212− }
213−
214− const nonce = await _web3.eth.getTransactionCount(_wallet.address);
215− const chainId = await _web3.eth.net.getId();
216− const planets = await azimuth.delegatedSending.getPlanetsToSend(
217− _contracts,
218− point,
219− inputs.length
220− );
221−
222− // account for the race condition where invites got used up while we were
223− // composing our target list
224− if (planets.length < inputs.length) {
225− // resync invites to the cache, since they're out of date
226− syncInvites(point);
180+ const generateInvites = useCallback(
181+ async values => {
182+ debugger;
183+ const _contracts = contracts.getOrElse(null);
184+ const _web3 = web3.getOrElse(null);
185+ const _wallet = wallet.getOrElse(null);
186+ if (!_contracts || !_web3 || !_wallet) {
187+ // not using need because we want a custom error
188+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
189+ }
227190
228− throw new Error(
229− `Can currently only send ${planets.length} invites. ` +
230− `Please remove invites until you are within the limit.`
191+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
192+ const chainId = await _web3.eth.net.getId();
193+ const planets = await azimuth.delegatedSending.getPlanetsToSend(
194+ _contracts,
195+ point,
196+ values.emails.length
231197 );
232− }
233198
234− clearInvites();
235− // NB(shrugs) - must be processed in serial because main thread, etc
236− let errorCount = 0;
237− for (let i = 0; i < inputs.length; i++) {
238− const input = inputs[i];
239− try {
240− const { data: email, name } = inputs[i];
199+ // account for the race condition where invites got used up while we were
200+ // composing our target list
201+ if (planets.length < values.emails.length) {
202+ // resync invites to the cache, since they're out of date
203+ syncInvites(point);
204+
205+ return {
206+ [FORM_ERROR]:
207+ `Can currently only send ${planets.length} invites. ` +
208+ `Please remove invites until you are within the limit.`,
209+ };
210+ }
241211
242− const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
243− MIN_PLANET
244− // we're always giving planets, so generate a ticket of the correct size
245− );
212+ clearInvites();
213+ // NB(shrugs) - must be processed in serial because main thread, etc
214+ let errorCount = 0;
215+ for (let i = 0; i < values.emails.length; i++) {
216+ try {
217+ const email = values.emails[i];
218+
219+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
220+ MIN_PLANET
221+ // we're always giving planets, so generate a ticket of the correct size
222+ );
223+
224+ const inviteTx = azimuth.delegatedSending.sendPoint(
225+ _contracts,
226+ point,
227+ planets[i],
228+ owner.keys.address
229+ );
230+ const signedTx = await signTransaction({
231+ wallet: _wallet,
232+ walletType,
233+ walletHdPath,
234+ networkType,
235+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
236+ txn: inviteTx,
237+ gasPrice: DEFAULT_GAS_PRICE_GWEI.toString(),
238+ gasLimit: GAS_LIMIT.toString(),
239+ nonce: nonce + i,
240+ chainId,
241+ });
242+ const rawTx = hexify(signedTx.serialize());
243+
244+ addInvite({ [email]: { email, ticket, signedTx, rawTx } });
245+ } catch (error) {
246+ console.error(error);
247+ errorCount++;
248+ return {
249+ [FORM_ERROR]: `Wallet Error: unable to generate invite wallets.`,
250+ };
251+ }
252+ }
246253
247− const inviteTx = azimuth.delegatedSending.sendPoint(
248− _contracts,
249− point,
250− planets[i],
251− owner.keys.address
254+ if (errorCount > 0) {
255+ throw new Error(
256+ `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
257+ errorCount,
258+ 'error'
259+ )} while generating wallets.`
252260 );
253− const signedTx = await signTransaction({
254− wallet: _wallet,
255− walletType,
256− walletHdPath,
257− networkType,
258− // TODO: ^ make a useTransactionSigner to encapsulate this logic
259− txn: inviteTx,
260− gasPrice: DEFAULT_GAS_PRICE_GWEI.toString(),
261− gasLimit: GAS_LIMIT.toString(),
262− nonce: nonce + i,
263− chainId,
264− });
265− const rawTx = hexify(signedTx.serialize());
266−
267− addInvite({ [name]: { email, ticket, signedTx, rawTx } });
268− } catch (error) {
269− console.error(error);
270− errorCount++;
271− addError({ [input.name]: `Wallet Error: ${input.email}` });
272261 }
273− }
274−
275− if (errorCount > 0) {
276− throw new Error(
277− `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
278− errorCount,
279− 'error'
280− )} while generating wallets.`
281− );
282− }
283− }, [
284− contracts,
285− web3,
286− wallet,
287− inputs,
288− point,
289− clearInvites,
290− getHasReceived,
291− addError,
292− syncInvites,
293− walletType,
294− walletHdPath,
295− networkType,
296− addInvite,
297− ]);
262+ },
263+ [
264+ contracts,
265+ web3,
266+ wallet,
267+ point,
268+ clearInvites,
269+ syncInvites,
270+ walletType,
271+ walletHdPath,
272+ networkType,
273+ addInvite,
274+ ]
275+ );
298276
299277 const sendInvites = useCallback(async () => {
300278 const _web3 = web3.getOrElse(null);
@@ -308,8 +286,8 @@ export default function InviteEmail() {
308286 _web3,
309287 point,
310288 _wallet.address,
311− INVITE_COST * inputs.length,
312− inputs.map(input => invites[input.name].rawTx),
289+ INVITE_COST * cachedEmails.current.length,
290+ cachedEmails.current.map(email => invites[email].rawTx),
313291 (address, minBalance, balance) =>
314292 setNeedFunds({ address, minBalance, balance }),
315293 () => setNeedFunds(undefined)
@@ -320,8 +298,8 @@ export default function InviteEmail() {
320298
321299 let unsentInvites = [];
322300 let orphanedInvites = [];
323− const txAndMailings = inputs.map(async input => {
324− const invite = invites[input.name];
301+ const txAndMailings = cachedEmails.current.map(async email => {
302+ const invite = invites[email];
325303 try {
326304 const txHash = await sendSignedTransaction(
327305 _web3,
@@ -339,19 +317,18 @@ export default function InviteEmail() {
339317 }
340318
341319 try {
342− const success = await sendMail(
320+ await sendMail(
343321 invite.email,
344322 invite.ticket,
345323 ob.patp(point),
346324 invite.rawTx
347325 );
348− if (!success) throw new Error('Failed to send mail');
349326 } catch (error) {
350327 console.error(error);
351328 orphanedInvites.push(invite);
352329 }
353330
354− addReceipt({ [input.name]: true });
331+ addReceipt({ [email]: true });
355332 });
356333
357334 await Promise.all(txAndMailings);
@@ -375,47 +352,30 @@ export default function InviteEmail() {
375352 if (errorString !== '') {
376353 throw new Error(errorString);
377354 }
378− }, [
379− web3,
380− inputs,
381− addReceipt,
382− clearReceipts,
383− invites,
384− point,
385− wallet,
386− sendMail,
387− ]);
388−
389− const onClick = useCallback(async () => {
355+ }, [web3, addReceipt, clearReceipts, invites, point, wallet, sendMail]);
356+
357+ const onSubmit = useCallback(
358+ async values => {
359+ setStatus(STATUS.GENERATING);
360+ await generateInvites(values);
361+ cachedEmails.current = values.emails;
362+ setStatus(STATUS.CAN_SEND);
363+ },
364+ [generateInvites]
365+ );
366+
367+ const doSend = useCallback(async () => {
390368 setGeneralError(null);
391369 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− }
370+ setStatus(STATUS.SENDING);
371+ await sendInvites();
372+ setStatus(STATUS.SUCCESS);
401373 } catch (error) {
402374 console.error(error);
403375 setGeneralError(error);
404376 setStatus(STATUS.FAILURE);
405377 }
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]);
378+ }, [sendInvites]);
419379
420380 // when we transition to done, sync invites because we just sent some
421381 // and therefore know that state has changed
@@ -425,109 +385,137 @@ export default function InviteEmail() {
425385 }
426386 }, [isDone, syncInvites, point]);
427387
428− useEffect(() => {
429− for (const input of inputs) {
430− if (input.pass) {
431− syncHasReceivedForEmail(input.data);
432− }
433− }
434− }, [inputs, syncHasReceivedForEmail]);
435−
436388 return (
437389 <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 && (
390+ <BridgeForm
391+ validate={validate}
392+ onSubmit={onSubmit}
393+ initialValues={INITIAL_VALUES}>
394+ {({ handleSubmit, valid, values }) => (
395+ <FieldArray name="emails">
396+ {({ fields }) => (
397+ <>
398+ <Grid.Item full as={Flex} justify="end">
399+ {/* use hidden class instead of removing component from dom */}
400+ {/* in order to avoid janky reflow */}
401+ <IconButton
402+ onClick={() => fields.push('')}
403+ disabled={!canInput || fields.length >= maxInvitesToSend}
404+ className={cn({ hidden: isDone })}
405+ solid>
406+ +
407+ </IconButton>
408+ </Grid.Item>
409+
410+ {isDone && (
411+ <>
412+ <Grid.Item as={Text} className="f5" full>
413+ <Highlighted>
414+ {pluralize(fields.length, 'invite')}
415+ </Highlighted>{' '}
416+ {pluralize(fields.length, 'has', 'have')} been
417+ successfully sent
418+ </Grid.Item>
419+
420+ {fields.map(name => (
421+ <Grid.Item as={HelpText} key={name} full>
422+ <Field name={name}>{({ value }) => value}</Field>
423+ </Grid.Item>
424+ ))}
425+ </>
426+ )}
427+
428+ {!isDone && (
429+ <>
430+ {fields.map((name, i) => {
431+ const isFirst = i === 0;
432+ return (
433+ <Grid.Item
434+ key={name}
435+ full
436+ as={Grid}
437+ gap={3}
438+ onMouseOver={() => setHovered({ [name]: true })}
439+ onMouseLeave={() => setHovered({ [name]: false })}>
440+ <Grid.Item
441+ cols={[1, 11]}
442+ as={EmailInput}
443+ name={name}
444+ label={isFirst ? 'Email Address' : undefined}
445+ accessory={accessoryFor(name)}
446+ />
447+ <Field name={name}>
448+ {({ meta: { active } }) => {
449+ return (
450+ <>
451+ {!isFirst &&
452+ (active || hovered[name]) &&
453+ canInput && (
454+ <Grid.Item
455+ cols={[11, 13]}
456+ justifySelf="end"
457+ alignSelf="center">
458+ <IconButton
459+ onClick={() => fields.remove(i)}
460+ solid
461+ secondary>
462+ -
463+ </IconButton>
464+ </Grid.Item>
465+ )}
466+ </>
467+ );
468+ }}
469+ </Field>
470+ </Grid.Item>
471+ );
472+ })}
473+
474+ {canInput && (
475+ <Grid.Item
476+ full
477+ as={SubmitButton}
478+ handleSubmit={handleSubmit}>
479+ {buttonText(status, fields.length)}
480+ </Grid.Item>
481+ )}
482+
486483 <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>
484+ full
485+ as={LoadableButton}
486+ disabled={!(canInput || canSend || valid)}
487+ accessory={`${visualProgress}/${fields.length}`}
488+ onClick={doSend}
489+ success={canSend}
490+ solid>
491+ {buttonText(status, fields.length)}
496492 </Grid.Item>
497− )}
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− )}
493+
494+ {needFunds && (
495+ <Grid.Item full>
496+ <Highlighted warning>
497+ Your ownership address {needFunds.address} needs at
498+ least {fromWei(needFunds.minBalance)} ETH and
499+ currently has {fromWei(needFunds.balance)} ETH.
500+ Waiting until the account has enough funds.
501+ </Highlighted>
502+ </Grid.Item>
503+ )}
504+
505+ <Grid.Item full as={FormError} />
506+
507+ {generalError && (
508+ <Grid.Item full>
509+ <ErrorText>{generalError.message.toString()}</ErrorText>
510+ </Grid.Item>
511+ )}
512+ </>
513+ )}
514+ </>
515+ )}
516+ </FieldArray>
517+ )}
518+ </BridgeForm>
531519 </Grid>
532520 );
533521}
src/views/IssueChild.js+91−84
@@ -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 {
23+ composeValidator,
24+ buildPointValidator,
25+ PointInput,
26+ AddressInput,
27+ buildAddressValidator,
28+} from 'form/Inputs';
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,37 @@ 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]
83− );
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;
85+ const validatePoint = useCallback(
86+ async name => {
87+ const point = patp2dec(name);
88+ const hasPoint = (await availablePointsPromise).has(point);
89+
90+ if (!hasPoint) {
91+ return 'This point cannot be spawned.';
12492 }
93+ },
94+ [availablePointsPromise]
95+ );
12596
126− setAvailablePoints(Just(new Set(availablePoints)));
127− })();
97+ const validate = useMemo(
98+ () =>
99+ composeValidator({
100+ point: buildPointValidator(4, validatePoint),
101+ owner: buildAddressValidator(),
102+ }),
103+ [validatePoint]
104+ );
128105
129− return () => (mounted = false);
130− });
106+ const onValues = useCallback(
107+ ({ valid, values }) => {
108+ if (valid) {
109+ construct(patp2dec(values.point), values.owner);
110+ } else {
111+ unconstruct();
112+ }
113+ },
114+ [construct, unconstruct]
115+ );
131116
132117 return (
133118 <View pop={pop} inset>
@@ -143,30 +128,52 @@ export default function IssueChild() {
143128 </Grid.Item>
144129 )}
145130
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− />
131+ <BridgeForm validate={validate} onSubmit={() => {}} onValues={onValues}>
132+ {({ handleSubmit, values }) => (
133+ <>
134+ {completed && (
135+ <Grid.Item
136+ full
137+ as={Text}
138+ className={cn('f5', {
139+ green3: completed,
140+ })}>
141+ {values.point} has been spawned and can be claimed by{' '}
142+ {values.owner}.
143+ </Grid.Item>
144+ )}
145+
146+ {!completed && (
147+ <>
148+ <Grid.Item
149+ full
150+ as={PointInput}
151+ name="point"
152+ disabled={inputsLocked}
153+ className="mt4"
154+ />
155+ <Grid.Item
156+ full
157+ as={AddressInput}
158+ className="mb4"
159+ name="owner"
160+ label="Ethereum Address"
161+ disabled={inputsLocked}
162+ />
163+ </>
164+ )}
165+
166+ <Grid.Item full as={FormError} />
167+
168+ <Grid.Item
169+ full
170+ as={InlineEthereumTransaction}
171+ {...bind}
172+ onReturn={() => pop()}
173+ />
174+ </>
175+ )}
176+ </BridgeForm>
170177 </Grid>
171178 </View>
172179 );
src/views/Party/PartySetPoolSize.js+79−49
@@ -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,7 +8,6 @@ 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';
@@ -19,6 +18,15 @@ import patp2dec from 'lib/patp2dec';
1918import View from 'components/View';
2019import useCurrentPermissions from 'lib/useCurrentPermissions';
2120import WarningBox from 'components/WarningBox';
21+import BridgeForm from 'form/BridgeForm';
22+import {
23+ PointInput,
24+ composeValidator,
25+ NumberInput,
26+ buildNumberValidator,
27+ buildPointValidator,
28+} from 'form/Inputs';
29+import FormError from 'form/FormError';
2230
2331function useSetPoolSize() {
2432 const { contracts } = useNetwork();
@@ -60,31 +68,25 @@ 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+ );
8890
8991 return (
9092 <View pop={pop} inset>
@@ -92,6 +94,7 @@ export default function PartySetPoolSize() {
9294 <Grid.Item full as={ViewHeader}>
9395 Set Pool Size
9496 </Grid.Item>
97+
9598 {!spawnIsDelegatedSending && (
9699 <Grid.Item full as={WarningBox} className="mb4">
97100 The spawn proxy must be set to{' '}
@@ -101,30 +104,57 @@ export default function PartySetPoolSize() {
101104 for invitations to be available.
102105 </Grid.Item>
103106 )}
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− )}
121107
122− <Grid.Item
123− full
124− as={InlineEthereumTransaction}
125− {...bind}
126− onReturn={() => pop()}
127− />
108+ <BridgeForm
109+ validate={validate}
110+ onSubmit={() => {}}
111+ onValues={onValues}
112+ initialValues={{ poolSize: 5 }}>
113+ {({ handleSubmit, values }) => (
114+ <>
115+ <Grid.Item
116+ full
117+ as={Text}
118+ className={cn('f5', {
119+ green3: completed,
120+ })}>
121+ {completed
122+ ? `${values.poolSize} invites have been allocated to ${values.poolOwner}`
123+ : `Allocate invites to a child point.`}
124+ </Grid.Item>
125+
126+ {!completed && (
127+ <>
128+ <Grid.Item
129+ full
130+ as={PointInput}
131+ className="mt4"
132+ name="poolOwner"
133+ label="Point"
134+ disabled={inputsLocked}
135+ />
136+ <Grid.Item
137+ full
138+ as={NumberInput}
139+ className="mb4"
140+ name="poolSize"
141+ label="Pool Size"
142+ disabled={inputsLocked}
143+ />
144+ </>
145+ )}
146+
147+ <Grid.Item full as={FormError} />
148+
149+ <Grid.Item
150+ full
151+ as={InlineEthereumTransaction}
152+ {...bind}
153+ onReturn={() => pop()}
154+ />
155+ </>
156+ )}
157+ </BridgeForm>
128158 </Grid>
129159 </View>
130160 );