@@ -1,504 +1,10 @@
1−import React, { useMemo, useCallback, useState, useEffect } from 'react';
2−import { Grid, ErrorText } from 'indigo-react';
3−import { get, uniq, zip, compact, isEqual } from 'lodash';
4−import * as azimuth from 'azimuth-js';
5−import { FORM_ERROR } from 'final-form';
6−import { toWei, toBN } from 'web3-utils';
7−import * as ob from 'urbit-ob';
8−
9−import * as need from 'lib/need';
10−import * as wg from 'lib/walletgen';
11−import * as tank from 'lib/tank';
12−import useCopiable from 'lib/useCopiable';
1+import React, { useState } from 'react';
2+import { Grid } from 'indigo-react';
133
144import Tabs from 'components/Tabs';
15−import ProgressButton from 'components/ProgressButton';
16−import { ForwardButton } from 'components/Buttons';
17−import NeedFundsNotice from 'components/NeedFundsNotice';
18−
19−import { useNetwork } from 'store/network';
20−import { useWallet } from 'store/wallet';
21−import { usePointCursor } from 'store/pointCursor';
22−import { usePointCache } from 'store/pointCache';
23−
24−import {
25− signTransaction,
26− sendSignedTransaction,
27− waitForTransactionConfirm,
28− hexify,
29−} from 'lib/txn';
30−import { MIN_PLANET, GAS_LIMITS } from 'lib/constants';
31−import pluralize from 'lib/pluralize';
32−import useGasPrice from 'lib/useGasPrice';
33−
34−import EmailChipInput from 'form/EmailChipInput';
35−import FormError from 'form/FormError';
36−import SubmitButton from 'form/SubmitButton';
37−import BridgeForm from 'form/BridgeForm';
38−import {
39− composeValidator,
40− hasErrors,
41− buildEmailValidator,
42− buildArrayValidator,
43−} from 'form/validators';
44−import { WARNING } from 'form/helpers';
45−import useMailer from 'lib/useMailer';
46−
47−const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
48−
49−const STATUS = {
50− INPUT: 'INPUT',
51− SENDING: 'SENDING',
52− SUCCESS: 'SUCCESS',
53− FAILURE: 'FAILURE',
54−};
55−
56−const HAS_RECEIVED_TEXT = 'This email has already received an invite.';
57−
58−const useInviter = sendInvites => {
59− const { contracts, web3, networkType } = useNetwork();
60− const { wallet, walletType, walletHdPath } = useWallet();
61− const { pointCursor } = usePointCursor();
62− const { syncInvites } = usePointCache();
63− const { gasPrice } = useGasPrice();
64− const point = need.point(pointCursor);
65−
66− const [needFunds, setNeedFunds] = useState(null);
67− const [invites, setInvites] = useState([]);
68−
69− const [progress, setProgress] = useState(0);
70− const [txStatus, setTxStatus] = useState(STATUS.INPUT);
71− // const [errors, addError] = useSetState();
72−
73− const isDone = txStatus === STATUS.SUCCESS;
74− useEffect(() => {
75− if (isDone) {
76− syncInvites(point);
77− }
78− }, [isDone, syncInvites, point]);
79−
80− const resetInvites = useCallback(() => {
81− setProgress(0);
82− setInvites([]);
83− setTxStatus(STATUS.INPUT);
84− }, [setProgress, setInvites, setTxStatus]);
85−
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− if (planets.length < numInvites) {
109− // resync invites to the cache, since they're out of date
110− syncInvites(point);
111−
112− setTxStatus(STATUS.FAILURE);
113−
114− return {
115− errors: {
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−
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− setProgress(x => x + 1);
128− try {
129− const planet = planets[i];
130−
131− const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
132− MIN_PLANET
133− // we're always giving planets, so generate a ticket of the correct size
134− );
135−
136− const inviteTx = azimuth.delegatedSending.sendPoint(
137− _contracts,
138− point,
139− planet,
140− owner.keys.address
141− );
142−
143− const signedTx = await signTransaction({
144− wallet: _wallet,
145− walletType,
146− walletHdPath,
147− networkType,
148− chainId,
149− nonce: nonce + i,
150− // TODO: ^ make a useTransactionSigner to encapsulate this logic
151− txn: inviteTx,
152− gasPrice: gasPrice.toFixed(), // expects string gwei
153− gasLimit: GAS_LIMIT.toFixed(),
154− });
155−
156− const rawTx = hexify(signedTx.serialize());
157−
158− signedInvites.push({ ticket, signedTx, rawTx });
159− } catch (error) {
160− console.error(error);
161− return {
162− errors: {
163− [WARNING]: `There was an error while generating wallets.`,
164− },
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− console.log(confirmedInvites);
208−
209− if (unsentInvites.length > 0) {
210− return { errors: { [FORM_ERROR]: unsentInvites } };
211− }
212−
213− if (errorCount > 0) {
214− return {
215− errors: {
216− [WARNING]: `There ${pluralize(
217− errorCount,
218− 'was',
219− 'were'
220− )} ${pluralize(
221− errorCount,
222− 'error'
223− )} while generating wallets. You can still send the invites that generated correctly.`,
224− },
225− };
226− }
227− return { invites: confirmedInvites };
228− },
229− [
230− contracts,
231− gasPrice,
232− invites,
233− networkType,
234− point,
235− syncInvites,
236− wallet,
237− walletHdPath,
238− walletType,
239− web3,
240− ]
241− );
242−
243− return {
244− progress,
245− txStatus,
246− needFunds,
247− invites,
248− generateInvites,
249− resetInvites,
250− };
251−};
2525
253−const InviteMail = () => {
254− const { progress, txStatus, needFunds, generateInvites } = useInviter();
255−
256− const { sendMail, getHasReceived } = useMailer();
257−
258− const [status, setStatus] = useState(STATUS.INPUT);
259−
260− const [count, setCount] = useState(0);
261− const canInput = status === STATUS.INPUT;
262− const isSending = txStatus === STATUS.SENDING || status === STATUS.SENDING;
263− const isDone = status === STATUS.SUCCESS;
264− const isFailed = status === STATUS.FAILED;
265−
266− const { pointCursor } = usePointCursor();
267− const point = need.point(pointCursor);
268−
269− const sendInvites = useCallback(
270− async (emails, invites) => {
271− const mailingErrors = compact(
272− await Promise.all(
273− zip(emails, invites).map(([email, invite]) =>
274− sendMail(email, invite.ticket, ob.patp(point), '', invite.rawTx)
275− .then(() => null)
276− .catch(
277− () => `Sending ticket ${invite.ticket} to ${email} failed.`
278− )
279− )
280− )
281− ).join(', ');
282− if (mailingErrors.length) {
283− return { [FORM_ERROR]: mailingErrors };
284− }
285− },
286− [point, sendMail]
287− );
288−
289− const onSubmit = useCallback(
290− async values => {
291− const emailCount = values.emails.length;
292− setCount(emailCount);
293− setStatus(STATUS.SENDING);
294− const { errors, invites } = await generateInvites(emailCount);
295− if (errors) {
296− setStatus(STATUS.FAILED);
297− return errors;
298− }
299− const mailErrors = await sendInvites(values.emails, invites);
300− console.log(mailErrors);
301− if (mailErrors) {
302− console.log(mailErrors);
303− setStatus(STATUS.FAILED);
304− return mailErrors;
305− }
306− setStatus(STATUS.SUCCESS);
307− },
308− [generateInvites, setCount, setStatus, sendInvites]
309− );
310−
311− const validateHasReceived = useCallback(
312− async email => {
313− const hasReceived = await getHasReceived(email);
314− if (hasReceived) {
315− return HAS_RECEIVED_TEXT;
316− }
317− },
318− [getHasReceived]
319− );
320−
321− const buttonText = () => {
322− switch (status) {
323− case STATUS.INPUT:
324− return 'Add to Invite Group';
325− case STATUS.SENDING:
326− return `Sending ${pluralize(count, 'Invite')}...`;
327− case STATUS.SUCCESS:
328− return `✓ ${pluralize(count, 'Invite')} sent`;
329− case STATUS.FAILURE:
330− default:
331− return 'Error';
332− }
333− };
334−
335− const renderButton = handleSubmit => {
336− if (canInput) {
337− return (
338− <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
339− {buttonText()}
340− </Grid.Item>
341− );
342− }
343−
344− if (isSending) {
345− const percentProgress = progress / count;
346− return (
347− <Grid.Item
348− full
349− as={ProgressButton}
350− className="mt4"
351− solid
352− disabled
353− success
354− progress={percentProgress}>
355− {buttonText(count)}
356− </Grid.Item>
357− );
358− }
359− if (isDone) {
360− return (
361− <Grid.Item full className="mt4 p3 bg-green3 white f5">
362− {buttonText(count)}
363− </Grid.Item>
364− );
365− }
366− if (isFailed) {
367− return (
368− <Grid.Item full className="mt4 p3 bg-red3 white f5">
369− {buttonText(count)}
370− </Grid.Item>
371− );
372− }
373−
374− return null;
375− };
376−
377− const validateForm = useCallback((values, errors) => {
378− if (hasErrors(errors)) {
379− return errors;
380− }
381−
382− // check for email uniqenesss
383− const emails = values.emails.filter(d => !!d);
384− if (uniq(emails).length !== emails.length) {
385− return { [FORM_ERROR]: 'Duplicate email.' };
386− }
387− }, []);
388−
389− const validate = useMemo(
390− () =>
391− composeValidator(
392− {
393− emails: buildArrayValidator(buildEmailValidator(validateHasReceived)),
394− },
395− validateForm
396− ),
397− [validateHasReceived, validateForm]
398− );
399−
400− const initialValues = { emails: [] };
401− return (
402− <Grid.Item full as={Grid}>
403− <BridgeForm
404− validate={validate}
405− initialValues={initialValues}
406− initialValuesEqual={isEqual}
407− onSubmit={onSubmit}>
408− {({ handleSubmit, values }) => (
409− <>
410− {console.log(values) && null}
411− <Grid.Item full as={EmailChipInput} name="emails" className="mt4" />
412− {renderButton(handleSubmit)}
413−
414− <Grid.Item full as={FormError} />
415− {needFunds && (
416− <Grid.Item full as={NeedFundsNotice} {...needFunds} />
417− )}
418− </>
419− )}
420− </BridgeForm>
421− </Grid.Item>
422− );
423−};
424−
425−const InviteUrl = () => {
426− const { txStatus, needFunds, invites, generateInvites } = useInviter();
427−
428− const [error, setError] = useState();
429−
430− const generateInvite = useCallback(async () => {
431− const errors = await generateInvites(1);
432− if (hasErrors(errors)) {
433− setError(errors);
434− }
435− }, [generateInvites]);
436−
437− const renderGenerateButton = () => {
438− if (error) {
439− return (
440− <Grid.Item full className="mt4 p3 bg-red3 white f5">
441− Error generating invite
442− </Grid.Item>
443− );
444− }
445− switch (txStatus) {
446− case STATUS.INPUT:
447− return (
448− <Grid.Item
449− solid
450− full
451− className="mt4"
452− as={ForwardButton}
453− onClick={() => generateInvite()}>
454− Generate Invite URL
455− </Grid.Item>
456− );
457− case STATUS.SENDING:
458− return (
459− <Grid.Item
460− full
461− as={ProgressButton}
462− className="mt4"
463− solid
464− disabled
465− success
466− progress={0.5}>
467− Generating Invite URL
468− </Grid.Item>
469− );
470− default:
471− return null;
472− }
473− };
474−
475− const url = `https://bridge.urbit.org/#${get(invites, '[0].ticket', '')}`;
476−
477− const [doCopy, didCopy] = useCopiable(url);
478−
479− return (
480− <Grid.Item full as={Grid}>
481− {invites.length > 0 && (
482− <>
483− <Grid.Item full className="b-gray3 b1 mv4 p1 flex flex-center">
484− <div className="flex1 ml1">{url}</div>
485− <Grid.Item
486− className="pv3 ph4 bg-black white"
487− onClick={() => doCopy()}>
488− {didCopy ? 'Copied' : 'Copy'}
489− </Grid.Item>
490− </Grid.Item>
491− <Grid.Item full>
492− Your invite link expires after consumption.
493− </Grid.Item>
494− </>
495− )}
496− {invites.length === 0 && renderGenerateButton()}
497− {needFunds && <Grid.Item full as={NeedFundsNotice} {...needFunds} />}
498− {error && <Grid.Item as={ErrorText}>{JSON.stringify(error)}</Grid.Item>}
499− </Grid.Item>
500− );
501−};
6+import InviteMail from 'views/Invite/Email';
7+import InviteUrl from 'views/Invite/Url';
5028
5039const InviteCohort = () => {
50410 const [tab, setTab] = useState(NAMES.MAIL);