@@ -0,0 +1,479 @@
1+import React, { useMemo, useCallback, useState, useEffect } from 'react';
2+import { Grid, ErrorText } from 'indigo-react';
3+import { get } from 'lodash';
4+import * as azimuth from 'azimuth-js';
5+import { FORM_ERROR } from 'final-form';
6+import { toWei, toBN } from 'web3-utils';
7+
8+import * as need from 'lib/need';
9+import * as wg from 'lib/walletgen';
10+import * as tank from 'lib/tank';
11+// import useMailer from 'lib/useMailer';
12+import timeout from 'lib/timeout';
13+import useCopiable from 'lib/useCopiable';
14+
15+import Tabs from 'components/Tabs';
16+import ProgressButton from 'components/ProgressButton';
17+import { ForwardButton } from 'components/Buttons';
18+import NeedFundsNotice from 'components/NeedFundsNotice';
19+
20+import { useNetwork } from 'store/network';
21+import { useWallet } from 'store/wallet';
22+import { usePointCursor } from 'store/pointCursor';
23+import { usePointCache } from 'store/pointCache';
24+
25+// import { } from ''
26+import {
27+ signTransaction,
28+ sendSignedTransaction,
29+ waitForTransactionConfirm,
30+ hexify,
31+} from 'lib/txn';
32+import { MIN_PLANET, GAS_LIMITS } from 'lib/constants';
33+import pluralize from 'lib/pluralize';
34+import useGasPrice from 'lib/useGasPrice';
35+import useSetState from 'lib/useSetState';
36+
37+import EmailChipInput from 'form/EmailChipInput';
38+import FormError from 'form/FormError';
39+import SubmitButton from 'form/SubmitButton';
40+import BridgeForm from 'form/BridgeForm';
41+import {
42+ buildEmailArrayValidator,
43+ composeValidator,
44+ hasErrors,
45+} from 'form/validators';
46+import { WARNING, onlyHasWarning } from 'form/helpers';
47+
48+const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
49+
50+const STATUS = {
51+ INPUT: 'INPUT',
52+ SENDING: 'SENDING',
53+ SUCCESS: 'SUCCESS',
54+ FAILURE: 'FAILURE',
55+};
56+
57+const nameForEmailField = i => `emails[${i}]`;
58+
59+const useInviter = sendInvites => {
60+ const { contracts, web3, networkType } = useNetwork();
61+ const { wallet, walletType, walletHdPath } = useWallet();
62+ const { pointCursor } = usePointCursor();
63+ const { syncInvites } = usePointCache();
64+ const { gasPrice } = useGasPrice();
65+ const point = need.point(pointCursor);
66+
67+ const [needFunds, setNeedFunds] = useState(null);
68+ const [invites, setInvites] = useState([]);
69+
70+ const [progress, setProgress] = useState(0);
71+ const [txStatus, setTxStatus] = useState(STATUS.INPUT);
72+ const [errors, addError] = useSetState();
73+
74+ const isDone = txStatus === STATUS.SUCCESS;
75+ useEffect(() => {
76+ if (isDone) {
77+ syncInvites(point);
78+ }
79+ }, [isDone, syncInvites, point]);
80+
81+ const resetInvites = useCallback(() => {
82+ setProgress(0);
83+ setInvites([]);
84+ setTxStatus(STATUS.INPUT);
85+ }, [setProgress, setInvites, setTxStatus]);
86+ const generateInvites = useCallback(
87+ async numInvites => {
88+ const _contracts = contracts.getOrElse(null);
89+ const _web3 = web3.getOrElse(null);
90+ const _wallet = wallet.getOrElse(null);
91+ if (!_contracts || !_web3 || !_wallet) {
92+ // not using need because we want a custom error
93+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
94+ }
95+
96+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
97+ const chainId = await _web3.eth.net.getId();
98+ debugger;
99+ const planets = await azimuth.delegatedSending.getPlanetsToSend(
100+ _contracts,
101+ point,
102+ numInvites
103+ );
104+
105+ setTxStatus(STATUS.SENDING);
106+ // account for the race condition where invites got used up while we were
107+ // composing our target list
108+ console.log('a');
109+ if (planets.length < numInvites) {
110+ // resync invites to the cache, since they're out of date
111+ syncInvites(point);
112+
113+ setTxStatus(STATUS.FAILURE);
114+
115+ return {
116+ [FORM_ERROR]:
117+ `Can currently only send ${planets.length} invites. ` +
118+ `Please remove invites until you are within the limit.`,
119+ };
120+ }
121+
122+ console.log('b');
123+ // NB(shrugs) - must be processed in serial because main thread, etc
124+ let signedInvites = [];
125+ let errorCount = 0;
126+ for (let i = 0; i < numInvites; i++) {
127+ console.log(`c: ${i}`);
128+ const name = nameForEmailField(i);
129+ setProgress(x => x + 1);
130+ try {
131+ const planet = planets[i];
132+
133+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
134+ MIN_PLANET
135+ // we're always giving planets, so generate a ticket of the correct size
136+ );
137+
138+ const inviteTx = azimuth.delegatedSending.sendPoint(
139+ _contracts,
140+ point,
141+ planet,
142+ owner.keys.address
143+ );
144+
145+ const signedTx = await signTransaction({
146+ wallet: _wallet,
147+ walletType,
148+ walletHdPath,
149+ networkType,
150+ chainId,
151+ nonce: nonce + i,
152+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
153+ txn: inviteTx,
154+ gasPrice: gasPrice.toFixed(), // expects string gwei
155+ gasLimit: GAS_LIMIT.toFixed(),
156+ });
157+
158+ const rawTx = hexify(signedTx.serialize());
159+
160+ signedInvites.push({ ticket, signedTx, rawTx });
161+ } catch (error) {
162+ console.error(error);
163+ return {
164+ [WARNING]: `There was an error while generating wallets.`,
165+ };
166+ }
167+ }
168+
169+ const totalCost = toBN(GAS_LIMIT)
170+ .mul(toBN(gasPrice))
171+ .mul(toBN(numInvites));
172+ const tankWasUsed = await tank.ensureFundsFor(
173+ _web3,
174+ point,
175+ _wallet.address,
176+ toWei(totalCost.toString(), 'gwei'),
177+ Object.keys(invites).map(name => invites[name].rawTx),
178+ (address, minBalance, balance) =>
179+ setNeedFunds({ address, minBalance, balance }),
180+ () => setNeedFunds(undefined)
181+ );
182+
183+ let unsentInvites = [];
184+ let confirmedInvites = [];
185+ const txAndMailings = signedInvites.map(async invite => {
186+ try {
187+ const txHash = await sendSignedTransaction(
188+ _web3,
189+ invite.signedTx,
190+ tankWasUsed
191+ );
192+
193+ await waitForTransactionConfirm(_web3, txHash);
194+ confirmedInvites.push(invite);
195+ } catch (error) {
196+ console.error(error);
197+ unsentInvites.push(invite);
198+ return;
199+ }
200+ });
201+
202+ await Promise.all(txAndMailings);
203+
204+ setTxStatus(STATUS.SUCCESS);
205+ setInvites(confirmedInvites);
206+ console.log(unsentInvites);
207+
208+ if (unsentInvites.length > 0) {
209+ return { [FORM_ERROR]: unsentInvites };
210+ }
211+
212+ if (errorCount > 0) {
213+ return {
214+ [WARNING]: `There ${pluralize(errorCount, 'was', 'were')} ${pluralize(
215+ errorCount,
216+ 'error'
217+ )} while generating wallets. You can still send the invites that generated correctly.`,
218+ };
219+ }
220+ },
221+ [
222+ contracts,
223+ gasPrice,
224+ invites,
225+ networkType,
226+ point,
227+ syncInvites,
228+ wallet,
229+ walletHdPath,
230+ walletType,
231+ web3,
232+ ]
233+ );
234+
235+ return {
236+ progress,
237+ txStatus,
238+ needFunds,
239+ invites,
240+ generateInvites,
241+ resetInvites,
242+ };
243+};
244+
245+const InviteMail = () => {
246+ const {
247+ progress,
248+ txStatus,
249+ needFunds,
250+ invites,
251+ generateInvites,
252+ resetInvites,
253+ } = useInviter();
254+
255+ const [status, setStatus] = useState(STATUS.INPUT);
256+
257+ const [count, setCount] = useState(0);
258+ const canInput = status === STATUS.INPUT;
259+ const isSending = txStatus === STATUS.SENDING || status === STATUS.SENDING;
260+ const isFailed = status === STATUS.FAILURE || txStatus === STATUS.FAILURE;
261+ const isDone = status === STATUS.SUCCESS;
262+ const onSubmit = useCallback(
263+ async values => {
264+ console.log('GENERATING');
265+ const emailCount = values.emails.length;
266+ console.log(values);
267+ setCount(emailCount);
268+ setStatus(STATUS.SENDING);
269+ const errors = await generateInvites(emailCount);
270+ console.log(errors);
271+ if (errors) {
272+ setStatus(STATUS.FAILED);
273+ return errors;
274+ }
275+ },
276+ [generateInvites, setCount, setStatus]
277+ );
278+
279+ useEffect(() => {
280+ async function processInvites() {
281+ if (invites.length !== 0 && txStatus === STATUS.SUCCESS) {
282+ console.log(JSON.stringify(invites));
283+ console.log('a');
284+ await timeout(500);
285+
286+ resetInvites();
287+ setStatus(STATUS.SUCCESS);
288+ }
289+ }
290+ processInvites();
291+ }, [invites, setStatus, status, txStatus, resetInvites]);
292+
293+ const buttonText = () => {
294+ switch (status) {
295+ case STATUS.INPUT:
296+ return 'Add to Invite Group';
297+ case STATUS.SENDING:
298+ return `Sending ${pluralize(count, 'Invite')}...`;
299+ case STATUS.SUCCESS:
300+ return `✓ ${pluralize(count, 'Invite')} sent`;
301+ case STATUS.FAILURE:
302+ default:
303+ return 'Error';
304+ }
305+ };
306+
307+ const renderButton = handleSubmit => {
308+ if (canInput) {
309+ return (
310+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
311+ {buttonText()}
312+ </Grid.Item>
313+ );
314+ }
315+
316+ if (isSending) {
317+ const percentProgress = progress / count;
318+ return (
319+ <Grid.Item
320+ full
321+ as={ProgressButton}
322+ className="mt4"
323+ solid
324+ disabled
325+ success
326+ progress={percentProgress}>
327+ {buttonText(count)}
328+ </Grid.Item>
329+ );
330+ }
331+ if (isDone) {
332+ return (
333+ <Grid.Item full className="mt4 p3 bg-green3 white f5">
334+ {buttonText(count)}
335+ </Grid.Item>
336+ );
337+ }
338+
339+ return null;
340+ };
341+
342+ const validate = useMemo(
343+ () => composeValidator({ emails: buildEmailArrayValidator() }),
344+ []
345+ );
346+ return (
347+ <Grid.Item full as={Grid}>
348+ <BridgeForm
349+ validate={validate}
350+ initialValues={{ emails: [] }}
351+ onSubmit={onSubmit}>
352+ {({ handleSubmit, values }) => (
353+ <>
354+ <Grid.Item full as={EmailChipInput} name="emails" className="mt4" />
355+ {renderButton(handleSubmit)}
356+
357+ <Grid.Item full as={FormError} />
358+ {needFunds && (
359+ <Grid.Item full as={NeedFundsNotice} {...needFunds} />
360+ )}
361+ </>
362+ )}
363+ </BridgeForm>
364+ </Grid.Item>
365+ );
366+};
367+
368+const InviteUrl = () => {
369+ const {
370+ txStatus,
371+ errors,
372+ needFunds,
373+ invites,
374+ generateInvites,
375+ resetInvites,
376+ } = useInviter();
377+
378+ const [error, setError] = useState();
379+
380+ const generateInvite = useCallback(async () => {
381+ const errors = await generateInvites(1);
382+ if (hasErrors(errors)) {
383+ setError(errors);
384+ }
385+ }, [generateInvites]);
386+
387+ const renderGenerateButton = () => {
388+ if (error) {
389+ return (
390+ <Grid.Item full className="mt4 p3 bg-red3 white f5">
391+ Error generating invite
392+ </Grid.Item>
393+ );
394+ }
395+ switch (txStatus) {
396+ case STATUS.INPUT:
397+ return (
398+ <Grid.Item
399+ solid
400+ full
401+ className="mt4"
402+ as={ForwardButton}
403+ onClick={() => generateInvite()}>
404+ Generate Invite URL
405+ </Grid.Item>
406+ );
407+ case STATUS.SENDING:
408+ return (
409+ <Grid.Item
410+ full
411+ as={ProgressButton}
412+ className="mt4"
413+ solid
414+ disabled
415+ success
416+ progress={0.5}>
417+ Generating Invite URL
418+ </Grid.Item>
419+ );
420+ }
421+
422+ return null;
423+ };
424+
425+ const url = `https://bridge.urbit.org/#${get(invites, '[0].ticket', '')}`;
426+
427+ const [doCopy, didCopy] = useCopiable(url);
428+
429+ return (
430+ <Grid.Item full as={Grid}>
431+ {invites.length > 0 && (
432+ <Grid.Item full className="b-gray3 b1 mv4 p1 flex flex-center">
433+ <div className="flex1 ml1">{url}</div>
434+ <Grid.Item
435+ className="pv3 ph4 bg-black white"
436+ onClick={() => doCopy()}>
437+ {didCopy ? 'Copied' : 'Copy'}
438+ </Grid.Item>
439+ </Grid.Item>
440+ )}
441+ {invites.length === 0 && renderGenerateButton()}
442+ {needFunds && <Grid.Item full as={NeedFundsNotice} {...needFunds} />}
443+ {error && <Grid.Item as={ErrorText}>{JSON.stringify(error)}</Grid.Item>}
444+ </Grid.Item>
445+ );
446+};
447+
448+const InviteCohort = () => {
449+ const [tab, setTab] = useState(NAMES.MAIL);
450+ return (
451+ <Grid.Item
452+ full
453+ as={Tabs}
454+ tabClassName="t-center flex1"
455+ className="flex1"
456+ views={VIEWS}
457+ options={OPTIONS}
458+ currentTab={tab}
459+ onTabChange={setTab}
460+ />
461+ );
462+};
463+
464+const NAMES = {
465+ MAIL: 'MAIL',
466+ URL: 'URL',
467+};
468+
469+const VIEWS = {
470+ [NAMES.MAIL]: InviteMail,
471+ [NAMES.URL]: InviteUrl,
472+};
473+
474+const OPTIONS = [
475+ { text: 'Email', value: NAMES.MAIL },
476+ { text: 'URL', value: NAMES.URL },
477+];
478+
479+export default InviteCohort;