on c19b2a8 help sign in
bridge/src/views/Invite/Cohort.js
534 lines 14 kB history raw
1import React, { useMemo, useCallback, useState, useEffect } from 'react';
2import { Grid, ErrorText } from 'indigo-react';
3import { get, uniq, zip, compact, isEqual } from 'lodash';
4import * as azimuth from 'azimuth-js';
5import { FORM_ERROR } from 'final-form';
6import { toWei, toBN } from 'web3-utils';
7import * as ob from 'urbit-ob';
9import * as need from 'lib/need';
10import * as wg from 'lib/walletgen';
11import * as tank from 'lib/tank';
12import useCopiable from 'lib/useCopiable';
14import Tabs from 'components/Tabs';
15import ProgressButton from 'components/ProgressButton';
16import { ForwardButton } from 'components/Buttons';
17import NeedFundsNotice from 'components/NeedFundsNotice';
19import { useNetwork } from 'store/network';
20import { useWallet } from 'store/wallet';
21import { usePointCursor } from 'store/pointCursor';
22import { usePointCache } from 'store/pointCache';
24import {
25 signTransaction,
26 sendSignedTransaction,
27 waitForTransactionConfirm,
28 hexify,
29} from 'lib/txn';
30import { MIN_PLANET, GAS_LIMITS } from 'lib/constants';
31import pluralize from 'lib/pluralize';
32import useGasPrice from 'lib/useGasPrice';
34import EmailChipInput from 'form/EmailChipInput';
35import FormError from 'form/FormError';
36import SubmitButton from 'form/SubmitButton';
37import BridgeForm from 'form/BridgeForm';
38import {
39 composeValidator,
40 hasErrors,
41 buildEmailValidator,
42 buildArrayValidator,
43} from 'form/validators';
44import { WARNING } from 'form/helpers';
45import useMailer from 'lib/useMailer';
47const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
49const STATUS = {
50 INPUT: 'INPUT',
51 SENDING: 'SENDING',
52 SUCCESS: 'SUCCESS',
53 FAILURE: 'FAILURE',
54};
56const HAS_RECEIVED_TEXT = 'This email has already received an invite.';
58const 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);
66 const [needFunds, setNeedFunds] = useState(null);
67 const [invites, setInvites] = useState([]);
69 const [progress, setProgress] = useState(0);
70 const [txStatus, setTxStatus] = useState(STATUS.INPUT);
71 // const [errors, addError] = useSetState();
73 const isDone = txStatus === STATUS.SUCCESS;
74 useEffect(() => {
75 if (isDone) {
76 syncInvites(point);
77 }
78 }, [isDone, syncInvites, point]);
80 const resetInvites = useCallback(() => {
81 setProgress(0);
82 setInvites([]);
83 setTxStatus(STATUS.INPUT);
84 }, [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 }
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 );
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);
112 setTxStatus(STATUS.FAILURE);
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 }
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];
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 );
136 const inviteTx = azimuth.delegatedSending.sendPoint(
137 _contracts,
138 point,
139 planet,
140 owner.keys.address
141 );
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 });
156 const rawTx = hexify(signedTx.serialize());
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 }
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 );
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 );
193 await waitForTransactionConfirm(_web3, txHash);
194 confirmedInvites.push(invite);
195 } catch (error) {
196 console.error(error);
197 unsentInvites.push(invite);
198 return;
199 }
200 });
202 await Promise.all(txAndMailings);
204 setTxStatus(STATUS.SUCCESS);
205 setInvites(confirmedInvites);
206 console.log(unsentInvites);
207 console.log(confirmedInvites);
209 if (unsentInvites.length > 0) {
210 return { errors: { [FORM_ERROR]: unsentInvites } };
211 }
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 );
243 return {
244 progress,
245 txStatus,
246 needFunds,
247 invites,
248 generateInvites,
249 resetInvites,
250 };
251};
253const InviteMail = () => {
254 const { progress, txStatus, needFunds, generateInvites } = useInviter();
256 const { sendMail, getHasReceived } = useMailer();
258 const [status, setStatus] = useState(STATUS.INPUT);
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;
266 const { pointCursor } = usePointCursor();
267 const point = need.point(pointCursor);
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 );
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 );
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 );
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 };
335 const renderButton = handleSubmit => {
336 if (canInput) {
337 return (
338 <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
339 {buttonText()}
340 </Grid.Item>
341 );
342 }
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 }
374 return null;
375 };
377 const validateForm = useCallback((values, errors) => {
378 if (hasErrors(errors)) {
379 return errors;
380 }
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 }, []);
389 const validate = useMemo(
390 () =>
391 composeValidator(
392 {
393 emails: buildArrayValidator(buildEmailValidator(validateHasReceived)),
394 },
395 validateForm
396 ),
397 [validateHasReceived, validateForm]
398 );
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)}
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};
425const InviteUrl = () => {
426 const { txStatus, needFunds, invites, generateInvites } = useInviter();
428 const [error, setError] = useState();
430 const generateInvite = useCallback(async () => {
431 const errors = await generateInvites(1);
432 if (hasErrors(errors)) {
433 setError(errors);
434 }
435 }, [generateInvites]);
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 };
475 const url = `https://bridge.urbit.org/#${get(invites, '[0].ticket', '')}`;
477 const [doCopy, didCopy] = useCopiable(url);
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};
503const InviteCohort = () => {
504 const [tab, setTab] = useState(NAMES.MAIL);
505 return (
506 <Grid.Item
507 full
508 as={Tabs}
509 tabClassName="t-center flex1"
510 className="flex1"
511 views={VIEWS}
512 options={OPTIONS}
513 currentTab={tab}
514 onTabChange={setTab}
515 />
516 );
517};
519const NAMES = {
520 MAIL: 'MAIL',
521 URL: 'URL',
522};
524const VIEWS = {
525 [NAMES.MAIL]: InviteMail,
526 [NAMES.URL]: InviteUrl,
527};
529const OPTIONS = [
530 { text: 'Email', value: NAMES.MAIL },
531 { text: 'URL', value: NAMES.URL },
532];
534export default InviteCohort;