on master help sign in
feat: implement issue child
1ddc838Matt Condon (shrugs)
src/lib/useEthereumTransaction.js+8−2
@@ -217,8 +217,14 @@ export default function useEthereumTransaction(
217 try {
217 try {
218 await refetch();
218 await refetch();
219 } catch (error) {
219 } catch (error) {
220− // NOTE: we don't handle this error on purpose
220+ // log the original
221 console.error(error);
221 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+ );
222 }
228 }
223 }
229 }
224
230
@@ -231,7 +237,7 @@ export default function useEthereumTransaction(
231 }
237 }
232
238
233 return () => (cancelled = true);
239 return () => (cancelled = true);
234− }, [confirmed, refetch, completed]);
240+ }, [confirmed, refetch, completed, setError]);
235
241
236 const values = useDeepEqualReference({
242 const values = useDeepEqualReference({
237 isDefaultState,
243 isDefaultState,
src/lib/useInputs.js+5−5
@@ -146,14 +146,15 @@ export function useTicketInput({ validators = [], deriving = false, ...rest }) {
146}
146}
147
147
148const kPointTransformers = [prependSig];
148const kPointTransformers = [prependSig];
149−export function usePointInput({ size = 4, ...rest }) {
149+export function usePointInput({ size = 4, validators = [], ...rest }) {
150− const validators = useMemo(
150+ const _validators = useMemo(
151 () => [
151 () => [
152+ ...validators,
152 validatePoint,
153 validatePoint,
153 validateMaximumPatpByteLength(size),
154 validateMaximumPatpByteLength(size),
154 validateNotEmpty,
155 validateNotEmpty,
155 ],
156 ],
156− [size]
157+ [size, validators]
157 );
158 );
158
159
159 return useFirstOf(
160 return useFirstOf(
@@ -162,7 +163,7 @@ export function usePointInput({ size = 4, ...rest }) {
162 type: 'text',
163 type: 'text',
163 label: 'Point',
164 label: 'Point',
164 placeholder: 'e.g. ~zod',
165 placeholder: 'e.g. ~zod',
165− validators,
166+ validators: _validators,
166 transformers: kPointTransformers,
167 transformers: kPointTransformers,
167 mono: true,
168 mono: true,
168 ...rest,
169 ...rest,
@@ -185,7 +186,6 @@ export function usePointInput({ size = 4, ...rest }) {
185
186
186export function useGalaxyInput(props) {
187export function useGalaxyInput(props) {
187 return usePointInput({
188 return usePointInput({
188− name: 'galaxy',
189 label: 'Galaxy Name',
189 label: 'Galaxy Name',
190 size: 1,
190 size: 1,
191 ...props,
191 ...props,
src/lib/validators.js+7
@@ -245,6 +245,13 @@ export const validateGreaterThan = l => m =>
245 error: `Must be at least ${l}.`,
245 error: `Must be at least ${l}.`,
246 });
246 });
247
247
248+export const validateInSet = (set, error) => m =>
249+ simpleValidatorWrapper({
250+ prevMessage: m,
251+ validator: d => set.has(d),
252+ error,
253+ });
254+
248export const validatePatpByteLength = byteLength => {
255export const validatePatpByteLength = byteLength => {
249 return validateLength(patpStringLength(byteLength));
256 return validateLength(patpStringLength(byteLength));
250};
257};
src/views/CreateGalaxy.js+3−2
@@ -62,12 +62,13 @@ export default function CreateGalaxy() {
62 { pass: validGalaxyName, syncPass: syncValidGalaxyName, value: galaxyName },
62 { pass: validGalaxyName, syncPass: syncValidGalaxyName, value: galaxyName },
63 // ^ we use value: here so our effect runs onChange
63 // ^ we use value: here so our effect runs onChange
64 ] = useGalaxyInput({
64 ] = useGalaxyInput({
65+ name: 'galaxy',
65 disabled: inputsLocked,
66 disabled: inputsLocked,
66 autoFocus: true,
67 autoFocus: true,
67 error:
68 error:
68 error ||
69 error ||
69 isAvailable.matchWith({
70 isAvailable.matchWith({
70− Nothing: () => 'Loading availabiliy...', // TODO: make async loading?
71+ Nothing: () => 'Loading availability...', // TODO: make async loading?
71 Just: p => (p.value ? undefined : 'This galaxy is already owned.'),
72 Just: p => (p.value ? undefined : 'This galaxy is already owned.'),
72 }),
73 }),
73 });
74 });
@@ -142,7 +143,7 @@ export default function CreateGalaxy() {
142 className={cn('f5', {
143 className={cn('f5', {
143 green3: completed,
144 green3: completed,
144 })}>
145 })}>
145− {galaxyName} has been created and is owned by {owner}.
146+ {galaxyName} has been created and can be claimed by {owner}.
146 </Grid.Item>
147 </Grid.Item>
147 )}
148 )}
148
149
src/views/IssueChild.js+165−243
@@ -1,254 +1,176 @@
1+import React, { useCallback, useEffect, useMemo, useState } from 'react';
1−import { Just, Nothing } from 'folktale/maybe';
2+import { Nothing, Just } from 'folktale/maybe';
2−import React from 'react';
3+import cn from 'classnames';
3−import { azimuth, ecliptic } from 'azimuth-js';
4+import { Grid, Text, Input } from 'indigo-react';
5+import * as azimuth from 'azimuth-js';
4import * as ob from 'urbit-ob';
6import * 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);
115
7
116− const txn = ecliptic.spawn(
8+import { useNetwork } from 'store/network';
117− validContracts,
9+import { useHistory } from 'store/history';
118− pointDec,
10+import { usePointCache } from 'store/pointCache';
119− state.receivingAddress
120− );
121
11
122− return Just(txn);
12+import * as need from 'lib/need';
123− }
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';
124
17
125− validatePoint = patp => {
18+import ViewHeader from 'components/ViewHeader';
126− const point = this.point;
19+import MiniBackButton from 'components/MiniBackButton';
127−
20+import InlineEthereumTransaction from 'components/InlineEthereumTransaction';
128− let vpatp = false;
21+import View from 'components/View';
129− try {
22+import useLifecycle from 'lib/useLifecycle';
130− vpatp = ob.isValidPatp(patp);
23+import { usePointCursor } from 'store/pointCursor';
131− } catch (_) {}
24+import { validateInSet } from 'lib/validators';
132−
25+import { getSpawnCandidate } from 'lib/child';
133− let vchild = false;
134− try {
135− vchild = ob.sein(patp) === ob.patp(point);
136− } catch (_) {}
137
26
138− return vpatp && vchild;
27+function useIssueChild() {
139− };
28+ const { contracts } = useNetwork();
29+ const { syncKnownPoint } = usePointCache();
140
30
141− render() {
31+ const _contracts = need.contracts(contracts);
142− const { props, state } = this;
143
32
144− const validAddress = isValidAddress(state.receivingAddress);
33+ const [spawnedPoint, setSpawnedPoint] = useState();
145
34
146− const canGenerate = props.web3.matchWith({
35+ return useEthereumTransaction(
147− Nothing: () => {
36+ useCallback(
148− return validAddress === true;
37+ (spawnedPoint, owner) => {
149− },
38+ setSpawnedPoint(spawnedPoint);
150− Just: () => {
39+ return azimuth.ecliptic.spawn(_contracts, spawnedPoint, owner);
151− return validAddress === true && state.isAvailable.value === true;
152 },
40 },
153− });
41+ [_contracts]
154−
42+ ),
155− const esvisible =
43+ useCallback(() => syncKnownPoint(spawnedPoint), [
156− props.networkType === NETWORK_TYPES.ROPSTEN ||
44+ spawnedPoint,
157− props.networkType === NETWORK_TYPES.MAINNET;
45+ syncKnownPoint,
158−
46+ ]),
159− const esdomain =
47+ GAS_LIMITS.DEFAULT
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− );
48+ );
248− }
249}
49}
250
50
251−export default compose(
51+export default function IssueChild() {
252− withNetwork,
52+ const { pop } = useHistory();
253− withPointCursor
53+ const { contracts } = useNetwork();
254−)(IssueChild);
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() {
23
23
24 const point = need.point(pointCursor);
24 const point = need.point(pointCursor);
25
25
26− const { isActiveOwner, canManage } = useCurrentPermissions();
26+ const { isActiveOwner, canManage, canSpawn } = useCurrentPermissions();
27
27
28 // fetch the invites for the current cursor
28 // fetch the invites for the current cursor
29 const { availableInvites } = useInvites(point);
29 const { availableInvites } = useInvites(point);
@@ -37,6 +37,11 @@ export default function Point() {
37 [push, names]
37 [push, names]
38 );
38 );
39
39
40+ const goIssuePoint = useCallback(() => push(names.ISSUE_CHILD), [
41+ names.ISSUE_CHILD,
42+ push,
43+ ]);
44+
40 const inviteButton = (() => {
45 const inviteButton = (() => {
41 switch (azimuth.getPointSize(point)) {
46 switch (azimuth.getPointSize(point)) {
42 case azimuth.PointSize.Planet:
47 case azimuth.PointSize.Planet:
@@ -76,6 +81,14 @@ export default function Point() {
76 Admin
81 Admin
77 </Grid.Item>
82 </Grid.Item>
78 <Grid.Divider />
83 <Grid.Divider />
84+ {canSpawn && (
85+ <>
86+ <Grid.Item full as={ForwardButton} onClick={goIssuePoint}>
87+ Issue Point
88+ </Grid.Item>
89+ <Grid.Divider />
90+ </>
91+ )}
79 <Grid.Item full as={BootArvoButton} disabled />
92 <Grid.Item full as={BootArvoButton} disabled />
80 </Grid>
93 </Grid>
81
94