on master help sign in
Merge pull request #176 from urbit/refactor/invite-email

refactor email list & invite flow logic

commit01c95c65630274fd57564a69efe2bee31c7c19aa authorMatt Condon <mattgcondon@gmail.com> dateWed 26 Jun 2019, 16:55 (7 years ago) parentdf1910fda9b631
84 files changed+2170−2697
package-lock.json+5 package.json+1 src/Bridge.js+4−3 src/_walletgen/Walletgen.js+1−1 src/_walletgen/views/Generate.js+7−2 src/_walletgen/views/Upload.js+1−1 src/assets/back.svg+4 src/components/Blinky.js+31 src/components/Buttons.js+2−2 src/components/Footer.js+4−13 src/components/Inputs.js+102−65 src/components/LoadableButton.js+7 src/components/MiniBackButton.js+11 src/components/Passport.js+8−6 src/components/View.js+1−1 src/components/old/Header.js+1−5 src/components/old/HorizontalSelector.js+1−1 src/indigo-react/components/AccessoryIcon.js+32 src/indigo-react/components/Button.js+14−7 src/indigo-react/components/Flex.js+1−1 src/indigo-react/components/Grid.js+6 src/indigo-react/components/IconButton.js+51 src/indigo-react/components/Input.js+64−145 src/indigo-react/components/Typography.js+1 src/indigo-react/index.js+2 src/indigo-react/indigo.css+66−21 src/indigo-react/lib/useForm.js+188 src/lib/LocalRouter.js+9 src/_walletgen/lib/constants.js → src/lib/constants.js+1 src/lib/invite.js+9−6 src/lib/loadingCharacter.js−7 src/lib/need.js+1−2 src/lib/pluralize.js+8 src/lib/routeNames.js−9 src/lib/router.js+2−53 src/lib/tank.js+6−1 src/lib/txn.js+5−2 src/lib/useArray.js+48 src/lib/useInterval.js+22 src/lib/useIsEclipticOwner.js+20 src/lib/useMailer.js+57 src/lib/usePreviousValue.js+16 src/lib/useRenderCount.js+7 src/lib/useSetState.js+16 src/lib/useSyncPoint.js−12 src/lib/useSyncPoints.js+39 src/lib/useWalletType.js+11 src/lib/validators.js+36−13 src/_walletgen/lib/lib.js → src/lib/walletgen.js+10−5 src/store/lib/useBirthdaysStore.js+54 src/store/lib/useControlledPointsStore.js+64 src/store/lib/useDetailsStore.js+51 src/store/lib/useEclipticOwnerStore.js+26 src/store/lib/useInvitesStore.js+65 src/store/lib/usePointStore.js+41 src/store/pointCache.js+5−111 src/store/wallet.js+25−3 src/style/indigo-proposals.scss+4 src/views/Invite.js+44−41 src/views/Invite/InviteEmail.js+435−439 src/views/Invite/InviteUrl.js+21−3 src/views/InviteTicket.js+1−2 src/views/Keystore.js−166 src/views/Landing.js−7 src/views/Ledger.js−220 src/views/Login.js+6−8 src/views/Login/Keystore.js+17−12 src/views/Login/Ledger.js+12−4 src/views/Login/Mnemonic.js+36−24 src/views/Login/PrivateKey.js+16−20 src/views/Login/Shards.js+44−36 src/views/Login/Ticket.js+73−77 src/views/Login/Trezor.js+7−2 src/views/Mnemonic.js−78 src/views/Network.js−62 src/views/Point.js+18−13 src/views/Point/Actions.js+23−49 src/views/Points.js+133−219 src/views/PrivateKey.js−84 src/views/Shards.js−212 src/views/Ticket.js−154 src/views/Trezor.js−146 src/views/ViewPoint.js+10−20 src/views/Wallet.js−101
package-lock.json+5
@@ -14511,6 +14511,11 @@
1451114511 }
1451214512 }
1451314513 },
14514+ "react-teleporter": {
14515+ "version": "1.1.0",
14516+ "resolved": "https://registry.npmjs.org/react-teleporter/-/react-teleporter-1.1.0.tgz",
14517+ "integrity": "sha512-6j3I29y3sRcekOP1IfbIbrrtwZGKQhRU3WFZoWgMtXN0lTA2/i9Yp3zzAkVBFRtZIfhux4M7tSYVu4AQjCRDlQ=="
14518+ },
1451414519 "read-pkg": {
1451514520 "version": "1.1.0",
1451614521 "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz",
package.json+1
@@ -36,6 +36,7 @@
3636 "react": "^16.8.6",
3737 "react-dom": "^16.8.6",
3838 "react-scripts": "3.0.1",
39+ "react-teleporter": "^1.1.0",
3940 "secp256k1": "^3.5.2",
4041 "sigil-js": "github:urbit/sigil-js#f7f29c5",
4142 "trezor-connect": "^7.0.1",
src/Bridge.js+4−3
@@ -24,9 +24,10 @@ const kIsStubbed = isDevelopment && shouldStubLocal;
2424const kInitialRoutes = kIsStubbed
2525 ? [
2626 { key: ROUTE_NAMES.LANDING },
27− { key: ROUTE_NAMES.NETWORK },
28− { key: ROUTE_NAMES.WALLET },
27+ { key: ROUTE_NAMES.LOGIN },
2928 { key: ROUTE_NAMES.POINTS },
29+ { key: ROUTE_NAMES.POINT },
30+ { key: ROUTE_NAMES.INVITE },
3031 ]
3132 : [{ key: ROUTE_NAMES.LANDING }];
3233
@@ -39,7 +40,7 @@ const kInitialWallet = kIsStubbed
3940const kInitialMnemonic = kIsStubbed
4041 ? Just(process.env.REACT_APP_DEV_MNEMONIC)
4142 : Nothing();
42−const kInitialPointCursor = kIsStubbed ? Just(0) : Nothing();
43+const kInitialPointCursor = kIsStubbed ? Just(65792) : Nothing();
4344
4445export default function Bridge() {
4546 return (
src/_walletgen/Walletgen.js+1−1
@@ -4,7 +4,7 @@ import { find } from 'lodash';
44import Header from './components/Header';
55import Footer from './components/Footer';
66
7−import { PROFILE_STATES, GEN_STATES } from './lib/constants';
7+import { PROFILE_STATES, GEN_STATES } from '../lib/constants';
88
99import PrivacyPolicy from './views/PrivacyPolicy';
1010import Welcome from './views/Welcome';
src/_walletgen/views/Generate.js+7−2
@@ -2,13 +2,18 @@ import React from 'react';
22import * as sigil from 'sigil-js';
33import ob from 'urbit-ob';
44import classnames from 'classnames';
5−import * as wg from '../lib/lib';
5+import * as wg from '../../lib/walletgen';
66
77import Button from '../components/Button';
88import ReactSVGComponents from '../components/ReactSVGComponents';
99import PaperCollateralRenderer from 'PaperCollateralRenderer';
1010
11−import { MAX_GALAXY, MIN_STAR, MAX_STAR, GEN_STATES } from '../lib/constants';
11+import {
12+ MAX_GALAXY,
13+ MIN_STAR,
14+ MAX_STAR,
15+ GEN_STATES,
16+} from '../../lib/constants';
1217
1318const NEXT_STEP_NUM = 6;
1419
src/_walletgen/views/Upload.js+1−1
@@ -5,7 +5,7 @@ import UploadButton from '../components/UploadButton';
55
66import { compose } from '../../bridge/lib/lib';
77
8−import { PROFILE_STATES } from '../lib/constants';
8+import { PROFILE_STATES } from '../../lib/constants';
99
1010const NEXT_STEP_NUM = 3;
1111
src/assets/back.svgnew file+4
@@ -0,0 +1,4 @@
1+<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
2+<path d="M13.8636 6.54545H4.56818V6.63636L8.06818 10.1364L7.20455 11L2.11364 5.90909L7.20455 0.818182L8.06818 1.68182L4.56818 5.18182V5.27273H13.8636V6.54545Z" fill="black"/>
3+<path d="M0 14.0227H16.0909V15.2727H0V14.0227Z" fill="black"/>
4+</svg>
src/components/Blinky.jsnew file+31
@@ -0,0 +1,31 @@
1+import React, { useState, useRef } from 'react';
2+
3+import useInterval from 'lib/useInterval';
4+
5+// TODO: make these characters display as the same width
6+export const kLoadingCharacter = '▓';
7+export const kInterstitialCharacter = '░';
8+const BLINK_AFTER_MS = 2500; // ms
9+
10+export const matchBlinky = obj =>
11+ obj.matchWith({
12+ Nothing: () => <Blinky />,
13+ Just: p => p.value,
14+ });
15+
16+export default function Blinky({
17+ a = kLoadingCharacter,
18+ b = kInterstitialCharacter,
19+}) {
20+ const [value, setValue] = useState(true);
21+ const now = useRef(new Date());
22+
23+ useInterval(() => {
24+ // only start blinking if we've elapsed enough time
25+ if (new Date() - now.current > BLINK_AFTER_MS) {
26+ setValue(val => !val);
27+ }
28+ }, 1000);
29+
30+ return value ? a : b;
31+}
src/components/Buttons.js+2−2
@@ -2,5 +2,5 @@ import React from 'react';
22import { Button } from 'indigo-react';
33
44// TODO: use svg icons
5−export const ForwardButton = props => <Button icon="→" {...props} />;
6−export const DownloadButton = props => <Button icon="⬇" {...props} />;
5+export const ForwardButton = props => <Button accessory="→" {...props} />;
6+export const DownloadButton = props => <Button accessory="⬇" {...props} />;
src/components/Footer.js+4−13
@@ -1,17 +1,8 @@
1−import React, { Children } from 'react';
2−import { createPortal } from 'react-dom';
1+import { createTeleporter } from 'react-teleporter';
32
4−const kFooterPortalId = 'footer-portal';
3+const FooterTeleporter = createTeleporter();
54
6−function Footer({ children }) {
7− return createPortal(
8− Children.only(children),
9− document.getElementById(kFooterPortalId)
10− );
11−}
12−
13−Footer.Portal = function FooterPortal(props) {
14− return <div {...props} id={kFooterPortalId} />;
15−};
5+const Footer = FooterTeleporter.Source;
6+Footer.Target = FooterTeleporter.Target;
167
178export default Footer;
src/components/Inputs.js+102−65
@@ -1,5 +1,4 @@
1−import React from 'react';
2−import { Input } from 'indigo-react';
1+import React, { useState } from 'react';
32import * as bip39 from 'bip39';
43
54import InputSigil from 'components/InputSigil';
@@ -10,10 +9,10 @@ import {
109 validateMnemonic,
1110 validatePoint,
1211 validateTicket,
12+ validateEmail,
1313} from 'lib/validators';
1414import { prependSig } from 'lib/transformers';
15−
16−const kExampleMnemonic = bip39.generateMnemonic();
15+import useForm from 'indigo-react/lib/useForm';
1716
1817// const RequiredInput = advancedInput({
1918// WrappedComponent: Input,
@@ -35,38 +34,47 @@ const kExampleMnemonic = bip39.generateMnemonic();
3534// validators: [validateEthereumAddress, validateNotEmpty],
3635// });
3736
38−export function PassphraseInput(props) {
39− return (
40− <Input
41− type="password"
42− autoComplete="off"
43− placeholder="Passphrase"
44− {...props}
45− />
37+const firstOf = state => state.inputs[0];
38+
39+export function usePassphraseInput(props) {
40+ return firstOf(
41+ useForm([
42+ {
43+ type: 'password',
44+ autoComplete: 'off',
45+ placeholder: 'Passphrase',
46+ ...props,
47+ },
48+ ])
4649 );
4750}
4851
49−export function MnemonicInput(props) {
50− return (
51− <Input
52− type="text"
53− autoComplete="off"
54− placeholder={`e.g. ${kExampleMnemonic}`}
55− validators={[validateMnemonic, validateNotEmpty]}
56− {...props}
57− />
52+const kExampleMnemonic = bip39.generateMnemonic();
53+const kMnemonicValidators = [validateMnemonic, validateNotEmpty];
54+export function useMnemonicInput(props) {
55+ return firstOf(
56+ useForm([
57+ {
58+ type: 'text',
59+ autoComplete: 'off',
60+ placeholder: `e.g. ${kExampleMnemonic}`,
61+ validators: kMnemonicValidators,
62+ ...props,
63+ },
64+ ])
5865 );
5966}
6067
61−export function HdPathInput(props) {
62− return (
63− <Input
64− type="text"
65− autoComplete="off"
66− placeholder={`${DEFAULT_HD_PATH}`}
67− validators={[validateNotEmpty]}
68− {...props}
69− />
68+export function useHdPathInput(props) {
69+ return firstOf(
70+ useForm([
71+ {
72+ type: 'text',
73+ autoComplete: 'off',
74+ placeholder: `e.g. ${DEFAULT_HD_PATH}`,
75+ ...props,
76+ },
77+ ])
7078 );
7179}
7280
@@ -76,43 +84,72 @@ export function HdPathInput(props) {
7684// transformers: [prependSig],
7785// });
7886
79−export function PointInput(props) {
80− return (
81− <Input
82− type="text"
83− placeholder="e.g. ~zod"
84− initialValue={props.initialValue || '~'}
85− validators={[validatePoint, validateNotEmpty]}
86− transformers={[prependSig]}
87− mono
88− accessory={
89− <InputSigil
90− patp={props.initialValue || '~'}
91− size={68}
92− margin={8}
93− pass={props.pass}
94− onPass={props.setPass}
95− focused={props.focused}
96− onFocus={props.setFocused}
97− error={props.error}
98− onError={props.setError}
99− />
100− }
101− {...props}
102− />
87+const kTicketValidators = [validateTicket, validateNotEmpty];
88+const kTicketTransformers = [prependSig];
89+//TODO needs to be fancier, displaying sig and dashes instead of •ing all
90+export function useTicketInput({ initialValue = '~', ...rest }) {
91+ return firstOf(
92+ useForm([
93+ {
94+ type: 'password',
95+ placeholder: '~master-ticket',
96+ validators: kTicketValidators,
97+ transformers: kTicketTransformers,
98+ mono: true,
99+ initialValue,
100+ ...rest,
101+ },
102+ ])
103103 );
104104}
105105
106−//TODO needs to be fancier, displaying sig and dashes instead of •ing all
107−export function TicketInput(props) {
108− return (
109− <Input
110− type="password"
111− placeholder="~master-ticket"
112− validators={[validateTicket, validateNotEmpty]}
113− transformers={[prependSig]}
114− mono
115− {...props}
116− />
106+const kPointValidators = [validatePoint, validateNotEmpty];
107+const kPointTransformers = [prependSig];
108+export function usePointInput({ initialValue = '~', ...rest }) {
109+ const [lastValidPoint, setLastValidPoint] = useState('');
110+ const input = firstOf(
111+ useForm([
112+ {
113+ type: 'text',
114+ placeholder: 'e.g. ~zod',
115+ validators: kPointValidators,
116+ transformers: kPointTransformers,
117+ mono: true,
118+ initialValue,
119+ onValue: setLastValidPoint,
120+ ...rest,
121+ },
122+ ])
117123 );
124+
125+ const { pass, focused, error } = input;
126+
127+ return {
128+ ...input,
129+ accessory: lastValidPoint && (
130+ <InputSigil
131+ patp={lastValidPoint}
132+ size={68}
133+ margin={8}
134+ pass={pass}
135+ focused={focused}
136+ error={error}
137+ />
138+ ),
139+ };
118140}
141+
142+const kEmailValidators = [validateEmail, validateNotEmpty];
143+export const buildEmailInputConfig = extra => ({
144+ type: 'email',
145+ autoComplete: 'off',
146+ validators: kEmailValidators,
147+ initialValue: '',
148+ ...extra,
149+});
150+
151+// const TicketInput = advancedInput({
152+// WrappedComponent: Input,
153+// validators: [validateTicket, validateNotEmpty],
154+// transformers: [prependSig],
155+// });
src/components/LoadableButton.jsnew file+7
@@ -0,0 +1,7 @@
1+import React from 'react';
2+import { Button } from 'indigo-react';
3+
4+// TODO: implement loading bar behind text
5+export default function LoadableButton({ ...rest }) {
6+ return <Button {...rest}></Button>;
7+}
src/components/MiniBackButton.jsnew file+11
@@ -0,0 +1,11 @@
1+import React from 'react';
2+import cn from 'classnames';
3+import { IconButton } from 'indigo-react';
4+
5+import { ReactComponent as Back } from 'assets/back.svg';
6+
7+export default ({ className, ...rest }) => (
8+ <IconButton {...rest}>
9+ <Back className={cn('black', className)} />
10+ </IconButton>
11+);
src/components/Passport.js+8−6
@@ -3,19 +3,21 @@ import * as ob from 'urbit-ob';
33import { Grid, Flex } from 'indigo-react';
44import { times } from 'lodash';
55
6−import Sigil from './Sigil';
76import usePointBirthday from 'lib/usePointBirthday';
87import { formatDots } from 'lib/dateFormat';
9−import { kLoadingCharacter } from 'lib/loadingCharacter';
108
11−const kLoadingDate = [4, 2, 2]
12− .map(t => times(t, () => kLoadingCharacter).join(''))
13− .join('.');
9+import Sigil from './Sigil';
10+import Blinky, { kLoadingCharacter, kInterstitialCharacter } from './Blinky';
11+
12+const buildDate = char =>
13+ [4, 2, 2].map(t => times(t, () => char).join('')).join('.');
14+const kDateA = buildDate(kLoadingCharacter);
15+const kDateB = buildDate(kInterstitialCharacter);
1416
1517export default function Passport({ point }) {
1618 const birthday = usePointBirthday(point);
1719 const birthDate = birthday.matchWith({
18− Nothing: () => kLoadingDate,
20+ Nothing: () => <Blinky a={kDateA} b={kDateB} />,
1921 Just: p => formatDots(p.value),
2022 });
2123 const name = ob.patp(point);
src/components/View.js+1−1
@@ -20,7 +20,7 @@ function View({ className, children, full = false, ...rest }) {
2020 )}
2121 {...rest}>
2222 <Flex.Item className="pv5">{children}</Flex.Item>
23− <Footer.Portal />
23+ <Footer.Target />
2424 </Flex>
2525 );
2626}
src/components/old/Header.js+1−5
@@ -5,18 +5,14 @@ import { getRouteBreadcrumb } from '../../lib/router';
55import { ROUTE_NAMES } from '../../lib/routeNames';
66import { useHistory } from '../../store/history';
77import { isLast } from '../../lib/lib';
8−import { useNetwork } from '../../store/network';
9−import { useWallet } from '../../store/wallet';
108import { usePointCursor } from '../../store/pointCursor';
119
1210// hook to create a breadcrumb builder function based on the current
1311// global states
1412function useRouteBreadcrumbBuilder() {
15− const { networkType } = useNetwork();
16− const { wallet } = useWallet();
1713 const { pointCursor } = usePointCursor();
1814
19− return getRouteBreadcrumb({ pointCursor, networkType, wallet });
15+ return getRouteBreadcrumb({ pointCursor });
2016}
2117
2218function Crumbs(props) {
src/components/old/HorizontalSelector.js+1−1
@@ -22,8 +22,8 @@ class HorizontalSelector extends React.Component {
2222 let active = opt.value === this.state.option ? 'btn-primary active' : '';
2323 return (
2424 <button
25+ key={opt.value.toString()}
2526 className={`btn ${active} h-10 ph-4 flex sans ${this.props.className}`}
26− value={opt.value}
2727 onClick={() => this.selectOption(opt.value)}>
2828 {opt.title}
2929 </button>
src/indigo-react/components/AccessoryIcon.jsnew file+32
@@ -0,0 +1,32 @@
1+import React from 'react';
2+
3+import Flex from './Flex';
4+
5+const PENDING_ACCESSORY = '⋯';
6+const SUCCESS_ACCESSORY = '✔';
7+const FAILURE_ACCESSORY = '×';
8+
9+function AccessoryIcon({ ...props }) {
10+ return (
11+ <Flex
12+ justify="center"
13+ align="center"
14+ style={{ height: '100%', width: '100%' }}
15+ {...props}
16+ />
17+ );
18+}
19+
20+AccessoryIcon.Pending = () => (
21+ <AccessoryIcon>{PENDING_ACCESSORY}</AccessoryIcon>
22+);
23+
24+AccessoryIcon.Success = () => (
25+ <AccessoryIcon>{SUCCESS_ACCESSORY}</AccessoryIcon>
26+);
27+
28+AccessoryIcon.Failure = () => (
29+ <AccessoryIcon>{FAILURE_ACCESSORY}</AccessoryIcon>
30+);
31+
32+export default AccessoryIcon;
src/indigo-react/components/Button.js+14−7
@@ -7,10 +7,12 @@ import { HelpText } from './Typography';
77
88export default function Button({
99 solid = false,
10+ success = false,
1011 disabled = false,
1112 detail,
1213 className,
13− icon = '→',
14+ accessory = '→',
15+ onClick,
1416 children,
1517 ...rest
1618}) {
@@ -24,12 +26,16 @@ export default function Button({
2426 as="a"
2527 gap={4}
2628 className={cn(
27− 'pointer p4 truncate flex-row justify-between',
29+ 'pointer pv4 truncate flex-row justify-between',
2830 {
29− 'bg-black bg-gray6-hover': solid && !disabled,
30− 'bg-gray3': solid && disabled,
31− 'bg-transparent bg-gray1-hover': !solid && !disabled,
32− 'bg-transparent': !solid && disabled,
31+ p4: solid,
32+ },
33+ {
34+ 'bg-green3': success && !disabled,
35+ 'bg-green1': success && disabled,
36+ 'bg-black': !success && solid && !disabled,
37+ 'bg-gray3': !success && solid && disabled,
38+ 'bg-transparent': !success && !solid,
3339 },
3440 className
3541 )}
@@ -39,10 +45,11 @@ export default function Button({
3945 cursor: 'not-allowed',
4046 }),
4147 }}
48+ onClick={!disabled && onClick ? onClick : undefined}
4249 {...rest}>
4350 <Grid.Item as={Flex} justify="between" full>
4451 <span className={cn(textColor)}>{children}</span>
45− <div className={cn('pl4', textColor)}>{icon}</div>
52+ <div className={cn('pl4', textColor)}>{accessory}</div>
4653 </Grid.Item>
4754 {detail && (
4855 <Grid.Item as={HelpText} full>
src/indigo-react/components/Flex.js+1−1
@@ -1,7 +1,7 @@
11import React from 'react';
22import cn from 'classnames';
33
4−import getComponent from 'indigo-react/lib/getComponent';
4+import getComponent from '../lib/getComponent';
55
66function Flex({
77 as = 'div',
src/indigo-react/components/Grid.js+6
@@ -21,6 +21,8 @@ Grid.Item = function GridItem({
2121 fourth = 0,
2222 rows = [],
2323 cols = [],
24+ justifySelf,
25+ alignSelf,
2426 className,
2527 ...rest
2628}) {
@@ -38,6 +40,10 @@ Grid.Item = function GridItem({
3840 [`r${rows[0]}-${rows[1]}`]: rows.length === 2,
3941 [`c${cols[0]}-${cols[1]}`]: cols.length === 2,
4042 },
43+ {
44+ [`justify-self-${justifySelf}`]: justifySelf,
45+ [`align-self-${alignSelf}`]: alignSelf,
46+ },
4147 className
4248 )}
4349 {...rest}
src/indigo-react/components/IconButton.jsnew file+51
@@ -0,0 +1,51 @@
1+import React from 'react';
2+import cn from 'classnames';
3+
4+import Flex from './Flex';
5+
6+export default function IconButton({
7+ children,
8+ className,
9+ onClick,
10+ disabled = false,
11+ secondary = false,
12+ solid = false,
13+ ...rest
14+}) {
15+ return (
16+ <Flex
17+ as="a"
18+ align="center"
19+ justify="center"
20+ className={cn(
21+ 'pointer p2',
22+ {
23+ 'bg-black': solid && !secondary && !disabled,
24+ 'bg-gray3': (solid && disabled) || (solid && secondary),
25+ 'bg-gray2': !solid && secondary && disabled,
26+ 'bg-transparent': !solid,
27+ },
28+ {
29+ white: solid,
30+ black: !solid && !disabled,
31+ gray4: !solid && disabled,
32+ },
33+ className
34+ )}
35+ style={{
36+ width: '1rem',
37+ height: '1rem',
38+ fontSize: '1rem',
39+ fontWeight: 'bold',
40+ // TODO: ^ replace with 1:1 svgs and remove explicit sizing
41+ ...(disabled && {
42+ pointerEvents: 'none',
43+ cursor: 'not-allowed',
44+ }),
45+ }}
46+ onClick={onClick ? () => !disabled && onClick() : undefined}
47+ {...rest}>
48+ {children}
49+ </Flex>
50+ );
51+}
src/indigo-react/components/Input.js+64−145
@@ -1,182 +1,85 @@
1−import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+import React, { useCallback, useEffect } from 'react';
22import cn from 'classnames';
33
4−import { Flex } from 'indigo-react';
5−
6−import { compose } from 'lib/lib';
7−import { kDefaultValidator } from 'lib/validators';
8−
9−function useInput({
10− validators = [],
11− transformers = [],
12− initialValue = '',
13− autoFocus = false,
14−}) {
15− const [value, setValue] = useState(initialValue);
16− const [focused, setFocused] = useState(autoFocus);
17− const [hasBeenFocused, setHasBeenFocused] = useState(false);
18−
19− // NB(shrugs)
20− // TL;DR: This is what we want, the exhaustive deps rule is being too nosy.
21− //
22− // Here we memo-construct the `validate` and `transform` functions
23− // based on the prop arguments. We explicitly disable the exhausive deps rule
24− // because eslint wants an inline array specifying our dependencies.
25− //
26− // If we were to place the array of validators or transformers into
27− // the dependency array like `[validators]`, we would end up creating a new
28− // `validate` function every render, since deps are shallow-compared with
29− // `Object.is`. Because the array of validators are constructed as part of
30− // a component's render() function like:
31− //
32− // export function MnemonicInput(props) {
33− // return (
34− // <Input
35− // type="text"
36− // autoComplete="off"
37− // placeholder={`e.g. ${kExampleMnemonic}`}
38− // validators={[validateMnemonic, validateNotEmpty]}
39− // {...props}
40− // />
41− // );
42− // }
43− //
44− // we create a new array, which shallowly compares as not-equal
45− // between renders.
46− //
47− // So, in this specific case, we disable the rule and pass the
48− // array of validators (and transformers) themselves as dependencies.
49− // This means that validators and transformers can change between renders
50− // and we will re-create the composed `validate` and `transform` functions
51− // as expected.
52− //
53− // For context, an alternative solution I tried was defining the array of
54− // validators as a constant array outside of the render method, but:
55− // 1. It wasn't as clean or useful as specifying them inline,
56− // 2. It would mean that validators could not be updated between renders,
57− // 3. And the situation we have now feels naturally more 'correct'.
58− //
59− /* eslint-disable react-hooks/exhaustive-deps */
60− const transform = useMemo(() => compose(...transformers), transformers);
61− const validate = useMemo(
62− () =>
63− compose(
64− ...validators,
65− kDefaultValidator
66− ),
67− validators
68− );
69− /* eslint-enable react-hooks/exhaustive-deps */
70−
71− const onChange = useCallback(
72− e => setValue(transform(e.target.value)), //
73− [transform]
74− );
75− const onFocus = useCallback(
76− e => setFocused(true), //
77− [setFocused]
78− );
79− const onBlur = useCallback(
80− e => {
81− setFocused(false);
82− setHasBeenFocused(true);
83− },
84− [setFocused, setHasBeenFocused]
85− );
86−
87− // memo-compute whether or not the current value is valid
88− const { pass, error: _error, data } = useMemo(
89− () => validate(value), //
90− [validate, value]
91− );
92−
93− // only set error if it should be visible
94− const error = !pass && hasBeenFocused && !focused && _error;
95− // visibly tell the user that their input has passed if it has passed
96− // and they are or have interacted with the input before
97− const visiblyPassed = pass && (hasBeenFocused || focused);
98−
99− return {
100− pass,
101− visiblyPassed,
102− error,
103− data,
104− focused,
105− bind: {
106− value,
107− onChange,
108− focused: focused ? 'true' : 'false',
109− // ^ dom wants string value for focused
110− onFocus,
111− onBlur,
112− },
113− };
114−}
4+import Flex from './Flex';
5+import { ErrorText } from './Typography';
1156
1167export default function Input({
8+ // visuals
1179 name,
11810 label,
119− initialValue = '',
120− className = '',
121− validators = [],
122− transformers = [],
11+ className,
12+ accessory,
13+ mono = false,
14+
15+ // callbacks
12316 onValue,
12417 onPass,
12518 onError,
12619 onFocus,
12720 onEnter,
128− accessory,
129− disabled = false,
130− mono = false,
131− autoFocus = false,
21+
22+ // state from hook
23+ focused,
24+ pass,
25+ visiblyPassed,
26+ error,
27+ data,
28+ bind,
29+ autoFocus,
30+ disabled,
31+
32+ // ignored
33+ initialValue,
34+ validators,
35+ transformers,
36+
37+ // extra
13238 ...rest
13339}) {
134− const actuallyAutoFocus = autoFocus && !disabled;
135− const { focused, pass, visiblyPassed, error, data, bind } = useInput({
136− validators,
137− transformers,
138− initialValue,
139− autoFocus: actuallyAutoFocus,
140− });
40+ // NB(shrugs): we disable exhaustive deps because we don't want the callbacks
41+ // (whose identity might change between renders) to trigger a re-notify of
42+ // state that we already know
43+ // this also happens to prevent render loops when rendering a map of inputs
14144
142− // notify parent of enter keypress iff not disabled
45+ // notify parent of enter keypress iff not disabled and passing
14346 const onKeyPress = useCallback(
144− e => !disabled && e.key === 'Enter' && onEnter && onEnter(),
145− [disabled, onEnter]
47+ e => !disabled && pass && e.key === 'Enter' && onEnter && onEnter(),
48+ [disabled, pass] // eslint-disable-line react-hooks/exhaustive-deps
14649 );
14750
14851 // notify parent of value only when passing
14952 useEffect(() => {
15053 pass && onValue && onValue(data);
151− }, [pass, data, onValue]);
54+ }, [pass, data]); // eslint-disable-line react-hooks/exhaustive-deps
15255
15356 // notify parent of pass
15457 useEffect(() => {
15558 onPass && onPass(pass);
156− }, [pass, onPass]);
59+ }, [pass]); // eslint-disable-line react-hooks/exhaustive-deps
15760
15861 // notify parent of error whenever error changes
15962 useEffect(() => {
16063 onError && onError(error);
161− }, [error, onError]);
64+ }, [error]); // eslint-disable-line react-hooks/exhaustive-deps
16265
16366 // notify parent of focus
16467 useEffect(() => {
16568 onFocus && onFocus(focused);
166− }, [focused, onFocus]);
69+ }, [focused]); // eslint-disable-line react-hooks/exhaustive-deps
16770
16871 return (
169− <Flex col>
72+ <Flex className={className} col>
17073 <Flex.Item as="label" className="f6 lh-tall" htmlFor={name}>
17174 {label}
17275 </Flex.Item>
173− <Flex row>
76+ <Flex.Item as={Flex} className="rel" row>
17477 <Flex.Item
17578 as="input"
17679 {...rest}
17780 cols={[1, accessory ? 11 : 13]}
17881 className={cn(
179− 'b b1 p3',
82+ 'rel b b1 p3 outline-none',
18083 { mono },
18184 {
18285 'bg-white': !disabled,
@@ -191,24 +94,40 @@ export default function Input({
19194 'b-black': focused && !visiblyPassed,
19295 'b-yellow3': !focused && !visiblyPassed && error,
19396 'b-gray3': !focused && !error && !visiblyPassed,
194− },
195− className
97+ }
98+ // TODO: inputClassName ?
19699 )}
197100 style={{
198− ...(disabled && { pointerEvents: 'none', cursor: 'not-allowed' }),
101+ ...(disabled && {
102+ pointerEvents: 'none',
103+ cursor: 'not-allowed',
104+ }),
199105 }}
200− onKeyPress={onKeyPress}
201106 id={name}
202− autoFocus={actuallyAutoFocus}
107+ name={name}
108+ onKeyPress={onKeyPress}
203109 {...bind}
204110 flex
205111 />
206112 {accessory && (
207− <Flex.Item style={{ height: '45px', overflow: 'hidden' }}>
113+ <div
114+ className="abs"
115+ style={{
116+ top: 0,
117+ right: 0,
118+ height: '100%',
119+ width: '44px',
120+ overflow: 'hidden',
121+ }}>
208122 {accessory}
209− </Flex.Item>
123+ </div>
210124 )}
211− </Flex>
125+ </Flex.Item>
126+ {error && (
127+ <Flex.Item as={ErrorText} className="mv1">
128+ {error}
129+ </Flex.Item>
130+ )}
212131 </Flex>
213132 );
214133}
src/indigo-react/components/Typography.js+1
@@ -21,3 +21,4 @@ export const P = typeWith('p');
2121export const Breadcrumb = typeWith('span', 'f6');
2222export const Text = typeWith('span', 'f5');
2323export const HelpText = typeWith('span', 'f6 gray4');
24+export const ErrorText = typeWith('span', 'f6 red3');
src/indigo-react/index.js+2
@@ -2,6 +2,8 @@ export { default as IndigoApp } from './components/IndigoApp';
22export { default as Grid } from './components/Grid';
33export { default as Flex } from './components/Flex';
44export { default as Input } from './components/Input';
5+export { default as AccessoryIcon } from './components/AccessoryIcon';
56export { default as Button } from './components/Button';
7+export { default as IconButton } from './components/IconButton';
68
79export * from './components/Typography';
src/indigo-react/indigo.css+66−21
@@ -13,7 +13,7 @@
1313 * git+https://github.com/urbit/Indigo.git
1414 * Bugs: https://github.com/urbit/Indigo/issues
1515 * License: MIT
16− * Timestamp: Wed Jun 19 2019 17:17:14 GMT-0700 (Pacific Daylight Time)
16+ * Timestamp: Mon Jun 24 2019 19:57:25 GMT-0700 (Pacific Daylight Time)
1717 *******************************************************************************/
1818/**********************
1919Color variables
@@ -1575,19 +1575,34 @@ caption,
15751575 justify-content: center;
15761576 align-items: center; }
15771577
1578−.self-start {
1578+.align-self-start {
15791579 align-self: flex-start; }
15801580
1581−.self-end {
1581+.align-self-end {
15821582 align-self: flex-end; }
15831583
1584−.self-center {
1584+.align-self-center {
15851585 align-self: center; }
15861586
1587−.self-baseline {
1587+.align-self-baseline {
15881588 align-self: baseline; }
15891589
1590−.self-stretch {
1590+.align-self-stretch {
1591+ align-self: stretch; }
1592+
1593+.justify-self-start {
1594+ justify-self: flex-start; }
1595+
1596+.justify-self-end {
1597+ justify-self: flex-end; }
1598+
1599+.justify-self-center {
1600+ justify-self: center; }
1601+
1602+.justify-self-baseline {
1603+ justify-self: baseline; }
1604+
1605+.align-self-stretch {
15911606 align-self: stretch; }
15921607
15931608.content-start {
@@ -1648,16 +1663,26 @@ caption,
16481663 .flex-center-ns {
16491664 justify-content: center;
16501665 align-items: center; }
1651− .self-start-ns {
1666+ .align-self-start-ns {
16521667 align-self: flex-start; }
1653− .self-end-ns {
1668+ .align-self-end-ns {
16541669 align-self: flex-end; }
1655− .self-center-ns {
1670+ .align-self-center-ns {
16561671 align-self: center; }
1657− .self-baseline-ns {
1672+ .align-self-baseline-ns {
16581673 align-self: baseline; }
1659− .self-stretch-ns {
1674+ .align-self-stretch-ns {
16601675 align-self: stretch; }
1676+ .justify-self-start-ns {
1677+ justify-self: flex-start; }
1678+ .justify-self-end-ns {
1679+ justify-self: flex-end; }
1680+ .justify-self-center-ns {
1681+ justify-self: center; }
1682+ .justify-self-baseline-ns {
1683+ justify-self: baseline; }
1684+ .justify-self-stretch-ns {
1685+ justify-self: stretch; }
16611686 .content-start-ns {
16621687 align-content: flex-start; }
16631688 .content-end-ns {
@@ -1711,16 +1736,26 @@ caption,
17111736 .flex-center-md {
17121737 justify-content: center;
17131738 align-items: center; }
1714− .self-start-md {
1739+ .align-self-start-md {
17151740 align-self: flex-start; }
1716− .self-end-md {
1741+ .align-self-end-md {
17171742 align-self: flex-end; }
1718− .self-center-md {
1743+ .align-self-center-md {
17191744 align-self: center; }
1720− .self-baseline-md {
1745+ .align-self-baseline-md {
17211746 align-self: baseline; }
1722− .self-stretch-md {
1747+ .align-self-stretch-md {
17231748 align-self: stretch; }
1749+ .justify-self-start-md {
1750+ justify-self: flex-start; }
1751+ .justify-self-end-md {
1752+ justify-self: flex-end; }
1753+ .justify-self-center-md {
1754+ justify-self: center; }
1755+ .justify-self-baseline-md {
1756+ justify-self: baseline; }
1757+ .justify-self-stretch-md {
1758+ justify-self: stretch; }
17241759 .content-start-md {
17251760 align-content: flex-start; }
17261761 .content-end-md {
@@ -1774,16 +1809,26 @@ caption,
17741809 .flex-center-lg {
17751810 justify-content: center;
17761811 align-items: center; }
1777− .self-start-lg {
1812+ .align-self-start-lg {
17781813 align-self: flex-start; }
1779− .self-end-lg {
1814+ .align-self-end-lg {
17801815 align-self: flex-end; }
1781− .self-center-lg {
1816+ .align-self-center-lg {
17821817 align-self: center; }
1783− .self-baseline-lg {
1818+ .align-self-baseline-lg {
17841819 align-self: baseline; }
1785− .self-stretch-lg {
1820+ .align-self-stretch-lg {
17861821 align-self: stretch; }
1822+ .justify-self-start-lg {
1823+ justify-self: flex-start; }
1824+ .justify-self-end-lg {
1825+ justify-self: flex-end; }
1826+ .justify-self-center-lg {
1827+ justify-self: center; }
1828+ .justify-self-baseline-lg {
1829+ justify-self: baseline; }
1830+ .justify-self-stretch-lg {
1831+ justify-self: stretch; }
17871832 .content-start-lg {
17881833 align-content: flex-start; }
17891834 .content-end-lg {
src/indigo-react/lib/useForm.jsnew file+188
@@ -0,0 +1,188 @@
1+import { useCallback, useMemo, useState } 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+
8+// interface InputConfig {
9+// name: string;
10+// validators: Function[];
11+// transformers: Function[];
12+// initialValue: string;
13+// autoFocus: boolean;
14+// disabled: boolean;
15+// ...extra,
16+// }
17+
18+const defaultsFor = (configs, mapper) =>
19+ configs.reduce(
20+ (memo, config) => ({ ...memo, [config.name]: mapper(config) }),
21+ {}
22+ );
23+
24+/**
25+ * useForm manages a set of inputs for rendering them in a loop
26+ */
27+export default function useForm(configs = []) {
28+ const byName = useMemo(() => keyBy(configs, 'name'), [configs]);
29+
30+ // track values
31+ const [values, _setValues] = useState(() =>
32+ defaultsFor(configs, config => config.initialValue)
33+ );
34+ const setValue = useCallback(
35+ (name, value) => _setValues(values => ({ ...values, [name]: value })),
36+ [_setValues]
37+ );
38+ // ^ NB(shrugs): because we're not syncing the initialValue of _additional_
39+ // configs that are added, we won't be able to set an initialValue for a
40+ // dynamic form that then triggers validation. for initial configs,
41+ // initialValue can be a validated string and everything will be happy,
42+ // but for dynamically added input configs, we won't get validation, etc
43+ // until there's a traditional state update.
44+
45+ // track focused states
46+ const [focused, setFocused] = useSetState(() =>
47+ defaultsFor(configs, config => config.autoFocus && !config.disabled)
48+ );
49+ const focuses = useMemo(
50+ () => configs.map(config => focused[config.name] || false), //
51+ [configs, focused]
52+ );
53+
54+ // track whether or not input has been focused
55+ const [hasBeenFocused, setHasBeenFocused] = useSetState({});
56+
57+ // build fn that transforms a value by input name
58+ const transform = useCallback(
59+ (name, value) => compose(...get(byName, [name, 'transformers'], []))(value),
60+ [byName]
61+ );
62+
63+ // build fn that validates a value by input name
64+ const validate = useCallback(
65+ (name, value) =>
66+ compose(
67+ ...get(byName, [name, 'validators'], []),
68+ kDefaultValidator
69+ )(value),
70+ [byName]
71+ );
72+
73+ // on change, transform and set value
74+ const onChange = useCallback(
75+ name => e => setValue(name, transform(name, e.target.value)), //
76+ [setValue, transform]
77+ );
78+ // on focus, update focus
79+ const onFocus = useCallback(
80+ name => e => setFocused({ [name]: true }), //
81+ [setFocused]
82+ );
83+
84+ // on blur, defocus and set has been focused
85+ const onBlur = useCallback(
86+ name => e => {
87+ setFocused({ [name]: false });
88+ setHasBeenFocused({ [name]: false });
89+ },
90+ [setFocused, setHasBeenFocused]
91+ );
92+
93+ // memo-compute validations of curent values
94+ const validations = useMemo(
95+ () => configs.map(config => validate(config.name, values[config.name])), //
96+ [configs, validate, values]
97+ );
98+
99+ // memo-compute the set of (perhaps changed by validation) data
100+ const datas = useMemo(() => validations.map(v => v.data), [validations]);
101+
102+ // the input has errored if it
103+ // 1) has been focused before
104+ // 2)
105+ // a) did not pass validation and has an error text
106+ // b) or has a specific error from parent state
107+ // (and we implicitly return the error text at the end)
108+ const errors = useMemo(
109+ () =>
110+ configs.map(
111+ (config, i) =>
112+ hasBeenFocused[config.name] && //
113+ (validations[i].error || config.error)
114+ ),
115+ [configs, validations, hasBeenFocused]
116+ );
117+
118+ // this input has passed if
119+ // 1) it has passed local validation
120+ // 2) there are no errors
121+ const passes = useMemo(
122+ () => validations.map((v, i) => v.pass && !errors[i]),
123+ [validations, errors]
124+ );
125+
126+ // visibly tell the user that their input has passed if
127+ // 1) they are or have interacted with the input before
128+ // 2) and it has passed as defined above
129+ const visiblePasses = useMemo(
130+ () =>
131+ configs.map(
132+ (config, i) =>
133+ (focused[config.name] || hasBeenFocused[config.name]) && //
134+ passes[i]
135+ ),
136+ [configs, hasBeenFocused, focused, passes]
137+ );
138+
139+ // generate the list of input states
140+ const inputs = useMemo(
141+ () =>
142+ configs.map(
143+ ({ name, error, autoFocus, disabled, initialValue, ...rest }, i) => ({
144+ // Input props
145+ name,
146+ data: datas[i],
147+ pass: passes[i],
148+ visiblyPassed: visiblePasses[i],
149+ error: errors[i],
150+ focused: focuses[i],
151+ autoFocus: autoFocus && !disabled,
152+ disabled,
153+ ...rest,
154+ // dom properties below:
155+ bind: {
156+ value: values[name] || initialValue,
157+ onChange: onChange(name),
158+ onFocus: onFocus(name),
159+ onBlur: onBlur(name),
160+ autoFocus: autoFocus && !disabled,
161+ },
162+ })
163+ ),
164+ [
165+ configs,
166+ datas,
167+ visiblePasses,
168+ passes,
169+ errors,
170+ focuses,
171+ values,
172+ onBlur,
173+ onChange,
174+ onFocus,
175+ ]
176+ );
177+
178+ // did all of the inputs pass validation?
179+ const pass = useMemo(() => every(passes), [passes]);
180+ // did any of the inputs error?
181+ const error = useMemo(() => some(errors), [errors]);
182+
183+ return {
184+ inputs,
185+ pass,
186+ error,
187+ };
188+}
src/lib/LocalRouter.jsnew file+9
@@ -0,0 +1,9 @@
1+import { createContext, useContext } from 'react';
2+
3+export const LocalRouterContext = createContext(null);
4+export const LocalRouterProvider = LocalRouterContext.Provider;
5+
6+// Hook version
7+export function useLocalRouter() {
8+ return useContext(LocalRouterContext);
9+}
src/_walletgen/lib/constants.js → src/lib/constants.jsrenamed+1
@@ -11,6 +11,7 @@ const GALAXY_ENTROPY_BITS = 384;
1111
1212const SEED_ENTROPY_BITS = 128;
1313
14+// TODO: this is walletgen-ui specific, move into a wallet router later
1415const GEN_STATES = {
1516 ENY_NOSTART: Symbol('ENY_NOSTART'),
1617 ENY_PENDING: Symbol('ENY_PENDING'),
src/lib/invite.js+9−6
@@ -3,7 +3,7 @@ import Tx from 'ethereumjs-tx';
33
44import * as azimuth from 'azimuth-js';
55import * as kg from 'urbit-key-generation/dist';
6−import * as wg from '../_walletgen/lib/lib.js';
6+import * as wg from './walletgen';
77import * as tank from './tank';
88
99import JSZip from 'jszip';
@@ -191,7 +191,7 @@ async function startTransactions(args) {
191191 });
192192
193193 // send transaction
194− await sendAndAwaitConfirm(web3, [rawTransferStx], usedTank);
194+ await sendAndAwaitConfirm(web3, [Just(transferStx)], usedTank);
195195
196196 //
197197 // we're gonna be operating as the new wallet from here on out, so change
@@ -249,10 +249,13 @@ async function startTransactions(args) {
249249 return tx;
250250 });
251251
252− let rawStxs = txs.map(tx => {
252+ let txPairs = txs.map(tx => {
253253 let stx = new Tx(tx);
254254 stx.sign(Buffer.from(realWallet.ownership.keys.private, 'hex'));
255− return '0x' + stx.serialize().toString('hex');
255+ return {
256+ raw: '0x' + stx.serialize().toString('hex'),
257+ signed: stx,
258+ };
256259 });
257260
258261 updateProgress({
@@ -264,7 +267,7 @@ async function startTransactions(args) {
264267 point,
265268 newAddress,
266269 totalCost,
267− rawStxs,
270+ txPairs.map(p => p.raw),
268271 askForFunding
269272 );
270273
@@ -273,7 +276,7 @@ async function startTransactions(args) {
273276 value: TRANSACTION_STATES.CONFIGURING,
274277 });
275278
276− await sendAndAwaitConfirm(web3, rawStxs, usedTank);
279+ await sendAndAwaitConfirm(web3, txPairs.map(p => Just(p.signed)), usedTank);
277280
278281 updateProgress({
279282 type: 'progress',
src/lib/loadingCharacter.jsdeleted−7
@@ -1,7 +0,0 @@
1−export const kLoadingCharacter = '▓';
2−
3−export default val =>
4− val.matchWith({
5− Nothing: () => kLoadingCharacter,
6− Just: p => p.value,
7− });
src/lib/need.js+1−2
@@ -22,8 +22,7 @@ export const wallet = needBuilder(BRIDGE_ERROR.MISSING_WALLET);
2222export const addressFromWallet = obj => wallet(obj).address;
2323export const pointCursor = needBuilder(BRIDGE_ERROR.MISSING_POINT);
2424export const pointCache = needBuilder(BRIDGE_ERROR.MISSING_POINT);
25−export const fromPointCache = (obj, point) => {
26− const cache = pointCache(obj);
25+export const fromPointCache = (cache, point) => {
2726 if (!(point in cache)) {
2827 throw BRIDGE_ERROR.MISSING_POINT;
2928 }
src/lib/pluralize.jsnew file+8
@@ -0,0 +1,8 @@
1+export default (count, singular, plural) => {
2+ const isPlural = count > 1;
3+ if (!plural) {
4+ return `${count} ${singular}${isPlural ? 's' : ''}`;
5+ }
6+
7+ return isPlural ? plural : singular;
8+};
src/lib/routeNames.js−9
@@ -11,15 +11,6 @@ export const ROUTE_NAMES = {
1111 ACCEPT_TRANSFER: Symbol('ACCEPT_TRANSFER'),
1212 CANCEL_TRANSFER: Symbol('CANCEL_TRANSFER'),
1313 LANDING: Symbol('LANDING'),
14− NETWORK: Symbol('NETWORK'),
15− WALLET: Symbol('WALLET'),
16− MNEMONIC: Symbol('MNEMONIC'),
17− TICKET: Symbol('TICKET'),
18− SHARDS: Symbol('SHARDS'),
19− LEDGER: Symbol('LEDGER'),
20− TREZOR: Symbol('TREZOR'),
21− PRIVATE_KEY: Symbol('PRIVATE_KEY'),
22− KEYSTORE: Symbol('KEYSTORE'),
2314 VIEW_POINT: Symbol('VIEW_POINT'),
2415 POINTS: Symbol('POINTS'),
2516 POINT: Symbol('POINT'),
src/lib/router.js+2−53
@@ -1,22 +1,15 @@
1−import React from 'react';
21import * as ob from 'urbit-ob';
32import * as need from './need';
43
54import Landing from '../views/Landing';
5+import Login from '../views/Login.js';
66import InviteTicket from '../views/InviteTicket';
77import InvitesManage from '../views/InvitesManage.js';
8−import Login from '../views/Login.js';
98import AcceptTransfer from '../views/AcceptTransfer';
109import CancelTransfer from '../views/CancelTransfer';
1110import CreateGalaxy from '../views/CreateGalaxy';
1211import GenKeyfile from '../views/GenKeyfile';
1312import IssueChild from '../views/IssueChild';
14−import Network from '../views/Network';
15−import Mnemonic from '../views/Mnemonic';
16−import Ledger from '../views/Ledger';
17−import Trezor from '../views/Trezor';
18−import PrivateKey from '../views/PrivateKey';
19−import Keystore from '../views/Keystore';
2013import ViewPoint from '../views/ViewPoint';
2114import SentTransaction from '../views/SentTransaction';
2215import SetKeys from '../views/SetKeys';
@@ -27,14 +20,9 @@ import {
2720} from '../views/SetProxy';
2821import Point from '../views/Point';
2922import Points from '../views/Points';
30−import Shards from '../views/Shards';
31−import Ticket from '../views/Ticket';
3223import Transfer from '../views/Transfer';
33−import Wallet from '../views/Wallet';
3424import Invite from 'views/Invite';
3525
36−import { EthereumWallet } from './wallet';
37−import { renderNetworkType } from './network';
3826import { ROUTE_NAMES } from './routeNames';
3927
4028export const ROUTES = {
@@ -46,16 +34,7 @@ export const ROUTES = {
4634 [ROUTE_NAMES.LOGIN]: Login,
4735 [ROUTE_NAMES.ACCEPT_TRANSFER]: AcceptTransfer,
4836 [ROUTE_NAMES.CANCEL_TRANSFER]: CancelTransfer,
49− [ROUTE_NAMES.NETWORK]: Network,
50− [ROUTE_NAMES.WALLET]: Wallet,
5137 [ROUTE_NAMES.VIEW_POINT]: ViewPoint,
52− [ROUTE_NAMES.MNEMONIC]: Mnemonic,
53− [ROUTE_NAMES.TICKET]: Ticket,
54− [ROUTE_NAMES.SHARDS]: Shards,
55− [ROUTE_NAMES.LEDGER]: Ledger,
56− [ROUTE_NAMES.TREZOR]: Trezor,
57− [ROUTE_NAMES.PRIVATE_KEY]: PrivateKey,
58− [ROUTE_NAMES.KEYSTORE]: Keystore,
5938 [ROUTE_NAMES.POINTS]: Points,
6039 [ROUTE_NAMES.POINT]: Point,
6140 [ROUTE_NAMES.SET_MANAGEMENT_PROXY]: SetManagementProxy,
@@ -69,11 +48,7 @@ export const ROUTES = {
6948 [ROUTE_NAMES.GEN_KEYFILE]: GenKeyfile,
7049};
7150
72−export const getRouteBreadcrumb = ({
73− pointCursor,
74− networkType,
75− wallet,
76−}) => route => {
51+export const getRouteBreadcrumb = ({ pointCursor }) => route => {
7752 switch (route.name) {
7853 case ROUTE_NAMES.INVITE_TICKET:
7954 return 'Invite code';
@@ -87,32 +62,6 @@ export const getRouteBreadcrumb = ({
8762 return 'Login to Bridge';
8863 case ROUTE_NAMES.POINT:
8964 return `Overview: ${ob.patp(need.pointCursor(pointCursor))}`;
90− case ROUTE_NAMES.NETWORK:
91− return renderNetworkType(networkType);
92− case ROUTE_NAMES.WALLET:
93− return wallet.matchWith({
94− Nothing: () => 'Wallet',
95− Just: wal =>
96− wal.value instanceof EthereumWallet ? (
97− <span className="text-mono">{wal.value.address}</span>
98− ) : (
99− <span className="text-mono">{wal.value.address}</span>
100− ),
101− });
102− case ROUTE_NAMES.MNEMONIC:
103− return 'Mnemonic';
104− case ROUTE_NAMES.TICKET:
105− return 'Urbit Ticket';
106− case ROUTE_NAMES.SHARDS:
107− return 'Urbit Ticket';
108− case ROUTE_NAMES.LEDGER:
109− return 'Ledger';
110− case ROUTE_NAMES.TREZOR:
111− return 'Trezor';
112− case ROUTE_NAMES.PRIVATE_KEY:
113− return 'Private Key';
114− case ROUTE_NAMES.KEYSTORE:
115− return 'Keystore File';
11665 case ROUTE_NAMES.POINTS:
11766 return 'Points';
11867 case ROUTE_NAMES.VIEW_POINT:
src/lib/tank.js+6−1
@@ -115,7 +115,12 @@ async function waitForBalance(
115115 resolve();
116116 } else {
117117 if (balance !== oldBalance) {
118− askForFunding(address, minBalance, balance);
118+ // TODO: minBalance is a `number` type
119+ // but we want to display ETH, which will never accept a number
120+ // but instead wants string or BN/BigNumber.
121+ // this will be heavily refactored to correctly do BN math, but until
122+ // then we'll manually convert this number to an integer string
123+ askForFunding(address, minBalance.toFixed(), balance);
119124 }
120125 setTimeout(checkForBalance, 13000);
121126 }
src/lib/txn.js+5−2
@@ -9,6 +9,9 @@ import { ledgerSignTransaction } from './ledger';
99import { trezorSignTransaction } from './trezor';
1010import { WALLET_TYPES, addHexPrefix } from './wallet';
1111
12+const CHECK_BLOCK_EVERY_MS =
13+ process.env.NODE_ENV === 'development' ? 1000 : 5000;
14+
1215const TXN_PURPOSE = {
1316 SET_MANAGEMENT_PROXY: Symbol('SET_MANAGEMENT_PROXY'),
1417 SET_TRANSFER_PROXY: Symbol('SET_TRANSFER_PROXY'),
@@ -161,7 +164,7 @@ const sendSignedTransaction = (web3, stx, doubtNonceError, confirmationCb) => {
161164 // if there's a nonce error, but we used the gas tank, it's likely
162165 // that it's because the tank already submitted our transaction.
163166 // we just wait for first confirmation here.
164− console.log('sent and got error', err);
167+ console.error(err);
165168 if (
166169 doubtNonceError &&
167170 (err.message || '').includes("the tx doesn't have the correct nonce.")
@@ -194,7 +197,7 @@ const waitForTransactionConfirm = (web3, txHash) => {
194197 console.log('tried, got', receipt);
195198 let confirmed = receipt !== null;
196199 if (confirmed) resolve(receipt.status === true);
197− else setTimeout(checkForConfirm, 13000);
200+ else setTimeout(checkForConfirm, CHECK_BLOCK_EVERY_MS);
198201 };
199202 checkForConfirm();
200203 });
src/lib/useArray.jsnew file+48
@@ -0,0 +1,48 @@
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/useInterval.jsnew file+22
@@ -0,0 +1,22 @@
1+import { useEffect, useRef } from 'react';
2+
3+// via: https://overreacted.io/making-setinterval-declarative-with-react-hooks/
4+export default function useInterval(callback, delay) {
5+ const savedCallback = useRef();
6+
7+ // Remember the latest callback.
8+ useEffect(() => {
9+ savedCallback.current = callback;
10+ }, [callback]);
11+
12+ // Set up the interval.
13+ useEffect(() => {
14+ function tick() {
15+ savedCallback.current();
16+ }
17+ if (delay !== null) {
18+ let id = setInterval(tick, delay);
19+ return () => clearInterval(id);
20+ }
21+ }, [delay]);
22+}
src/lib/useIsEclipticOwner.jsnew file+20
@@ -0,0 +1,20 @@
1+import { useWallet } from 'store/wallet';
2+import { usePointCache } from 'store/pointCache';
3+
4+import { eqAddr } from 'lib/wallet';
5+
6+export default function useIsEclipticOwner() {
7+ const { wallet } = useWallet();
8+ const { eclipticOwner } = usePointCache();
9+
10+ return wallet.matchWith({
11+ Nothing: () => false,
12+ Just: p => {
13+ if (!eclipticOwner) {
14+ return false;
15+ }
16+
17+ return eqAddr(p.value.address, eclipticOwner);
18+ },
19+ });
20+}
src/lib/useMailer.jsnew file+57
@@ -0,0 +1,57 @@
1+import { useCallback } from 'react';
2+import Maybe from 'folktale/maybe';
3+
4+import { hasReceived, sendMail } from './inviteMail';
5+import useSetState from './useSetState';
6+
7+const STUB_MAILER = process.env.REACT_APP_STUB_MAILER === 'true';
8+
9+function useHasReceivedCache() {
10+ const [cache, addToCache] = useSetState();
11+
12+ const getHasRecieved = useCallback(
13+ email => cache[email] || Maybe.Nothing(), //
14+ [cache]
15+ );
16+
17+ const syncHasReceivedForEmail = useCallback(
18+ async email => {
19+ if (Maybe.Just.hasInstance(getHasRecieved(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]: Maybe.Just(false) });
27+ }
28+
29+ const _hasReceived = await hasReceived(email);
30+ addToCache({ [email]: Maybe.Just(_hasReceived) });
31+ },
32+ [getHasRecieved, addToCache]
33+ );
34+
35+ return { getHasRecieved, syncHasReceivedForEmail };
36+}
37+
38+export default function useMailer(emails) {
39+ const hasReceivedCache = useHasReceivedCache(emails);
40+
41+ // prefix to avoid clobbering sendMail import
42+ // also throws if return value is false
43+ const _sendMail = useCallback(async (email, ticket, rawTx) => {
44+ if (STUB_MAILER) {
45+ console.log(`${email} - ${ticket}`);
46+ return;
47+ }
48+
49+ const mailSuccess = await sendMail(email, ticket, rawTx);
50+
51+ if (!mailSuccess) {
52+ throw new Error(`Internal mailing error when mailing ${email}`);
53+ }
54+ }, []);
55+
56+ return { ...hasReceivedCache, sendMail: _sendMail };
57+}
src/lib/usePreviousValue.jsnew file+16
@@ -0,0 +1,16 @@
1+import { useRef } from 'react';
2+
3+export default function usePreviousValue(value) {
4+ const prev = useRef(null);
5+ const curr = useRef(null);
6+
7+ if (prev.current !== curr.current) {
8+ prev.current = curr.current;
9+ }
10+
11+ if (curr.current !== value) {
12+ curr.current = value;
13+ }
14+
15+ return prev.current;
16+}
src/lib/useRenderCount.jsnew file+7
@@ -0,0 +1,7 @@
1+import { useRef } from 'react';
2+
3+export default function useRenderCount(debubgLabel = 'Component') {
4+ const count = useRef(0);
5+ count.current++;
6+ console.log(`${debubgLabel} — ${count.current}`);
7+}
src/lib/useSetState.jsnew file+16
@@ -0,0 +1,16 @@
1+import { useCallback, useState } from 'react';
2+
3+export default function useSetState(initialState = {}) {
4+ const [state, _setState] = useState(initialState);
5+ const setState = useCallback(
6+ items => _setState(state => ({ ...state, ...items })),
7+ [_setState]
8+ );
9+
10+ const reset = useCallback(() => _setState(initialState), [
11+ _setState,
12+ initialState,
13+ ]);
14+
15+ return [state, setState, reset];
16+}
src/lib/useSyncPoint.jsdeleted−12
@@ -1,12 +0,0 @@
1−import { useEffect } from 'react';
2−
3−import { usePointCache } from 'store/pointCache';
4−
5−// sync a specified point to the cache
6−export default function useSyncPoint(point) {
7− const { fetchPoint } = usePointCache();
8−
9− useEffect(() => {
10− fetchPoint(point);
11− }, [fetchPoint, point]);
12−}
src/lib/useSyncPoints.jsnew file+39
@@ -0,0 +1,39 @@
1+import { useEffect } from 'react';
2+import { isEqual } from 'lodash';
3+
4+import { usePointCache } from 'store/pointCache';
5+import usePreviousValue from 'lib/usePreviousValue';
6+
7+// sync a known (i.e. mine) point to the cache (i.e display values)
8+export function useSyncKnownPoints(points = []) {
9+ // TBD: right now we don't need anything more than the point itself
10+ // but soon we'll need to know information around "key type"
11+}
12+
13+// sync a foreign (not the user's) point to the cache
14+export function useSyncForeignPoints(points = []) {
15+ const { syncForeignPoint } = usePointCache();
16+ const prevPoints = usePreviousValue(points);
17+
18+ useEffect(() => {
19+ if (!isEqual(prevPoints, points)) {
20+ // if the actual set of points changes, sync the whole array
21+ // TODO: we should get clever about caching
22+ points.forEach(point => syncForeignPoint(point));
23+ }
24+ }, [syncForeignPoint, prevPoints, points]);
25+}
26+
27+// sync an owned point to the cache
28+export function useSyncOwnedPoints(points = []) {
29+ const { syncOwnedPoint } = usePointCache();
30+ const prevPoints = usePreviousValue(points);
31+
32+ useEffect(() => {
33+ if (!isEqual(prevPoints, points)) {
34+ // if the actual set of points changes, sync the whole array
35+ // TODO: we should get clever about caching
36+ points.forEach(point => syncOwnedPoint(point));
37+ }
38+ }, [syncOwnedPoint, prevPoints, points]);
39+}
src/lib/useWalletType.jsnew file+11
@@ -0,0 +1,11 @@
1+import { useEffect } from 'react';
2+
3+import { useWallet } from 'store/wallet';
4+
5+export default function useWalletType(walletType) {
6+ const { setWalletType } = useWallet();
7+
8+ useEffect(() => {
9+ setWalletType(walletType);
10+ }, [setWalletType, walletType]);
11+}
src/lib/validators.js+36−13
@@ -3,6 +3,10 @@ import * as ob from 'urbit-ob';
33
44import { isValidAddress } from './wallet';
55
6+// via: https://emailregex.com/
7+const 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,}))$/;
8+
9+// is this 64 hex chars?
610const hexRegExp = /[0-9A-Fa-f]{64}/g;
711
812// Wraps single validation functions in a controlled and predictable way.
@@ -18,7 +22,7 @@ export const simpleValidatorWrapper = config => {
1822 // Run the validator and return the result.
1923 return config.validator(config.prevMessage.data)
2024 ? newMessage(config.prevMessage.data, true, null)
21− : newMessage(config.prevMessage.data, false, config.errorMessage);
25+ : newMessage(config.prevMessage.data, false, config.error);
2226};
2327
2428// Validation message
@@ -45,15 +49,21 @@ export const validateMnemonic = m =>
4549 simpleValidatorWrapper({
4650 prevMessage: m,
4751 validator: d => bip39.validateMnemonic(d),
48− errorMessage: 'This is not a valid mnemonic.',
52+ error: 'This is not a valid mnemonic.',
4953 });
5054
5155// Checks an empty field
5256export const validateNotEmpty = m =>
5357 simpleValidatorWrapper({
5458 prevMessage: m,
55− validator: d => d.length > 1,
56− errorMessage: 'This field is required.',
59+ validator: d => {
60+ try {
61+ return d.length > 1;
62+ } catch {
63+ return false;
64+ }
65+ },
66+ error: 'This field is required.',
5767 });
5868
5969// Checks if a patp is a valid galaxy
@@ -69,7 +79,7 @@ export const validateGalaxy = m =>
6979 return false;
7080 }
7181 },
72− errorMessage: 'This is not a valid galaxy',
82+ error: 'This is not a valid galaxy',
7383 });
7484
7585export const validatePoint = m =>
@@ -82,7 +92,7 @@ export const validatePoint = m =>
8292 return false;
8393 }
8494 },
85− errorMessage: 'This is not a valid point',
95+ error: 'This is not a valid point',
8696 });
8797
8898export const validateTicket = m =>
@@ -95,7 +105,7 @@ export const validateTicket = m =>
95105 return false;
96106 }
97107 },
98− errorMessage: 'This is not a valid ticket',
108+ error: 'This is not a valid ticket',
99109 });
100110
101111export const validateShard = m =>
@@ -108,28 +118,34 @@ export const validateShard = m =>
108118 return false;
109119 }
110120 },
111− errorMessage: 'This is not a valid shard',
121+ error: 'This is not a valid shard',
112122 });
113123
114124export const validateLength = (m, l) =>
115125 simpleValidatorWrapper({
116126 prevMessage: m,
117− validator: d => d.length === l,
118− errorMessage: 'This is of an invalid length',
127+ validator: d => {
128+ try {
129+ return d.length === l;
130+ } catch {
131+ return false;
132+ }
133+ },
134+ error: 'This is of an invalid length',
119135 });
120136
121137export const validateNetworkKey = m =>
122138 simpleValidatorWrapper({
123139 prevMessage: m,
124140 validator: d => hexRegExp.test(d),
125− errorMessage: 'This is not a valid network key',
141+ error: 'This is not a valid network key',
126142 });
127143
128144export const validateNetworkSeed = m =>
129145 simpleValidatorWrapper({
130146 prevMessage: m,
131147 validator: d => hexRegExp.test(d),
132− errorMessage: 'This is not a valid network seed',
148+ error: 'This is not a valid network seed',
133149 });
134150
135151// Checks if a string is a valid ethereum address
@@ -137,5 +153,12 @@ export const validateEthereumAddress = m =>
137153 simpleValidatorWrapper({
138154 prevMessage: m,
139155 validator: d => isValidAddress(d),
140− errorMessage: 'This is not a valid Ethereum address',
156+ error: 'This is not a valid Ethereum address',
157+ });
158+
159+export const validateEmail = m =>
160+ simpleValidatorWrapper({
161+ prevMessage: m,
162+ validator: d => emailRegExp.test(d),
163+ error: 'This is not a valid email address',
141164 });
src/_walletgen/lib/lib.js → src/lib/walletgen.jsrenamed+10−5
@@ -2,6 +2,7 @@ import * as ob from 'urbit-ob';
22import * as kg from 'urbit-key-generation/dist';
33import * as more from 'more-entropy';
44import { chunk, flatMap, zipWith } from 'lodash';
5+
56import {
67 MIN_STAR,
78 MIN_PLANET,
@@ -14,7 +15,7 @@ import {
1415const SEED_LENGTH_BYTES = SEED_ENTROPY_BITS / 8;
1516
1617// returns a promise for a ticket string
17−const makeTicket = point => {
18+export const makeTicket = point => {
1819 const bits =
1920 point < MIN_STAR
2021 ? GALAXY_ENTROPY_BITS
@@ -43,7 +44,7 @@ const makeTicket = point => {
4344};
4445
4546// return a wallet object
46−const generateWallet = async (point, ticket, boot) => {
47+export const generateWallet = async (point, ticket, boot) => {
4748 const config = {
4849 ticket: ticket,
4950 seedSize: SEED_LENGTH_BYTES,
@@ -62,8 +63,12 @@ const generateWallet = async (point, ticket, boot) => {
6263 return wallet;
6364};
6465
65−// returns a promise
66−const generateOwnershipWallet = (ship, ticket) =>
66+export const generateOwnershipWallet = (ship, ticket) =>
6767 kg.generateOwnershipWallet({ ship, ticket });
6868
69−export { makeTicket, generateWallet, generateOwnershipWallet };
69+export const generateTemporaryTicketAndWallet = async point => {
70+ const ticket = await makeTicket(point);
71+ const owner = await generateOwnershipWallet(0, ticket);
72+
73+ return { ticket, owner };
74+};
src/store/lib/useBirthdaysStore.jsnew file+54
@@ -0,0 +1,54 @@
1+import { useCallback, useState } from 'react';
2+import Maybe from 'folktale/maybe';
3+import * as azimuth from 'azimuth-js';
4+
5+import { useNetwork } from '../network';
6+
7+export default function useBirthdayStore() {
8+ const { contracts, web3 } = useNetwork();
9+ const [birthdayCache, _setBirthdayCache] = useState({});
10+
11+ const addToBirthdayCache = useCallback(
12+ entry =>
13+ _setBirthdayCache(cache => ({
14+ ...cache,
15+ ...entry,
16+ })),
17+ [_setBirthdayCache]
18+ );
19+
20+ const getBirthday = useCallback(
21+ point => birthdayCache[point] || Maybe.Nothing(),
22+ [birthdayCache]
23+ );
24+
25+ const syncBirthday = useCallback(
26+ async point => {
27+ const _contracts = contracts.getOrElse(null);
28+ const _web3 = web3.getOrElse(null);
29+ if (!_contracts || !web3) {
30+ return;
31+ }
32+
33+ // birthday will not change after being set, so bail if we already know
34+ if (Maybe.Just.hasInstance(getBirthday(point))) {
35+ return;
36+ }
37+
38+ const birthBlock = await azimuth.azimuth.getActivationBlock(
39+ _contracts,
40+ point
41+ );
42+
43+ if (birthBlock > 0) {
44+ const block = await _web3.eth.getBlock(birthBlock);
45+ addToBirthdayCache({
46+ [point]: Maybe.Just(new Date(block.timestamp * 1000)),
47+ });
48+ }
49+ },
50+ [contracts, web3, addToBirthdayCache, getBirthday]
51+ );
52+
53+ return { getBirthday, syncBirthday };
54+}
src/store/lib/useControlledPointsStore.jsnew file+64
@@ -0,0 +1,64 @@
1+import { useState, useCallback } from 'react';
2+import Maybe from 'folktale/maybe';
3+import * as azimuth from 'azimuth-js';
4+
5+import { useNetwork } from '../network';
6+import { useWallet } from 'store/wallet';
7+
8+const kEmptyControlledPoints = {
9+ ownedPoints: Maybe.Nothing(),
10+ incomingPoints: Maybe.Nothing(),
11+ managingPoints: Maybe.Nothing(),
12+ votingPoints: Maybe.Nothing(),
13+ spawningPoints: Maybe.Nothing(),
14+};
15+
16+export default function useControlledPointsStore() {
17+ const { contracts } = useNetwork();
18+ const { wallet } = useWallet();
19+ const [controlledPointsCache, _setControlledPointsCache] = useState(
20+ kEmptyControlledPoints
21+ );
22+
23+ const getControlledPoints = useCallback(
24+ () => controlledPointsCache || kEmptyControlledPoints,
25+ [controlledPointsCache]
26+ );
27+
28+ const syncControlledPoints = useCallback(async () => {
29+ const _contracts = contracts.getOrElse(null);
30+ const _wallet = wallet.getOrElse(null);
31+ if (!_contracts || !_wallet) {
32+ return;
33+ }
34+
35+ const address = _wallet.address;
36+
37+ const [
38+ ownedPoints,
39+ incomingPoints,
40+ managingPoints,
41+ votingPoints,
42+ spawningPoints,
43+ ] = await Promise.all([
44+ azimuth.azimuth.getOwnedPoints(_contracts, address),
45+ azimuth.azimuth.getTransferringFor(_contracts, address),
46+ azimuth.azimuth.getManagerFor(_contracts, address),
47+ azimuth.azimuth.getVotingFor(_contracts, address),
48+ azimuth.azimuth.getSpawningFor(_contracts, address),
49+ ]);
50+
51+ _setControlledPointsCache({
52+ ownedPoints: Maybe.Just(ownedPoints),
53+ incomingPoints: Maybe.Just(incomingPoints),
54+ managingPoints: Maybe.Just(managingPoints),
55+ votingPoints: Maybe.Just(votingPoints),
56+ spawningPoints: Maybe.Just(spawningPoints),
57+ });
58+ }, [contracts, wallet, _setControlledPointsCache]);
59+
60+ return {
61+ getControlledPoints,
62+ syncControlledPoints,
63+ };
64+}
src/store/lib/useDetailsStore.jsnew file+51
@@ -0,0 +1,51 @@
1+import { useState, useCallback } from 'react';
2+import Maybe from 'folktale/maybe';
3+import * as azimuth from 'azimuth-js';
4+
5+import { useNetwork } from '../network';
6+
7+export default function useDetailsStore() {
8+ const { contracts } = useNetwork();
9+ const [detailsCache, _setDetailsCache] = useState({});
10+
11+ const addToDetails = useCallback(
12+ entry =>
13+ _setDetailsCache(cache => ({
14+ ...cache,
15+ ...entry,
16+ })),
17+ [_setDetailsCache]
18+ );
19+
20+ // TODO: refactor detailsCache access to use accessor like bithday
21+ // Maybe<{}>
22+ const getDetails = useCallback(
23+ point =>
24+ point in detailsCache ? Maybe.Just(detailsCache[point]) : Maybe.Nothing(),
25+ [detailsCache]
26+ );
27+
28+ const syncDetails = useCallback(
29+ async point => {
30+ const _contracts = contracts.getOrElse(null);
31+ if (!_contracts) {
32+ return;
33+ }
34+
35+ // fetch point details
36+ const details = await azimuth.azimuth.getPoint(_contracts, point);
37+ addToDetails({
38+ [point]: details,
39+ });
40+ },
41+ [contracts, addToDetails]
42+ );
43+
44+ return {
45+ // TODO: refactor accessors to use getDetails instead of
46+ // touching the cache directly
47+ pointCache: detailsCache,
48+ getDetails,
49+ syncDetails,
50+ };
51+}
src/store/lib/useEclipticOwnerStore.jsnew file+26
@@ -0,0 +1,26 @@
1+import { useCallback, useState } from 'react';
2+import * as azimuth from 'azimuth-js';
3+
4+import { useNetwork } from 'store/network';
5+
6+export default function useEclipticOwnerStore() {
7+ const { contracts } = useNetwork();
8+ const [eclipticOwner, _setEclipticOwner] = useState(null);
9+
10+ const syncEclipticOwner = useCallback(async () => {
11+ // owner is generally immutable
12+ if (eclipticOwner) {
13+ return;
14+ }
15+
16+ const _contracts = contracts.getOrElse(null);
17+ if (!_contracts) {
18+ return;
19+ }
20+
21+ const owner = await azimuth.ecliptic.owner(_contracts);
22+ _setEclipticOwner(owner);
23+ }, [contracts, eclipticOwner]);
24+
25+ return { eclipticOwner, syncEclipticOwner };
26+}
src/store/lib/useInvitesStore.jsnew file+65
@@ -0,0 +1,65 @@
1+import { useCallback, useState } from 'react';
2+import Maybe from 'folktale/maybe';
3+import * as azimuth from 'azimuth-js';
4+
5+import { useNetwork } from '../network';
6+
7+// the default value of a point's invites
8+const kEmptyInvites = {
9+ availableInvites: Maybe.Nothing(),
10+ sentInvites: Maybe.Nothing(),
11+ acceptedInvites: Maybe.Nothing(),
12+};
13+
14+export default function useInvitesStore() {
15+ const { contracts } = useNetwork();
16+ const [invitesCache, _setInvitesCache] = useState({});
17+
18+ const addToInvitesCache = useCallback(
19+ entry =>
20+ _setInvitesCache(cache => ({
21+ ...cache,
22+ ...entry,
23+ })),
24+ [_setInvitesCache]
25+ );
26+
27+ const getInvites = useCallback(
28+ point => invitesCache[point] || kEmptyInvites,
29+ [invitesCache]
30+ );
31+
32+ const syncInvites = useCallback(
33+ async point => {
34+ const _contracts = contracts.getOrElse(null);
35+ if (!_contracts) {
36+ return;
37+ }
38+
39+ const [availableInvites, invitedPoints] = await Promise.all([
40+ azimuth.delegatedSending.getTotalUsableInvites(_contracts, point),
41+ azimuth.delegatedSending.getInvited(_contracts, point),
42+ ]);
43+
44+ const activity = await Promise.all(
45+ invitedPoints.map(invitedPoint =>
46+ azimuth.azimuth.isActive(_contracts, invitedPoint)
47+ )
48+ );
49+
50+ const sentInvites = invitedPoints.length;
51+ const acceptedInvites = activity.filter(i => i.active).length;
52+
53+ addToInvitesCache({
54+ [point]: {
55+ availableInvites: Maybe.Just(availableInvites),
56+ sentInvites: Maybe.Just(sentInvites),
57+ acceptedInvites: Maybe.Just(acceptedInvites),
58+ },
59+ });
60+ },
61+ [contracts, addToInvitesCache]
62+ );
63+
64+ return { getInvites, syncInvites };
65+}
src/store/lib/usePointStore.jsnew file+41
@@ -0,0 +1,41 @@
1+import { useCallback } from 'react';
2+
3+import useDetailsStore from './useDetailsStore';
4+import useBirthdaysStore from './useBirthdaysStore';
5+import useInvitesStore from './useInvitesStore';
6+import useControlledPointsStore from './useControlledPointsStore';
7+
8+export default function usePointStore() {
9+ const { syncDetails, ...details } = useDetailsStore();
10+ const { syncBirthday, ...birthdays } = useBirthdaysStore();
11+ const { syncInvites, ...invites } = useInvitesStore();
12+ const {
13+ syncControlledPoints,
14+ ...controlledPoints
15+ } = useControlledPointsStore();
16+
17+ // sync all of the on-chain info required to display a foreign point
18+ const syncForeignPoint = useCallback(async point => Promise.all([]), []);
19+
20+ // sync all of the on-chain info required for a point that the user owns
21+ const syncOwnedPoint = useCallback(
22+ async point =>
23+ Promise.all([
24+ syncDetails(point),
25+ syncBirthday(point),
26+ syncInvites(point),
27+ ]),
28+ [syncDetails, syncBirthday, syncInvites]
29+ );
30+
31+ return {
32+ ...details,
33+ ...birthdays,
34+ ...invites,
35+ ...controlledPoints,
36+ syncInvites,
37+ syncControlledPoints,
38+ syncOwnedPoint,
39+ syncForeignPoint,
40+ };
41+}
src/store/pointCache.js+5−111
@@ -1,120 +1,14 @@
1−import React, {
2− createContext,
3− forwardRef,
4− useCallback,
5− useContext,
6− useState,
7−} from 'react';
8−import Maybe from 'folktale/maybe';
9−import * as azimuth from 'azimuth-js';
1+import React, { createContext, forwardRef, useContext } from 'react';
102
11−import { useNetwork } from './network';
3+import usePointStore from './lib/usePointStore';
124
135export const PointCacheContext = createContext(null);
146
15−// the default value of a point's invites
16−const kEmptyInvites = {
17− availableInvites: Maybe.Nothing(),
18− sentInvites: Maybe.Nothing(),
19− acceptedInvites: Maybe.Nothing(),
20−};
21−
22−function _usePointCache() {
23− const { contracts, web3 } = useNetwork();
24− const [pointCache, _setPointCache] = useState({});
25− const [birthdayCache, _setBirthdayCache] = useState({});
26− const [invitesCache, _setInvitesCache] = useState({});
27−
28− const addToPointCache = useCallback(
29− entry => _setPointCache(cache => ({ ...cache, ...entry })),
30− [_setPointCache]
31− );
32−
33− const addToBirthdayCache = useCallback(
34− entry => _setBirthdayCache(cache => ({ ...cache, ...entry })),
35− [_setBirthdayCache]
36− );
37−
38− const addToInvitesCache = useCallback(
39− entry => _setInvitesCache(cache => ({ ...cache, ...entry })),
40− [_setInvitesCache]
41− );
42−
43− // TODO: refactor pointCache access to use accessor like bithday
44− const getBirthday = useCallback(
45− point => birthdayCache[point] || Maybe.Nothing(),
46− [birthdayCache]
47− );
48− const getInvites = useCallback(
49− point => invitesCache[point] || kEmptyInvites,
50− [invitesCache]
51− );
52−
53− const fetchPoint = useCallback(
54− async point => {
55− const _contracts = contracts.getOrElse(null);
56− const _web3 = web3.getOrElse(null);
57− if (!_contracts || !_web3) {
58− return;
59− }
60−
61− // fetch point details
62− const details = await azimuth.azimuth.getPoint(_contracts, point);
63− addToPointCache({ [point]: details });
64−
65− // fetch invites
66− const count = await azimuth.delegatedSending.getTotalUsableInvites(
67− _contracts,
68− point
69− );
70− addToInvitesCache({
71− [point]: {
72− availableInvites: Maybe.Just(count),
73− // TODO: look up sent/accepted on-chain
74− sentInvites: Maybe.Just(6),
75− acceptedInvites: Maybe.Just(5),
76− },
77− });
78−
79− // fetch birthday (if not already known — will not change after being set)
80− if (Maybe.Nothing.hasInstance(getBirthday(point))) {
81− const birthBlock = await azimuth.azimuth.getActivationBlock(
82− _contracts,
83− point
84− );
85−
86− if (birthBlock > 0) {
87− const block = await _web3.eth.getBlock(birthBlock);
88− addToBirthdayCache({
89− [point]: Maybe.Just(new Date(block.timestamp * 1000)),
90− });
91− }
92− }
93− },
94− [
95− contracts,
96− web3,
97− addToPointCache,
98− addToBirthdayCache,
99− getBirthday,
100− addToInvitesCache,
101− ]
102− );
103−
104− return {
105− pointCache,
106− getBirthday,
107− getInvites,
108− addToPointCache,
109− fetchPoint,
110− };
111−}
112−
1137export function PointCacheProvider({ children }) {
114− const pointCache = _usePointCache();
8+ const cache = usePointStore();
1159
11610 return (
117− <PointCacheContext.Provider value={pointCache}>
11+ <PointCacheContext.Provider value={cache}>
11812 {children}
11913 </PointCacheContext.Provider>
12014 );
@@ -129,6 +23,6 @@ export function usePointCache() {
12923export const withPointCache = Component =>
13024 forwardRef((props, ref) => (
13125 <PointCacheContext.Consumer>
132− {pointCache => <Component ref={ref} {...pointCache} {...props} />}
26+ {cache => <Component ref={ref} {...cache} {...props} />}
13327 </PointCacheContext.Consumer>
13428 ));
src/store/wallet.js+25−3
@@ -13,11 +13,13 @@ import {
1313 DEFAULT_HD_PATH,
1414 addressFromSecp256k1Public,
1515 walletFromMnemonic,
16−} from '../lib/wallet';
17−import { BRIDGE_ERROR } from '../lib/error';
16+} from 'lib/wallet';
17+import { BRIDGE_ERROR } from 'lib/error';
1818
1919export const WalletContext = createContext(null);
2020
21+const kDefaultWalletType = WALLET_TYPES.TICKET;
22+
2123// NB (jtobin):
2224//
2325// Note that the 'wallet' prop has type depending on the 'walletType' prop.
@@ -34,7 +36,7 @@ function _useWallet(
3436 initialWallet = Maybe.Nothing(),
3537 initialMnemonic = Maybe.Nothing()
3638) {
37− const [walletType, _setWalletType] = useState(WALLET_TYPES.MNEMONIC);
39+ const [walletType, _setWalletType] = useState(kDefaultWalletType);
3840 const [walletHdPath, setWalletHdPath] = useState(DEFAULT_HD_PATH);
3941 const [wallet, _setWallet] = useState(initialWallet);
4042 const [urbitWallet, _setUrbitWallet] = useState(Maybe.Nothing());
@@ -93,6 +95,24 @@ function _useWallet(
9395 [setWallet]
9496 );
9597
98+ const resetWallet = useCallback(() => {
99+ _setWalletType(kDefaultWalletType);
100+ setWalletHdPath(DEFAULT_HD_PATH);
101+ _setWallet(Maybe.Nothing());
102+ _setUrbitWallet(Maybe.Nothing());
103+ setAuthMnemonic(Maybe.Nothing());
104+ setNetworkSeed(Maybe.Nothing());
105+ setNetworkRevision(Maybe.Nothing());
106+ }, [
107+ _setWalletType,
108+ setWalletHdPath,
109+ _setWallet,
110+ _setUrbitWallet,
111+ setAuthMnemonic,
112+ setNetworkSeed,
113+ setNetworkRevision,
114+ ]);
115+
96116 return {
97117 //
98118 walletType,
@@ -113,6 +133,8 @@ function _useWallet(
113133 setNetworkSeed,
114134 networkRevision,
115135 setNetworkRevision,
136+ //
137+ resetWallet,
116138 };
117139}
118140
src/style/indigo-proposals.scss+4
@@ -27,3 +27,7 @@
2727.minh-100 {
2828 min-height: 100vh;
2929}
30+
31+.outline-none {
32+ outline: none;
33+}
src/views/Invite.js+44−41
@@ -4,20 +4,20 @@ import { Grid, H5, Text } from 'indigo-react';
44
55import { usePointCursor } from 'store/pointCursor';
66import { useHistory } from 'store/history';
7−// import { usePointCache } from 'store/pointCache';
87
98import View from 'components/View';
109import Crumbs from 'components/Crumbs';
10+import Highlighted from 'components/Highlighted';
11+import { matchBlinky } from 'components/Blinky';
1112
1213import useInvites from 'lib/useInvites';
13−import useSyncPoint from 'lib/useSyncPoint';
14+import { useSyncOwnedPoints } from 'lib/useSyncPoints';
1415import useCurrentPointName from 'lib/useCurrentPointName';
1516import useRouter from 'lib/useRouter';
16−import loadingCharacter from 'lib/loadingCharacter';
17+import { LocalRouterProvider } from 'lib/LocalRouter';
1718
1819import InviteUrl from './Invite/InviteUrl';
1920import InviteEmail from './Invite/InviteEmail';
20−import Highlighted from 'components/Highlighted';
2121
2222const kInviteNames = {
2323 URL: 'URL',
@@ -31,7 +31,7 @@ const kInviteViews = {
3131
3232export default function Invite() {
3333 const history = useHistory();
34− const { Route, ...routeProps } = useRouter({
34+ const { Route, ...router } = useRouter({
3535 names: kInviteNames,
3636 views: kInviteViews,
3737 initialRoutes: [{ key: kInviteNames.URL }],
@@ -40,45 +40,48 @@ export default function Invite() {
4040 const point = need.pointCursor(pointCursor);
4141 const name = useCurrentPointName();
4242
43− // sync the current cursor
44− useSyncPoint(point);
43+ // sync the current point
44+ useSyncOwnedPoints([point]);
4545
46− // fetch the invites for the current cursor
46+ // get the invites for the current point
4747 const { availableInvites, sentInvites, acceptedInvites } = useInvites(point);
48− const availableInvitesText = loadingCharacter(availableInvites);
49− const sentInvitesText = loadingCharacter(sentInvites);
50− const acceptedInvitesText = loadingCharacter(acceptedInvites);
48+
49+ // number or loading character
50+ const availableInvitesText = matchBlinky(availableInvites);
51+ const sentInvitesText = matchBlinky(sentInvites);
52+ const acceptedInvitesText = matchBlinky(acceptedInvites);
5153
5254 return (
53− <View>
54− <Grid className="mb3">
55− <Grid.Item
56− as={Crumbs}
57− routes={[
58− {
59− text: name,
60− action: () => history.pop(),
61− },
62− {
63− text: 'Invite',
64− },
65− ]}
66− full
67− />
68− <Grid.Item as={H5} className="mv4" full>
69− Invite
70− </Grid.Item>
71− <Grid.Item as={Text} full>
72− You currently have{' '}
73− <Highlighted>{availableInvitesText} invitations</Highlighted> left.
74− <br />
75− Out of the <Highlighted>{sentInvitesText}</Highlighted> invites you
76− sent, <Highlighted>{acceptedInvitesText}</Highlighted> have been
77− accepted.
78− </Grid.Item>
79− </Grid>
80− {/* TODO(shrugs): use context isntead of this routeProps nonsense */}
81− <Route {...routeProps} />
82− </View>
55+ <LocalRouterProvider value={router}>
56+ <View>
57+ <Grid className="mb4">
58+ <Grid.Item
59+ as={Crumbs}
60+ routes={[
61+ {
62+ text: name,
63+ action: () => history.pop(),
64+ },
65+ {
66+ text: 'Invite',
67+ },
68+ ]}
69+ full
70+ />
71+ <Grid.Item as={H5} className="mv4" full>
72+ Invite
73+ </Grid.Item>
74+ <Grid.Item as={Text} full>
75+ You currently have{' '}
76+ <Highlighted>{availableInvitesText} invitations</Highlighted> left.
77+ <br />
78+ Out of the <Highlighted>{sentInvitesText}</Highlighted> invites you
79+ sent, <Highlighted>{acceptedInvitesText}</Highlighted> have been
80+ accepted.
81+ </Grid.Item>
82+ </Grid>
83+ <Route />
84+ </View>
85+ </LocalRouterProvider>
8386 );
8487}
src/views/Invite/InviteEmail.js+435−439
@@ -1,13 +1,21 @@
1−import { Just, Nothing } from 'folktale/maybe';
2−import React from 'react';
1+import React, { useCallback, useEffect, useMemo, useState } from 'react';
2+import cn from 'classnames';
3+import Maybe from 'folktale/maybe';
34import * as azimuth from 'azimuth-js';
4−import * as need from 'lib/need';
5−
6−import { hasReceived, sendMail } from 'lib/inviteMail';
7−import { Button, Input, H3, Warning } from 'components/old/Base';
5+import {
6+ Grid,
7+ Flex,
8+ Input,
9+ IconButton,
10+ HelpText,
11+ Text,
12+ ErrorText,
13+ AccessoryIcon,
14+} from 'indigo-react';
15+import { uniq } from 'lodash';
816
9−// for wallet generation
10−import * as wg from '_walletgen/lib/lib';
17+import * as need from 'lib/need';
18+import * as wg from 'lib/walletgen';
1119
1220// for transaction generation and signing
1321import {
@@ -19,483 +27,471 @@ import {
1927 toWei,
2028} from 'lib/txn';
2129import * as tank from 'lib/tank';
22−import { withNetwork } from 'store/network';
23−import { compose } from 'lib/lib';
24−import { withWallet } from 'store/wallet';
25−import { withPointCursor } from 'store/pointCursor';
26−import View from 'components/View';
30+import { useLocalRouter } from 'lib/LocalRouter';
31+
32+import MiniBackButton from 'components/MiniBackButton';
33+import { usePointCursor } from 'store/pointCursor';
34+import LoadableButton from 'components/LoadableButton';
35+import useArray from 'lib/useArray';
36+import useForm from 'indigo-react/lib/useForm';
37+import { buildEmailInputConfig } from 'components/Inputs';
38+import { MIN_PLANET } from 'lib/constants';
39+import Highlighted from 'components/Highlighted';
40+import { useNetwork } from 'store/network';
41+import { usePointCache } from 'store/pointCache';
42+import { useWallet } from 'store/wallet';
43+import useSetState from 'lib/useSetState';
44+import pluralize from 'lib/pluralize';
45+import useMailer from 'lib/useMailer';
2746
2847const GAS_PRICE_GWEI = 20; // we pay the premium for faster ux
29−const GAS_LIMIT = 350000;
48+const GAS_LIMIT = 400000;
3049const INVITE_COST = toWei((GAS_PRICE_GWEI * GAS_LIMIT).toString(), 'gwei');
50+const HAS_RECEIVED_TEXT = 'This email has already received an invite.';
3151
3252const STATUS = {
33− INPUT: 'Generate invites',
34− GENERATING: 'Generating invites',
35− CAN_SEND: 'Send invites',
36− FUNDING: 'Funding invites',
37− SENDING: 'Sending invites',
38− DONE: 'Done',
39− FAIL: 'Some failed',
53+ INPUT: 'INPUT',
54+ GENERATING: 'GENERATING',
55+ CAN_SEND: 'CAN_SEND',
56+ FUNDING: 'FUNDING',
57+ SENDING: 'SENDING',
58+ SUCCESS: 'SUCCESS',
59+ FAILURE: 'FAILURE',
4060};
4161
42−const EMAIL_STATUS = {
43− BUSY: '⋯',
44− DONE: '✔',
45− FAIL: '×',
46−};
47−
48−class InviteEmail extends React.Component {
49− constructor(props) {
50− super(props);
51−
52− this.state = {
53− invitesAvailable: Nothing(),
54− invited: Nothing(),
55− targets: [{ email: '', hasReceived: Nothing(), status: Nothing() }],
56− status: STATUS.INPUT,
57− errors: Nothing(),
58− //
59− fundingMessage: Nothing(),
60− wipInvites: [],
61− };
62−
63− this.findInvited = this.findInvited.bind(this);
64− this.generateInvites = this.generateInvites.bind(this);
65− this.sendInvites = this.sendInvites.bind(this);
66− this.canSend = this.canSend.bind(this);
67− this.addError = this.addError.bind(this);
68− this.handleEmailInput = this.handleEmailInput.bind(this);
69− this.addTarget = this.addTarget.bind(this);
70− this.removeTarget = this.removeTarget.bind(this);
71− this.setEmailStatus = this.setEmailStatus.bind(this);
72− this.askForFunding = this.askForFunding.bind(this);
73− }
74−
75− componentDidMount() {
76− this.point = need.pointCursor(this.props.pointCursor);
77− this.address = need.addressFromWallet(this.props.wallet);
78− this.contracts = need.contracts(this.props.contracts);
79− this.web3 = need.web3(this.props.web3);
80−
81− console.log(azimuth.delegatedSending);
82− azimuth.delegatedSending
83− .getTotalUsableInvites(this.contracts, this.point)
84− .then(count => {
85− this.setState({ invitesAvailable: Just(count) });
86− });
87− this.findInvited();
88− }
89−
90− componentDidUpdate(prevProps) {
91− //
92− }
93−
94− async findInvited() {
95− let invited = await azimuth.delegatedSending.getInvited(
96− this.contracts,
97− this.point
98− );
99− invited = invited.map(async point => {
100− const active = await azimuth.azimuth.isActive(this.contracts, point);
101− const res = { point: Number(point), active: active };
102− return res;
103− });
104− invited = await Promise.all(invited);
105− const total = invited.length;
106− const accepted = invited.filter(i => i.active).length;
107− this.setState({ invited: Just({ total, accepted }) });
62+const buttonText = (status, count) => {
63+ switch (status) {
64+ case STATUS.INPUT:
65+ return `Generate ${pluralize(count, 'Invite')}`;
66+ case STATUS.GENERATING:
67+ return `Generating ${pluralize(count, 'Invite')}...`;
68+ case STATUS.CAN_SEND:
69+ return `Send ${pluralize(count, 'Invite')}`;
70+ case STATUS.FUNDING:
71+ return `Waiting on Funds...`;
72+ case STATUS.SENDING:
73+ return `Sending ${pluralize(count, 'Invite')}...`;
74+ case STATUS.FAILURE:
75+ default:
76+ return 'Error';
10877 }
78+};
10979
110− async getTemporaryWallet() {
111− const ticket = await wg.makeTicket(0x10000); // planet-sized ticket
112− const owner = await wg.generateOwnershipWallet(0, ticket);
113− return { ticket, owner: owner.keys.address };
114− }
80+// world's simplest uid
81+let id = 0;
82+const buildInputConfig = (extra = {}) =>
83+ buildEmailInputConfig({
84+ name: `email-${id++}`,
85+ placeholder: 'Email Address',
86+ ...extra,
87+ });
88+
89+const buildAccessoryFor = (dones, errors) => name => {
90+ if (dones[name]) return <AccessoryIcon.Success />;
91+ if (errors[name]) return <AccessoryIcon.Failure />;
92+ return <AccessoryIcon.Pending />;
93+};
11594
116− async generateInvites() {
117− this.setState({ status: STATUS.GENERATING });
95+// TODO: test with tank, successful txs
96+export default function InviteEmail() {
97+ // TODO: resumption after error?
98+ const { pop } = useLocalRouter();
99+ const { contracts, web3, networkType } = useNetwork();
100+ const { wallet, walletType, walletHdPath } = useWallet();
101+ const { syncInvites, getInvites } = usePointCache();
102+ const { pointCursor } = usePointCursor();
103+ const point = need.pointCursor(pointCursor);
104+ const { getHasRecieved, syncHasReceivedForEmail, sendMail } = useMailer();
105+
106+ const { availableInvites } = getInvites(point);
107+ const maxInvitesToSend = availableInvites.matchWith({
108+ Nothing: () => 0,
109+ Just: p => p.value,
110+ });
111+
112+ // manage the array of input configs
113+ const [
114+ inputConfigs,
115+ { append: appendInput, removeAt: removeInputAt },
116+ ] = useArray(
117+ [buildInputConfig({ label: 'Email Address' })],
118+ buildInputConfig
119+ );
120+
121+ // manage per-input state
122+ const [hovered, setHovered] = useSetState();
123+ const [invites, addInvite, clearInvites] = useSetState();
124+ const [receipts, addReceipt, clearReceipts] = useSetState();
125+ const [errors, addError, clearErrors] = useSetState();
126+
127+ // manage general state that affects the whole form
128+ const [status, setStatus] = useState(STATUS.INPUT);
129+ const [needFunds, setNeedFunds] = useState(null);
130+ const [generalError, setGeneralError] = useState(null);
131+
132+ // derive booleans from status
133+ const canInput = status === STATUS.INPUT;
134+ const canSend = status === STATUS.CAN_SEND;
135+ const isGenerating = status === STATUS.GENERATING;
136+ const isSending = status === STATUS.SENDING;
137+ const isFailed = status === STATUS.FAILURE;
138+ const isDone = status === STATUS.SUCCESS;
139+
140+ // add disabled, error info to input configs
141+ const dynamicConfigs = useMemo(
142+ () =>
143+ inputConfigs.map(config => {
144+ config.disabled = !canInput;
145+ const hasReceivedError = getHasRecieved(config.name).matchWith({
146+ Nothing: () => null, // loading
147+ Just: p => p.value && HAS_RECEIVED_TEXT,
148+ });
149+ config.error = hasReceivedError || errors[config.name];
150+ return config;
151+ }),
152+ [inputConfigs, errors, canInput, getHasRecieved]
153+ );
154+
155+ // construct the state of the set of inputs we're rendering below
156+ const { inputs, pass } = useForm(dynamicConfigs);
157+ // did all of the inputs pass inspection (and there are no general errors)?
158+ const allPass = pass && !generalError;
159+ // the form is submittable iff passing and input is allowed
160+ const canGenerate = allPass && canInput;
161+ // additional inputs can be added iff input is allowed and we have enough
162+ // invites to send
163+ const canAddInvite = canInput && inputConfigs.length < maxInvitesToSend;
164+
165+ // progress is [0, .length] of invites or receipts, as we're generating them
166+ const progress = isGenerating
167+ ? Object.keys(invites).length
168+ : isSending
169+ ? Object.keys(receipts).length
170+ : null;
171+ const visualProgress = progress === null ? '-' : `${progress + 1}`;
172+
173+ // build a builder for the accessory to the input, depending on status
174+ const accessoryFor = (() => {
175+ if (isGenerating || canSend) return buildAccessoryFor(invites, errors);
176+ if (isSending) return buildAccessoryFor(receipts, errors);
177+ if (isFailed) return buildAccessoryFor({}, errors);
178+ return () => null;
179+ })();
180+
181+ const generateInvites = useCallback(async () => {
182+ const _contracts = contracts.getOrElse(null);
183+ const _web3 = web3.getOrElse(null);
184+ const _wallet = wallet.getOrElse(null);
185+ if (!_contracts || !_web3 || !_wallet) {
186+ // not using need because we want a custom error
187+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
188+ }
118189
119− const web3 = this.web3;
120− const targets = this.state.targets;
190+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
191+ const chainId = await _web3.eth.net.getId();
121192 const planets = await azimuth.delegatedSending.getPlanetsToSend(
122− this.contracts,
123− this.point,
124− targets.length
193+ _contracts,
194+ point,
195+ inputs.length
125196 );
126197
127198 // account for the race condition where invites got used up while we were
128199 // composing our target list
129− if (planets.length < targets.length) {
130− this.addError('Can currently only send ' + planets.length + ' invites!');
131− return;
200+ if (planets.length < inputs.length) {
201+ // resync invites to the cache, since they're out of date
202+ syncInvites(point);
203+
204+ throw new Error(
205+ `Can currently only send ${planets.length} invites. ` +
206+ `Please remove invites until you are within the limit.`
207+ );
132208 }
133209
134− const nonce = await web3.eth.getTransactionCount(this.address);
135− const chainId = await web3.eth.net.getId();
210+ let errorCount = 0;
211+ clearInvites();
212+ // NB(shrugs) - must be processed in serial because main thread, etc
213+ for (let i = 0; i < inputs.length; i++) {
214+ const input = inputs[i];
215+ try {
216+ const { data: email, name } = inputs[i];
136217
137− // create invite wallets and transactions
138− let invites = []; // { email, ticket, signedTx, rawTx }
139− for (let i = 0; i < targets.length; i++) {
140− console.log('generating email', i, targets[i].email);
141− this.setEmailStatus(i, EMAIL_STATUS.BUSY);
218+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
219+ MIN_PLANET
220+ // we're always giving planets, so generate a ticket of the correct size
221+ );
142222
143− const email = targets[i].email;
144− const wallet = await this.getTemporaryWallet();
223+ const inviteTx = azimuth.delegatedSending.sendPoint(
224+ _contracts,
225+ point,
226+ planets[i],
227+ owner.keys.address
228+ );
229+ const signedTx = await signTransaction({
230+ wallet,
231+ walletType,
232+ walletHdPath,
233+ networkType,
234+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
235+ txn: Maybe.Just(inviteTx),
236+ gasPrice: GAS_PRICE_GWEI.toString(),
237+ gasLimit: GAS_LIMIT.toString(),
238+ nonce: nonce + i,
239+ chainId,
240+ setStx: () => {},
241+ });
242+ const rawTx = hexify(signedTx.serialize());
145243
146− let inviteTx = azimuth.delegatedSending.sendPoint(
147− this.contracts,
148− this.point,
149− planets[i],
150− wallet.owner
151− );
152− const signedTx = await signTransaction({
153− ...this.props,
154− txn: Just(inviteTx),
155− gasPrice: GAS_PRICE_GWEI.toString(),
156− gasLimit: GAS_LIMIT.toString(),
157− nonce: nonce + i,
158− chainId: chainId,
159− setStx: () => {},
160− });
161− const rawTx = hexify(signedTx.serialize());
162−
163− invites.push({ email, ticket: wallet.ticket, signedTx, rawTx });
164− this.setEmailStatus(i, EMAIL_STATUS.DONE);
244+ addInvite({ [name]: { email, ticket, signedTx, rawTx } });
245+ } catch (error) {
246+ console.error(error);
247+ errorCount++;
248+ addError({ [input.name]: `Wallet Error: ${input.email}` });
249+ }
165250 }
166251
167− this.setState({ wipInvites: invites, status: STATUS.CAN_SEND });
168− }
169−
170− async sendInvites() {
171− const invites = this.state.wipInvites;
172− const web3 = this.web3;
173−
174− //TODO invite status isn't changing/re-rendering... why?
175− this.setState({
176− status: STATUS.FUNDING,
177− invites: invites.map(i => {
178− return { ...i, status: Nothing() };
179− }),
180− });
252+ if (errorCount > 0) {
253+ throw new Error(
254+ `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
255+ errorCount,
256+ 'error'
257+ )} while generating wallets.`
258+ );
259+ }
260+ }, [
261+ point,
262+ contracts,
263+ web3,
264+ addInvite,
265+ clearInvites,
266+ syncInvites,
267+ inputs,
268+ networkType,
269+ wallet,
270+ walletType,
271+ walletHdPath,
272+ addError,
273+ ]);
274+
275+ const sendInvites = useCallback(async () => {
276+ const _web3 = web3.getOrElse(null);
277+ const _wallet = wallet.getOrElse(null);
278+ if (!_web3 || !_wallet) {
279+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
280+ }
181281
282+ setStatus(STATUS.FUNDING);
182283 const tankWasUsed = await tank.ensureFundsFor(
183− web3,
184− this.point,
185− this.address,
186− INVITE_COST * invites.length,
187− invites.map(i => i.rawTx),
188− this.askForFunding,
189− () => {
190− this.setState({ fundingMessage: Nothing() });
191− }
284+ _web3,
285+ point,
286+ _wallet.address,
287+ INVITE_COST * inputs.length,
288+ inputs.map(input => invites[input.name].rawTx),
289+ (address, minBalance, balance) =>
290+ setNeedFunds({ address, minBalance, balance }),
291+ () => setNeedFunds(false)
192292 );
193293
194− this.setState({ status: STATUS.SENDING });
195−
196− //TODO nasty loop, probably move into lib... but so many callbacks!
197− let promises = [];
198− for (let i = 0; i < invites.length; i++) {
199− const invite = invites[i];
294+ setStatus(STATUS.SENDING);
295+ clearReceipts();
200296
201− // send transaction. if it fails, inform the user and skip to next
202− let txHash;
297+ let errorCount = 0;
298+ const txAndMailings = inputs.map(async input => {
299+ const invite = invites[input.name];
203300 try {
204− txHash = await sendSignedTransaction(
205− web3,
206− Just(invite.signedTx),
301+ const txHash = await sendSignedTransaction(
302+ _web3,
303+ Maybe.Just(invite.signedTx),
207304 tankWasUsed,
208305 () => {}
209306 );
210− } catch (err) {
211− console.error('invite tx sending failed', err);
212− this.addError('Invite transaction not sent for ' + invite.email);
213− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
214− continue;
307+
308+ // TODO: waitForTransactionConfirm never rejects
309+ const didConfirm = await waitForTransactionConfirm(_web3, txHash);
310+ if (!didConfirm) throw new Error();
311+ } catch (error) {
312+ console.error(error);
313+ errorCount++;
314+ addError({
315+ [input.name]: `Transaction Failure for ${invite.email}`,
316+ });
317+ return;
215318 }
216319
217− // ask the email sender to send this. we can do this prior to tx confirm,
218− // because the sender service waits for confirm & success for us.
219− // if this fails, inform the user and skip to next
220320 try {
221− const mailSuccess = await sendMail(
222− invite.email,
223− invite.ticket,
224− invite.rawTx
225− );
226− if (!mailSuccess) throw new Error();
227− this.setEmailStatus(i, EMAIL_STATUS.DONE);
228− } catch (e) {
229− console.log('email send failed');
230− //NOTE this assumes that the transaction succeeds, but we don't know
231− // that for a fact yet...
232− this.addError(
233− 'Invite email failed to send for ' +
234− invite.email +
235− '. Please give them this ticket: ' +
236− invite.ticket
237− );
238− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
239− continue;
321+ await sendMail(invite.email, invite.ticket, invite.rawTx);
322+ } catch (error) {
323+ console.error(error);
324+ errorCount++;
325+ addError({
326+ [input.name]: `Mailing Failure for ${invite.email}`,
327+ });
240328 }
241329
242− // update status on transaction confirm, but don't block on it
243− promises.push(
244− waitForTransactionConfirm(web3, txHash).then(success => {
245− if (success) {
246− this.setEmailStatus(i, EMAIL_STATUS.DONE);
247− } else {
248− console.log('invite tx rejected');
249− this.addError('Invite transaction failed for ' + invite.email);
250− this.setEmailStatus(i, EMAIL_STATUS.FAIL);
251− }
252− return success;
253− })
330+ addReceipt({ [input.name]: true });
331+ });
332+
333+ await Promise.all(txAndMailings);
334+ // if there are any receipt errors, throw a general error
335+ if (errorCount > 0) {
336+ throw new Error(
337+ `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
338+ errorCount,
339+ 'error'
340+ )} while sending transactions.`
254341 );
255342 }
256−
257− const txStatus = await Promise.all(promises);
258− if (txStatus.every(e => e)) {
259− this.setState({ status: STATUS.DONE });
260− } else {
261− this.setState({ status: STATUS.FAIL });
343+ }, [
344+ web3,
345+ inputs,
346+ addReceipt,
347+ clearReceipts,
348+ invites,
349+ point,
350+ wallet,
351+ addError,
352+ sendMail,
353+ ]);
354+
355+ const onClick = useCallback(async () => {
356+ setGeneralError(null);
357+ try {
358+ if (canGenerate) {
359+ setStatus(STATUS.GENERATING);
360+ await generateInvites();
361+ setStatus(STATUS.CAN_SEND);
362+ } else if (canSend) {
363+ setStatus(STATUS.SENDING);
364+ await sendInvites();
365+ setStatus(STATUS.SUCCESS);
366+ }
367+ } catch (error) {
368+ console.error(error);
369+ setGeneralError(error);
370+ setStatus(STATUS.FAILURE);
262371 }
263− }
264−
265− setEmailStatus(i, status) {
266− const targets = this.state.targets;
267− targets[i].status = Just(status);
268− this.setState({ targets });
269− }
270−
271− addError(error) {
272− const newError = this.state.errors.matchWith({
273− Nothing: () => [error],
274− Just: errs => {
275− errs.value.push(error);
276− return errs.value;
277− },
278− });
279− this.setState({ errors: Just(newError) });
280− }
281−
282− askForFunding(address, minBalance, balance) {
283− this.setState({
284− fundingMessage: Just(
285− `Please make sure ${address} has at least ${fromWei(minBalance)} ETH,` +
286− `we'll continue once that's true. Current balance: ${fromWei(
287− balance
288− )}.` +
289− `Waiting...`
290− ),
291− });
292− }
293−
294− handleEmailInput(i, email) {
295− let targets = this.state.targets;
296− targets[i].email = email;
297−
298− // if it's a valid address, check if it has received an invite before
299− if (email.match(/.*@.*\...+/)) {
300− hasReceived(email).then(res => {
301− targets[i].hasReceived = Just(res);
302− this.setState({ targets });
303− });
372+ }, [canGenerate, canSend, generateInvites, sendInvites]);
373+
374+ // when inputs update, check to see if any of them are duplicates
375+ useEffect(() => {
376+ // compute the list of valid emails
377+ const emails = inputs.map(i => i.data).filter(d => !!d);
378+ if (uniq(emails).length !== emails.length) {
379+ setGeneralError(new Error(`Duplicate email.`));
304380 } else {
305− targets[i].hasReceived = Nothing();
381+ setGeneralError(null);
306382 }
383+ }, [inputs]);
307384
308− this.setState({ targets });
309− }
310−
311− canSend() {
312− const targets = this.state.targets;
313−
314− // may not have duplicate targets
315− if (new Set(targets.map(t => t.email)).size !== targets.length)
316− return false;
317−
318− // none may have received invites already
319− for (let i = 0; i < targets.length; i++) {
320− const target = targets[i];
321− const res = target.hasReceived.matchWith({
322− Nothing: () => true,
323− Just: has => has.value,
324− });
325− if (res) return false;
385+ // when we transition to done, sync invites because we just sent some
386+ // and therefore know that state has changed
387+ useEffect(() => {
388+ if (isDone) {
389+ syncInvites(point);
326390 }
327−
328− return this.state.invitesAvailable.matchWith({
329− Nothing: () => false,
330− Just: invites => invites.value > 0,
331− });
332− }
333−
334− addTarget() {
335− let targets = this.state.targets;
336− targets.push({ email: '', hasReceived: Nothing(), status: Nothing() });
337− this.setState({ targets });
338− }
339−
340− removeTarget() {
341− let targets = this.state.targets;
342− targets.length = targets.length - 1;
343− this.setState({ targets });
344− }
345−
346− render() {
347− const dif =
348− this.state.status === STATUS.DONE ? this.state.targets.length : null;
349−
350− const invitesAvailable = this.state.invitesAvailable.matchWith({
351− Nothing: () => 'Loading...',
352− Just: invites => {
353− const dia = dif ? `(-${dif})` : '';
354− return `You currently have ${invites.value}${dia} invitations left.`;
355− },
356− });
357−
358− const invitesSent = this.state.invited.matchWith({
359− Nothing: () => <>{'Loading...'}</>,
360− Just: invited => {
361− const dit = dif ? `(+${dif})` : '';
362− return (
363− <>
364− {'Out of the '}
365− {invited.value.total + dit}
366− {' invites you sent, '}
367− {invited.value.accepted}
368− {' have been accepted.'}
369− </>
370− );
371− },
372− });
373−
374− const error = this.state.errors.matchWith({
375− Nothing: () => null,
376− Just: errors => (
377− <Warning className={'mt-8'}>
378− <H3 style={{ marginTop: 0, paddingTop: 0 }}>
379− {'Something went wrong!'}
380− </H3>
381− {errors.value.map(err => (
382− <p>{err}</p>
391+ }, [isDone, syncInvites, point]);
392+
393+ return (
394+ <Grid gap={12}>
395+ <Grid.Item as={Grid} full>
396+ <Grid.Item as={Flex} cols={[1, 11]} align="center">
397+ <MiniBackButton onClick={() => pop()} />
398+ </Grid.Item>
399+ <Grid.Item cols={[11, 13]} justifySelf="end">
400+ {/* use hidden class instead of removing component from dom */}
401+ {/* in order to avoid janky reflow */}
402+ <IconButton
403+ onClick={() => appendInput()}
404+ disabled={!canAddInvite}
405+ className={cn({ hidden: isDone })}
406+ solid>
407+ +
408+ </IconButton>
409+ </Grid.Item>
410+ </Grid.Item>
411+
412+ {isDone && (
413+ <>
414+ <Grid.Item as={Text} full>
415+ <Highlighted>{pluralize(inputs.length, 'invite')}</Highlighted>{' '}
416+ {pluralize(inputs.length, 'has', 'have')} been successfully sent
417+ </Grid.Item>
418+ {inputs.map(input => (
419+ <Grid.Item as={HelpText} key={input.name} full>
420+ {invites[input.name].email}
421+ </Grid.Item>
383422 ))}
384− </Warning>
385− ),
386− });
387−
388− const fundingMessage = this.state.fundingMessage.matchWith({
389− Nothing: () => null,
390− Just: msg => <Warning className={'mt-8'}>{msg.value}</Warning>,
391− });
392−
393− let inputs;
394− if (this.state.status === STATUS.DONE) {
395− //TODO proper clickable router thing
396− inputs = '<- Back to Home';
397− } else {
398− const inputDisabled = this.state.status !== STATUS.INPUT;
399−
400− const lessButton = (
401− <Button
402− prop-size={'s narrow'}
403− className={'mt-8'}
404− disabled={inputDisabled || this.state.targets.length === 1}
405− onClick={this.removeTarget}>
406− {'-'}
407− </Button>
408− );
409−
410− const moreButton = (
411− <Button
412− prop-size={'s narrow'}
413− className={'mt-8'}
414− disabled={
415− inputDisabled ||
416− this.state.invitesAvailable.matchWith({
417− Nothing: () => true,
418− Just: inv => inv.value <= this.state.targets.length,
419− })
420− }
421− onClick={this.addTarget}>
422− {'+'}
423− </Button>
424− );
425−
426− let targetInputs = [];
427− for (let i = 0; i < this.state.targets.length; i++) {
428− const target = this.state.targets[i];
429− let progress = target.status.matchWith({
430− Nothing: () => '',
431− Just: status => status.value,
432− });
433− targetInputs.push(
434− <>
435− <Input
436− value={target.email}
437− onChange={value => {
438− this.handleEmailInput(i, value);
439− }}
440− placeholder={'Email address'}
441− disabled={inputDisabled}
442− />
443− {target.hasReceived.matchWith({
444− Nothing: () => null,
445− Just: has =>
446− has.value ? <span>{'×'}</span> : <span>{'°'}</span>,
447− })}
448− {progress}
449− </>
450− );
451− }
452−
453− const submitButton = (
454− <Button
455− prop-size={'xl wide'}
456− className={'mt-8'}
457− disabled={
458− (inputDisabled && this.state.status !== STATUS.CAN_SEND) ||
459− !this.canSend()
460− }
461− onClick={() => {
462− if (this.state.status === STATUS.INPUT) this.generateInvites();
463− if (this.state.status === STATUS.CAN_SEND) this.sendInvites();
464− }}>
465− {this.state.status}
466− </Button>
467− );
423+ </>
424+ )}
468425
469− inputs = (
426+ {!isDone && (
470427 <>
471− {lessButton}
472− {moreButton}
473− {targetInputs}
474− {submitButton}
428+ {/* email inputs */}
429+ {inputs.map((input, i) => {
430+ const isFirst = i === 0;
431+ return (
432+ <Grid.Item
433+ key={input.name}
434+ as={Grid}
435+ gap={12}
436+ onMouseOver={() => setHovered({ [input.name]: true })}
437+ onMouseLeave={() => setHovered({ [input.name]: false })}
438+ full>
439+ <Grid.Item
440+ as={Input}
441+ cols={[1, 11]}
442+ {...input}
443+ onValue={email => syncHasReceivedForEmail(email)}
444+ // NB(shrugs): ^ this feels like a hack?
445+ accessory={accessoryFor(input.name)}
446+ />
447+ {!isFirst &&
448+ (input.focused || hovered[input.name]) &&
449+ canInput && (
450+ <Grid.Item
451+ cols={[11, 13]}
452+ justifySelf="end"
453+ alignSelf="center">
454+ <IconButton
455+ onClick={() => removeInputAt(i)}
456+ solid
457+ secondary>
458+ -
459+ </IconButton>
460+ </Grid.Item>
461+ )}
462+ </Grid.Item>
463+ );
464+ })}
465+
466+ <Grid.Item
467+ full
468+ as={LoadableButton}
469+ disabled={!canGenerate && !canSend}
470+ accessory={`${visualProgress}/${inputs.length}`}
471+ onClick={onClick}
472+ success={canSend}
473+ solid>
474+ {buttonText(status, inputs.length)}
475+ </Grid.Item>
476+
477+ {needFunds && (
478+ <Grid.Item full>
479+ <Highlighted>
480+ Your ownership address {needFunds.address} needs at least{' '}
481+ {fromWei(needFunds.minBalance)} ETH and currently has{' '}
482+ {fromWei(needFunds.balance)} ETH. Waiting until the account has
483+ enough funds.
484+ </Highlighted>
485+ </Grid.Item>
486+ )}
487+
488+ {generalError && (
489+ <Grid.Item full>
490+ <ErrorText>{generalError.message.toString()}</ErrorText>
491+ </Grid.Item>
492+ )}
475493 </>
476− );
477− }
478−
479− return (
480− <View>
481− <p>{'send invites here, for planets'}</p>
482−
483− <p>{invitesAvailable}</p>
484−
485− <ul>{invitesSent}</ul>
486−
487− {error}
488−
489− {fundingMessage}
490−
491− {inputs}
492− </View>
493− );
494− }
494+ )}
495+ </Grid>
496+ );
495497}
496−
497−export default compose(
498− withNetwork,
499− withWallet,
500− withPointCursor
501−)(InviteEmail);
src/views/Invite/InviteUrl.js+21−3
@@ -1,10 +1,25 @@
11import React, { useCallback } from 'react';
22import { Grid, Button } from 'indigo-react';
33
4+import { usePointCursor } from 'store/pointCursor';
5+
6+import * as need from 'lib/need';
7+import { useLocalRouter } from 'lib/LocalRouter';
8+import useInvites from 'lib/useInvites';
9+
410import { ForwardButton } from 'components/Buttons';
511import FooterButton from 'components/FooterButton';
612
7−export default function InviteUrl({ names, push }) {
13+export default function InviteUrl() {
14+ const { names, push } = useLocalRouter();
15+ const { pointCursor } = usePointCursor();
16+ const point = need.pointCursor(pointCursor);
17+ const { availableInvites } = useInvites(point);
18+ const canInvite = availableInvites.matchWith({
19+ Nothing: () => false,
20+ Just: p => p.value > 0,
21+ });
22+
823 const pushEmail = useCallback(() => push(names.EMAIL), [push, names]);
924
1025 return (
@@ -15,7 +30,7 @@ export default function InviteUrl({ names, push }) {
1530 as={Button}
1631 detail="Generate a URL to send to one guest"
1732 full
18− disabled>
33+ disabled={!canInvite || true}>
1934 Generate Invite
2035 </Grid.Item>
2136 <Grid.Divider />
@@ -23,11 +38,14 @@ export default function InviteUrl({ names, push }) {
2338 as={ForwardButton}
2439 onClick={pushEmail}
2540 detail="Send invitation to your guest's email"
41+ disabled={!canInvite}
2642 full>
2743 Send Invite via Email
2844 </Grid.Item>
2945 </Grid>
30− <FooterButton detail="i.e. Ethereum Address, Text File" disabled>
46+ <FooterButton
47+ detail="i.e. Ethereum Address, Text File"
48+ disabled={!canInvite || true}>
3149 Advanced
3250 </FooterButton>
3351 </>
src/views/InviteTicket.js+1−2
@@ -68,8 +68,7 @@ class InviteTicket extends React.Component {
6868 }
6969
7070 navigateLogin() {
71− this.props.history.pop();
72− this.props.history.push(ROUTE_NAMES.NETWORK);
71+ this.props.history.popAndPush(ROUTE_NAMES.LOGIN);
7372 }
7473
7574 handleInviteTicketInput(inviteTicket) {
src/views/Keystore.jsdeleted−166
@@ -1,166 +0,0 @@
1−import { Just, Nothing } from 'folktale/maybe';
2−import { Ok, Error } from 'folktale/result';
3−import React from 'react';
4−import * as keythereum from 'keythereum';
5−import { Button, UploadButton } from '../components/old/Base';
6−import { Input, InnerLabel, InputCaption } from '../components/old/Base';
7−import { H1, H3, Warning } from '../components/old/Base';
8−
9−import { BRIDGE_ERROR } from '../lib/error';
10−import { ROUTE_NAMES } from '../lib/routeNames';
11−import { withHistory } from '../store/history';
12−import { EthereumWallet } from '../lib/wallet';
13−import { compose } from '../lib/lib';
14−import { withWallet } from '../store/wallet';
15−import View from 'components/View';
16−
17−class Keystore extends React.Component {
18− constructor(props) {
19− super(props);
20−
21− this.state = {
22− keystore: Nothing(), // Maybe<Result<String, String>>
23− password: '',
24− decryptionProblem: false,
25− };
26−
27− this.handleKeystoreUpload = this.handleKeystoreUpload.bind(this);
28− this.handlePasswordInput = this.handlePasswordInput.bind(this);
29− this.constructWallet = this.constructWallet.bind(this);
30− }
31−
32− handlePasswordInput(password) {
33− this.setState({ password });
34− }
35−
36− constructWallet() {
37− const { setWallet } = this.props;
38− const { keystore, password } = this.state;
39−
40− try {
41− const text = keystore.matchWith({
42− Nothing: _ => {
43− throw BRIDGE_ERROR.MISSING_KEYSTORE;
44− },
45− Just: ks =>
46− ks.value.matchWith({
47− Ok: result => result.value,
48− Error: _ => {
49− throw BRIDGE_ERROR.MISSING_KEYSTORE;
50− },
51− }),
52− });
53−
54− const json = JSON.parse(text);
55− const privateKey = keythereum.recover(password, json);
56−
57− const wallet = new EthereumWallet(privateKey);
58− this.setState({ decryptionProblem: false });
59− setWallet(Just(wallet));
60− } catch (err) {
61− this.setState({ decryptionProblem: true });
62− setWallet(Nothing());
63− }
64− }
65−
66− handleKeystoreUpload = event => {
67− const file = event.files.item(0);
68− const reader = new FileReader();
69−
70− reader.onload = e => {
71− const keystore = e.target.result;
72− this.setState({ keystore: Just(Ok(keystore)) });
73− };
74−
75− const failure = _ => {
76− const message = 'There was a problem uploading your Keystore file';
77− this.setState({ keystore: Just(Error(message)) });
78− };
79−
80− reader.onerror = failure;
81− reader.onabort = failure;
82−
83− reader.readAsText(file);
84− };
85−
86− render() {
87− const { history, wallet } = this.props;
88− const { keystore, password, decryptionProblem } = this.state;
89−
90− const uploadButtonClass = keystore.matchWith({
91− Nothing: _ => 'bg-blue white',
92− Just: ks =>
93− ks.value.matchWith({
94− Ok: _ => 'bg-green white',
95− Error: _ => 'bg-yellow black',
96− }),
97− });
98−
99− const decryptMessage =
100− decryptionProblem === false ? (
101− <div />
102− ) : (
103− <Warning className={'mt-8'}>
104− <H3 style={{ marginTop: 0, paddingTop: 0 }}>
105− {"Couldn't decrypt wallet."}
106− </H3>
107− {'You may have entered an incorrect password.'}
108− </Warning>
109− );
110−
111− return (
112− <View>
113− <H1 className={'mb-4'}>{'Upload Your Keystore File'}</H1>
114− <InputCaption>
115− {`Please upload your Ethereum keystore file. If your keystore
116− file is encrypted with a password, you'll also need to enter
117− that below.`}
118− </InputCaption>
119−
120− <UploadButton
121− className={`btn ${uploadButtonClass} mt-10`}
122− onChange={this.handleKeystoreUpload}>
123− <div className={'flex-center-all fs-4 h-11 pointer'}>
124− {'Upload Keystore file'}
125− </div>
126− </UploadButton>
127−
128− <Input
129− className="pt-8 mt-8"
130− prop-size="md"
131− prop-format="innerLabel"
132− type="password"
133− name="password"
134− onChange={this.handlePasswordInput}
135− value={password}
136− autocomplete="off"
137− autoFocus>
138− <InnerLabel>{'Password'}</InnerLabel>
139− </Input>
140−
141− <Button
142− className={` mt-10`}
143− prop-size={'wide lg'}
144− disabled={Nothing.hasInstance(keystore)}
145− onClick={this.constructWallet}>
146− {'Decrypt'}
147− </Button>
148−
149− {decryptMessage}
150−
151− <Button
152− className={'mt-10'}
153− prop-size={'wide lg'}
154− disabled={Nothing.hasInstance(wallet)}
155− onClick={() => history.popAndPush(ROUTE_NAMES.POINTS)}>
156− {'Continue →'}
157− </Button>
158− </View>
159− );
160− }
161−}
162−
163−export default compose(
164− withHistory,
165− withWallet
166−)(Keystore);
src/views/Landing.js−7
@@ -33,13 +33,6 @@ function Landing(props) {
3333 Login
3434 </ForwardButton>
3535
36− <ForwardButton
37− className="mt3"
38− detail="If you own Azimuth assets and want to manage them in some way."
39− onClick={() => history.push(ROUTE_NAMES.NETWORK)}>
40− Unlock a Wallet
41− </ForwardButton>
42−
4336 <ForwardButton
4437 className="mt3"
4538 detail="View an Azimuth point without signing into a wallet."
src/views/Ledger.jsdeleted−220
@@ -1,220 +0,0 @@
1−import * as bip32 from 'bip32';
2−import React from 'react';
3−import Maybe from 'folktale/maybe';
4−import { Button } from '../components/old/Base';
5−import { H1, P, H2 } from '../components/old/Base';
6−import { Input, InnerLabel, InnerLabelDropdown } from '../components/old/Base';
7−import Transport from '@ledgerhq/hw-transport-u2f';
8−import Eth from '@ledgerhq/hw-app-eth';
9−import * as secp256k1 from 'secp256k1';
10−
11−import { LEDGER_LIVE_PATH, LEDGER_LEGACY_PATH } from '../lib/ledger';
12−import { ROUTE_NAMES } from '../lib/routeNames';
13−import { withHistory } from '../store/history';
14−import { compose } from '../lib/lib';
15−import { withWallet } from '../store/wallet';
16−import View from 'components/View';
17−
18−const chopHdPrefix = str => (str.slice(0, 2) === 'm/' ? str.slice(2) : str);
19−
20−const addHdPrefix = str => (str.slice(0, 2) === 'm/' ? str : 'm/' + str);
21−
22−class Ledger extends React.Component {
23− constructor(props) {
24− super(props);
25−
26− this.state = {
27− basePath: LEDGER_LIVE_PATH,
28− hdpath: LEDGER_LIVE_PATH.replace(/x/g, 0),
29− account: 0,
30− };
31−
32− this.pollDevice = this.pollDevice.bind(this);
33− this.handleHdPathInput = this.handleHdPathInput.bind(this);
34− this.handlePathSelection = this.handlePathSelection.bind(this);
35− this.handleAccountSelection = this.handleAccountSelection.bind(this);
36− this.updateHdPath = this.updateHdPath.bind(this);
37− }
38−
39− handlePathSelection(basePath) {
40− this.updateHdPath(basePath, this.state.account);
41− }
42−
43− handleAccountSelection(account) {
44− this.updateHdPath(this.state.basePath, account);
45− }
46−
47− updateHdPath(basePath, account) {
48− let hdpath = this.state.hdpath;
49− if (basePath !== 'custom') {
50− hdpath = basePath.replace(/x/g, account);
51− }
52− this.setState({ basePath, account, hdpath });
53− }
54−
55− handleHdPathInput(hdpath) {
56− this.setState({ hdpath });
57− }
58−
59− async pollDevice() {
60− const { setWallet, setWalletHdPath } = this.props;
61− const { hdpath } = this.state;
62−
63− const transport = await Transport.create();
64− const eth = new Eth(transport);
65− const path = chopHdPrefix(hdpath);
66−
67− eth.getAddress(path, false, true).then(
68− info => {
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− setWallet(Maybe.Just(hd));
74− setWalletHdPath(addHdPrefix(hdpath));
75− },
76− _ => {
77− setWallet(Maybe.Nothing());
78− }
79− );
80− }
81−
82− render() {
83− const { history, wallet } = this.props;
84− const { basePath, hdpath, account } = this.state;
85− const { handlePathSelection, handleAccountSelection } = this;
86−
87− const pathOptions = [
88− { title: 'Ledger Live', value: LEDGER_LIVE_PATH },
89− { title: 'Ledger Legacy', value: LEDGER_LEGACY_PATH },
90− {
91− title: 'Custom path',
92− value: 'custom',
93− },
94− ];
95− let basePathTitle = pathOptions.find(o => o.value === basePath).title;
96−
97− let accountOptions = [];
98− for (let i = 0; i < 20; i++) {
99− accountOptions.push({
100− title: 'Account #' + (i + 1),
101− value: i,
102− });
103− }
104− let accountTitle = accountOptions.find(o => o.value === account).title;
105−
106− const basePathSelection = (
107− <InnerLabelDropdown
108− className="mt-8"
109− options={pathOptions}
110− handleUpdate={handlePathSelection}
111− title={'Derivation path'}
112− currentSelectionTitle={basePathTitle}
113− fullWidth={true}
114− />
115− );
116−
117− const truePathSelection =
118− basePath === 'custom' ? (
119− <Input
120− className="pt-8 mt-4 text-mono"
121− prop-size="md"
122− prop-format="innerLabel"
123− name="hdpath"
124− value={addHdPrefix(hdpath)}
125− autocomplete="off"
126− onChange={this.handleHdPathInput}>
127− <InnerLabel>{'HD Path'}</InnerLabel>
128− </Input>
129− ) : (
130− <InnerLabelDropdown
131− className="mt-4"
132− prop-size="md"
133− prop-format="innerLabel"
134− options={accountOptions}
135− handleUpdate={handleAccountSelection}
136− title={'Account'}
137− currentSelectionTitle={accountTitle}
138− fullWidth={true}
139− />
140− );
141−
142− return (
143− <View>
144− <H1>{'Authenticate With Your Ledger'}</H1>
145−
146− <H2>{'Running on HTTPS?'}</H2>
147−
148− <P>
149− {`Connect and authenticate to your Ledger, and then open the
150− "Ethereum" application. If you're running on older firmware,
151− make sure the "browser support" option is turned on. To sign
152− transactions, you'll also need to enable the "contract data"
153− option.`}
154− </P>
155−
156− <P>
157− {`If you'd like to use a custom derivation path, you may enter
158− it below.`}
159− </P>
160−
161− <H2>{'Running on HTTP?'}</H2>
162−
163− <P>{`To authenticate and sign transactions with a Ledger, Bridge
164− must be serving over HTTPS on localhost. You can do this via the
165− following:`}</P>
166−
167− <ol className={'measure-md'}>
168− <li className={'mt-4'}>
169− {'Install'}{' '}
170− <a target={'_blank'} href={'https://github.com/FiloSottile/mkcert'}>
171− {'mkcert'}
172− </a>
173− </li>
174− <li className={'mt-4'}>
175− {'Install a local certificate authority via '}
176− <code>{'mkcert -install'}</code>
177− </li>
178− <li className={'mt-4'}>
179− {'In your '}
180− <code>{'bridge'}</code>
181− {' directory, generate a certificate valid for localhost via '}{' '}
182− <code>{'mkcert localhost'}</code>
183− {'.'}
184− {'This will produce two files: '}
185− <code>{'localhost.pem'}</code> {', the local certificate, and '}
186− <code>{'localhost-key.pem'}</code>
187− {', its corresponding private key.'}
188− </li>
189− <li className={'mt-4'}>
190− {'Run '}
191− <code>{'python bridge-https.py'}</code>
192− </li>
193− </ol>
194−
195− {basePathSelection}
196− {truePathSelection}
197−
198− <Button
199− prop-size={'wide lg'}
200− className={'mt-8'}
201− onClick={this.pollDevice}>
202− {'Authenticate →'}
203− </Button>
204−
205− <Button
206− className={'mt-8'}
207− prop-size={'wide lg'}
208− disabled={Maybe.Nothing.hasInstance(wallet)}
209− onClick={() => history.popAndPush(ROUTE_NAMES.POINTS)}>
210− {'Continue →'}
211− </Button>
212− </View>
213− );
214− }
215−}
216−
217−export default compose(
218− withHistory,
219− withWallet
220−)(Ledger);
src/views/Login.js+6−8
@@ -41,7 +41,7 @@ export default function Login() {
4141 // globals
4242 const history = useHistory();
4343 const { contracts } = useNetwork();
44− const { wallet, setUrbitWallet } = useWallet();
44+ const { wallet, resetWallet } = useWallet();
4545 const { pointCursor, setPointCursor } = usePointCursor();
4646
4747 // inputs
@@ -49,8 +49,7 @@ export default function Login() {
4949
5050 // on-mount
5151 useLifecycle(() => {
52− setUrbitWallet(Nothing());
53− setPointCursor(Nothing());
52+ resetWallet();
5453 });
5554
5655 const doContinue = async () => {
@@ -58,8 +57,8 @@ export default function Login() {
5857 const _contracts = need.contracts(contracts);
5958
6059 // if no point cursor set by login logic, try to deduce it
61− let deduced = Nothing();
62− if (Nothing.hasInstance(pointCursor)) {
60+ let deduced = pointCursor;
61+ if (Nothing.hasInstance(deduced)) {
6362 const owned = await azimuth.azimuth.getOwnedPoints(
6463 _contracts,
6564 _wallet.address
@@ -81,9 +80,8 @@ export default function Login() {
8180 // navigate to that specific point, otherwise navigate to list of points
8281 if (Just.hasInstance(deduced)) {
8382 setPointCursor(deduced);
84− history.popAndPush(ROUTE_NAMES.POINT);
85− } else if (Just.hasInstance(pointCursor)) {
86− history.popAndPush(ROUTE_NAMES.POINT);
83+ history.popAndPush(ROUTE_NAMES.POINTS);
84+ history.push(ROUTE_NAMES.POINT);
8785 } else {
8886 history.popAndPush(ROUTE_NAMES.POINTS);
8987 }
src/views/Login/Keystore.js+17−12
@@ -2,25 +2,35 @@ import { Just, Nothing } from 'folktale/maybe';
22import { Ok, Error } from 'folktale/result';
33import React, { useState } from 'react';
44import * as keythereum from 'keythereum';
5+import { H3, Input } from 'indigo-react';
56
67import View from 'components/View';
7−import { PassphraseInput } from 'components/Inputs';
8+import { usePassphraseInput } from 'components/Inputs';
89import { ForwardButton } from 'components/Buttons';
9−import { H3 } from 'indigo-react';
1010import { InputCaption, UploadButton, Warning } from 'components/old/Base';
1111
12−import * as need from 'lib/need';
13−import { EthereumWallet } from 'lib/wallet';
1412import { useWallet } from 'store/wallet';
1513
14+import * as need from 'lib/need';
15+import { EthereumWallet, WALLET_TYPES } from 'lib/wallet';
16+import useWalletType from 'lib/useWalletType';
17+
1618export default function Keystore({ loginCompleted }) {
19+ useWalletType(WALLET_TYPES.KEYSTORE);
20+
1721 // globals
1822 const { wallet, setWallet } = useWallet();
1923
2024 // inputs
2125 // keystore: Maybe<Result<String, String>>
2226 const [keystore, setKeystore] = useState(Nothing());
23− const [password, setPassword] = useState('');
27+ const passphraseInput = usePassphraseInput({
28+ name: 'password',
29+ label: 'Keystore password',
30+ autoFocus: true,
31+ });
32+ const passphrase = passphraseInput.data;
33+
2434 const [decryptionProblem, setDecryptionProblem] = useState(false);
2535
2636 const constructWallet = () => {
@@ -28,7 +38,7 @@ export default function Keystore({ loginCompleted }) {
2838 const text = need.keystore(keystore);
2939
3040 const json = JSON.parse(text);
31− const privateKey = keythereum.recover(password, json);
41+ const privateKey = keythereum.recover(passphrase, json);
3242
3343 const newWallet = new EthereumWallet(privateKey);
3444 setDecryptionProblem(false);
@@ -95,12 +105,7 @@ export default function Keystore({ loginCompleted }) {
95105 </div>
96106 </UploadButton>
97107
98− <PassphraseInput
99− name="password"
100− label="Keystore password"
101− initialValue={password}
102− onValue={setPassword}
103− autoFocus></PassphraseInput>
108+ <Input {...passphraseInput} />
104109
105110 <ForwardButton
106111 className="mt3"
src/views/Login/Ledger.js+12−4
@@ -5,15 +5,18 @@ import Maybe from 'folktale/maybe';
55import Transport from '@ledgerhq/hw-transport-u2f';
66import Eth from '@ledgerhq/hw-app-eth';
77import * as secp256k1 from 'secp256k1';
8+import { H1, H2, P, Input } from 'indigo-react';
89
910import View from 'components/View';
1011import { ForwardButton } from 'components/Buttons';
11−import { H1, H2, P, Input } from 'indigo-react';
12−import { InnerLabel, InnerLabelDropdown } from 'components/old/Base';
12+import { InnerLabelDropdown } from 'components/old/Base';
1313
14−import { LEDGER_LIVE_PATH, LEDGER_LEGACY_PATH } from 'lib/ledger';
1514import { useWallet } from 'store/wallet';
1615
16+import { LEDGER_LIVE_PATH, LEDGER_LEGACY_PATH } from 'lib/ledger';
17+import useWalletType from 'lib/useWalletType';
18+import { WALLET_TYPES } from 'lib/wallet';
19+
1720const pathOptions = [
1821 { title: 'Ledger Live', value: LEDGER_LIVE_PATH },
1922 { title: 'Ledger Legacy', value: LEDGER_LEGACY_PATH },
@@ -25,6 +28,8 @@ const chopHdPrefix = str => (str.slice(0, 2) === 'm/' ? str.slice(2) : str);
2528const addHdPrefix = str => (str.slice(0, 2) === 'm/' ? str : 'm/' + str);
2629
2730export default function Ledger({ loginCompleted }) {
31+ useWalletType(WALLET_TYPES.LEDGER);
32+
2833 const { wallet, setWallet, setWalletHdPath } = useWallet();
2934
3035 const [basePath, setBasePath] = useState(LEDGER_LIVE_PATH);
@@ -123,7 +128,10 @@ export default function Ledger({ loginCompleted }) {
123128 <ol className="measure-md">
124129 <li className="mt-4">
125130 Install
126− <a target="_blank" href="https://github.com/FiloSottile/mkcert">
131+ <a
132+ target="_blank"
133+ href="https://github.com/FiloSottile/mkcert"
134+ rel="noopener noreferrer">
127135 mkcert
128136 </a>
129137 </li>
src/views/Login/Mnemonic.js+36−24
@@ -1,14 +1,22 @@
1−import React, { useCallback, useEffect, useState } from 'react';
1+import React, { useCallback, useEffect } from 'react';
22import { Just, Nothing } from 'folktale/maybe';
3+import { Input } from 'indigo-react';
34
45import View from 'components/View';
5−import { MnemonicInput, PassphraseInput, HdPathInput } from 'components/Inputs';
6+import {
7+ usePassphraseInput,
8+ useMnemonicInput,
9+ useHdPathInput,
10+} from 'components/Inputs';
611import { ForwardButton } from 'components/Buttons';
712
8−import { walletFromMnemonic } from 'lib/wallet';
913import { useWallet } from 'store/wallet';
1014
15+import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet';
16+import useWalletType from 'lib/useWalletType';
17+
1118export default function Mnemonic({ advanced, loginCompleted }) {
19+ useWalletType(WALLET_TYPES.MNEMONIC);
1220 const {
1321 wallet,
1422 setWallet,
@@ -17,9 +25,8 @@ export default function Mnemonic({ advanced, loginCompleted }) {
1725 walletHdPath,
1826 setWalletHdPath,
1927 } = useWallet();
20− const [passphrase, setPassphrase] = useState('');
21− const mnemonic = authMnemonic.getOrElse('');
2228
29+ const mnemonic = authMnemonic.getOrElse('');
2330 // TODO: move this into transformers?
2431 // transform the result of the mnemonic to Maybe<string>
2532 const _setAuthMnemonic = useCallback(
@@ -27,6 +34,27 @@ export default function Mnemonic({ advanced, loginCompleted }) {
2734 [setAuthMnemonic]
2835 );
2936
37+ const mnemonicInput = useMnemonicInput({
38+ name: 'mnemonic',
39+ label: 'BIP39 Mnemonic',
40+ initialValue: mnemonic,
41+ onValue: _setAuthMnemonic,
42+ autoFocus: true,
43+ });
44+
45+ const passphraseInput = usePassphraseInput({
46+ name: 'passphrase',
47+ label: '(Optional) Wallet Passphrase',
48+ });
49+ const passphrase = passphraseInput.data;
50+
51+ const hdPathInput = useHdPathInput({
52+ name: 'hdpath',
53+ label: 'HD Path',
54+ initialValue: walletHdPath,
55+ onValue: setWalletHdPath,
56+ });
57+
3058 // when the properties change, re-derive wallet
3159 useEffect(() => {
3260 let mounted = true;
@@ -39,29 +67,13 @@ export default function Mnemonic({ advanced, loginCompleted }) {
3967
4068 return (
4169 <View>
42− <MnemonicInput
43− name="mnemonic"
44− label="BIP39 Mnemonic"
45− initialValue={mnemonic}
46− onValue={_setAuthMnemonic}
47− autoFocus
48− />
70+ <Input {...mnemonicInput} />
4971
5072 {!advanced ? null : (
5173 <>
52− <PassphraseInput
53− name="passphrase"
54− label="(Optional) Wallet Passphrase"
55− initialValue={passphrase}
56− onValue={setPassphrase}
57− />
74+ <Input {...passphraseInput} />
5875
59− <HdPathInput
60− name="hdpath"
61− label="HD Path"
62− initialValue={walletHdPath}
63− onValue={setWalletHdPath}
64− />
76+ <Input {...hdPathInput} />
6577 </>
6678 )}
6779
src/views/Login/PrivateKey.js+16−20
@@ -1,24 +1,28 @@
11import { Just, Nothing } from 'folktale/maybe';
2−import React, { useState } from 'react';
2+import React, { useEffect } from 'react';
3+import { Input } from 'indigo-react';
34
45import View from 'components/View';
5−import { PassphraseInput } from 'components/Inputs';
6+import { usePassphraseInput } from 'components/Inputs';
67import { ForwardButton } from 'components/Buttons';
78
8−import { EthereumWallet } from 'lib/wallet';
99import { useWallet } from 'store/wallet';
1010
11+import { EthereumWallet, WALLET_TYPES } from 'lib/wallet';
12+import useWalletType from 'lib/useWalletType';
13+
1114export default function PrivateKey({ loginCompleted }) {
15+ useWalletType(WALLET_TYPES.PRIVATE_KEY);
1216 const { wallet, setWallet } = useWallet();
1317
14− const [privateKey, setPrivateKey] = useState('');
15−
16− const handlePrivateKeyInput = privateKey => {
17− setPrivateKey(privateKey);
18− constructWallet(privateKey);
19− };
18+ const privateKeyInput = usePassphraseInput({
19+ name: 'privateKey',
20+ label: 'Private key',
21+ autoFocus: true,
22+ });
23+ const privateKey = privateKeyInput.data;
2024
21− const constructWallet = privateKey => {
25+ useEffect(() => {
2226 if (/^[0-9A-Fa-f]{64}$/g.test(privateKey) === true) {
2327 const sec = Buffer.from(privateKey, 'hex');
2428 const newWallet = new EthereumWallet(sec);
@@ -26,20 +30,12 @@ export default function PrivateKey({ loginCompleted }) {
2630 } else {
2731 setWallet(Nothing());
2832 }
29− };
33+ }, [privateKey, setWallet]);
3034
3135 return (
3236 <View>
3337 Please enter your raw Ethereum private key here.
34− <PassphraseInput
35− type="password"
36− name="privateKey"
37− label="Private key"
38− initialValue={privateKey}
39− onValue={handlePrivateKeyInput}
40− autocomplete="off"
41− autoFocus
42− />
38+ <Input {...privateKeyInput} />
4339 <ForwardButton
4440 className="mt3"
4541 disabled={Nothing.hasInstance(wallet)}
src/views/Login/Shards.js+44−36
@@ -1,22 +1,53 @@
11import { Just, Nothing } from 'folktale/maybe';
2−import React, { useState } from 'react';
2+import React from 'react';
33import * as kg from 'urbit-key-generation/dist/index';
4+import { Input } from 'indigo-react';
45
56import View from 'components/View';
6−import { PointInput, TicketInput, PassphraseInput } from 'components/Inputs';
7+import {
8+ useTicketInput,
9+ usePassphraseInput,
10+ usePointInput,
11+} from 'components/Inputs';
712import { ForwardButton } from 'components/Buttons';
813
9−import { urbitWalletFromTicket } from 'lib/wallet';
1014import { useWallet } from 'store/wallet';
1115
16+import { urbitWalletFromTicket, WALLET_TYPES } from 'lib/wallet';
17+import useWalletType from 'lib/useWalletType';
18+
1219export default function Shards({ loginCompleted }) {
20+ useWalletType(WALLET_TYPES.SHARDS);
21+
1322 const { wallet, setUrbitWallet } = useWallet();
1423
15− const [shard1, setShard1] = useState('');
16− const [shard2, setShard2] = useState('');
17− const [shard3, setShard3] = useState('');
18− const [passphrase, setPassphrase] = useState('');
19− const [pointName, setPointName] = useState('');
24+ const pointInput = usePointInput({
25+ name: 'point',
26+ label: 'Point',
27+ autoFocus: true,
28+ });
29+ const pointName = pointInput.data;
30+ const shard1Input = useTicketInput({
31+ name: 'shard1',
32+ label: 'Shard 1',
33+ });
34+ const shard1 = shard1Input.data;
35+ const shard2Input = useTicketInput({
36+ name: 'shard2',
37+ label: 'Shard 2',
38+ });
39+ const shard2 = shard2Input.data;
40+ const shard3Input = useTicketInput({
41+ name: 'shard3',
42+ label: 'Shard 3',
43+ });
44+ const shard3 = shard3Input.data;
45+
46+ const passphraseInput = usePassphraseInput({
47+ name: 'passphrase',
48+ label: '(Optional) Wallet Passphrase',
49+ });
50+ const passphrase = passphraseInput.data;
2051
2152 const walletFromShards = async () => {
2253 const s1 = shard1 === '' ? undefined : shard1;
@@ -43,35 +74,12 @@ export default function Shards({ loginCompleted }) {
4374 <View>
4475 Enter your point and at least two of your three Urbit master ticket shards
4576 here. The index of the input field needs to match the index of the shard.
46− <PointInput
47− name="point"
48− label="Point"
49− initialValue={pointName}
50− onValue={setPointName}
51− autoFocus
52− />
53− <TicketInput
54− name="shard1"
55− label="Shard 1"
56− initialValue={shard1}
57− onValue={setShard1}></TicketInput>
58− <TicketInput
59− name="shard2"
60− label="Shard 2"
61− initialValue={shard2}
62− onValue={setShard2}></TicketInput>
63− <TicketInput
64− name="shard3"
65− label="Shard 3"
66− initialValue={shard3}
67− onValue={setShard3}></TicketInput>
77+ <Input {...pointInput} />
78+ <Input {...shard1Input} />
79+ <Input {...shard2Input} />
80+ <Input {...shard3Input} />
6881 If your wallet requires a passphrase, you may enter it below.
69− <PassphraseInput
70− name="passphrase"
71− label="(Optional) Wallet Passphrase"
72− initialValue={passphrase}
73− onValue={setPassphrase}
74− />
82+ <Input {...passphraseInput} />
7583 <ForwardButton
7684 className="mt3"
7785 disabled={!ready}
src/views/Login/Ticket.js+73−77
@@ -1,19 +1,25 @@
11import { Just, Nothing } from 'folktale/maybe';
2−import React, { useState, useEffect } from 'react';
2+import React, { useCallback, useState, useEffect } from 'react';
33import * as azimuth from 'azimuth-js';
44import * as ob from 'urbit-ob';
5+import { Input, AccessoryIcon } from 'indigo-react';
56
67import View from 'components/View';
7−import { PointInput, TicketInput, PassphraseInput } from 'components/Inputs';
8+import {
9+ usePointInput,
10+ useTicketInput,
11+ usePassphraseInput,
12+} from 'components/Inputs';
813import { ForwardButton } from 'components/Buttons';
914
10−import * as need from 'lib/need';
11−import { WALLET_TYPES, urbitWalletFromTicket } from 'lib/wallet';
12−
1315import { useNetwork } from 'store/network';
1416import { useWallet } from 'store/wallet';
1517import { usePointCursor } from 'store/pointCursor';
1618
19+import * as need from 'lib/need';
20+import { WALLET_TYPES, urbitWalletFromTicket } from 'lib/wallet';
21+import useWalletType from 'lib/useWalletType';
22+
1723//TODO should be part of InputWithStatus component
1824const INPUT_STATUS = {
1925 SPIN: Symbol('SPIN'),
@@ -22,53 +28,72 @@ const INPUT_STATUS = {
2228};
2329
2430export default function Ticket({ advanced, loginCompleted }) {
31+ useWalletType(WALLET_TYPES.TICKET);
2532 // globals
2633 const { contracts } = useNetwork();
27− const { wallet, setWalletType, setUrbitWallet } = useWallet();
34+ const { wallet, setUrbitWallet } = useWallet();
2835 const { setPointCursor } = usePointCursor();
2936
3037 // inputs
31− //TODO deduce point name from URL if we can, prefill input if we found it
32− const [pointName, setPointName] = useState('');
33− const [ticket, setTicket] = useState('');
34− const [passphrase, setPassphrase] = useState('');
38+ const pointInput = usePointInput({
39+ name: 'point',
40+ label: 'Point',
41+ //TODO deduce point name from URL if we can, prefill input if we found it
42+ // initialValue: '~',
43+ autoFocus: true,
44+ });
45+ const pointName = pointInput.data;
46+
47+ const ticketInput = useTicketInput({
48+ name: 'ticket',
49+ label: 'Master ticket',
50+ });
51+ const ticket = ticketInput.data;
52+
53+ const passphraseInput = usePassphraseInput({
54+ name: 'passphrase',
55+ label: '(Optional) Wallet Passphrase',
56+ });
57+ const passphrase = passphraseInput.data;
3558
3659 // display state
3760 const [ticketStatus, setTicketStatus] = useState(Nothing());
3861
62+ //TODO maybe want to do this only on-go, because wallet derivation is slow...
63+ const verifyTicket = useCallback(
64+ async (pointName, ticket, passphrase) => {
65+ setUrbitWallet(Nothing());
66+ if (!ob.isValidPatq(ticket) || !ob.isValidPatp(pointName)) {
67+ setTicketStatus(Nothing());
68+ return;
69+ }
70+ setTicketStatus(Just(INPUT_STATUS.SPIN));
71+ const _contracts = need.contracts(contracts);
72+ const pointNumber = ob.patp2dec(pointName);
73+ const uhdw = await urbitWalletFromTicket(ticket, pointName, passphrase);
74+ const isOwner = azimuth.azimuth.isOwner(
75+ _contracts,
76+ pointNumber,
77+ uhdw.ownership.keys.address
78+ );
79+ const isTransferProxy = azimuth.azimuth.isTransferProxy(
80+ _contracts,
81+ pointNumber,
82+ uhdw.ownership.keys.address
83+ );
84+ setUrbitWallet(Just(uhdw));
85+ const newStatus =
86+ (await isOwner) || (await isTransferProxy)
87+ ? INPUT_STATUS.GOOD
88+ : INPUT_STATUS.FAIL;
89+ setTicketStatus(Just(newStatus));
90+ },
91+ [contracts, setUrbitWallet, setTicketStatus]
92+ );
93+
3994 useEffect(() => {
4095 verifyTicket(pointName, ticket, passphrase);
41− return () => {};
42− }, [pointName, ticket, passphrase]);
43−
44− //TODO maybe want to do this only on-go, because wallet derivation is slow...
45− const verifyTicket = async (pointName, ticket, passphrase) => {
46− setUrbitWallet(Nothing());
47− if (!ob.isValidPatq(ticket) || !ob.isValidPatp(pointName)) {
48− setTicketStatus(Nothing());
49− return;
50− }
51− setTicketStatus(Just(INPUT_STATUS.SPIN));
52− const _contracts = need.contracts(contracts);
53− const pointNumber = ob.patp2dec(pointName);
54− const uhdw = await urbitWalletFromTicket(ticket, pointName, passphrase);
55− const isOwner = azimuth.azimuth.isOwner(
56− _contracts,
57− pointNumber,
58− uhdw.ownership.keys.address
59− );
60− const isTransferProxy = azimuth.azimuth.isTransferProxy(
61− _contracts,
62− pointNumber,
63− uhdw.ownership.keys.address
64− );
65− setUrbitWallet(Just(uhdw));
66− const newStatus =
67− (await isOwner) || (await isTransferProxy)
68− ? INPUT_STATUS.GOOD
69− : INPUT_STATUS.FAIL;
70− setTicketStatus(Just(newStatus));
71− };
96+ }, [verifyTicket, pointName, ticket, passphrase]);
7297
7398 const canContinue = () => {
7499 // this is our only requirement, since we still want people with
@@ -77,61 +102,32 @@ export default function Ticket({ advanced, loginCompleted }) {
77102 };
78103
79104 const doContinue = () => {
80− setWalletType(WALLET_TYPES.TICKET);
81105 setPointCursor(Just(ob.patp2dec(pointName)));
82106 loginCompleted();
83107 };
84108
85− const pointInput = (
86− <PointInput
87− name="point"
88− label="Point"
89− initialValue={pointName}
90− onValue={setPointName}
91− autoFocus
92− />
93− );
94−
95109 //TODO integrate into TicketInput?
96110 const displayTicketStatus = ticketStatus.matchWith({
97111 Nothing: () => <span />,
98112 Just: status => {
99113 switch (status.value) {
100114 case INPUT_STATUS.SPIN:
101− return <span>⋯</span>;
115+ return <AccessoryIcon.Pending />;
102116 case INPUT_STATUS.GOOD:
103− return <span>✓</span>;
117+ return <AccessoryIcon.Success />;
104118 case INPUT_STATUS.FAIL:
105− return <span>𐄂</span>;
119+ return <AccessoryIcon.Failure />;
106120 default:
107− throw new Error('weird input status ' + status.value);
121+ return null;
108122 }
109123 },
110124 });
111125
112− const ticketInput = (
113− <TicketInput
114− name="ticket"
115− label="Master ticket"
116− initialValue={ticket}
117− onValue={setTicket}
118− />
119− );
120−
121− const passphraseInput = !advanced ? null : (
122− <PassphraseInput
123− name="passphrase"
124− label="(Optional) Wallet Passphrase"
125− initialValue={passphrase}
126− onValue={setPassphrase}
127− />
128− );
129−
130126 return (
131127 <View>
132− {pointInput}
133− {ticketInput}
134− {passphraseInput}
128+ <Input {...pointInput} />
129+ <Input {...ticketInput} accessory={displayTicketStatus} />
130+ {advanced && <Input {...passphraseInput} />}
135131
136132 <ForwardButton
137133 className={'mt3'}
src/views/Login/Trezor.js+7−2
@@ -4,13 +4,16 @@ import Maybe from 'folktale/maybe';
44import { times } from 'lodash';
55import TrezorConnect from 'trezor-connect';
66import * as secp256k1 from 'secp256k1';
7+import { H1, P, Input } from 'indigo-react';
78
89import View from 'components/View';
910import { ForwardButton } from 'components/Buttons';
10−import { H1, P, Input } from 'indigo-react';
11−import { InnerLabel, InnerLabelDropdown } from 'components/old/Base';
11+import { InnerLabelDropdown } from 'components/old/Base';
1212
1313import { TREZOR_PATH } from 'lib/trezor';
14+import useWalletType from 'lib/useWalletType';
15+import { WALLET_TYPES } from 'lib/wallet';
16+
1417import { useWallet } from 'store/wallet';
1518
1619const accountOptions = [
@@ -19,6 +22,8 @@ const accountOptions = [
1922];
2023
2124export default function Trezor({ loginCompleted }) {
25+ useWalletType(WALLET_TYPES.TREZOR);
26+
2227 const { wallet, setWallet, setWalletHdPath } = useWallet();
2328
2429 const [hdPath, setHdPath] = useState(TREZOR_PATH.replace(/x/g, 0));
src/views/Mnemonic.jsdeleted−78
@@ -1,78 +0,0 @@
1−import React, { useCallback, useEffect, useState } from 'react';
2−import { Just, Nothing } from 'folktale/maybe';
3−
4−import View from 'components/View';
5−import { MnemonicInput, PassphraseInput, HdPathInput } from 'components/Inputs';
6−import { ForwardButton } from 'components/Buttons';
7−
8−import { ROUTE_NAMES } from 'lib/routeNames';
9−import { useHistory } from 'store/history';
10−import { walletFromMnemonic } from 'lib/wallet';
11−import { useWallet } from 'store/wallet';
12−
13−export default function Mnemonic() {
14− const history = useHistory();
15− const {
16− wallet,
17− setWallet,
18− authMnemonic,
19− setAuthMnemonic,
20− walletHdPath,
21− setWalletHdPath,
22− } = useWallet();
23− const [passphrase, setPassphrase] = useState('');
24− const mnemonic = authMnemonic.getOrElse('');
25−
26− // TODO: move this into transformers?
27− // transform the result of the mnemonic to Maybe<string>
28− const _setAuthMnemonic = useCallback(
29− mnemonic => setAuthMnemonic(mnemonic === '' ? Nothing() : Just(mnemonic)),
30− [setAuthMnemonic]
31− );
32−
33− // when the properties change, re-derive wallet
34− useEffect(() => {
35− let mounted = true;
36− // allow the browser to do something else while we derive this wallet
37− // on the main thread by making this an async function
38− (async () => {
39− const wallet = walletFromMnemonic(mnemonic, walletHdPath, passphrase);
40− mounted && setWallet(wallet);
41− })();
42− return () => (mounted = false);
43− }, [mnemonic, passphrase, walletHdPath, setWallet]);
44−
45− return (
46− <View>
47− <MnemonicInput
48− name="mnemonic"
49− label="BIP39 Mnemonic"
50− initialValue={mnemonic}
51− onValue={_setAuthMnemonic}
52− autoFocus
53− />
54−
55− <PassphraseInput
56− name="passphrase"
57− label="(Optional) Wallet Passphrase"
58− initialValue={passphrase}
59− onValue={setPassphrase}
60− />
61−
62− <HdPathInput
63− name="hdpath"
64− label="HD Path"
65− initialValue={walletHdPath}
66− onValue={setWalletHdPath}
67− />
68−
69− <ForwardButton
70− className="mt3"
71− disabled={Nothing.hasInstance(wallet)}
72− onClick={() => history.popAndPush(ROUTE_NAMES.POINTS)}
73− solid>
74− Continue
75− </ForwardButton>
76− </View>
77− );
78−}
src/views/Network.jsdeleted−62
@@ -1,62 +0,0 @@
1−import React from 'react';
2−import { Button } from 'indigo-react';
3−
4−import { InnerLabelDropdown } from '../components/old/Base';
5−import { H1, P } from '../components/old/Base';
6−import { NETWORK_TYPES, renderNetworkType } from '../lib/network';
7−import { ROUTE_NAMES } from '../lib/routeNames';
8−import { useHistory } from '../store/history';
9−import { useNetwork } from '../store/network';
10−import View from 'components/View';
11−
12−const kNetworkOptions = [
13− {
14− title: 'Main Network (default)',
15− value: NETWORK_TYPES.MAINNET,
16− },
17− {
18− title: 'Ropsten',
19− value: NETWORK_TYPES.ROPSTEN,
20− },
21− {
22− title: 'Local Node',
23− value: NETWORK_TYPES.LOCAL,
24− },
25− {
26− type: 'divider',
27− },
28− {
29− title: 'Offline',
30− value: NETWORK_TYPES.OFFLINE,
31− },
32−];
33−
34−function Network() {
35− const { networkType, setNetworkType } = useNetwork();
36− const history = useHistory();
37−
38− return (
39− <View>
40− <H1>{'Select Network'}</H1>
41−
42− <P>
43− {"Please select the Ethereum node you'd like to send " +
44− 'transactions to. For highly valuable keys, please select ' +
45− 'offline mode.'}
46− </P>
47−
48− <InnerLabelDropdown
49− title={'Node:'}
50− handleUpdate={setNetworkType}
51− options={kNetworkOptions}
52− currentSelectionTitle={renderNetworkType(networkType)}
53− />
54−
55− <Button icon="→" onClick={() => history.push(ROUTE_NAMES.WALLET)}>
56− Continue
57− </Button>
58− </View>
59− );
60−}
61−
62−export default Network;
src/views/Point.js+18−13
@@ -1,39 +1,43 @@
11import React, { useCallback } from 'react';
2+import Maybe from 'folktale/maybe';
23import * as need from 'lib/need';
34import { Grid } from 'indigo-react';
45
56import { usePointCursor } from 'store/pointCursor';
6−// import { usePointCache } from 'store/pointCache';
7+import { useHistory } from 'store/history';
78
89import View from 'components/View';
910import Passport from 'components/Passport';
1011import { ForwardButton } from 'components/Buttons';
12+import FooterButton from 'components/FooterButton';
13+import { matchBlinky } from 'components/Blinky';
1114
1215import useInvites from 'lib/useInvites';
13−import useSyncPoint from 'lib/useSyncPoint';
16+import { useSyncOwnedPoints } from 'lib/useSyncPoints';
1417import { ROUTE_NAMES } from 'lib/routeNames';
1518
16−import { useHistory } from 'store/history';
17−import FooterButton from 'components/FooterButton';
19+import Actions from './Point/Actions';
20+import { useWallet } from 'store/wallet';
1821
1922export default function Point() {
2023 const history = useHistory();
2124 const { pointCursor } = usePointCursor();
22− // const { pointCache } = usePointCache();
25+ const { wallet } = useWallet();
2326
2427 const point = need.pointCursor(pointCursor);
2528
2629 // fetch the invites for the current cursor
2730 const { availableInvites } = useInvites(point);
31+ const availableInvitesText = matchBlinky(availableInvites);
32+
33+ const showActions = Maybe.Just.hasInstance(wallet);
2834
2935 const goInvite = useCallback(() => history.push(ROUTE_NAMES.INVITE), [
3036 history,
3137 ]);
3238
3339 // sync the current cursor
34− useSyncPoint(point);
35−
36− // const pointDetails = need.fromPointCache(pointCache, point);
40+ useSyncOwnedPoints([point]);
3741
3842 return (
3943 <View>
@@ -48,14 +52,15 @@ export default function Point() {
4852 Boot Arvo
4953 </ForwardButton>
5054 </Grid.Item>
55+ {showActions && (
56+ <Grid.Item full>
57+ <Actions />
58+ </Grid.Item>
59+ )}
5160 </Grid>
5261
5362 <FooterButton onClick={goInvite}>
54− Invite
55− {availableInvites.matchWith({
56− Nothing: () => null,
57− Just: p => <sup className="ml1">{p.value}</sup>,
58− })}
63+ Invite <sup>{availableInvitesText}</sup>
5964 </FooterButton>
6065 </View>
6166 );
src/views/Point/Actions.js+23−49
@@ -9,20 +9,23 @@ import { ROUTE_NAMES } from '../../lib/routeNames';
99import { useHistory } from '../../store/history';
1010import { useWallet } from '../../store/wallet';
1111import { Grid } from 'indigo-react';
12+import { usePointCursor } from 'store/pointCursor';
13+import { usePointCache } from 'store/pointCache';
1214
1315const isPlanet = point =>
1416 azimuth.getPointSize(point) === azimuth.PointSize.Planet;
15−const isStar = point => azimuth.getPointSize(point) === azimuth.PointSize.Star;
1617
17−function Actions(props) {
18+function Actions() {
1819 const history = useHistory();
1920 const { wallet } = useWallet();
20− const { online, point, pointDetails, invites } = props;
21+ const { pointCursor } = usePointCursor();
22+ const point = need.pointCursor(pointCursor);
23+ const { getDetails } = usePointCache();
24+ const pointDetails = getDetails(point);
2125
2226 const addr = need.addressFromWallet(wallet);
2327
2428 const planet = isPlanet(point);
25− const star = isStar(point);
2629
2730 const isOwner = pointDetails.matchWith({
2831 Nothing: _ => false,
@@ -57,11 +60,6 @@ function Actions(props) {
5760 },
5861 });
5962
60− const hasInvites = invites.matchWith({
61− Nothing: () => false,
62− Just: count => count.value > 0,
63− });
64−
6563 const canTransfer = pointDetails.matchWith({
6664 Nothing: () => false,
6765 Just: deets =>
@@ -104,37 +102,6 @@ function Actions(props) {
104102 },
105103 });
106104
107− let inviteAction = null;
108− if (planet) {
109− inviteAction = (
110− <Button
111− disabled={!isActiveOwner || !online || !hasInvites}
112− prop-size={'sm'}
113− prop-type={'link'}
114− onClick={() => history.push(ROUTE_NAMES.INVITE)}>
115− {'Send invites ('}
116− {invites.matchWith({
117− Nothing: () => '?',
118− Just: count => count.value,
119− })}
120− {')'}
121− </Button>
122− );
123− }
124− if (star) {
125− inviteAction = (
126− <Button
127− disabled={!(isActiveOwner && canIssueChild) || !online}
128− prop-size={'sm'}
129− prop-type={'link'}
130− onClick={() => {
131− history.push(ROUTE_NAMES.INVITES_MANAGE);
132− }}>
133− {'Manage invites'}
134− </Button>
135− );
136− }
137−
138105 return (
139106 <div>
140107 <H2>{'Actions'}</H2>
@@ -149,7 +116,7 @@ function Actions(props) {
149116 <Button
150117 prop-size={'sm'}
151118 prop-type={'link'}
152− disabled={(online || planet) && !canIssueChild}
119+ disabled={planet && !canIssueChild}
153120 onClick={() => {
154121 history.push(ROUTE_NAMES.ISSUE_CHILD);
155122 }}>
@@ -159,7 +126,7 @@ function Actions(props) {
159126 <Button
160127 prop-size={'sm'}
161128 prop-type={'link'}
162− disabled={online && !canAcceptTransfer}
129+ disabled={!canAcceptTransfer}
163130 onClick={() => {
164131 history.push(ROUTE_NAMES.ACCEPT_TRANSFER);
165132 }}>
@@ -169,7 +136,7 @@ function Actions(props) {
169136 <Button
170137 prop-size={'sm'}
171138 prop-type={'link'}
172− disabled={online && !canCancelTransfer}
139+ disabled={!canCancelTransfer}
173140 onClick={() => {
174141 history.push(ROUTE_NAMES.CANCEL_TRANSFER);
175142 }}>
@@ -179,7 +146,7 @@ function Actions(props) {
179146 <Button
180147 prop-size={'sm'}
181148 prop-type={'link'}
182− disabled={online && !canGenKeyfile}
149+ disabled={!canGenKeyfile}
183150 onClick={() => {
184151 history.push(ROUTE_NAMES.GEN_KEYFILE);
185152 }}>
@@ -188,7 +155,7 @@ function Actions(props) {
188155 </Grid.Item>
189156 <Grid.Item third={2}>
190157 <Button
191− disabled={online && !canSetSpawnProxy}
158+ disabled={!canSetSpawnProxy}
192159 prop-size={'sm'}
193160 prop-type={'link'}
194161 onClick={() => {
@@ -198,7 +165,7 @@ function Actions(props) {
198165 </Button>
199166
200167 <Button
201− disabled={online && !canSetManagementProxy}
168+ disabled={!canSetManagementProxy}
202169 prop-size={'sm'}
203170 prop-type={'link'}
204171 onClick={() => {
@@ -208,7 +175,7 @@ function Actions(props) {
208175 </Button>
209176
210177 <Button
211− disabled={online && !canConfigureKeys}
178+ disabled={!canConfigureKeys}
212179 prop-size={'sm'}
213180 prop-type={'link'}
214181 onClick={() => {
@@ -218,7 +185,7 @@ function Actions(props) {
218185 </Button>
219186
220187 <Button
221− disabled={online && !canTransfer}
188+ disabled={!canTransfer}
222189 prop-size={'sm'}
223190 prop-type={'link'}
224191 onClick={() => {
@@ -226,8 +193,15 @@ function Actions(props) {
226193 }}>
227194 {'Transfer'}
228195 </Button>
196+ <Button
197+ prop-size={'sm'}
198+ prop-type={'link'}
199+ onClick={() => {
200+ history.push(ROUTE_NAMES.INVITES_MANAGE);
201+ }}>
202+ {'Invite'}
203+ </Button>
229204 </Grid.Item>
230− <Grid.Item third={3}>{inviteAction}</Grid.Item>
231205 </Grid>
232206 </div>
233207 );
src/views/Points.js+133−219
@@ -1,245 +1,159 @@
11import React from 'react';
2−import { H1, H2, P } from '../components/old/Base';
3−import { Button } from '../components/old/Base';
4−import * as azimuth from 'azimuth-js';
5−
6−import PointList from '../components/old/PointList';
7−import { NETWORK_TYPES } from '../lib/network';
8−import { ROUTE_NAMES } from '../lib/routeNames';
9−import { withHistory } from '../store/history';
10−import { ETH_ZERO_ADDR, eqAddr } from '../lib/wallet';
11−import { withNetwork } from '../store/network';
12−import { compose } from '../lib/lib';
13−import { withWallet } from '../store/wallet';
14−import { withPointCache } from '../store/pointCache';
2+import Maybe from 'folktale/maybe';
3+import { H1, H2, P, Grid } from 'indigo-react';
4+
5+import { ROUTE_NAMES } from 'lib/routeNames';
6+import { ETH_ZERO_ADDR, eqAddr } from 'lib/wallet';
7+import useIsEclipticOwner from 'lib/useIsEclipticOwner';
8+import { useSyncKnownPoints } from 'lib/useSyncPoints';
9+
10+import { useHistory } from 'store/history';
11+import { useWallet } from 'store/wallet';
12+import { usePointCache } from 'store/pointCache';
13+
14+import PointList from 'components/old/PointList';
1515import View from 'components/View';
16−import { Grid } from 'indigo-react';
17−
18−const hasTransferProxy = (cache, point) =>
19− point in cache ? !eqAddr(cache[point].transferProxy, ETH_ZERO_ADDR) : false;
20−
21−class Points extends React.Component {
22− constructor(props) {
23− super(props);
24−
25− const { networkType } = props;
26− const loading = networkType !== NETWORK_TYPES.OFFLINE;
27−
28− this.state = {
29− points: [],
30− incoming: [],
31− managing: [],
32− voting: [],
33− spawning: [],
34− eclipticOwner: false,
35− loading: loading,
36− };
37− }
38−
39− componentDidMount() {
40− const { web3, wallet, contracts, addToPointCache } = this.props;
41−
42− web3.chain(_ =>
43− contracts.chain(ctrcs =>
44− wallet.chain(async wal => {
45− const addr = wal.address;
46−
47− const points = await azimuth.azimuth.getOwnedPoints(ctrcs, addr);
48−
49− const incoming = await azimuth.azimuth.getTransferringFor(
50− ctrcs,
51− addr
52− );
53−
54− const managing = await azimuth.azimuth.getManagerFor(ctrcs, addr);
55−
56− const voting = await azimuth.azimuth.getVotingFor(ctrcs, addr);
57−
58− const spawning = await azimuth.azimuth.getSpawningFor(ctrcs, addr);
59−
60− const owner = await azimuth.ecliptic.owner(ctrcs);
61−
62− const eclipticOwner = eqAddr(addr, owner);
63−
64− this.setState({
65− points,
66− incoming,
67− managing,
68− voting,
69− spawning,
70− eclipticOwner,
71− loading: false,
72− });
73−
74− this.cachePoints(ctrcs, addToPointCache, points);
75− })
76− )
77− );
78− }
79−
80− cachePoints(contracts, addToPointCache, points) {
81− points.forEach(point => {
82− azimuth.azimuth
83− .getPoint(contracts, point)
84− .then(details => addToPointCache({ [point]: details }));
85− });
86− }
87−
88− render() {
89− const { history, pointCache } = this.props;
90−
91− const {
92− points,
93− incoming,
94− managing,
95− voting,
96− spawning,
97− loading,
98− eclipticOwner,
99− } = this.state;
100−
101− const lookupPointButton = (
16+import { ForwardButton } from 'components/Buttons';
17+import useLifecycle from 'lib/useLifecycle';
18+
19+const matchArray = obj =>
20+ obj.matchWith({ Nothing: () => [], Just: p => p.value });
21+
22+const hasTransferProxy = (details, point) =>
23+ !eqAddr(details.transferProxy, ETH_ZERO_ADDR);
24+
25+export default function Points() {
26+ const { wallet } = useWallet();
27+ const history = useHistory();
28+ const {
29+ getControlledPoints,
30+ syncControlledPoints,
31+ getDetails,
32+ } = usePointCache();
33+ const {
34+ ownedPoints,
35+ incomingPoints,
36+ managingPoints,
37+ votingPoints,
38+ spawningPoints,
39+ } = getControlledPoints(wallet);
40+ const isEclipticOwner = useIsEclipticOwner();
41+
42+ const owned = matchArray(ownedPoints);
43+ const incoming = matchArray(incomingPoints);
44+ const managing = matchArray(managingPoints);
45+ const voting = matchArray(votingPoints);
46+ const spawning = matchArray(spawningPoints);
47+
48+ // sync controlled points on mount
49+ useLifecycle(() => {
50+ syncControlledPoints();
51+ });
52+
53+ // sync display details for known points
54+ useSyncKnownPoints([
55+ ...owned,
56+ ...incoming,
57+ ...managing,
58+ ...voting,
59+ ...spawning,
60+ ]);
61+
62+ const loading = Maybe.Nothing.hasInstance(ownedPoints);
63+
64+ const outgoing = ownedPoints.filter(point =>
65+ getDetails(point).matchWith({
66+ Nothing: () => false,
67+ Just: p => hasTransferProxy(p.value),
68+ })
69+ );
70+
71+ return (
72+ <View>
73+ <H1>Points</H1>
74+
75+ <P>
76+ A point is an identity on the Ethereum blockchain. Points declare keys
77+ for ships on the Arvo network.
78+ </P>
79+
10280 <Grid>
10381 <Grid.Item full>
104− <Button
105− prop-type={'link'}
106− prop-size={'md'}
82+ <ForwardButton
83+ detail="View a point on Azimuth"
10784 onClick={() => history.push(ROUTE_NAMES.VIEW_POINT)}>
108− {'View a point →'}
109− </Button>
110− <P>{'View a point on Azimuth.'}</P>
85+ View a point
86+ </ForwardButton>
11187 </Grid.Item>
11288 </Grid>
113− );
11489
115− const createGalaxyButton =
116− eclipticOwner === false ? (
117− <div />
118− ) : (
90+ {isEclipticOwner && (
11991 <Grid>
12092 <Grid.Item full>
121− <Button
122− prop-type={'link'}
123− prop-size={'md'}
93+ <ForwardButton
94+ detail="You have the authority to create a new Galaxy."
12495 onClick={() => history.push(ROUTE_NAMES.CREATE_GALAXY)}>
125− {'Create a galaxy →'}
126− </Button>
127− <P>
128− {
129− 'You have the authority to create a new Galaxy and you can do so here.'
130− }
131− </P>
96+ Create a galaxy
97+ </ForwardButton>
13298 </Grid.Item>
13399 </Grid>
134− );
100+ )}
135101
136− const outgoing = points.filter(point =>
137− hasTransferProxy(pointCache, point)
138− );
139−
140− const outgoingPoints =
141− outgoing.length !== 0 ? (
142− <React.Fragment>
143− <H2>{'Outgoing Transfers'}</H2>
102+ {incoming.length > 0 && (
103+ <>
104+ <H2>Incoming Transfers</H2>
144105 <P>
145− {`You own these points until the recipient accepts the incoming
146− transfer. You may cancel the transfer until accepted.`}
106+ You do not own these points until you accept the incoming transfer.
107+ You may reject any incoming transfers.
147108 </P>
148− <PointList routeHandler={history.push} points={outgoing} />
149− </React.Fragment>
150− ) : (
151− <div />
152− );
153−
154− const incomingPoints =
155− incoming.length !== 0 ? (
156− <React.Fragment>
157− <H2>{'Incoming Transfers'}</H2>
109+ <PointList routeHandler={history.push} points={incoming} />
110+ </>
111+ )}
112+
113+ {outgoing.length > 0 && (
114+ <>
115+ <H2>Outgoing Transfers</H2>
158116 <P>
159− {`You do not own these points until you accept the incoming transfer.
160− You may reject any incoming transfers.`}
117+ You own these points until the recipient accepts the incoming
118+ transfer. You may cancel the transfer until accepted.
161119 </P>
162− <PointList routeHandler={history.push} points={incoming} />
163− </React.Fragment>
164− ) : (
165− <div />
166− );
167−
168− const managingPoints =
169− managing.length !== 0 ? (
170− <React.Fragment>
171− <H2>{'You Are a Management Proxy For'}</H2>
120+ <PointList routeHandler={history.push} points={outgoing} />
121+ </>
122+ )}
123+
124+ <H2>Your Points</H2>
125+
126+ <PointList routeHandler={history.push} points={owned} loading={loading} />
127+
128+ {managing.length > 0 && (
129+ <>
130+ <H2>You Are a Management Proxy For</H2>
172131 <P>
173− {`You can configure or set network keys and conduct sponsorship
174− related operations for these points.`}
132+ You can configure or set network keys and conduct sponsorship
133+ related operations for these points.
175134 </P>
176135 <PointList routeHandler={history.push} points={managing} />
177− </React.Fragment>
178− ) : (
179− <div />
180− );
181−
182− const votingPoints =
183− voting.length !== 0 ? (
184− <React.Fragment>
185− <H2>{'You Are a Voting Proxy For'}</H2>
136+ </>
137+ )}
138+
139+ {voting.length > 0 && (
140+ <>
141+ <H2>You Are a Voting Proxy For</H2>
186142 <P>
187− {`Since you are part of the Galactic Senate, you can cast votes on
188− new Azimuth proposals on behalf of these points.`}
143+ Since you are part of the Galactic Senate, you can cast votes on new
144+ Azimuth proposals on behalf of these points.
189145 </P>
190146 <PointList routeHandler={history.push} points={voting} />
191− </React.Fragment>
192− ) : (
193− <div />
194− );
195−
196− const spawningPoints =
197− spawning.length !== 0 ? (
198− <React.Fragment>
199− <H2>{'You Are a Spawn Proxy For'}</H2>
200− <P>{`You can create new child ships under these points.`}</P>
201− <PointList routeHandler={history.push} points={spawning} />
202− </React.Fragment>
203− ) : (
204− <div />
205− );
206−
207− return (
208− <View>
209− <H1>{'Points'}</H1>
210−
211− <P>{`A point is an identity on the Ethereum blockchain.
212− Points declare keys for ships on the Arvo network.`}</P>
213−
214− {lookupPointButton}
215−
216− {createGalaxyButton}
217−
218− {incomingPoints}
219−
220− {outgoingPoints}
147+ </>
148+ )}
221149
222− <H2>{'Your Points'}</H2>
223−
224− <PointList
225− routeHandler={history.push}
226− points={points}
227− loading={loading}
228− />
229−
230− {managingPoints}
231−
232− {votingPoints}
233−
234− {spawningPoints}
235− </View>
236− );
237− }
150+ {spawning.length > 0 && (
151+ <>
152+ <H2>You Are a Spawn Proxy For</H2>
153+ <P>You can create new child points under these points.</P>
154+ <PointList routeHandler={history.push} points={spawning} />
155+ </>
156+ )}
157+ </View>
158+ );
238159}
239−
240−export default compose(
241− withNetwork,
242− withHistory,
243− withWallet,
244− withPointCache
245−)(Points);
src/views/PrivateKey.jsdeleted−84
@@ -1,84 +0,0 @@
1−import Maybe from 'folktale/maybe';
2−import React from 'react';
3−import { Button } from '../components/old/Base';
4−import {
5− RequiredInput,
6− InnerLabel,
7− InputCaption,
8−} from '../components/old/Base';
9−import { H1 } from '../components/old/Base';
10−
11−import { ROUTE_NAMES } from '../lib/routeNames';
12−import { withHistory } from '../store/history';
13−import { EthereumWallet } from '../lib/wallet';
14−import { compose } from '../lib/lib';
15−import { withWallet } from '../store/wallet';
16−import View from 'components/View';
17−
18−class PrivateKey extends React.Component {
19− constructor(props) {
20− super(props);
21−
22− this.state = {
23− privateKey: '',
24− };
25−
26− this.handlePrivateKeyInput = this.handlePrivateKeyInput.bind(this);
27− }
28−
29− handlePrivateKeyInput(privateKey) {
30− this.setState({ privateKey });
31− this.constructWallet(privateKey);
32− }
33−
34− constructWallet(privateKey) {
35− const { setWallet } = this.props;
36− if (/^[0-9A-Fa-f]{64}$/g.test(privateKey) === true) {
37− const sec = Buffer.from(privateKey, 'hex');
38− const wallet = new EthereumWallet(sec);
39− setWallet(Maybe.Just(wallet));
40− } else {
41− setWallet(Maybe.Nothing());
42− }
43− }
44−
45− render() {
46− const { history, wallet } = this.props;
47− const { privateKey } = this.state;
48−
49− return (
50− <View>
51− <H1 className={'mb-4'}>{'Enter Your Private Key'}</H1>
52− <InputCaption>
53− {`Please enter your raw Ethereum private key here.`}
54− </InputCaption>
55−
56− <RequiredInput
57− className="pt-8 mt-8"
58− prop-size="md"
59− prop-format="innerLabel"
60− type="password"
61− name="privateKey"
62− onChange={this.handlePrivateKeyInput}
63− value={privateKey}
64− autocomplete="off"
65− autoFocus>
66− <InnerLabel>{'Private Key'}</InnerLabel>
67− </RequiredInput>
68−
69− <Button
70− className={'mt-10'}
71− prop-size={'wide lg'}
72− disabled={Maybe.Nothing.hasInstance(wallet)}
73− onClick={() => history.popAndPush(ROUTE_NAMES.POINTS)}>
74− {'Continue →'}
75− </Button>
76− </View>
77− );
78− }
79−}
80−
81−export default compose(
82− withHistory,
83− withWallet
84−)(PrivateKey);
src/views/Shards.jsdeleted−212
@@ -1,212 +0,0 @@
1−import { Just, Nothing } from 'folktale/maybe';
2−import React from 'react';
3−import { Button } from '../components/old/Base';
4−import {
5− InnerLabel,
6− ValidatedSigil,
7− PointInput,
8− ShardInput,
9− InputCaption,
10− Input,
11−} from '../components/old/Base';
12−import { H1, P } from '../components/old/Base';
13−import * as kg from 'urbit-key-generation/dist';
14−import * as ob from 'urbit-ob';
15−
16−import { ROUTE_NAMES } from '../lib/routeNames';
17−import { withHistory } from '../store/history';
18−import { urbitWalletFromTicket } from '../lib/wallet';
19−import { compose } from '../lib/lib';
20−import { withWallet } from '../store/wallet';
21−import View from 'components/View';
22−
23−const placeholder = len => {
24− let bytes = window.crypto.getRandomValues(new Uint8Array(len));
25− let hex = bytes.reduce(
26− (acc, byt) => acc + byt.toString(16).padStart(2, '0'),
27− ''
28− );
29− return ob.hex2patq(hex);
30−};
31−
32−const SHARDS = {
33− SHARD1: Symbol('SHARD1'),
34− SHARD2: Symbol('SHARD2'),
35− SHARD3: Symbol('SHARD3'),
36−};
37−
38−class Shards extends React.Component {
39− constructor(props) {
40− super(props);
41−
42− this.state = {
43− shard1: '',
44− shard2: '',
45− shard3: '',
46− passphrase: '',
47− pointName: '',
48− };
49−
50− this.pointPlaceholder = placeholder(4);
51− this.ticketPlaceholder = placeholder(16);
52−
53− this.handlePassphraseInput = this.handlePassphraseInput.bind(this);
54− this.handleShardInput = this.handleShardInput.bind(this);
55− this.handlePointNameInput = this.handlePointNameInput.bind(this);
56− }
57−
58− handleShardInput(shard, input) {
59− if (shard === SHARDS.SHARD1) {
60− this.setState({ shard1: input });
61− } else if (shard === SHARDS.SHARD2) {
62− this.setState({ shard2: input });
63− } else if (shard === SHARDS.SHARD3) {
64− this.setState({ shard3: input });
65− }
66− }
67−
68− handlePassphraseInput(passphrase) {
69− this.setState({ passphrase });
70− }
71−
72− handlePointNameInput(pointName) {
73− if (pointName.length < 15) {
74− this.setState({ pointName });
75− }
76− }
77−
78− async walletFromShards(shard1, shard2, shard3, pointName, passphrase) {
79− const s1 = shard1 === '' ? undefined : shard1;
80− const s2 = shard2 === '' ? undefined : shard2;
81− const s3 = shard3 === '' ? undefined : shard3;
82−
83− let ticket = undefined;
84− try {
85− ticket = kg.combine([s1, s2, s3]);
86− } catch (_) {
87− // do nothing
88− }
89−
90− if (ticket !== undefined) {
91− const uhdw = await urbitWalletFromTicket(ticket, pointName, passphrase);
92− this.props.setUrbitWallet(Just(uhdw));
93− }
94− }
95−
96− render() {
97− const { history, wallet } = this.props;
98− const { shard1, shard2, shard3, pointName, passphrase } = this.state;
99−
100− const phPoint = this.pointPlaceholder;
101− const phTick = this.ticketPlaceholder;
102−
103− const shards = [shard1, shard2, shard3];
104− const ready = shards.filter(x => x !== '').length > 1;
105−
106− return (
107− <View>
108− <H1>{'Authenticate'}</H1>
109−
110− <P>
111− {`Enter your point and at least two of your three Urbit master
112− ticket shards here. The index of the input field needs to
113− match the index of the shard.`}
114− </P>
115−
116− <PointInput
117− className="mono mt-8"
118− prop-size="lg"
119− prop-format="innerLabel"
120− type="text"
121− autoFocus
122− placeholder={`e.g. ${phPoint}`}
123− value={pointName}
124− onChange={this.handlePointNameInput}>
125− <InnerLabel>{'Point'}</InnerLabel>
126− <ValidatedSigil
127− className={'tr-0 mt-05 mr-0 abs'}
128− patp={pointName}
129− size={68}
130− margin={8}
131− />
132− </PointInput>
133−
134− <ShardInput
135− className="mono mt-8"
136− prop-size="md"
137− prop-format="innerLabel"
138− type="text"
139− name="shard1"
140− placeholder={`e.g. ${phTick}`}
141− value={shard1}
142− onChange={inp => this.handleShardInput(SHARDS.SHARD1, inp)}>
143− <InnerLabel>{'Shard 1'}</InnerLabel>
144− </ShardInput>
145−
146− <ShardInput
147− className="mono mt-8"
148− prop-size="md"
149− prop-format="innerLabel"
150− type="text"
151− name="shard2"
152− placeholder={`e.g. ${phTick}`}
153− value={shard2}
154− onChange={inp => this.handleShardInput(SHARDS.SHARD2, inp)}>
155− <InnerLabel>{'Shard 2'}</InnerLabel>
156− </ShardInput>
157−
158− <ShardInput
159− className="mono mt-8"
160− prop-size="md"
161− prop-format="innerLabel"
162− type="text"
163− name="shard3"
164− placeholder={`e.g. ${phTick}`}
165− value={shard3}
166− onChange={inp => this.handleShardInput(SHARDS.SHARD3, inp)}>
167− <InnerLabel>{'Shard 3'}</InnerLabel>
168− </ShardInput>
169−
170− <InputCaption>
171− {'If your wallet requires a passphrase, you may enter it below.'}
172− </InputCaption>
173−
174− <Input
175− className="pt-8"
176− prop-size="md"
177− prop-format="innerLabel"
178− name="passphrase"
179− type="password"
180− value={passphrase}
181− autocomplete="off"
182− onChange={this.handlePassphraseInput}>
183− <InnerLabel>{'Passphrase'}</InnerLabel>
184− </Input>
185−
186− <Button
187− disabled={!ready}
188− className={'mt-8'}
189− prop-size={'lg wide'}
190− // prop-color={this.buttonTriState(wallet)}
191− onClick={() =>
192− this.walletFromShards(shard1, shard2, shard3, pointName, passphrase)
193− }>
194− {'Unlock Wallet →'}
195− </Button>
196−
197− <Button
198− className={'mt-4'}
199− prop-size={'xl wide'}
200− disabled={Nothing.hasInstance(wallet)}
201− onClick={() => history.popAndPush(ROUTE_NAMES.POINTS)}>
202− {'Continue →'}
203− </Button>
204− </View>
205− );
206− }
207−}
208−
209−export default compose(
210− withHistory,
211− withWallet
212−)(Shards);
src/views/Ticket.jsdeleted−154
@@ -1,154 +0,0 @@
1−import { Just, Nothing } from 'folktale/maybe';
2−import React from 'react';
3−import { Button } from '../components/old/Base';
4−import {
5− InnerLabel,
6− ValidatedSigil,
7− PointInput,
8− TicketInput,
9− Input,
10− InputCaption,
11−} from '../components/old/Base';
12−import { H1, P } from '../components/old/Base';
13−
14−import { randomPatq, compose } from '../lib/lib';
15−import { ROUTE_NAMES } from '../lib/routeNames';
16−import { withHistory } from '../store/history';
17−import { urbitWalletFromTicket } from '../lib/wallet';
18−import { withWallet } from '../store/wallet';
19−import View from 'components/View';
20−
21−class Ticket extends React.Component {
22− constructor(props) {
23− super(props);
24−
25− this.state = {
26− ticket: '',
27− pointName: '',
28− passphrase: '',
29− isUnlocking: false,
30− };
31−
32− this.pointPlaceholder = randomPatq(4);
33− this.ticketPlaceholder = randomPatq(8);
34−
35− this.handleTicketInput = this.handleTicketInput.bind(this);
36− this.handlePassphraseInput = this.handlePassphraseInput.bind(this);
37− this.handlePointNameInput = this.handlePointNameInput.bind(this);
38− }
39−
40− handleTicketInput(ticket) {
41− this.setState({ ticket });
42− }
43−
44− handlePassphraseInput(passphrase) {
45− this.setState({ passphrase });
46− }
47−
48− handlePointNameInput(pointName) {
49− if (pointName.length < 15) {
50− this.setState({ pointName });
51− }
52− }
53−
54− async walletFromTicket(ticket, pointName, passphrase) {
55− this.setState({
56− isUnlocking: true,
57− });
58−
59− const uhdw = await urbitWalletFromTicket(ticket, pointName, passphrase);
60− this.props.setUrbitWallet(Just(uhdw));
61−
62− this.setState({
63− isUnlocking: false,
64− });
65− }
66−
67− render() {
68− const { history, wallet } = this.props;
69− const { ticket, pointName, passphrase } = this.state;
70−
71− const phPoint = this.pointPlaceholder;
72− const phTick = this.ticketPlaceholder;
73−
74− return (
75− <View>
76− <H1>{'Authenticate'}</H1>
77−
78− <P>
79− {`Please enter your point and Urbit master ticket here. This information is written on your Urbit HD paper wallets.`}
80− </P>
81−
82− <PointInput
83− className="mono mt-8"
84− prop-size="lg"
85− prop-format="innerLabel"
86− type="text"
87− autoFocus
88− placeholder={`e.g. ${phPoint}`}
89− value={pointName}
90− onChange={this.handlePointNameInput}>
91− <InnerLabel>{'Point'}</InnerLabel>
92− <ValidatedSigil
93− className={'tr-0 mt-05 mr-0 abs'}
94− patp={pointName}
95− size={68}
96− margin={8}
97− />
98− </PointInput>
99−
100− <TicketInput
101− className="mono mt-8"
102− prop-size="lg"
103− prop-format="innerLabel"
104− type="password"
105− name="ticket"
106− placeholder={`e.g. ${phTick}`}
107− value={ticket}
108− onChange={this.handleTicketInput}>
109− <InnerLabel>{'Ticket'}</InnerLabel>
110− </TicketInput>
111−
112− <InputCaption>
113− {'If your wallet requires a passphrase, you may enter it below.'}
114− </InputCaption>
115−
116− <Input
117− className="pt-8"
118− prop-size="md"
119− prop-format="innerLabel"
120− name="passphrase"
121− type="password"
122− value={passphrase}
123− autocomplete="off"
124− onChange={this.handlePassphraseInput}>
125− <InnerLabel>{'Passphrase'}</InnerLabel>
126− </Input>
127−
128− <Button
129− className={'mt-8'}
130− prop-size={'lg wide'}
131− disabled={this.state.isUnlocking || Just.hasInstance(wallet)}
132− onClick={() => this.walletFromTicket(ticket, pointName, passphrase)}>
133− <span className="relative">
134− {this.state.isUnlocking && <span className="btn-spinner" />}
135− {'Unlock Wallet →'}
136− </span>
137− </Button>
138−
139− <Button
140− className={'mt-4'}
141− prop-size={'xl wide'}
142− disabled={Nothing.hasInstance(wallet)}
143− onClick={() => history.popAndPush(ROUTE_NAMES.POINTS)}>
144− {'Continue →'}
145− </Button>
146− </View>
147− );
148− }
149−}
150−
151−export default compose(
152− withHistory,
153− withWallet
154−)(Ticket);
src/views/Trezor.jsdeleted−146
@@ -1,146 +0,0 @@
1−import * as bip32 from 'bip32';
2−import React from 'react';
3−import Maybe from 'folktale/maybe';
4−import { Button } from '../components/old/Base';
5−import { H1, P } from '../components/old/Base';
6−import { InnerLabel, Input, InnerLabelDropdown } from '../components/old/Base';
7−import TrezorConnect from 'trezor-connect';
8−import * as secp256k1 from 'secp256k1';
9−
10−import { TREZOR_PATH } from '../lib/trezor';
11−import { ROUTE_NAMES } from '../lib/routeNames';
12−import { withHistory } from '../store/history';
13−import { compose } from '../lib/lib';
14−import { withWallet } from '../store/wallet';
15−import View from 'components/View';
16−
17−class Trezor extends React.Component {
18− constructor(props) {
19− super(props);
20−
21− this.state = {
22− hdpath: TREZOR_PATH.replace(/x/g, 0),
23− account: 0,
24− };
25−
26− this.handleAccountSelection = this.handleAccountSelection.bind(this);
27− this.handleHdPathInput = this.handleHdPathInput.bind(this);
28− this.pollDevice = this.pollDevice.bind(this);
29− }
30−
31− handleAccountSelection(account) {
32− let hdpath = this.state.hdpath;
33− if (account !== 'custom') {
34− hdpath = TREZOR_PATH.replace(/x/g, account);
35− }
36− this.setState({ account, hdpath });
37− }
38−
39− handleHdPathInput(hdpath) {
40− this.setState({ hdpath });
41− }
42−
43− async pollDevice() {
44− const { setWallet, setWalletHdPath } = this.props;
45− const { hdpath } = this.state;
46−
47− TrezorConnect.manifest({
48− email: 'bridge-trezor@urbit.org',
49− appUrl: 'https://github.com/urbit/bridge',
50− });
51− TrezorConnect.getPublicKey({ path: hdpath }).then(info => {
52− if (info.success === true) {
53− const payload = info.payload;
54− const publicKey = Buffer.from(payload.publicKey, 'hex');
55− const chainCode = Buffer.from(payload.chainCode, 'hex');
56− const pub = secp256k1.publicKeyConvert(publicKey, true);
57− const hd = bip32.fromPublicKey(pub, chainCode);
58− setWallet(Maybe.Just(hd));
59− setWalletHdPath(hdpath);
60− } else {
61− setWallet(Maybe.Nothing());
62− }
63− });
64− }
65−
66− render() {
67− const { history, wallet } = this.props;
68− const { hdpath, account } = this.state;
69− const { handleAccountSelection } = this;
70−
71− let accountOptions = [
72− {
73− title: 'Custom path',
74− value: 'custom',
75− },
76− ];
77− for (let i = 0; i < 20; i++) {
78− accountOptions.push({
79− title: 'Account #' + (i + 1),
80− value: i,
81− });
82− }
83− let accountTitle = accountOptions.find(o => o.value === account).title;
84−
85− const accountSelection = (
86− <InnerLabelDropdown
87− className="mt-8"
88− prop-size="md"
89− prop-format="innerLabel"
90− options={accountOptions}
91− handleUpdate={handleAccountSelection}
92− title={'Account'}
93− currentSelectionTitle={accountTitle}
94− fullWidth={true}
95− />
96− );
97−
98− const pathSelection =
99− account !== 'custom' ? null : (
100− <Input
101− className="mt-8 pt-8 text-mono"
102− prop-size="md"
103− prop-format="innerLabel"
104− name="hdpath"
105− value={hdpath}
106− autocomplete="off"
107− onChange={this.handleHdPathInput}>
108− <InnerLabel>{'HD Path'}</InnerLabel>
109− </Input>
110− );
111−
112− return (
113− <View>
114− <H1>{'Authenticate With Your Trezor'}</H1>
115−
116− <P>
117− {`Connect and authenticate to your Trezor. If you'd like
118− to use a custom derivation path, you may enter it below.`}
119− </P>
120−
121− {accountSelection}
122− {pathSelection}
123−
124− <Button
125− className={'mt-8'}
126− prop-size={'wide lg'}
127− onClick={this.pollDevice}>
128− {'Authenticate →'}
129− </Button>
130−
131− <Button
132− className={'mt-8'}
133− prop-size={'wide lg'}
134− disabled={Maybe.Nothing.hasInstance(wallet)}
135− onClick={() => history.popAndPush(ROUTE_NAMES.POINTS)}>
136− {'Continue →'}
137− </Button>
138− </View>
139− );
140− }
141−}
142−
143−export default compose(
144− withHistory,
145− withWallet
146−)(Trezor);
src/views/ViewPoint.js+10−20
@@ -1,6 +1,7 @@
1−import React, { useCallback, useState } from 'react';
1+import React, { useCallback } from 'react';
22import Maybe from 'folktale/maybe';
33import * as ob from 'urbit-ob';
4+import { Input } from 'indigo-react';
45
56import { H1, P } from '../components/old/Base';
67
@@ -11,15 +12,17 @@ import { ROUTE_NAMES } from 'lib/routeNames';
1112
1213import View from 'components/View';
1314import { ForwardButton } from 'components/Buttons';
14−import { PointInput } from 'components/Inputs';
15+import { usePointInput } from 'components/Inputs';
1516
1617export default function ViewPoint() {
1718 const history = useHistory();
1819 const { setPointCursor } = usePointCursor();
19− const [pointName, setPointName] = useState('');
20− const [pass, setPass] = useState(false);
21− const [error, setError] = useState();
22− const [focused, setFocused] = useState(true);
20+ const pointInput = usePointInput({
21+ name: 'point',
22+ label: 'Point Name',
23+ autoFocus: true,
24+ });
25+ const { data: pointName, pass } = pointInput;
2326
2427 const disabled = !pass;
2528 const goForward = useCallback(() => {
@@ -34,20 +37,7 @@ export default function ViewPoint() {
3437
3538 <P>Enter a point name to view its public information.</P>
3639
37− <PointInput
38− name="point"
39− label="Point Name"
40− initialValue={pointName}
41− onValue={setPointName}
42− pass={pass}
43− onPass={setPass}
44− error={error}
45− onError={setError}
46− focused={focused}
47− onFocus={setFocused}
48− onEnter={goForward}
49− autoFocus
50− />
40+ <Input {...pointInput} onEnter={goForward} />
5141
5242 <ForwardButton
5343 className="mt3"
src/views/Wallet.jsdeleted−101
@@ -1,101 +0,0 @@
1−import React from 'react';
2−import Maybe from 'folktale/maybe';
3−
4−import { H1, P } from '../components/old/Base';
5−import { InnerLabelDropdown } from '../components/old/Base';
6−
7−import View from 'components/View';
8−import { ForwardButton } from 'components/Buttons';
9−
10−import { useHistory } from '../store/history';
11−import { useWallet } from '../store/wallet';
12−
13−import { ROUTE_NAMES } from '../lib/routeNames';
14−import { WALLET_TYPES, renderWalletType } from '../lib/wallet';
15−import useLifecycle from 'lib/useLifecycle';
16−
17−const kWalletOptions = [
18− {
19− title: 'Urbit Master Ticket',
20− value: WALLET_TYPES.TICKET,
21− },
22− {
23− title: 'Urbit Master Ticket (Shards)',
24− value: WALLET_TYPES.SHARDS,
25− },
26− {
27− type: 'divider',
28− },
29− {
30− title: 'Ledger',
31− value: WALLET_TYPES.LEDGER,
32− },
33− {
34− title: 'Trezor',
35− value: WALLET_TYPES.TREZOR,
36− },
37− {
38− type: 'divider',
39− },
40− {
41− title: 'BIP39 Mnemonic',
42− value: WALLET_TYPES.MNEMONIC,
43− },
44− {
45− title: 'Ethereum Private Key',
46− value: WALLET_TYPES.PRIVATE_KEY,
47− },
48− {
49− title: 'Ethereum Keystore File',
50− value: WALLET_TYPES.KEYSTORE,
51− },
52−];
53−
54−export default function Wallet() {
55− const history = useHistory();
56− const { setWallet, walletType, setWalletType } = useWallet();
57−
58− // reset wallet when this component is mounted
59− useLifecycle(() => setWallet(Maybe.Nothing()));
60−
61− return (
62− <View>
63− <H1>Unlock a Wallet</H1>
64−
65− <P>
66− To manage your assets, you need to unlock a wallet. Please select how
67− you would like to access your wallet.
68− </P>
69−
70− <InnerLabelDropdown
71− title="Wallet Type:"
72− options={kWalletOptions}
73− handleUpdate={setWalletType}
74− currentSelectionTitle={renderWalletType(walletType)}
75− />
76−
77− <ForwardButton
78− onClick={() =>
79− history.push(
80− walletType === WALLET_TYPES.MNEMONIC
81− ? ROUTE_NAMES.MNEMONIC
82− : walletType === WALLET_TYPES.TICKET
83− ? ROUTE_NAMES.TICKET
84− : walletType === WALLET_TYPES.SHARDS
85− ? ROUTE_NAMES.SHARDS
86− : walletType === WALLET_TYPES.LEDGER
87− ? ROUTE_NAMES.LEDGER
88− : walletType === WALLET_TYPES.TREZOR
89− ? ROUTE_NAMES.TREZOR
90− : walletType === WALLET_TYPES.PRIVATE_KEY
91− ? ROUTE_NAMES.PRIVATE_KEY
92− : walletType === WALLET_TYPES.KEYSTORE
93− ? ROUTE_NAMES.KEYSTORE
94− : ROUTE_NAMES.DEFAULT
95− )
96− }>
97− Continue
98− </ForwardButton>
99− </View>
100− );
101−}