on master help sign in
Merge branch 'master' into reticket-metamask
0466486Fang
package-lock.json+92−30
@@ -1,6 +1,6 @@
1{
1{
2 "name": "bridge",
2 "name": "bridge",
3− "version": "2.2.0",
3+ "version": "2.2.1",
4 "lockfileVersion": 1,
4 "lockfileVersion": 1,
5 "requires": true,
5 "requires": true,
6 "dependencies": {
6 "dependencies": {
@@ -1448,54 +1448,116 @@
1448 }
1448 }
1449 },
1449 },
1450 "@ledgerhq/devices": {
1450 "@ledgerhq/devices": {
1451− "version": "5.0.0",
1451+ "version": "5.5.0",
1452− "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-5.0.0.tgz",
1452+ "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-5.5.0.tgz",
1453− "integrity": "sha512-wEnf2RMFELgj4q1ApG2bNzyobYgeX3Jl92FA8af/1VfcBB9esApFAO8cuE7aQruv2S6k5sTR5RMMnomrS9myiw==",
1453+ "integrity": "sha512-t3Xl2ObF4yTVz3rEdtETv1Vjy1FYQVcxAoTV52S2I/mT3Ac/z7UWBjKG/w4YRc/Itn371PkTguMoSKDL2GXq3g==",
1454 "requires": {
1454 "requires": {
1455− "@ledgerhq/errors": "^5.0.0",
1455+ "@ledgerhq/errors": "^5.5.0",
1456− "@ledgerhq/logs": "^5.0.0",
1456+ "@ledgerhq/logs": "^5.3.0",
1457− "rxjs": "^6.5.3"
1457+ "rxjs": "^6.5.4"
1458+ },
1459+ "dependencies": {
1460+ "rxjs": {
1461+ "version": "6.5.4",
1462+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
1463+ "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
1464+ "requires": {
1465+ "tslib": "^1.9.0"
1466+ }
1467+ }
1458 }
1468 }
1459 },
1469 },
1460 "@ledgerhq/errors": {
1470 "@ledgerhq/errors": {
1461− "version": "5.0.0",
1471+ "version": "5.5.0",
1462− "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-5.0.0.tgz",
1472+ "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-5.5.0.tgz",
1463− "integrity": "sha512-sguPj6FeYOdkjuQEBfZSM6j5xYBk9RK1L9AfkLlO0zXSYHFZ+9OAlTA6Q0d5W7xmktx+32tUYxiT+JQ9WaeGmw=="
1473+ "integrity": "sha512-e6VlIVC3QR8EHXm/BtBcOeo+KqIKmeufr9cg38CK2KOD6CAEGJseipkyxVWWS0AR+kJIvWHl7aFqn2OdXe9EDQ=="
1464 },
1474 },
1465 "@ledgerhq/hw-app-eth": {
1475 "@ledgerhq/hw-app-eth": {
1466− "version": "5.1.0",
1476+ "version": "5.5.0",
1467− "resolved": "https://registry.npmjs.org/@ledgerhq/hw-app-eth/-/hw-app-eth-5.1.0.tgz",
1477+ "resolved": "https://registry.npmjs.org/@ledgerhq/hw-app-eth/-/hw-app-eth-5.5.0.tgz",
1468− "integrity": "sha512-TjOVdDV4J6+yfEnF8TI5e5RjbP9tI16MUsHHGWNVH8NFK34KJjDJurcjVECktDXsMxYXldlPS50otCSwJqn8JQ==",
1478+ "integrity": "sha512-hP3ouvX/tukriLGS6i7GJ89VmeXx3siWbbyR9kR9ee7v9qPc2k3Y1X9R+CosvN/i//Dx+5VSr1KbyzyMGEhJgA==",
1469 "requires": {
1479 "requires": {
1470− "@ledgerhq/errors": "^5.0.0",
1480+ "@ledgerhq/errors": "^5.5.0",
1481+ "@ledgerhq/hw-transport": "^5.5.0"
1482+ },
1483+ "dependencies": {
1484+ "@ledgerhq/devices": {
1485+ "version": "5.5.0",
1486+ "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-5.5.0.tgz",
1487+ "integrity": "sha512-t3Xl2ObF4yTVz3rEdtETv1Vjy1FYQVcxAoTV52S2I/mT3Ac/z7UWBjKG/w4YRc/Itn371PkTguMoSKDL2GXq3g==",
1488+ "requires": {
1489+ "@ledgerhq/errors": "^5.5.0",
1490+ "@ledgerhq/logs": "^5.3.0",
1491+ "rxjs": "^6.5.4"
1492+ }
1493+ },
1494+ "@ledgerhq/errors": {
1495+ "version": "5.5.0",
1496+ "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-5.5.0.tgz",
1497+ "integrity": "sha512-e6VlIVC3QR8EHXm/BtBcOeo+KqIKmeufr9cg38CK2KOD6CAEGJseipkyxVWWS0AR+kJIvWHl7aFqn2OdXe9EDQ=="
1498+ },
1471− "@ledgerhq/hw-transport": "^5.0.0"
1499+ "@ledgerhq/hw-transport": {
1500+ "version": "5.5.0",
1501+ "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-5.5.0.tgz",
1502+ "integrity": "sha512-VetoCESuyjYhkKAx61e+8B9c279c7BiWjp0uoZctsWp+M1PrzPobirKO2Yr1mFnoJSY87uuZhCEAOW3mfac8QQ==",
1503+ "requires": {
1504+ "@ledgerhq/devices": "^5.5.0",
1505+ "@ledgerhq/errors": "^5.5.0",
1506+ "events": "^3.1.0"
1507+ }
1508+ },
1509+ "@ledgerhq/logs": {
1510+ "version": "5.3.0",
1511+ "resolved": "https://registry.npmjs.org/@ledgerhq/logs/-/logs-5.3.0.tgz",
1512+ "integrity": "sha512-OeCSsXM6WuFLpNI4s9Lr69xwb/blmE4K67AlFTPRW6Xc4miXXqRVn0YmEkEi/4h7iS/3Aq24cytZawBzr+evdw=="
1513+ },
1514+ "events": {
1515+ "version": "3.1.0",
1516+ "resolved": "https://registry.npmjs.org/events/-/events-3.1.0.tgz",
1517+ "integrity": "sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg=="
1518+ },
1519+ "rxjs": {
1520+ "version": "6.5.4",
1521+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz",
1522+ "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==",
1523+ "requires": {
1524+ "tslib": "^1.9.0"
1525+ }
1526+ }
1472 }
1527 }
1473 },
1528 },
1474 "@ledgerhq/hw-transport": {
1529 "@ledgerhq/hw-transport": {
1475− "version": "5.0.0",
1530+ "version": "5.5.0",
1476− "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-5.0.0.tgz",
1531+ "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-5.5.0.tgz",
1477− "integrity": "sha512-TS2h3RmFQj6igKGfE8ThN1Mk8+w4lK+xHvrDUymkJHzbQhboRTGcgp6psg5F3L2ZYKIq8Q3NFDSqhfygI2ZFqg==",
1532+ "integrity": "sha512-VetoCESuyjYhkKAx61e+8B9c279c7BiWjp0uoZctsWp+M1PrzPobirKO2Yr1mFnoJSY87uuZhCEAOW3mfac8QQ==",
1478 "requires": {
1533 "requires": {
1479− "@ledgerhq/devices": "^5.0.0",
1534+ "@ledgerhq/devices": "^5.5.0",
1480− "@ledgerhq/errors": "^5.0.0",
1535+ "@ledgerhq/errors": "^5.5.0",
1536+ "events": "^3.1.0"
1537+ },
1538+ "dependencies": {
1481− "events": "^3.0.0"
1539+ "events": {
1540+ "version": "3.1.0",
1541+ "resolved": "https://registry.npmjs.org/events/-/events-3.1.0.tgz",
1542+ "integrity": "sha512-Rv+u8MLHNOdMjTAFeT3nCjHn2aGlx435FP/sDHNaRhDEMwyI/aB22Kj2qIN8R0cw3z28psEQLYwxVKLsKrMgWg=="
1543+ }
1482 }
1544 }
1483 },
1545 },
1484 "@ledgerhq/hw-transport-u2f": {
1546 "@ledgerhq/hw-transport-u2f": {
1485− "version": "5.0.0",
1547+ "version": "5.5.0",
1486− "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-u2f/-/hw-transport-u2f-5.0.0.tgz",
1548+ "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport-u2f/-/hw-transport-u2f-5.5.0.tgz",
1487− "integrity": "sha512-8936tpH5Ey8tQaycL2iTRWpb/dc5XgFnrCtzRPXiF/UbtIZ6Cy3l9G6Wd/OyzwipT1AZbJNf4l07WUOJFUcL7w==",
1549+ "integrity": "sha512-+bvFwW+adP9Ak4lCovtI0j1KTWgeUlMjnneuzuu7P9N7REAMGX68+yAslwVl4cs+8u7HbT/XN8A3O3kuR8yyXA==",
1488 "requires": {
1550 "requires": {
1489− "@ledgerhq/errors": "^5.0.0",
1551+ "@ledgerhq/errors": "^5.5.0",
1490− "@ledgerhq/hw-transport": "^5.0.0",
1552+ "@ledgerhq/hw-transport": "^5.5.0",
1491− "@ledgerhq/logs": "^5.0.0",
1553+ "@ledgerhq/logs": "^5.3.0",
1492 "u2f-api": "0.2.7"
1554 "u2f-api": "0.2.7"
1493 }
1555 }
1494 },
1556 },
1495 "@ledgerhq/logs": {
1557 "@ledgerhq/logs": {
1496− "version": "5.0.0",
1558+ "version": "5.3.0",
1497− "resolved": "https://registry.npmjs.org/@ledgerhq/logs/-/logs-5.0.0.tgz",
1559+ "resolved": "https://registry.npmjs.org/@ledgerhq/logs/-/logs-5.3.0.tgz",
1498− "integrity": "sha512-+Rgnp0T5ximUUwEUCSRbJNTdfVFrchO7sqY4MtVChXSMqu8vvzeg9KKakAE6t9IKEnoQ7aaG/BIEDtLXvrgRBQ=="
1560+ "integrity": "sha512-OeCSsXM6WuFLpNI4s9Lr69xwb/blmE4K67AlFTPRW6Xc4miXXqRVn0YmEkEi/4h7iS/3Aq24cytZawBzr+evdw=="
1499 },
1561 },
1500 "@mrmlnc/readdir-enhanced": {
1562 "@mrmlnc/readdir-enhanced": {
1501 "version": "2.2.1",
1563 "version": "2.2.1",
package.json+2−2
@@ -15,8 +15,8 @@
15 },
15 },
16 "dependencies": {
16 "dependencies": {
17 "@hot-loader/react-dom": "^16.8.6",
17 "@hot-loader/react-dom": "^16.8.6",
18− "@ledgerhq/hw-app-eth": "^5.1.0",
18+ "@ledgerhq/hw-app-eth": "^5.5.0",
19− "@ledgerhq/hw-transport-u2f": "^5.0.0",
19+ "@ledgerhq/hw-transport-u2f": "^5.5.0",
20 "@welldone-software/why-did-you-render": "^3.2.3",
20 "@welldone-software/why-did-you-render": "^3.2.3",
21 "async-retry": "^1.2.3",
21 "async-retry": "^1.2.3",
22 "azimuth-js": "^0.17.0",
22 "azimuth-js": "^0.17.0",
src/assets/search.svgnew file+3
@@ -0,0 +1,3 @@
1+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
2+<path d="M9.18806 10.1969C8.04395 11.0861 6.60411 11.5056 5.16172 11.37C3.71932 11.2344 2.38282 10.5538 1.42436 9.46684C0.465895 8.3799 -0.0424681 6.96833 0.00278089 5.51955C0.0480299 4.07077 0.643489 2.69371 1.66791 1.66876C2.69234 0.643816 4.0687 0.0480543 5.51674 0.00278231C6.96479 -0.0424897 8.37564 0.466132 9.46203 1.42508C10.5484 2.38404 11.2286 3.72121 11.3642 5.16434C11.4998 6.60747 11.0805 8.04805 10.1917 9.19274L14 12.9887L12.9892 14L9.19518 10.1969H9.18806ZM5.70012 9.96902C6.26099 9.96902 6.81637 9.85849 7.33454 9.64375C7.85272 9.429 8.32354 9.11425 8.72014 8.71745C9.11673 8.32066 9.43133 7.84959 9.64596 7.33115C9.8606 6.81271 9.97107 6.25705 9.97107 5.6959C9.97107 5.13474 9.8606 4.57908 9.64596 4.06064C9.43133 3.54221 9.11673 3.07114 8.72014 2.67434C8.32354 2.27755 7.85272 1.96279 7.33454 1.74805C6.81637 1.5333 6.26099 1.42278 5.70012 1.42278C4.56739 1.42278 3.48106 1.87298 2.6801 2.67434C1.87914 3.47571 1.42917 4.5626 1.42917 5.6959C1.42917 6.8292 1.87914 7.91609 2.6801 8.71745C3.48106 9.51882 4.56739 9.96902 5.70012 9.96902Z" fill="#7F7F7F"/>
3+</svg>
src/components/BarGraph.jsnew file+29
@@ -0,0 +1,29 @@
1+import React from 'react';
2+import cn from 'classnames';
3+import { Nothing } from 'folktale/maybe';
4+
5+const BarGraph = ({
6+ className,
7+ available = Nothing(),
8+ sent = Nothing(),
9+ accepted = Nothing(),
10+}) => {
11+ const _available = available.getOrElse(1);
12+ const _sent = sent.getOrElse(0);
13+ const _accepted = accepted.getOrElse(0);
14+ const total = _available + _sent;
15+ return (
16+ <div className={cn('h7 p1 b1 b-black flex', className)}>
17+ <div
18+ className="bg-black"
19+ style={{ width: `${(_accepted / total) * 100}%` }}></div>
20+ <div
21+ className="bg-yellow4"
22+ style={{ width: `${((_sent - _accepted) / total) * 100}%` }}></div>
23+
24+ <div className="flex-grow" />
25+ </div>
26+ );
27+};
28+
29+export default BarGraph;
src/components/Chip.jsnew file+16
@@ -0,0 +1,16 @@
1+import React from 'react';
2+import cn from 'classnames';
3+
4+export default function Chip({ children, bgColor, fgColor, className }) {
5+ return (
6+ <div
7+ className={cn(
8+ className,
9+ 'h6 r-full flex-center ph2 mh2 f6',
10+ bgColor,
11+ fgColor
12+ )}>
13+ {children}
14+ </div>
15+ );
16+}
src/components/InviteSigilList.jsnew file+50
@@ -0,0 +1,50 @@
1+import React from 'react';
2+import cn from 'classnames';
3+import { Just, Nothing } from 'folktale/maybe';
4+import * as ob from 'urbit-ob';
5+import { take } from 'lodash';
6+
7+import MaybeSigil from 'components/MaybeSigil';
8+
9+const InviteSigilList = ({ className, pendingPoints, acceptedPoints }) => {
10+ const _acceptedPoints = take(
11+ acceptedPoints.getOrElse([]).map(x => Just(ob.patp(x))),
12+ 6
13+ );
14+
15+ const _pendingPoints = take(
16+ pendingPoints.getOrElse([]).map(x => Just(ob.patp(x))),
17+ 6 - _acceptedPoints.length
18+ );
19+
20+ const empty = [
21+ ...Array(
22+ Math.max(6 - _acceptedPoints.length - _pendingPoints.length, 0)
23+ ).keys(),
24+ ].map(() => Nothing());
25+
26+ const renderSigil = (points, colors, klassName) => {
27+ return (
28+ <>
29+ {points.map((point, idx) => (
30+ <div
31+ key={idx}
32+ title={point.getOrElse(null)}
33+ className={cn(klassName, 'h9 w9')}>
34+ <MaybeSigil patp={point} size={50} colors={colors} />
35+ </div>
36+ ))}
37+ </>
38+ );
39+ };
40+
41+ return (
42+ <div className={cn('flex justify-between', className)}>
43+ {renderSigil(_acceptedPoints, ['#000000', '#FFFFFF'])}
44+ {renderSigil(_pendingPoints, ['#ee892b', '#FFFFFF'])}
45+ {renderSigil(empty, [], 'b1 b-gray3 b-dashed')}
46+ </div>
47+ );
48+};
49+
50+export default InviteSigilList;
src/components/NeedFundsNotice.js+3−3
@@ -1,5 +1,5 @@
1import React from 'react';
1import React from 'react';
2−import { fromWei, toBN } from 'web3-utils';
2+import { safeFromWei } from '../lib/lib';
3
3
4import Highlighted from './Highlighted';
4import Highlighted from './Highlighted';
5import CopiableAddress from './CopiableAddress';
5import CopiableAddress from './CopiableAddress';
@@ -16,8 +16,8 @@ export default function NeedFundsNotice({
16 <WarningBox {...rest}>
16 <WarningBox {...rest}>
17 <Highlighted warning>
17 <Highlighted warning>
18 Your ownership address <CopiableAddress>{address}</CopiableAddress>{' '}
18 Your ownership address <CopiableAddress>{address}</CopiableAddress>{' '}
19− needs at least {fromWei(toBN(minBalance))} ETH and currently has{' '}
19+ needs at least {safeFromWei(minBalance)} ETH and currently has{' '}
20− {fromWei(toBN(balance))} ETH. The transaction will automatically resume
20+ {safeFromWei(balance)} ETH. The transaction will automatically resume
21 once enough ETH is available. Waiting... <Blinky />
21 once enough ETH is available. Waiting... <Blinky />
22 </Highlighted>
22 </Highlighted>
23 </WarningBox>
23 </WarningBox>
src/components/Tabs.js+7−3
@@ -8,6 +8,7 @@ export default function Tabs({
8 currentTab,
8 currentTab,
9 onTabChange,
9 onTabChange,
10 className,
10 className,
11+ center = false,
11 ...rest
12 ...rest
12}) {
13}) {
13 const Tab = views[currentTab];
14 const Tab = views[currentTab];
@@ -15,7 +16,7 @@ export default function Tabs({
15 return (
16 return (
16 <Grid className={className}>
17 <Grid className={className}>
17 <Grid.Item full as={Flex} className="b-gray3 bb1 scroll-x hidden-y">
18 <Grid.Item full as={Flex} className="b-gray3 bb1 scroll-x hidden-y">
18− <Flex.Item as={Flex}>
19+ <Flex.Item as={Flex} className="flex1">
19 {options.map((option, i) => {
20 {options.map((option, i) => {
20 const isActive = option.value === currentTab;
21 const isActive = option.value === currentTab;
21 const isFirst = i === 0;
22 const isFirst = i === 0;
@@ -30,11 +31,14 @@ export default function Tabs({
30 'black b-black bb1': isActive,
31 'black b-black bb1': isActive,
31 gray3: !isActive,
32 gray3: !isActive,
32 },
33 },
34+ {
35+ 't-center flex1': center,
36+ },
33 {
37 {
34 // all items have right margin/padding
38 // all items have right margin/padding
35− 'mr2 pr2': true,
39+ 'mr2 pr2': !center,
36 // the first one is flush to the left
40 // the first one is flush to the left
37− 'ml2 pl2': !isFirst,
41+ 'ml2 pl2': !isFirst && !center,
38 }
42 }
39 )}>
43 )}>
40 {option.text}
44 {option.text}
src/form/EmailChipInput.jsnew file+122
@@ -0,0 +1,122 @@
1+import React, { useState, useCallback } from 'react';
2+import { Flex, ErrorText } from 'indigo-react';
3+import cn from 'classnames';
4+import { useField } from 'react-final-form';
5+import { useFieldArray } from 'react-final-form-arrays';
6+
7+import { hasErrors } from 'form/validators';
8+
9+const Chip = ({ onDelete, disabled, name }) => {
10+ const { input } = useField(name, { subscription: { value: true } });
11+ return (
12+ <Flex
13+ className={cn('r32 ph2 m1 flex-center h7', {
14+ 'bg-gray2': !disabled,
15+ 'bg-gray1 gray4': disabled,
16+ })}>
17+ <Flex.Item>{input.value}</Flex.Item>
18+ <Flex.Item
19+ onClick={disabled ? undefined : onDelete}
20+ className="ml1 f6 pointer-hover">
21+ ✗
22+ </Flex.Item>
23+ </Flex>
24+ );
25+};
26+
27+const EmailChipInput = ({ className, name, label, disabled }) => {
28+ const {
29+ fields,
30+ meta: { error, active },
31+ } = useFieldArray(name, { subscription: { error: true, active: true } });
32+
33+ const [value, setValue] = useState('');
34+
35+ const handleChange = useCallback(
36+ event => {
37+ event.preventDefault();
38+ setValue(event.target.value);
39+ },
40+ [setValue]
41+ );
42+
43+ const addToChips = useCallback(() => {
44+ fields.push(value);
45+ setValue('');
46+ }, [value, fields, setValue]);
47+
48+ const handleKeyDown = useCallback(
49+ event => {
50+ if (['Enter', 'Tab', ',', ' '].includes(event.key) && value !== '') {
51+ event.preventDefault();
52+ addToChips();
53+ } else if ('Backspace' === event.key && value === '') {
54+ event.preventDefault();
55+ fields.pop();
56+ } else if (disabled) {
57+ event.preventDefault();
58+ }
59+ },
60+ [fields, value, disabled, addToChips]
61+ );
62+
63+ const handleDelete = useCallback(
64+ idx => () => {
65+ fields.remove(idx);
66+ },
67+ [fields]
68+ );
69+
70+ const handleBlur = useCallback(
71+ e => {
72+ e.preventDefault();
73+ if (value !== '') {
74+ addToChips();
75+ }
76+ },
77+ [addToChips, value]
78+ );
79+
80+ const hasError = hasErrors(error);
81+
82+ return (
83+ <Flex col className={className}>
84+ {label && (
85+ <Flex.Item as="label" className={cn('f6 lh-tall')} htmlFor={name}>
86+ {label}
87+ </Flex.Item>
88+ )}
89+ <Flex.Item
90+ className={cn('flex b1 flex-wrap', {
91+ 'b-green2': active && !disabled && !hasError,
92+ 'b-red3': active && !disabled && hasError,
93+ })}>
94+ {fields.map((name, idx) => (
95+ <Chip
96+ key={idx}
97+ name={name}
98+ disabled={disabled}
99+ onDelete={handleDelete(idx)}
100+ />
101+ ))}
102+
103+ <input
104+ type="textarea"
105+ className="mv1 mh2 h7 b-none flex1"
106+ placeholder="Enter an email address"
107+ value={value}
108+ onChange={handleChange}
109+ onKeyDown={handleKeyDown}
110+ onBlur={handleBlur}
111+ />
112+ </Flex.Item>
113+ {error && (
114+ <Flex.Item as={ErrorText} className="mv1">
115+ {error}
116+ </Flex.Item>
117+ )}
118+ </Flex>
119+ );
120+};
121+
122+export default EmailChipInput;
src/form/validators.js+3
@@ -17,6 +17,7 @@ import {
17 validateNotNullAddress,
17 validateNotNullAddress,
18 validateHexPrefix,
18 validateHexPrefix,
19 validateLength,
19 validateLength,
20+ validateUnique,
20} from 'lib/validators';
21} from 'lib/validators';
21import isPromise from 'lib/isPromise';
22import isPromise from 'lib/isPromise';
22
23
@@ -115,6 +116,8 @@ export const buildNumberValidator = (min = 0, max = null) => {
115export const buildEmailValidator = validate =>
116export const buildEmailValidator = validate =>
116 buildValidator([validateNotEmpty, validateEmail], validate);
117 buildValidator([validateNotEmpty, validateEmail], validate);
117
118
119+export const buildEmailArrayValidator = () =>
120+ buildValidator([validateUnique, buildArrayValidator(buildEmailValidator())]);
118// the form validator is the composition of all of the field validators
121// the form validator is the composition of all of the field validators
119// plus an additional form validator function
122// plus an additional form validator function
120export const composeValidator = (
123export const composeValidator = (
src/indigo-react/components/Button.js+7−2
@@ -19,6 +19,7 @@ export default function Button({
19 background,
19 background,
20 type,
20 type,
21 children,
21 children,
22+ center = false,
22 ...rest
23 ...rest
23}) {
24}) {
24 const handleKeyPress = e => {
25 const handleKeyPress = e => {
@@ -60,10 +61,14 @@ export default function Button({
60 onClick={!disabled && onClick ? onClick : undefined}
61 onClick={!disabled && onClick ? onClick : undefined}
61 {...rest}>
62 {...rest}>
62 {background}
63 {background}
64+ <Grid.Item
65+ full
66+ as={Flex}
63− <Grid.Item full as={Flex} justify="between" className="z2">
67+ justify="between"
68+ className={cn('z2', { 'flex-center': center })}>
64 <span>{children}</span>
69 <span>{children}</span>
65 {type && <button type={type} style={{ display: 'none' }}></button>}
70 {type && <button type={type} style={{ display: 'none' }}></button>}
66− <div className={cn('pl4')}>{accessory}</div>
71+ {!center && <div className={cn('pl4')}>{accessory}</div>}
67 </Grid.Item>
72 </Grid.Item>
68 {detail && (
73 {detail && (
69 <Grid.Item full as={HelpText} className={cn('z2', detailClassName)}>
74 <Grid.Item full as={HelpText} className={cn('z2', detailClassName)}>
src/indigo-react/indigo.css+23−12
@@ -806,18 +806,6 @@ caption,
806.r-full {
806.r-full {
807 border-radius: 9999px; }
807 border-radius: 9999px; }
808
808
809−.b {
810− border-style: solid; }
811−
812−.b-hidden {
813− border-style: hidden; }
814−
815−.b-dotted {
816− border-style: dotted; }
817−
818−.b-none {
819− border-style: none;
820− border-width: 0; }
821
809
822.b1 {
810.b1 {
823 border-style: solid;
811 border-style: solid;
@@ -919,6 +907,22 @@ caption,
919 border-left-style: none;
907 border-left-style: none;
920 border-left-width: 0px; }
908 border-left-width: 0px; }
921
909
910+.b {
911+ border-style: solid; }
912+
913+.b-hidden {
914+ border-style: hidden; }
915+
916+.b-dotted {
917+ border-style: dotted; }
918+
919+.b-dashed {
920+ border-style: dashed; }
921+
922+.b-none {
923+ border-style: none;
924+ border-width: 0; }
925+
922.b-blue4 {
926.b-blue4 {
923 border-color: #190d7b; }
927 border-color: #190d7b; }
924
928
@@ -1578,6 +1582,10 @@ caption,
1578.justify-around {
1582.justify-around {
1579 justify-content: space-around; }
1583 justify-content: space-around; }
1580
1584
1585+.justify-evenly {
1586+ justify-content: space-evenly;
1587+}
1588+
1581.flex-center {
1589.flex-center {
1582 display: flex;
1590 display: flex;
1583 justify-content: center;
1591 justify-content: center;
@@ -3607,6 +3615,9 @@ caption,
3607 .h12-lg {
3615 .h12-lg {
3608 height: 288px; } }
3616 height: 288px; } }
3609
3617
3618+.pointer-hover {
3619+ cursor: pointer; }
3620+
3610.pointer:hover {
3621.pointer:hover {
3611 cursor: pointer; }
3622 cursor: pointer; }
3612
3623
src/lib/error.js+3−3
@@ -1,4 +1,4 @@
1−import { fromWei, toBN } from 'web3-utils';
1+import { safeFromWei } from './lib';
2
2
3const BRIDGE_ERROR = {
3const BRIDGE_ERROR = {
4 MISSING_WEB3: 'no web3 object found',
4 MISSING_WEB3: 'no web3 object found',
@@ -18,13 +18,13 @@ const BRIDGE_ERROR = {
18const renderTxnError = (web3, msg) => {
18const renderTxnError = (web3, msg) => {
19 const txnCost = (web3, msg) => {
19 const txnCost = (web3, msg) => {
20 const costWei = msg.match(/upfront cost is: ([0-9]+)/);
20 const costWei = msg.match(/upfront cost is: ([0-9]+)/);
21− const costEth = fromWei(toBN(costWei[1]), 'ether');
21+ const costEth = safeFromWei(costWei[1], 'ether');
22 return parseFloat(costEth).toFixed(4);
22 return parseFloat(costEth).toFixed(4);
23 };
23 };
24
24
25 const acctBalance = (web3, msg) => {
25 const acctBalance = (web3, msg) => {
26 const balWei = msg.match(/sender's account only has: ([0-9]+)/);
26 const balWei = msg.match(/sender's account only has: ([0-9]+)/);
27− const balEth = fromWei(toBN(balWei[1]), 'ether');
27+ const balEth = safeFromWei(balWei[1], 'ether');
28 return parseFloat(balEth).toFixed(4);
28 return parseFloat(balEth).toFixed(4);
29 };
29 };
30
30
src/lib/lib.js+27
@@ -1,5 +1,30 @@
1import ob from 'urbit-ob';
1import ob from 'urbit-ob';
2import patp2dec from './patp2dec';
2import patp2dec from './patp2dec';
3+import { fromWei, toWei, toBN } from 'web3-utils';
4+
5+const safeFromWei = (num, target) => {
6+ try {
7+ return fromWei(toBN(num), target);
8+ } catch (e) {
9+ e.message =
10+ `(safeFromWei got ${typeof num} (${num}), made ${typeof toBN(
11+ num
12+ )}. Please report this issue!) ` + e.message;
13+ throw e;
14+ }
15+};
16+
17+const safeToWei = (num, source) => {
18+ try {
19+ return toWei(toBN(num), source);
20+ } catch (e) {
21+ e.message =
22+ `(safeToWei got ${typeof num} (${num}), made ${typeof toBN(
23+ num
24+ )}. Please report this issue!) ` + e.message;
25+ throw e;
26+ }
27+};
3
28
4const compose = (...fs) =>
29const compose = (...fs) =>
5 fs.reduceRight((pF, nF) => (...args) => nF(pF(...args)), v => v);
30 fs.reduceRight((pF, nF) => (...args) => nF(pF(...args)), v => v);
@@ -43,6 +68,8 @@ const patpStringLength = byteLength =>
43 byteLength * 3 + Math.ceil(byteLength / 2);
68 byteLength * 3 + Math.ceil(byteLength / 2);
44
69
45export {
70export {
71+ safeFromWei,
72+ safeToWei,
46 compose,
73 compose,
47 allFalse,
74 allFalse,
48 isLast,
75 isLast,
src/lib/reticket.js+3−2
@@ -15,7 +15,8 @@ import {
15} from './txn';
15} from './txn';
16import getSuggestedGasPrice from './getSuggestedGasPrice';
16import getSuggestedGasPrice from './getSuggestedGasPrice';
17import { GAS_LIMITS } from './constants';
17import { GAS_LIMITS } from './constants';
18−import { toWei, toBN } from 'web3-utils';
18+import { toBN } from 'web3-utils';
19+import { safeToWei } from './lib';
19
20
20// the initial network key revision is always 1
21// the initial network key revision is always 1
21const INITIAL_NETWORK_KEY_REVISION = 1;
22const INITIAL_NETWORK_KEY_REVISION = 1;
@@ -149,7 +150,7 @@ export async function reticketPointBetweenWallets({
149
150
150 const suggestedGasPrice = await getSuggestedGasPrice(networkType);
151 const suggestedGasPrice = await getSuggestedGasPrice(networkType);
151 const chainId = await web3.eth.net.getId();
152 const chainId = await web3.eth.net.getId();
152− const gasPrice = toWei(suggestedGasPrice.toFixed(), 'gwei');
153+ const gasPrice = safeToWei(suggestedGasPrice.toFixed(), 'gwei');
153 const gasPriceBN = toBN(gasPrice);
154 const gasPriceBN = toBN(gasPrice);
154 let inviteNonce = await web3.eth.getTransactionCount(fromWallet.address);
155 let inviteNonce = await web3.eth.getTransactionCount(fromWallet.address);
155 const totalCost = txs.reduce(
156 const totalCost = txs.reduce(
src/lib/routeNames.js+1
@@ -20,5 +20,6 @@ export const ROUTE_NAMES = {
20 CREATE_GALAXY: Symbol('CREATE_GALAXY'),
20 CREATE_GALAXY: Symbol('CREATE_GALAXY'),
21 ISSUE_CHILD: Symbol('ISSUE_CHILD'),
21 ISSUE_CHILD: Symbol('ISSUE_CHILD'),
22 PARTY_SET_POOL_SIZE: Symbol('PARTY_SET_POOL_SIZE'),
22 PARTY_SET_POOL_SIZE: Symbol('PARTY_SET_POOL_SIZE'),
23+ INVITE_COHORT: Symbol('INVITE_COHORT'),
23 STAR_RELEASE: Symbol('STAR_RELEASE'),
24 STAR_RELEASE: Symbol('STAR_RELEASE'),
24};
25};
src/lib/router.js+2
@@ -12,6 +12,7 @@ import Invite from 'views/Invite';
12import Activate from 'views/Activate';
12import Activate from 'views/Activate';
13
13
14import PartySetPoolSize from 'views/Party/PartySetPoolSize';
14import PartySetPoolSize from 'views/Party/PartySetPoolSize';
15+import InviteCohort from 'views/Invite/Cohort';
15import AcceptTransfer from 'views/AcceptTransfer';
16import AcceptTransfer from 'views/AcceptTransfer';
16import CancelTransfer from 'views/CancelTransfer';
17import CancelTransfer from 'views/CancelTransfer';
17
18
@@ -33,5 +34,6 @@ export const ROUTES = {
33 [ROUTE_NAMES.ACCEPT_TRANSFER]: AcceptTransfer,
34 [ROUTE_NAMES.ACCEPT_TRANSFER]: AcceptTransfer,
34 // TODO: replace this with deep link to AdminCancelTransfer
35 // TODO: replace this with deep link to AdminCancelTransfer
35 [ROUTE_NAMES.CANCEL_TRANSFER]: CancelTransfer,
36 [ROUTE_NAMES.CANCEL_TRANSFER]: CancelTransfer,
37+ [ROUTE_NAMES.INVITE_COHORT]: InviteCohort,
36 [ROUTE_NAMES.STAR_RELEASE]: StarRelease,
38 [ROUTE_NAMES.STAR_RELEASE]: StarRelease,
37};
39};
src/lib/txn.js+4−3
@@ -1,5 +1,6 @@
1import Tx from 'ethereumjs-tx';
1import Tx from 'ethereumjs-tx';
2−import { toWei, fromWei, toHex, toBN } from 'web3-utils';
2+import { toHex } from 'web3-utils';
3+import { safeFromWei, safeToWei } from './lib';
3import retry from 'async-retry';
4import retry from 'async-retry';
4
5
5import { NETWORK_TYPES } from './network';
6import { NETWORK_TYPES } from './network';
@@ -31,7 +32,7 @@ const signTransaction = async ({
31 // TODO: require these in txn object
32 // TODO: require these in txn object
32 nonce = toHex(nonce);
33 nonce = toHex(nonce);
33 chainId = toHex(chainId);
34 chainId = toHex(chainId);
34− gasPrice = toHex(toWei(gasPrice, 'gwei'));
35+ gasPrice = toHex(safeToWei(gasPrice, 'gwei'));
35 gasLimit = toHex(gasLimit);
36 gasLimit = toHex(gasLimit);
36
37
37 const txParams = { nonce, chainId, gasPrice, gasLimit };
38 const txParams = { nonce, chainId, gasPrice, gasLimit };
@@ -204,7 +205,7 @@ const getTxnInfo = async (web3, addr) => {
204 return {
205 return {
205 nonce: nonce,
206 nonce: nonce,
206 chainId: chainId,
207 chainId: chainId,
207− gasPrice: fromWei(toBN(gasPrice), 'gwei'),
208+ gasPrice: safeFromWei(gasPrice, 'gwei'),
208 };
209 };
209};
210};
210
211
src/lib/useBlockWindowClose.js+16
@@ -15,3 +15,19 @@ export default function useBlockWindowClose() {
15 };
15 };
16 });
16 });
17}
17}
18+
19+export function useConditionalBlockWindowClose(condition) {
20+ return useEffect(() => {
21+ if (condition) {
22+ window.addEventListener('beforeunload', onBeforeUnload);
23+ } else {
24+ window.removeEventListener('beforeunload', onBeforeUnload);
25+ }
26+
27+ return function cleanup() {
28+ if (condition) {
29+ window.removeEventListener('beforeunload', onBeforeUnload);
30+ }
31+ };
32+ });
33+}
src/lib/useEthereumTransaction.js+3−2
@@ -1,6 +1,6 @@
1import { useCallback, useEffect, useState } from 'react';
1import { useCallback, useEffect, useState } from 'react';
2import { Just } from 'folktale/maybe';
2import { Just } from 'folktale/maybe';
3−import { toWei, toBN } from 'web3-utils';
3+import { toBN } from 'web3-utils';
4
4
5import { useNetwork } from 'store/network';
5import { useNetwork } from 'store/network';
6import { useWallet } from 'store/wallet';
6import { useWallet } from 'store/wallet';
@@ -22,6 +22,7 @@ import { ensureFundsFor } from 'lib/tank';
22import useDeepEqualReference from 'lib/useDeepEqualReference';
22import useDeepEqualReference from 'lib/useDeepEqualReference';
23import useGasPrice from 'lib/useGasPrice';
23import useGasPrice from 'lib/useGasPrice';
24import timeout from 'lib/timeout';
24import timeout from 'lib/timeout';
25+import { safeToWei } from 'lib/lib';
25
26
26const STATE = {
27const STATE = {
27 NONE: 'NONE',
28 NONE: 'NONE',
@@ -148,7 +149,7 @@ export default function useEthereumTransaction(
148 const costGwei = toBN(gasLimit)
149 const costGwei = toBN(gasLimit)
149 .mul(toBN(gasPrice))
150 .mul(toBN(gasPrice))
150 .mul(toBN(rawTxs.length));
151 .mul(toBN(rawTxs.length));
151− const cost = toWei(costGwei.toString(), 'gwei');
152+ const cost = safeToWei(costGwei, 'gwei');
152 let usedTank = false;
153 let usedTank = false;
153 // if this ethereum transaction is being executed by a specific point
154 // if this ethereum transaction is being executed by a specific point
154 // see if we can use the tank
155 // see if we can use the tank
src/lib/useInviter.jsnew file+227
@@ -0,0 +1,227 @@
1+import { useState, useCallback, useEffect } from 'react';
2+import { 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+import { safeToWei } from 'lib/lib';
20+
21+import { useNetwork } from 'store/network';
22+import { useWallet } from 'store/wallet';
23+import { usePointCursor } from 'store/pointCursor';
24+import { usePointCache } from 'store/pointCache';
25+
26+import { WARNING } from 'form/helpers';
27+
28+const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
29+const STATUS = {
30+ INPUT: 'INPUT',
31+ SENDING: 'SENDING',
32+ SUCCESS: 'SUCCESS',
33+ FAILURE: 'FAILURE',
34+};
35+
36+const useInviter = () => {
37+ const { contracts, web3, networkType } = useNetwork();
38+ const { wallet, walletType, walletHdPath } = useWallet();
39+ const { pointCursor } = usePointCursor();
40+ const { syncInvites } = usePointCache();
41+ const { gasPrice } = useGasPrice();
42+ const point = need.point(pointCursor);
43+
44+ const [needFunds, setNeedFunds] = useState(null);
45+ const [invites, setInvites] = useState([]);
46+
47+ const [progress, setProgress] = useState(0);
48+ const [txStatus, setTxStatus] = useState(STATUS.INPUT);
49+
50+ const isDone = txStatus === STATUS.SUCCESS;
51+ useEffect(() => {
52+ if (isDone) {
53+ syncInvites(point);
54+ }
55+ }, [isDone, syncInvites, point]);
56+
57+ const resetInvites = useCallback(() => {
58+ setProgress(0);
59+ setInvites([]);
60+ setTxStatus(STATUS.INPUT);
61+ }, [setProgress, setInvites, setTxStatus]);
62+
63+ const generateInvites = useCallback(
64+ async numInvites => {
65+ const _contracts = contracts.getOrElse(null);
66+ const _web3 = web3.getOrElse(null);
67+ const _wallet = wallet.getOrElse(null);
68+ if (!_contracts || !_web3 || !_wallet) {
69+ // not using need because we want a custom error
70+ throw new Error('Internal Error: Missing Contracts/Web3/Wallet');
71+ }
72+
73+ const nonce = await _web3.eth.getTransactionCount(_wallet.address);
74+ const chainId = await _web3.eth.net.getId();
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+ safeToWei(totalCost, '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+
183+ if (unsentInvites.length > 0) {
184+ return { errors: { [FORM_ERROR]: unsentInvites } };
185+ }
186+
187+ if (errorCount > 0) {
188+ return {
189+ errors: {
190+ [WARNING]: `There ${pluralize(
191+ errorCount,
192+ 'was',
193+ 'were'
194+ )} ${pluralize(
195+ errorCount,
196+ 'error'
197+ )} while generating wallets. You can still send the invites that generated correctly.`,
198+ },
199+ };
200+ }
201+ return { invites: confirmedInvites };
202+ },
203+ [
204+ contracts,
205+ gasPrice,
206+ invites,
207+ networkType,
208+ point,
209+ syncInvites,
210+ wallet,
211+ walletHdPath,
212+ walletType,
213+ web3,
214+ ]
215+ );
216+
217+ return {
218+ progress,
219+ txStatus,
220+ needFunds,
221+ invites,
222+ generateInvites,
223+ resetInvites,
224+ };
225+};
226+
227+export default useInviter;
src/lib/validators.js+5
@@ -108,3 +108,8 @@ export const validateNotNullAddress = validateNotAny([
108 ETH_ZERO_ADDR,
108 ETH_ZERO_ADDR,
109 ETH_ZERO_ADDR_SHORT,
109 ETH_ZERO_ADDR_SHORT,
110]);
110]);
111+
112+export const validateUnique = arr => {
113+ const res = [...new Set(arr)].length !== arr.length && 'Must be unique';
114+ return res;
115+};
src/store/lib/useInvitesStore.js+13
@@ -1,6 +1,7 @@
1import { useCallback } from 'react';
1import { useCallback } from 'react';
2import { Just, Nothing } from 'folktale/maybe';
2import { Just, Nothing } from 'folktale/maybe';
3import * as azimuth from 'azimuth-js';
3import * as azimuth from 'azimuth-js';
4+import { chain } from 'lodash';
4
5
5import { useNetwork } from '../network';
6import { useNetwork } from '../network';
6import useSetState from 'lib/useSetState';
7import useSetState from 'lib/useSetState';
@@ -10,6 +11,8 @@ const EMPTY_INVITES = {
10 availableInvites: Nothing(),
11 availableInvites: Nothing(),
11 sentInvites: Nothing(),
12 sentInvites: Nothing(),
12 acceptedInvites: Nothing(),
13 acceptedInvites: Nothing(),
14+ acceptedPoints: Nothing(),
15+ pendingPoints: Nothing(),
13};
16};
14
17
15export default function useInvitesStore() {
18export default function useInvitesStore() {
@@ -42,11 +45,21 @@ export default function useInvitesStore() {
42 const sentInvites = invitedPoints.length;
45 const sentInvites = invitedPoints.length;
43 const acceptedInvites = activity.filter(i => !!i).length;
46 const acceptedInvites = activity.filter(i => !!i).length;
44
47
48+ const [acceptedPoints, pendingPoints] = chain(invitedPoints)
49+ .zipWith(activity, (point, active) => ({
50+ point,
51+ active: !!active,
52+ }))
53+ .partition('active')
54+ .map(arr => arr.map(x => x.point));
55+
45 addToInvitesCache({
56 addToInvitesCache({
46 [point]: {
57 [point]: {
47 availableInvites: Just(availableInvites),
58 availableInvites: Just(availableInvites),
48 sentInvites: Just(sentInvites),
59 sentInvites: Just(sentInvites),
49 acceptedInvites: Just(acceptedInvites),
60 acceptedInvites: Just(acceptedInvites),
61+ pendingPoints: Just(pendingPoints),
62+ acceptedPoints: Just(acceptedPoints),
50 },
63 },
51 });
64 });
52 },
65 },
src/views/Invite/Cohort.jsnew file+241
@@ -0,0 +1,241 @@
1+import React, { useMemo, useEffect, useCallback, useState } from 'react';
2+import { Grid, Flex, Button } from 'indigo-react';
3+import * as ob from 'urbit-ob';
4+import { Just } from 'folktale/maybe';
5+import cn from 'classnames';
6+
7+import { usePointCursor } from 'store/pointCursor';
8+import { usePointCache } from 'store/pointCache';
9+
10+import View from 'components/View';
11+import Tabs from 'components/Tabs';
12+import BarGraph from 'components/BarGraph';
13+import MaybeSigil from 'components/MaybeSigil';
14+import { matchBlinky } from 'components/Blinky';
15+import { ForwardButton } from 'components/Buttons';
16+import Chip from 'components/Chip';
17+import Crumbs from 'components/Crumbs';
18+
19+import { useLocalRouter } from 'lib/LocalRouter';
20+import useInvites from 'lib/useInvites';
21+import * as need from 'lib/need';
22+
23+import Inviter from 'views/Invite/Inviter';
24+
25+import { ReactComponent as SearchIcon } from 'assets/search.svg';
26+
27+function SearchInput({ className, value, onChange }) {
28+ return (
29+ <Flex align="center" full className={cn('b b-gray2 b1', className)}>
30+ <Flex.Item className="p2">
31+ <SearchIcon />
32+ </Flex.Item>
33+
34+ <Flex.Item
35+ flex={1}
36+ as={'input'}
37+ value={value}
38+ onChange={onChange}
39+ className="b-none h7"
40+ />
41+ </Flex>
42+ );
43+}
44+
45+function CohortMember({ point, pending = false, className }) {
46+ const { sentInvites } = useInvites(point);
47+ const patp = useMemo(() => ob.patp(point), [point]);
48+ const colors = pending ? ['#ee892b', '#FFFFFF'] : ['#000000', '#FFFFFF'];
49+
50+ const DetailText = useCallback(
51+ () =>
52+ pending ? 'Pending' : <> {matchBlinky(sentInvites)} points invited </>,
53+ [sentInvites]
54+ );
55+
56+ return (
57+ <Flex justify="between" className={cn('b1 b-gray2', className)}>
58+ <Flex.Item className="w9 h9">
59+ <MaybeSigil patp={Just(patp)} size={64} colors={colors} />
60+ </Flex.Item>
61+
62+ <Flex.Item
63+ flex={1}
64+ justify="evenly"
65+ as={Flex}
66+ col
67+ className="mono f6 ph4">
68+ <Flex.Item>{patp}</Flex.Item>
69+ <Flex.Item className="gray4">
70+ <DetailText />
71+ </Flex.Item>
72+ </Flex.Item>
73+ </Flex>
74+ );
75+}
76+function CohortList({ acceptedPoints, pendingPoints, className, onlyPending }) {
77+ const { syncInvites } = usePointCache();
78+
79+ const [query, setQuery] = useState('');
80+
81+ useEffect(() => {
82+ acceptedPoints.map(syncInvites);
83+ }, [acceptedPoints, syncInvites]);
84+
85+ const handleChange = useCallback(
86+ e => {
87+ setQuery(e.target.value);
88+ e.preventDefault();
89+ },
90+ [setQuery]
91+ );
92+
93+ const filterPoints = useCallback(
94+ points =>
95+ points.filter(p => {
96+ const patp = ob.patp(p).slice(1);
97+ return patp.startsWith(query);
98+ }),
99+ [query]
100+ );
101+
102+ const _pendingPoints = filterPoints(pendingPoints);
103+ const _acceptedPoints = filterPoints(acceptedPoints);
104+
105+ const offset = onlyPending ? 0 : _acceptedPoints.length;
106+
107+ return (
108+ <Grid gap={3} className={cn('mt4', className)}>
109+ <Grid.Item full as={SearchInput} value={query} onChange={handleChange} />
110+ <>
111+ {!onlyPending &&
112+ _acceptedPoints.map((p, idx) => (
113+ <Grid.Item
114+ key={p}
115+ half={(idx % 2) + 1}
116+ as={CohortMember}
117+ point={p}
118+ />
119+ ))}
120+ {_pendingPoints.map((p, idx) => (
121+ <Grid.Item
122+ key={p}
123+ half={((offset + idx) % 2) + 1}
124+ as={CohortMember}
125+ point={p}
126+ pending
127+ />
128+ ))}
129+ </>
130+
131+ {_pendingPoints.length === 0 &&
132+ (_acceptedPoints.length === 0 || onlyPending) && (
133+ <Grid.Item full className="p4 t-center">
134+ {' '}
135+ No invites accepted yet.
136+ </Grid.Item>
137+ )}
138+ </Grid>
139+ );
140+}
141+
142+export default function InviteCohort() {
143+ const { pop } = useLocalRouter();
144+
145+ const { pointCursor } = usePointCursor();
146+ const point = need.point(pointCursor);
147+
148+ const {
149+ acceptedInvites,
150+ acceptedPoints,
151+ pendingPoints,
152+ availableInvites,
153+ sentInvites,
154+ } = useInvites(point);
155+
156+ const [showInviteForm, setShowInviteForm] = useState(false);
157+ const [tab, setTab] = useState(NAMES.ALL);
158+
159+ const _acceptedPoints = acceptedPoints.getOrElse([]);
160+ const _pendingPoints = pendingPoints.getOrElse([]);
161+ const _pendingInvites = _pendingPoints.length;
162+ const _acceptedInvites = _acceptedPoints.length;
163+ const _totalInvites = availableInvites
164+ .map(a => sentInvites.getOrElse(0) + a)
165+ .getOrElse(0);
166+
167+ return (
168+ <View pop={pop} inset>
169+ <Grid gap={3}>
170+ <Grid.Item
171+ full
172+ as={Crumbs}
173+ className="mb1"
174+ routes={[
175+ { text: ob.patp(point), action: () => pop() },
176+ { text: 'Invite Group' },
177+ ]}
178+ />
179+
180+ <Grid.Item full>Invite Group</Grid.Item>
181+ <Grid.Item full>
182+ <Flex align="center">
183+ <Flex.Item>
184+ {_acceptedInvites} / {_totalInvites}
185+ </Flex.Item>
186+ {_pendingInvites !== 0 && (
187+ <Flex.Item as={Chip} className="bg-yellow4 white">
188+ {_pendingInvites} pending
189+ </Flex.Item>
190+ )}
191+ </Flex>
192+ </Grid.Item>
193+ <Grid.Item
194+ full
195+ as={BarGraph}
196+ available={availableInvites}
197+ sent={sentInvites}
198+ accepted={acceptedInvites}
199+ />
200+ {availableInvites.getOrElse(0) !== 0 && !showInviteForm && (
201+ <Grid.Item
202+ full
203+ solid
204+ center
205+ as={Button}
206+ onClick={() => setShowInviteForm(true)}>
207+ Add Members
208+ </Grid.Item>
209+ )}
210+ {showInviteForm && <Inviter />}
211+
212+ <Grid.Item
213+ full
214+ center
215+ as={Tabs}
216+ views={VIEWS}
217+ options={OPTIONS}
218+ currentTab={tab}
219+ onTabChange={setTab}
220+ acceptedPoints={_acceptedPoints}
221+ pendingPoints={_pendingPoints}
222+ onlyPending={tab === NAMES.PENDING}
223+ />
224+ </Grid>
225+ </View>
226+ );
227+}
228+const NAMES = {
229+ ALL: 'ALL',
230+ PENDING: 'PENDING',
231+};
232+
233+const VIEWS = {
234+ [NAMES.PENDING]: CohortList,
235+ [NAMES.ALL]: CohortList,
236+};
237+
238+const OPTIONS = [
239+ { text: 'All', value: NAMES.ALL },
240+ { text: 'Pending', value: NAMES.PENDING },
241+];
src/views/Invite/Email.jsnew file+214
@@ -0,0 +1,214 @@
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 = ({ setSubmitting }) => {
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+ setSubmitting(true);
79+ const { errors, invites } = await generateInvites(emailCount);
80+ if (errors) {
81+ setStatus(STATUS.FAILED);
82+ setSubmitting(false);
83+ return errors;
84+ }
85+ const mailErrors = await sendInvites(values.emails, invites);
86+ if (mailErrors) {
87+ console.log(mailErrors);
88+ setStatus(STATUS.FAILED);
89+ setSubmitting(false);
90+ return mailErrors;
91+ }
92+ setSubmitting(false);
93+ setStatus(STATUS.SUCCESS);
94+ },
95+ [generateInvites, setCount, setStatus, sendInvites, setSubmitting]
96+ );
97+
98+ const validateHasReceived = useCallback(
99+ async email => {
100+ const hasReceived = await getHasReceived(email);
101+ if (hasReceived) {
102+ return HAS_RECEIVED_TEXT + email;
103+ }
104+ },
105+ [getHasReceived]
106+ );
107+
108+ const buttonText = useCallback(() => {
109+ switch (status) {
110+ case STATUS.INPUT:
111+ return 'Add to Invite Group';
112+ case STATUS.SENDING:
113+ return `Sending ${pluralize(count, 'Invite')}...`;
114+ case STATUS.SUCCESS:
115+ return `✓ ${pluralize(count, 'Invite')} sent`;
116+ case STATUS.FAILURE:
117+ default:
118+ return 'Error';
119+ }
120+ }, [status, count]);
121+
122+ const renderButton = useCallback(
123+ handleSubmit => {
124+ if (canInput) {
125+ return (
126+ <Grid.Item full as={SubmitButton} handleSubmit={handleSubmit}>
127+ {buttonText()}
128+ </Grid.Item>
129+ );
130+ }
131+
132+ if (isSending) {
133+ const percentProgress = progress / count;
134+ return (
135+ <Grid.Item
136+ full
137+ as={ProgressButton}
138+ className="mt4"
139+ solid
140+ disabled
141+ success
142+ progress={percentProgress}>
143+ {buttonText(count)}
144+ </Grid.Item>
145+ );
146+ }
147+ if (isDone) {
148+ return (
149+ <Grid.Item full className="mt4 p3 bg-green3 white f5">
150+ {buttonText(count)}
151+ </Grid.Item>
152+ );
153+ }
154+ if (isFailed) {
155+ return (
156+ <Grid.Item full className="mt4 p3 bg-red3 white f5">
157+ {buttonText(count)}
158+ </Grid.Item>
159+ );
160+ }
161+
162+ return null;
163+ },
164+ [buttonText, isDone, isFailed, isSending, canInput, count, progress]
165+ );
166+
167+ const validateForm = useCallback((values, errors) => {
168+ if (hasErrors(errors)) {
169+ return errors;
170+ }
171+
172+ // check for email uniqenesss
173+ const emails = values.emails.filter(d => !!d);
174+ if (uniq(emails).length !== emails.length) {
175+ return { [FORM_ERROR]: 'Duplicate email.' };
176+ }
177+ }, []);
178+
179+ const validate = useMemo(
180+ () =>
181+ composeValidator(
182+ {
183+ emails: buildArrayValidator(buildEmailValidator(validateHasReceived)),
184+ },
185+ validateForm
186+ ),
187+ [validateHasReceived, validateForm]
188+ );
189+
190+ const initialValues = { emails: [] };
191+ return (
192+ <Grid.Item full as={Grid}>
193+ <BridgeForm
194+ validate={validate}
195+ initialValues={initialValues}
196+ initialValuesEqual={isEqual}
197+ onSubmit={onSubmit}>
198+ {({ handleSubmit, values }) => (
199+ <>
200+ <Grid.Item full as={EmailChipInput} name="emails" className="mt4" />
201+ {renderButton(handleSubmit)}
202+
203+ <Grid.Item full as={FormError} />
204+ {needFunds && (
205+ <Grid.Item full as={NeedFundsNotice} {...needFunds} />
206+ )}
207+ </>
208+ )}
209+ </BridgeForm>
210+ </Grid.Item>
211+ );
212+};
213+
214+export default InviteMail;
src/views/Invite/InviteEmail.js+2−2
@@ -66,7 +66,7 @@ import timeout from 'lib/timeout';
66const INITIAL_VALUES = { emails: [''] };
66const INITIAL_VALUES = { emails: [''] };
67
67
68const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
68const GAS_LIMIT = GAS_LIMITS.GIFT_PLANET;
69−const HAS_RECEIVED_TEXT = 'This email has already received an invite.';
69+const HAS_RECEIVED_TEXT = 'This email has already received an invite: ';
70
70
71const STATUS = {
71const STATUS = {
72 INPUT: 'INPUT',
72 INPUT: 'INPUT',
@@ -146,7 +146,7 @@ export default function InviteEmail() {
146 async email => {
146 async email => {
147 const hasReceived = await getHasReceived(email);
147 const hasReceived = await getHasReceived(email);
148 if (hasReceived) {
148 if (hasReceived) {
149− return HAS_RECEIVED_TEXT;
149+ return HAS_RECEIVED_TEXT + email;
150 }
150 }
151 },
151 },
152 [getHasReceived]
152 [getHasReceived]
src/views/Invite/Inviter.jsnew file+54
@@ -0,0 +1,54 @@
1+import React, { useState, useCallback } from 'react';
2+import { Grid } from 'indigo-react';
3+
4+import { useConditionalBlockWindowClose } from 'lib/useBlockWindowClose';
5+
6+import Tabs from 'components/Tabs';
7+
8+import InviteMail from 'views/Invite/Email';
9+import InviteUrl from 'views/Invite/Url';
10+
11+const Inviter = () => {
12+ const [tab, setTab] = useState(NAMES.MAIL);
13+ const [submitting, setSubmitting] = useState(false);
14+
15+ useConditionalBlockWindowClose(submitting);
16+ const onTabChange = useCallback(
17+ newTab => {
18+ if (!submitting) {
19+ setTab(newTab);
20+ }
21+ },
22+ [submitting, setTab]
23+ );
24+ return (
25+ <Grid.Item
26+ full
27+ as={Tabs}
28+ center
29+ className="flex1"
30+ views={VIEWS}
31+ options={OPTIONS}
32+ currentTab={tab}
33+ onTabChange={onTabChange}
34+ setSubmitting={setSubmitting}
35+ />
36+ );
37+};
38+
39+const NAMES = {
40+ MAIL: 'MAIL',
41+ URL: 'URL',
42+};
43+
44+const VIEWS = {
45+ [NAMES.MAIL]: InviteMail,
46+ [NAMES.URL]: InviteUrl,
47+};
48+
49+const OPTIONS = [
50+ { text: 'Email', value: NAMES.MAIL },
51+ { text: 'URL', value: NAMES.URL },
52+];
53+
54+export default Inviter;
src/views/Invite/Url.jsnew file+108
@@ -0,0 +1,108 @@
1+import React, { useCallback, useState } from 'react';
2+import { Grid, ErrorText, Flex } from 'indigo-react';
3+import { get } from 'lodash';
4+import { FORM_ERROR } from 'final-form';
5+
6+import useCopiable from 'lib/useCopiable';
7+import useInviter from 'lib/useInviter';
8+
9+import ProgressButton from 'components/ProgressButton';
10+import { ForwardButton } from 'components/Buttons';
11+import NeedFundsNotice from 'components/NeedFundsNotice';
12+
13+import { hasErrors } from 'form/validators';
14+import { WARNING } from 'form/helpers';
15+const STATUS = {
16+ INPUT: 'INPUT',
17+ SENDING: 'SENDING',
18+ SUCCESS: 'SUCCESS',
19+ FAILURE: 'FAILURE',
20+};
21+
22+const InviteUrl = ({ setSubmitting }) => {
23+ const { txStatus, needFunds, invites, generateInvites } = useInviter();
24+
25+ const [error, setError] = useState();
26+
27+ const generateInvite = useCallback(async () => {
28+ setSubmitting(true);
29+ const { errors } = await generateInvites(1);
30+ if (hasErrors(errors)) {
31+ setError(errors);
32+ }
33+ setSubmitting(false);
34+ }, [generateInvites, setSubmitting]);
35+
36+ const renderGenerateButton = useCallback(() => {
37+ if (error) {
38+ return (
39+ <Grid.Item full className="mt4 p3 bg-red3 white f5">
40+ Error generating invite
41+ </Grid.Item>
42+ );
43+ }
44+ switch (txStatus) {
45+ case STATUS.INPUT:
46+ return (
47+ <Grid.Item
48+ solid
49+ full
50+ className="mt4"
51+ as={ForwardButton}
52+ onClick={() => generateInvite()}>
53+ Generate Invite URL
54+ </Grid.Item>
55+ );
56+ case STATUS.SENDING:
57+ return (
58+ <Grid.Item
59+ full
60+ as={ProgressButton}
61+ className="mt4"
62+ solid
63+ disabled
64+ success
65+ progress={0.5}>
66+ Generating Invite URL
67+ </Grid.Item>
68+ );
69+ default:
70+ return null;
71+ }
72+ }, [txStatus, error, generateInvite]);
73+
74+ const url = `https://bridge.urbit.org/#${get(invites, '[0].ticket', '').slice(
75+ 1
76+ )}`;
77+
78+ const [doCopy, didCopy] = useCopiable(url);
79+
80+ return (
81+ <Grid.Item full as={Grid} gap={3}>
82+ {invites.length > 0 && (
83+ <Grid.Item as={Flex} full col>
84+ <Flex.Item full className="b-gray3 b1 mt4 mb1 p1 flex flex-center">
85+ <div className="flex1 ml1 f6">{url}</div>
86+ <Flex.Item
87+ className="p2 bg-black white pointer-hover f6"
88+ onClick={() => doCopy()}>
89+ {didCopy ? 'Copied' : 'Copy'}
90+ </Flex.Item>
91+ </Flex.Item>
92+ <Flex.Item full className="f6 mb4 gray5">
93+ Your invite link expires after consumption.
94+ </Flex.Item>
95+ </Grid.Item>
96+ )}
97+ {invites.length === 0 && renderGenerateButton()}
98+ {needFunds && <Grid.Item full as={NeedFundsNotice} {...needFunds} />}
99+ {error && (
100+ <Grid.Item as={ErrorText}>
101+ {error[FORM_ERROR] || error[WARNING]}
102+ </Grid.Item>
103+ )}
104+ </Grid.Item>
105+ );
106+};
107+
108+export default InviteUrl;
src/views/Point.js+127−36
@@ -1,6 +1,7 @@
1−import React, { useCallback } from 'react';
1+import React, { useCallback, useState } from 'react';
2+import cn from 'classnames';
2import { Just } from 'folktale/maybe';
3import { Just } from 'folktale/maybe';
3−import { Grid } from 'indigo-react';
4+import { Grid, Flex, Button } from 'indigo-react';
4import { azimuth } from 'azimuth-js';
5import { azimuth } from 'azimuth-js';
5
6
6import { usePointCursor } from 'store/pointCursor';
7import { usePointCursor } from 'store/pointCursor';
@@ -11,8 +12,11 @@ import Greeting from 'components/Greeting';
11import Passport from 'components/Passport';
12import Passport from 'components/Passport';
12import { ForwardButton, BootUrbitOSButton } from 'components/Buttons';
13import { ForwardButton, BootUrbitOSButton } from 'components/Buttons';
13import CopyButton from 'components/CopyButton';
14import CopyButton from 'components/CopyButton';
14−import { matchBlinky } from 'components/Blinky';
15+import Blinky, { matchBlinky } from 'components/Blinky';
15import DownloadSigilButton from 'components/DownloadSigilButton';
16import DownloadSigilButton from 'components/DownloadSigilButton';
17+import BarGraph from 'components/BarGraph';
18+import Chip from 'components/Chip';
19+import InviteSigilList from 'components/InviteSigilList';
16
20
17import * as need from 'lib/need';
21import * as need from 'lib/need';
18import useInvites from 'lib/useInvites';
22import useInvites from 'lib/useInvites';
@@ -21,6 +25,8 @@ import useCurrentPermissions from 'lib/useCurrentPermissions';
21import { useLocalRouter } from 'lib/LocalRouter';
25import { useLocalRouter } from 'lib/LocalRouter';
22import useKeyfileGenerator from 'lib/useKeyfileGenerator';
26import useKeyfileGenerator from 'lib/useKeyfileGenerator';
23
27
28+import Inviter from 'views/Invite/Inviter';
29+
24export default function Point() {
30export default function Point() {
25 const { pop, push, names } = useLocalRouter();
31 const { pop, push, names } = useLocalRouter();
26 const { pointCursor } = usePointCursor();
32 const { pointCursor } = usePointCursor();
@@ -39,7 +45,28 @@ export default function Point() {
39 } = useCurrentPermissions();
45 } = useCurrentPermissions();
40
46
41 // fetch the invites for the current cursor
47 // fetch the invites for the current cursor
48+ const invites = useInvites(point);
49+ const {
42− const { availableInvites } = useInvites(point);
50+ availableInvites,
51+ sentInvites,
52+ acceptedInvites,
53+ pendingPoints,
54+ acceptedPoints,
55+ } = invites;
56+
57+ const showInvites = !(
58+ acceptedInvites.getOrElse(0) === 0 && sentInvites.getOrElse(0) === 0
59+ );
60+
61+ const hasInvites = showInvites || availableInvites.getOrElse(0) !== 0;
62+
63+ const _totalInvites =
64+ sentInvites.getOrElse(0) + availableInvites.getOrElse(0);
65+ const _pendingInvites = pendingPoints.getOrElse([]).length;
66+
67+ const loadedInvites = Just.hasInstance(availableInvites);
68+ //
69+ // availableInvites.getOrElse(0) === 0
43
70
44 const goAdmin = useCallback(() => push(names.ADMIN), [push, names]);
71 const goAdmin = useCallback(() => push(names.ADMIN), [push, names]);
45
72
@@ -47,6 +74,8 @@ export default function Point() {
47
74
48 const goInvite = useCallback(() => push(names.INVITE), [push, names]);
75 const goInvite = useCallback(() => push(names.INVITE), [push, names]);
49
76
77+ const goCohort = useCallback(() => push(names.INVITE_COHORT), [push, names]);
78+
50 const goPartiesSetPoolSize = useCallback(
79 const goPartiesSetPoolSize = useCallback(
51 () => push(names.PARTY_SET_POOL_SIZE),
80 () => push(names.PARTY_SET_POOL_SIZE),
52 [push, names]
81 [push, names]
@@ -57,40 +86,27 @@ export default function Point() {
57 push,
86 push,
58 ]);
87 ]);
59
88
89+ const isPlanet = azimuth.getPointSize(point) === azimuth.PointSize.Planet;
90+
91+ const [showInviteForm, setShowInviteForm] = useState(false);
92+
60 const inviteButton = (() => {
93 const inviteButton = (() => {
61− switch (azimuth.getPointSize(point)) {
94+ if (azimuth.getPointSize(point) === azimuth.PointSize.Star) {
62− case azimuth.PointSize.Planet:
63− const availableInvitesText = matchBlinky(availableInvites);
64− return (
65− <>
66− <Grid.Item
67− full
68− as={ForwardButton}
69− disabled={!isActiveOwner}
70− onClick={goInvite}>
71− Invite <sup>{availableInvitesText}</sup>
72− </Grid.Item>
73− <Grid.Divider />
74− </>
75− );
76− //
77− case azimuth.PointSize.Star:
78− return (
95+ return (
79− <>
96+ <>
80− <Grid.Item
97+ <Grid.Item
81− full
98+ full
82− as={ForwardButton}
99+ as={ForwardButton}
83− disabled={!isActiveOwner}
100+ disabled={!isActiveOwner}
84− onClick={goPartiesSetPoolSize}>
101+ onClick={goPartiesSetPoolSize}>
85− Manage Invite Pools
102+ Manage Invite Pools
86− </Grid.Item>
103+ </Grid.Item>
87− <Grid.Divider />
104+ <Grid.Divider />
88− </>
105+ </>
89− );
106+ );
90− //
91− default:
92− return null;
93 }
107 }
108+
109+ return null;
94 })();
110 })();
95
111
96 const senateButton = (() => {
112 const senateButton = (() => {
@@ -124,6 +140,81 @@ export default function Point() {
124 address={Just(address)}
140 address={Just(address)}
125 animationMode={'slide'}
141 animationMode={'slide'}
126 />
142 />
143+ <Grid gap={3}>
144+ {isPlanet && hasInvites && (
145+ <>
146+ <Grid.Item cols={[1, 11]}>
147+ Invite Group
148+ <br />
149+ </Grid.Item>
150+
151+ <Grid.Item
152+ className={cn('t-right underline pointer-hover', {
153+ gray4: sentInvites.getOrElse(0) === 0,
154+ })}
155+ onClick={goCohort}
156+ cols={[11, 13]}>
157+ View
158+ </Grid.Item>
159+ <Grid.Item full>
160+ <Flex align="center">
161+ <Flex.Item>
162+ {acceptedInvites.getOrElse(0)} / {_totalInvites}
163+ </Flex.Item>
164+ {_pendingInvites > 0 && (
165+ <Flex.Item as={Chip} className="bg-yellow1 yellow4">
166+ {_pendingInvites} pending
167+ </Flex.Item>
168+ )}
169+ </Flex>
170+ </Grid.Item>
171+
172+ {showInvites && (
173+ <>
174+ <Grid.Item
175+ full
176+ as={BarGraph}
177+ available={availableInvites}
178+ sent={sentInvites}
179+ accepted={acceptedInvites}
180+ />
181+ <Grid.Item
182+ full
183+ as={InviteSigilList}
184+ pendingPoints={pendingPoints}
185+ acceptedPoints={acceptedPoints}
186+ />
187+ </>
188+ )}
189+ {!showInvites && (
190+ <>
191+ <Grid.Item full className="b-gray4 b-dotted b1 self-center">
192+ <div className="p4 pv8 t-center gray4">
193+ Start your invite group by adding members
194+ </div>
195+ </Grid.Item>
196+ </>
197+ )}
198+ {!showInviteForm && availableInvites.getOrElse(0) > 0 && (
199+ <Grid.Item
200+ full
201+ solid
202+ as={Button}
203+ center
204+ onClick={() => setShowInviteForm(true)}>
205+ Add Members
206+ </Grid.Item>
207+ )}
208+ {showInviteForm && <Inviter />}
209+ <Grid.Item full className="mb2" />
210+ </>
211+ )}
212+ {!loadedInvites && isPlanet && (
213+ <Grid.Item className="mv2" full>
214+ Invite Group <Blinky />
215+ </Grid.Item>
216+ )}
217+ </Grid>
127 <Grid className="pt2">
218 <Grid className="pt2">
128 {inviteButton}
219 {inviteButton}
129 <Grid.Item
220 <Grid.Item
src/views/Points.js+1−1
@@ -1,6 +1,6 @@
1import React, { useCallback, useEffect, useMemo } from 'react';
1import React, { useCallback, useEffect, useMemo } from 'react';
2import { Just, Nothing } from 'folktale/maybe';
2import { Just, Nothing } from 'folktale/maybe';
3−import { Grid, H5, H1, HelpText, LinkButton, Flex } from 'indigo-react';
3+import { Grid, H5, HelpText, LinkButton, Flex } from 'indigo-react';
4import { get } from 'lodash';
4import { get } from 'lodash';
5
5
6import { useHistory } from 'store/history';
6import { useHistory } from 'store/history';