on master help sign in
feat: implement issue child
commit1ddc838871174471d9631bbc340fab7e3d94386d authorMatt Condon (shrugs) <mattgcondon@gmail.com> dateThu 18 Jul 2019, 17:29 (7 years ago) parente94ff4f
6 files changed+202−253
src/lib/useEthereumTransaction.js+8−2 src/lib/useInputs.js+5−5 src/lib/validators.js+7 src/views/CreateGalaxy.js+3−2 src/views/IssueChild.js+165−243 src/views/Point.js+14−1
src/lib/useEthereumTransaction.js+8−2
@@ -217,8 +217,14 @@ export default function useEthereumTransaction(
217217 try {
218218 await refetch();
219219 } catch (error) {
220− // NOTE: we don't handle this error on purpose
220+ // log the original
221221 console.error(error);
222+ // track a user-friendly error
223+ setError(
224+ new Error(
225+ 'The transaction succeeded but we were unable to refresh chain state. Refresh to continue.'
226+ )
227+ );
222228 }
223229 }
224230
@@ -231,7 +237,7 @@ export default function useEthereumTransaction(
231237 }
232238
233239 return () => (cancelled = true);
234− }, [confirmed, refetch, completed]);
240+ }, [confirmed, refetch, completed, setError]);
235241
236242 const values = useDeepEqualReference({
237243 isDefaultState,
src/lib/useInputs.js+5−5
@@ -146,14 +146,15 @@ export function useTicketInput({ validators = [], deriving = false, ...rest }) {
146146}
147147
148148const kPointTransformers = [prependSig];
149−export function usePointInput({ size = 4, ...rest }) {
150− const validators = useMemo(
149+export function usePointInput({ size = 4, validators = [], ...rest }) {
150+ const _validators = useMemo(
151151 () => [
152+ ...validators,
152153 validatePoint,
153154 validateMaximumPatpByteLength(size),
154155 validateNotEmpty,
155156 ],
156− [size]
157+ [size, validators]
157158 );
158159
159160 return useFirstOf(
@@ -162,7 +163,7 @@ export function usePointInput({ size = 4, ...rest }) {
162163 type: 'text',
163164 label: 'Point',
164165 placeholder: 'e.g. ~zod',
165− validators,
166+ validators: _validators,
166167 transformers: kPointTransformers,
167168 mono: true,
168169 ...rest,
@@ -185,7 +186,6 @@ export function usePointInput({ size = 4, ...rest }) {
185186
186187export function useGalaxyInput(props) {
187188 return usePointInput({
188− name: 'galaxy',
189189 label: 'Galaxy Name',
190190 size: 1,
191191 ...props,
src/lib/validators.js+7
@@ -245,6 +245,13 @@ export const validateGreaterThan = l => m =>
245245 error: `Must be at least ${l}.`,
246246 });
247247
248+export const validateInSet = (set, error) => m =>
249+ simpleValidatorWrapper({
250+ prevMessage: m,
251+ validator: d => set.has(d),
252+ error,
253+ });
254+
248255export const validatePatpByteLength = byteLength => {
249256 return validateLength(patpStringLength(byteLength));
250257};
src/views/CreateGalaxy.js+3−2
@@ -62,12 +62,13 @@ export default function CreateGalaxy() {
6262 { pass: validGalaxyName, syncPass: syncValidGalaxyName, value: galaxyName },
6363 // ^ we use value: here so our effect runs onChange
6464 ] = useGalaxyInput({
65+ name: 'galaxy',
6566 disabled: inputsLocked,
6667 autoFocus: true,
6768 error:
6869 error ||
6970 isAvailable.matchWith({
70− Nothing: () => 'Loading availabiliy...', // TODO: make async loading?
71+ Nothing: () => 'Loading availability...', // TODO: make async loading?
7172 Just: p => (p.value ? undefined : 'This galaxy is already owned.'),
7273 }),
7374 });
@@ -142,7 +143,7 @@ export default function CreateGalaxy() {
142143 className={cn('f5', {
143144 green3: completed,
144145 })}>
145− {galaxyName} has been created and is owned by {owner}.
146+ {galaxyName} has been created and can be claimed by {owner}.
146147 </Grid.Item>
147148 )}
148149
src/views/IssueChild.js+165−243
@@ -1,254 +1,176 @@
1−import { Just, Nothing } from 'folktale/maybe';
2−import React from 'react';
3−import { azimuth, ecliptic } from 'azimuth-js';
1+import React, { useCallback, useEffect, useMemo, useState } from 'react';
2+import { Nothing, Just } from 'folktale/maybe';
3+import cn from 'classnames';
4+import { Grid, Text, Input } from 'indigo-react';
5+import * as azimuth from 'azimuth-js';
46import * as ob from 'urbit-ob';
5−import * as need from '../lib/need';
6−
7−import {
8− H1,
9− P,
10− Anchor,
11− ShowBlockie,
12− ValidatedSigil,
13− PointInput,
14− AddressInput,
15− InnerLabel,
16−} from '../components/old/Base';
17−
18−import StatelessTransaction from '../components/old/StatelessTransaction';
19−
20−import { NETWORK_TYPES } from '../lib/network';
21−import { getSpawnCandidate } from '../lib/child';
22−import { canDecodePatp } from '../lib/txn';
23−
24−import { isValidAddress } from '../lib/wallet';
25−import { withNetwork } from '../store/network';
26−import { compose } from '../lib/lib';
27−import { withPointCursor } from '../store/pointCursor';
28−import View from 'components/View';
29−
30−const setFind = (set, pred) => {
31− for (const e of set) {
32− if (pred(e)) {
33− return e;
34− }
35− }
36− return undefined;
37−};
38−
39−class IssueChild extends React.Component {
40− constructor(props) {
41− super(props);
42−
43− const issuingPoint = need.point(props.pointCursor);
44−
45− const getCandidate = () => ob.patp(getSpawnCandidate(issuingPoint));
46−
47− const suggestions = [
48− getCandidate(),
49− getCandidate(),
50− getCandidate(),
51− getCandidate(),
52− ];
53−
54− this.state = {
55− receivingAddress: '',
56− issuingPoint: issuingPoint,
57− desiredPoint: '',
58− isAvailable: Nothing(), // use Nothing to allow attempt when offline
59− suggestions: suggestions,
60− validChildren: new Set(),
61− autoComplete: '',
62− };
63−
64− this.handlePointInput = this.handlePointInput.bind(this);
65− this.handleAddressInput = this.handleAddressInput.bind(this);
66− this.createUnsignedTxn = this.createUnsignedTxn.bind(this);
67− this.statelessRef = React.createRef();
68− }
69−
70− handleAddressInput = receivingAddress => {
71− this.setState({ receivingAddress });
72− this.statelessRef.current.clearTxn();
73− };
74−
75− componentWillMount() {
76− const { issuingPoint } = this.state;
77−
78− const validContracts = need.contracts(this.props.contracts);
79−
80− azimuth
81− .getUnspawnedChildren(validContracts, parseInt(issuingPoint, 10))
82− .then(ps => this.setState({ validChildren: new Set(ps.map(ob.patp)) }));
83− }
84−
85− handlePointInput(desiredPoint) {
86− if (desiredPoint.length < 15) {
87− const suggestedPoint =
88− setFind(this.state.validChildren, e => e.startsWith(desiredPoint)) ||
89− '';
90− const rendered =
91− ' '.repeat(desiredPoint.length) +
92− suggestedPoint.substring(desiredPoint.length);
93−
94− const available = Just(this.state.validChildren.has(desiredPoint));
95−
96− this.setState({
97− desiredPoint,
98− isAvailable: available,
99− autoComplete: rendered,
100− });
101− this.statelessRef.current.clearTxn();
102− }
103− }
104−
105− createUnsignedTxn() {
106− const { state, props } = this;
107−
108− if (isValidAddress(state.receivingAddress) === false) return Nothing();
109− if (state.isAvailable === false) return Nothing();
110− if (canDecodePatp(state.desiredPoint) === false) return Nothing();
111−
112− const validContracts = need.contracts(props.contracts);
113−
114− const pointDec = ob.patp2dec(state.desiredPoint);
1157
116− const txn = ecliptic.spawn(
117− validContracts,
118− pointDec,
119− state.receivingAddress
120− );
8+import { useNetwork } from 'store/network';
9+import { useHistory } from 'store/history';
10+import { usePointCache } from 'store/pointCache';
12111
122− return Just(txn);
123− }
12+import * as need from 'lib/need';
13+import { useAddressInput, usePointInput } from 'lib/useInputs';
14+import useEthereumTransaction from 'lib/useEthereumTransaction';
15+import { GAS_LIMITS } from 'lib/constants';
16+import patp2dec from 'lib/patp2dec';
12417
125− validatePoint = patp => {
126− const point = this.point;
127−
128− let vpatp = false;
129− try {
130− vpatp = ob.isValidPatp(patp);
131− } catch (_) {}
132−
133− let vchild = false;
134− try {
135− vchild = ob.sein(patp) === ob.patp(point);
136− } catch (_) {}
18+import ViewHeader from 'components/ViewHeader';
19+import MiniBackButton from 'components/MiniBackButton';
20+import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
21+import View from 'components/View';
22+import useLifecycle from 'lib/useLifecycle';
23+import { usePointCursor } from 'store/pointCursor';
24+import { validateInSet } from 'lib/validators';
25+import { getSpawnCandidate } from 'lib/child';
13726
138− return vpatp && vchild;
139− };
27+function useIssueChild() {
28+ const { contracts } = useNetwork();
29+ const { syncKnownPoint } = usePointCache();
14030
141− render() {
142− const { props, state } = this;
31+ const _contracts = need.contracts(contracts);
14332
144− const validAddress = isValidAddress(state.receivingAddress);
33+ const [spawnedPoint, setSpawnedPoint] = useState();
14534
146− const canGenerate = props.web3.matchWith({
147− Nothing: () => {
148− return validAddress === true;
149− },
150− Just: () => {
151− return validAddress === true && state.isAvailable.value === true;
35+ return useEthereumTransaction(
36+ useCallback(
37+ (spawnedPoint, owner) => {
38+ setSpawnedPoint(spawnedPoint);
39+ return azimuth.ecliptic.spawn(_contracts, spawnedPoint, owner);
15240 },
153− });
154−
155− const esvisible =
156− props.networkType === NETWORK_TYPES.ROPSTEN ||
157− props.networkType === NETWORK_TYPES.MAINNET;
158−
159− const esdomain =
160− props.networkType === NETWORK_TYPES.ROPSTEN
161− ? 'ropsten.etherscan.io'
162− : 'etherscan.io';
163−
164− return (
165− <View>
166− <H1>
167− {'Issue a Child From '}{' '}
168− <code>{`${ob.patp(state.issuingPoint)}`}</code>
169− </H1>
170−
171− <P>
172− {`Please enter the point you would like to issue, and specify the
173− receiving Ethereum address. If you need to create an address, you
174− can also use Wallet Generator.`}
175− </P>
176−
177− <P>
178− {`Your point can only issue children with particular names. Some
179− valid suggestions for `}
180− {<code>{ob.patp(state.issuingPoint)}</code>}
181− {' are '}
182− <code>{state.suggestions[0]}</code>
183− {', '}
184− <code>{state.suggestions[1]}</code>
185− {', and '}
186− <code>{state.suggestions[2]}</code>
187− {'.'}
188− </P>
189−
190− <PointInput
191− prop-size="lg"
192− prop-format="innerLabel"
193− className={'mono mt-8'}
194− placeholder={`e.g. ${state.suggestions[3]}`}
195− value={state.desiredPoint}
196− onChange={this.handlePointInput}>
197− <InnerLabel>{'Point to Issue'}</InnerLabel>
198− <div
199− style={{
200− marginTop: '8.8rem',
201− marginLeft: '4.6rem',
202− fontSize: '5rem',
203− color: '#757575',
204− whiteSpace: 'pre-wrap',
205− }}
206− className="abs tl-0 mono">
207− {state.autoComplete}
208− </div>
209− <ValidatedSigil
210− className={'tr-0 mt-05 mr-0 abs'}
211− patp={state.desiredPoint}
212− size={68}
213− margin={8}
214− validator={() => this.validatePoint(state.desiredPoint)}
215− />
216− </PointInput>
217−
218− <AddressInput
219− className="text-mono mt-8"
220− prop-size="lg"
221− prop-format="innerLabel"
222− placeholder={`e.g. 0x84295d5e054d8cff5a22428b195f5a1615bd644f`}
223− value={state.receivingAddress}
224− disabled={
225− Nothing.hasInstance(state.isAvailable) || !state.isAvailable.value
226− }
227− onChange={v => this.handleAddressInput(v)}>
228− <InnerLabel>{'Receiving Address'}</InnerLabel>
229− <ShowBlockie className={'mt-1'} address={state.receivingAddress} />
230− </AddressInput>
231−
232− <Anchor
233− className={'mt-1'}
234− prop-size={'sm'}
235− prop-disabled={!isValidAddress(state.receivingAddress) || !esvisible}
236− target={'_blank'}
237− href={`https://${esdomain}/address/${state.receivingAddress}`}>
238− {'View on Etherscan ↗'}
239− </Anchor>
240−
241− <StatelessTransaction
242− canGenerate={canGenerate}
243− createUnsignedTxn={this.createUnsignedTxn}
244− ref={this.statelessRef}
245− />
246− </View>
247− );
248− }
41+ [_contracts]
42+ ),
43+ useCallback(() => syncKnownPoint(spawnedPoint), [
44+ spawnedPoint,
45+ syncKnownPoint,
46+ ]),
47+ GAS_LIMITS.DEFAULT
48+ );
24949}
25050
251−export default compose(
252− withNetwork,
253− withPointCursor
254−)(IssueChild);
51+export default function IssueChild() {
52+ const { pop } = useHistory();
53+ const { contracts } = useNetwork();
54+ const { pointCursor } = usePointCursor();
55+
56+ const _contracts = need.contracts(contracts);
57+ const _point = need.point(pointCursor);
58+
59+ const [availablePointNames, setAvailablePointNames] = useState(Nothing());
60+
61+ const candidates = useMemo(() => {
62+ const getCandidate = () => ob.patp(getSpawnCandidate(_point));
63+
64+ return [getCandidate(), getCandidate(), getCandidate(), getCandidate()];
65+ }, [_point]);
66+
67+ const {
68+ isDefaultState,
69+ construct,
70+ unconstruct,
71+ completed,
72+ inputsLocked,
73+ bind,
74+ } = useIssueChild();
75+
76+ const validators = useMemo(
77+ () => [
78+ validateInSet(
79+ availablePointNames.getOrElse(new Set()),
80+ 'This point cannot be spawned.'
81+ ),
82+ ],
83+ [availablePointNames]
84+ );
85+ const [
86+ pointNameInput,
87+ { pass: validPointName, value: pointName },
88+ // ^ we use value: here so our effect runs onChange
89+ ] = usePointInput({
90+ name: 'point',
91+ disabled: inputsLocked,
92+ autoFocus: true,
93+ validators,
94+ error: availablePointNames.matchWith({
95+ Nothing: () => 'Loading availability...',
96+ Just: () => undefined,
97+ }),
98+ });
99+
100+ const [ownerInput, { pass: validOwner, data: owner }] = useAddressInput({
101+ name: 'owner',
102+ label: `Ethereum Address`,
103+ disabled: inputsLocked,
104+ });
105+
106+ useEffect(() => {
107+ if (validPointName && validOwner) {
108+ construct(patp2dec(pointName), owner);
109+ } else {
110+ unconstruct();
111+ }
112+ }, [owner, construct, unconstruct, validPointName, validOwner, pointName]);
113+
114+ useLifecycle(() => {
115+ let mounted = true;
116+
117+ (async () => {
118+ const availablePoints = await azimuth.azimuth.getUnspawnedChildren(
119+ _contracts,
120+ _point
121+ );
122+
123+ if (!mounted) {
124+ return;
125+ }
126+
127+ setAvailablePointNames(Just(new Set(availablePoints.map(ob.patp))));
128+ })();
129+
130+ return () => (mounted = false);
131+ });
132+
133+ return (
134+ <View inset>
135+ <Grid>
136+ <Grid.Item full as={MiniBackButton} onClick={() => pop()} />
137+
138+ <Grid.Item full as={ViewHeader}>
139+ Issue Child Point
140+ </Grid.Item>
141+
142+ {isDefaultState && (
143+ <Grid.Item full as={Text}>
144+ Perhaps one of {candidates.slice(0, 3).join(', ')}, or{' '}
145+ {candidates[candidates.length - 1]}?
146+ </Grid.Item>
147+ )}
148+
149+ {completed && (
150+ <Grid.Item
151+ full
152+ as={Text}
153+ className={cn('f5', {
154+ green3: completed,
155+ })}>
156+ {pointName} has been spawned and can be claimed by {owner}.
157+ </Grid.Item>
158+ )}
159+
160+ {!completed && (
161+ <>
162+ <Grid.Item full as={Input} {...pointNameInput} className="mt4" />
163+ <Grid.Item full as={Input} {...ownerInput} className="mb4" />
164+ </>
165+ )}
166+
167+ <Grid.Item
168+ full
169+ as={InlineEthereumTransaction}
170+ {...bind}
171+ onReturn={() => pop()}
172+ />
173+ </Grid>
174+ </View>
175+ );
176+}
src/views/Point.js+14−1
@@ -23,7 +23,7 @@ export default function Point() {
2323
2424 const point = need.point(pointCursor);
2525
26− const { isActiveOwner, canManage } = useCurrentPermissions();
26+ const { isActiveOwner, canManage, canSpawn } = useCurrentPermissions();
2727
2828 // fetch the invites for the current cursor
2929 const { availableInvites } = useInvites(point);
@@ -37,6 +37,11 @@ export default function Point() {
3737 [push, names]
3838 );
3939
40+ const goIssuePoint = useCallback(() => push(names.ISSUE_CHILD), [
41+ names.ISSUE_CHILD,
42+ push,
43+ ]);
44+
4045 const inviteButton = (() => {
4146 switch (azimuth.getPointSize(point)) {
4247 case azimuth.PointSize.Planet:
@@ -76,6 +81,14 @@ export default function Point() {
7681 Admin
7782 </Grid.Item>
7883 <Grid.Divider />
84+ {canSpawn && (
85+ <>
86+ <Grid.Item full as={ForwardButton} onClick={goIssuePoint}>
87+ Issue Point
88+ </Grid.Item>
89+ <Grid.Divider />
90+ </>
91+ )}
7992 <Grid.Item full as={BootArvoButton} disabled />
8093 </Grid>
8194