on master help sign in
split up inviter component
8982cf8Liam Fitzgerald
src/lib/useInviter.jsnew file+229
@@ -0,0 +1,229 @@
1+import { useState, useCallback, useEffect } from 'react';
2+import { toWei, toBN } from 'web3-utils';
3+import * as azimuth from 'azimuth-js';
4+import { FORM_ERROR } from 'final-form';
5+
6+import * as wg from 'lib/walletgen';
7+import * as tank from 'lib/tank';
8+import * as need from 'lib/need';
9+import pluralize from 'lib/pluralize';
10+import { GAS_LIMITS } from 'lib/constants';
11+import {
12+ signTransaction,
13+ sendSignedTransaction,
14+ waitForTransactionConfirm,
15+ hexify,
16+} from 'lib/txn';
17+import useGasPrice from 'lib/useGasPrice';
18+import { MIN_PLANET } from 'lib/constants';
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 { WARNING } from 'form/helpers';
26+
27+const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
28+const STATUS = {
29+ INPUT: 'INPUT',
30+ SENDING: 'SENDING',
31+ SUCCESS: 'SUCCESS',
32+ FAILURE: 'FAILURE',
33+};
34+
35+const useInviter = () => {
36+ const { contracts, web3, networkType } = useNetwork();
37+ const { wallet, walletType, walletHdPath } = useWallet();
38+ const { pointCursor } = usePointCursor();
39+ const { syncInvites } = usePointCache();
40+ const { gasPrice } = useGasPrice();
41+ const point = need.point(pointCursor);
42+
43+ const [needFunds, setNeedFunds] = useState(null);
44+ const [invites, setInvites] = useState([]);
45+
46+ const [progress, setProgress] = useState(0);
47+ const [txStatus, setTxStatus] = useState(STATUS.INPUT);
48+
49+ const isDone = txStatus === STATUS.SUCCESS;
50+ useEffect(() => {
51+ if (isDone) {
52+ syncInvites(point);
53+ }
54+ }, [isDone, syncInvites, point]);
55+
56+ const resetInvites = useCallback(() => {
57+ setProgress(0);
58+ setInvites([]);
59+ setTxStatus(STATUS.INPUT);
60+ }, [setProgress, setInvites, setTxStatus]);
61+
62+ const generateInvites = useCallback(
63+ async numInvites => {
64+ const _contracts = contracts.getOrElse(null);
65+ const _web3 = web3.getOrElse(null);
66+ const _wallet = wallet.getOrElse(null);
67+ if (!_contracts || !_web3 || !_wallet) {
68+ // not using need because we want a custom error
69+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
70+ }
71+
72+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
73+ const chainId = await _web3.eth.net.getId();
74+ debugger;
75+ const planets = await azimuth.delegatedSending.getPlanetsToSend(
76+ _contracts,
77+ point,
78+ numInvites
79+ );
80+
81+ setTxStatus(STATUS.SENDING);
82+ // account for the race condition where invites got used up while we were
83+ // composing our target list
84+ if (planets.length < numInvites) {
85+ // resync invites to the cache, since they're out of date
86+ syncInvites(point);
87+
88+ setTxStatus(STATUS.FAILURE);
89+
90+ return {
91+ errors: {
92+ [FORM_ERROR]:
93+ `Can currently only send ${planets.length} invites. ` +
94+ `Please remove invites until you are within the limit.`,
95+ },
96+ };
97+ }
98+
99+ // NB(shrugs) - must be processed in serial because main thread, etc
100+ let signedInvites = [];
101+ let errorCount = 0;
102+ for (let i = 0; i < numInvites; i++) {
103+ setProgress(x => x + 1);
104+ try {
105+ const planet = planets[i];
106+
107+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
108+ MIN_PLANET
109+ // we're always giving planets, so generate a ticket of the correct size
110+ );
111+
112+ const inviteTx = azimuth.delegatedSending.sendPoint(
113+ _contracts,
114+ point,
115+ planet,
116+ owner.keys.address
117+ );
118+
119+ const signedTx = await signTransaction({
120+ wallet: _wallet,
121+ walletType,
122+ walletHdPath,
123+ networkType,
124+ chainId,
125+ nonce: nonce + i,
126+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
127+ txn: inviteTx,
128+ gasPrice: gasPrice.toFixed(), // expects string gwei
129+ gasLimit: GAS_LIMIT.toFixed(),
130+ });
131+
132+ const rawTx = hexify(signedTx.serialize());
133+
134+ signedInvites.push({ ticket, signedTx, rawTx });
135+ } catch (error) {
136+ console.error(error);
137+ return {
138+ errors: {
139+ [WARNING]: `There was an error while generating wallets.`,
140+ },
141+ };
142+ }
143+ }
144+
145+ const totalCost = toBN(GAS_LIMIT)
146+ .mul(toBN(gasPrice))
147+ .mul(toBN(numInvites));
148+ const tankWasUsed = await tank.ensureFundsFor(
149+ _web3,
150+ point,
151+ _wallet.address,
152+ toWei(totalCost.toString(), 'gwei'),
153+ Object.keys(invites).map(name => invites[name].rawTx),
154+ (address, minBalance, balance) =>
155+ setNeedFunds({ address, minBalance, balance }),
156+ () => setNeedFunds(undefined)
157+ );
158+
159+ let unsentInvites = [];
160+ let confirmedInvites = [];
161+ const txAndMailings = signedInvites.map(async invite => {
162+ try {
163+ const txHash = await sendSignedTransaction(
164+ _web3,
165+ invite.signedTx,
166+ tankWasUsed
167+ );
168+
169+ await waitForTransactionConfirm(_web3, txHash);
170+ confirmedInvites.push(invite);
171+ } catch (error) {
172+ console.error(error);
173+ unsentInvites.push(invite);
174+ return;
175+ }
176+ });
177+
178+ await Promise.all(txAndMailings);
179+
180+ setTxStatus(STATUS.SUCCESS);
181+ setInvites(confirmedInvites);
182+ console.log(unsentInvites);
183+ console.log(confirmedInvites);
184+
185+ if (unsentInvites.length > 0) {
186+ return { errors: { [FORM_ERROR]: unsentInvites } };
187+ }
188+
189+ if (errorCount > 0) {
190+ return {
191+ errors: {
192+ [WARNING]: `There ${pluralize(
193+ errorCount,
194+ 'was',
195+ 'were'
196+ )} ${pluralize(
197+ errorCount,
198+ 'error'
199+ )} while generating wallets. You can still send the invites that generated correctly.`,
200+ },
201+ };
202+ }
203+ return { invites: confirmedInvites };
204+ },
205+ [
206+ contracts,
207+ gasPrice,
208+ invites,
209+ networkType,
210+ point,
211+ syncInvites,
212+ wallet,
213+ walletHdPath,
214+ walletType,
215+ web3,
216+ ]
217+ );
218+
219+ return {
220+ progress,
221+ txStatus,
222+ needFunds,
223+ invites,
224+ generateInvites,
225+ resetInvites,
226+ };
227+};
228+
229+export default useInviter;
src/views/Invite/Cohort.js+4−498
@@ -1,504 +1,10 @@
1−import React, { useMemo, useCallback, useState, useEffect } from 'react';
1+import React, { useState } from 'react';
2−import { Grid, ErrorText } from 'indigo-react';
2+import { Grid } 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';
13
3
14import Tabs from 'components/Tabs';
4import 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−};
252
5
253−const InviteMail = () => {
6+import InviteMail from 'views/Invite/Email';
254− const { progress, txStatus, needFunds, generateInvites } = useInviter();
7+import InviteUrl from 'views/Invite/Url';
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−};
502
8
503const InviteCohort = () => {
9const InviteCohort = () => {
504 const [tab, setTab] = useState(NAMES.MAIL);
10 const [tab, setTab] = useState(NAMES.MAIL);
src/views/Invite/Email.jsnew file+211
@@ -0,0 +1,211 @@
1+import React, { useState, useCallback, useMemo } from 'react';
2+import { Grid } from 'indigo-react';
3+import { FORM_ERROR } from 'final-form';
4+import * as ob from 'urbit-ob';
5+import { uniq, zip, compact, isEqual } from 'lodash';
6+
7+import { usePointCursor } from 'store/pointCursor';
8+
9+import * as need from 'lib/need';
10+import useMailer from 'lib/useMailer';
11+import pluralize from 'lib/pluralize';
12+import useInviter from 'lib/useInviter';
13+
14+import {
15+ composeValidator,
16+ hasErrors,
17+ buildEmailValidator,
18+ buildArrayValidator,
19+} from 'form/validators';
20+import EmailChipInput from 'form/EmailChipInput';
21+import FormError from 'form/FormError';
22+import SubmitButton from 'form/SubmitButton';
23+import BridgeForm from 'form/BridgeForm';
24+
25+import NeedFundsNotice from 'components/NeedFundsNotice';
26+import ProgressButton from 'components/ProgressButton';
27+
28+const HAS_RECEIVED_TEXT = 'This email has already received an invite.';
29+
30+const STATUS = {
31+ INPUT: 'INPUT',
32+ SENDING: 'SENDING',
33+ SUCCESS: 'SUCCESS',
34+ FAILURE: 'FAILURE',
35+};
36+
37+const InviteMail = () => {
38+ const { progress, txStatus, needFunds, generateInvites } = useInviter();
39+
40+ const { sendMail, getHasReceived } = useMailer();
41+
42+ const [status, setStatus] = useState(STATUS.INPUT);
43+
44+ const [count, setCount] = useState(0);
45+ const canInput = status === STATUS.INPUT;
46+ const isSending = txStatus === STATUS.SENDING || status === STATUS.SENDING;
47+ const isDone = status === STATUS.SUCCESS;
48+ const isFailed = status === STATUS.FAILED;
49+
50+ const { pointCursor } = usePointCursor();
51+ const point = need.point(pointCursor);
52+
53+ const sendInvites = useCallback(
54+ async (emails, invites) => {
55+ const mailingErrors = compact(
56+ await Promise.all(
57+ zip(emails, invites).map(([email, invite]) =>
58+ sendMail(email, invite.ticket, ob.patp(point), '', invite.rawTx)
59+ .then(() => null)
60+ .catch(
61+ () => `Sending ticket ${invite.ticket} to ${email} failed.`
62+ )
63+ )
64+ )
65+ ).join(', ');
66+ if (mailingErrors.length) {
67+ return { [FORM_ERROR]: mailingErrors };
68+ }
69+ },
70+ [point, sendMail]
71+ );
72+
73+ const onSubmit = useCallback(
74+ async values => {
75+ const emailCount = values.emails.length;
76+ setCount(emailCount);
77+ setStatus(STATUS.SENDING);
78+ const { errors, invites } = await generateInvites(emailCount);
79+ if (errors) {
80+ setStatus(STATUS.FAILED);
81+ return errors;
82+ }
83+ const mailErrors = await sendInvites(values.emails, invites);
84+ if (mailErrors) {
85+ console.log(mailErrors);
86+ setStatus(STATUS.FAILED);
87+ return mailErrors;
88+ }
89+ setStatus(STATUS.SUCCESS);
90+ },
91+ [generateInvites, setCount, setStatus, sendInvites]
92+ );
93+
94+ const validateHasReceived = useCallback(
95+ async email => {
96+ const hasReceived = await getHasReceived(email);
97+ if (hasReceived) {
98+ return HAS_RECEIVED_TEXT;
99+ }
100+ },
101+ [getHasReceived]
102+ );
103+
104+ const buttonText = useCallback(() => {
105+ switch (status) {
106+ case STATUS.INPUT:
107+ return 'Add to Invite Group';
108+ case STATUS.SENDING:
109+ return `Sending ${pluralize(count, 'Invite')}...`;
110+ case STATUS.SUCCESS:
111+ return `✓ ${pluralize(count, 'Invite')} sent`;
112+ case STATUS.FAILURE:
113+ default:
114+ return 'Error';
115+ }
116+ }, [status, count]);
117+
118+ const renderButton = useCallback(
119+ handleSubmit => {
120+ if (canInput) {
121+ return (
122+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
123+ {buttonText()}
124+ </Grid.Item>
125+ );
126+ }
127+
128+ if (isSending) {
129+ const percentProgress = progress / count;
130+ return (
131+ <Grid.Item
132+ full
133+ as={ProgressButton}
134+ className="mt4"
135+ solid
136+ disabled
137+ success
138+ progress={percentProgress}>
139+ {buttonText(count)}
140+ </Grid.Item>
141+ );
142+ }
143+ if (isDone) {
144+ return (
145+ <Grid.Item full className="mt4 p3 bg-green3 white f5">
146+ {buttonText(count)}
147+ </Grid.Item>
148+ );
149+ }
150+ if (isFailed) {
151+ return (
152+ <Grid.Item full className="mt4 p3 bg-red3 white f5">
153+ {buttonText(count)}
154+ </Grid.Item>
155+ );
156+ }
157+
158+ return null;
159+ },
160+ [buttonText, isDone, isFailed, isSending, canInput, count, progress]
161+ );
162+
163+ const validateForm = useCallback((values, errors) => {
164+ if (hasErrors(errors)) {
165+ return errors;
166+ }
167+
168+ // check for email uniqenesss
169+ const emails = values.emails.filter(d => !!d);
170+ if (uniq(emails).length !== emails.length) {
171+ return { [FORM_ERROR]: 'Duplicate email.' };
172+ }
173+ }, []);
174+
175+ const validate = useMemo(
176+ () =>
177+ composeValidator(
178+ {
179+ emails: buildArrayValidator(buildEmailValidator(validateHasReceived)),
180+ },
181+ validateForm
182+ ),
183+ [validateHasReceived, validateForm]
184+ );
185+
186+ const initialValues = { emails: [] };
187+ return (
188+ <Grid.Item full as={Grid}>
189+ <BridgeForm
190+ validate={validate}
191+ initialValues={initialValues}
192+ initialValuesEqual={isEqual}
193+ onSubmit={onSubmit}>
194+ {({ handleSubmit, values }) => (
195+ <>
196+ {console.log(values) && null}
197+ <Grid.Item full as={EmailChipInput} name="emails" className="mt4" />
198+ {renderButton(handleSubmit)}
199+
200+ <Grid.Item full as={FormError} />
201+ {needFunds && (
202+ <Grid.Item full as={NeedFundsNotice} {...needFunds} />
203+ )}
204+ </>
205+ )}
206+ </BridgeForm>
207+ </Grid.Item>
208+ );
209+};
210+
211+export default InviteMail;
src/views/Invite/Url.jsnew file+99
@@ -0,0 +1,99 @@
1+import React, { useCallback, useState } from 'react';
2+import { Grid, ErrorText } from 'indigo-react';
3+import { get } from 'lodash';
4+
5+import useCopiable from 'lib/useCopiable';
6+import useInviter from 'lib/useInviter';
7+
8+import ProgressButton from 'components/ProgressButton';
9+import { ForwardButton } from 'components/Buttons';
10+import NeedFundsNotice from 'components/NeedFundsNotice';
11+
12+import { hasErrors } from 'form/validators';
13+
14+const STATUS = {
15+ INPUT: 'INPUT',
16+ SENDING: 'SENDING',
17+ SUCCESS: 'SUCCESS',
18+ FAILURE: 'FAILURE',
19+};
20+
21+const InviteUrl = () => {
22+ const { txStatus, needFunds, invites, generateInvites } = useInviter();
23+
24+ const [error, setError] = useState();
25+
26+ const generateInvite = useCallback(async () => {
27+ const errors = await generateInvites(1);
28+ if (hasErrors(errors)) {
29+ setError(errors);
30+ }
31+ }, [generateInvites]);
32+
33+ const renderGenerateButton = () => {
34+ if (error) {
35+ return (
36+ <Grid.Item full className="mt4 p3 bg-red3 white f5">
37+ Error generating invite
38+ </Grid.Item>
39+ );
40+ }
41+ switch (txStatus) {
42+ case STATUS.INPUT:
43+ return (
44+ <Grid.Item
45+ solid
46+ full
47+ className="mt4"
48+ as={ForwardButton}
49+ onClick={() => generateInvite()}>
50+ Generate Invite URL
51+ </Grid.Item>
52+ );
53+ case STATUS.SENDING:
54+ return (
55+ <Grid.Item
56+ full
57+ as={ProgressButton}
58+ className="mt4"
59+ solid
60+ disabled
61+ success
62+ progress={0.5}>
63+ Generating Invite URL
64+ </Grid.Item>
65+ );
66+ default:
67+ return null;
68+ }
69+ };
70+
71+ const url = `https://bridge.urbit.org/#${get(invites, '[0].ticket', '')}`;
72+
73+ const [doCopy, didCopy] = useCopiable(url);
74+
75+ return (
76+ <Grid.Item full as={Grid}>
77+ {invites.length > 0 && (
78+ <>
79+ <Grid.Item full className="b-gray3 b1 mv4 p1 flex flex-center">
80+ <div className="flex1 ml1">{url}</div>
81+ <Grid.Item
82+ className="pv3 ph4 bg-black white"
83+ onClick={() => doCopy()}>
84+ {didCopy ? 'Copied' : 'Copy'}
85+ </Grid.Item>
86+ </Grid.Item>
87+ <Grid.Item full>
88+ Your invite link expires after consumption.
89+ </Grid.Item>
90+ </>
91+ )}
92+ {invites.length === 0 && renderGenerateButton()}
93+ {needFunds && <Grid.Item full as={NeedFundsNotice} {...needFunds} />}
94+ {error && <Grid.Item as={ErrorText}>{JSON.stringify(error)}</Grid.Item>}
95+ </Grid.Item>
96+ );
97+};
98+
99+export default InviteUrl;