on master help sign in
Merge pull request #369 from urbit/mino-cohort-screens

New Invite Screen

commitb05785377fc0d983a8f8599e8d7a66881cb74dc7 authorFang <git@fang.io> dateTue 21 Jan 2020, 12:12 (7 years ago) parent0add80cf20d1ed
22 files changed+1269−55
src/assets/search.svg+3 src/components/BarGraph.js+29 src/components/Chip.js+16 src/components/InviteSigilList.js+50 src/components/Tabs.js+7−3 src/components/View.js+1−1 src/form/EmailChipInput.js+122 src/form/validators.js+3 src/indigo-react/components/Button.js+7−2 src/indigo-react/indigo.css+23−12 src/lib/routeNames.js+1 src/lib/router.js+2 src/lib/useBlockWindowClose.js+16 src/lib/useInviter.js+226 src/lib/validators.js+5 src/store/lib/useInvitesStore.js+13 src/views/Invite/Cohort.js+241 src/views/Invite/Email.js+214 src/views/Invite/Inviter.js+54 src/views/Invite/Url.js+108 src/views/Point.js+127−36 src/views/Points.js+1−1
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/Tabs.js+7−3
@@ -8,6 +8,7 @@ export default function Tabs({
88 currentTab,
99 onTabChange,
1010 className,
11+ center = false,
1112 ...rest
1213}) {
1314 const Tab = views[currentTab];
@@ -15,7 +16,7 @@ export default function Tabs({
1516 return (
1617 <Grid className={className}>
1718 <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">
1920 {options.map((option, i) => {
2021 const isActive = option.value === currentTab;
2122 const isFirst = i === 0;
@@ -30,11 +31,14 @@ export default function Tabs({
3031 'black b-black bb1': isActive,
3132 gray3: !isActive,
3233 },
34+ {
35+ 't-center flex1': center,
36+ },
3337 {
3438 // all items have right margin/padding
35− 'mr2 pr2': true,
39+ 'mr2 pr2': !center,
3640 // the first one is flush to the left
37− 'ml2 pl2': !isFirst,
41+ 'ml2 pl2': !isFirst && !center,
3842 }
3943 )}>
4044 {option.text}
src/components/View.js+1−1
@@ -53,7 +53,7 @@ function View({
5353 <Flex.Item className={insetPadding} as={NavHeader.Target} />
5454 );
5555 },
56− [insetPadding, showBackButton]
56+ [goLogout, insetPadding, showBackButton]
5757 );
5858
5959 return (
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 {
1717 validateNotNullAddress,
1818 validateHexPrefix,
1919 validateLength,
20+ validateUnique,
2021} from 'lib/validators';
2122import isPromise from 'lib/isPromise';
2223
@@ -115,6 +116,8 @@ export const buildNumberValidator = (min = 0, max = null) => {
115116export const buildEmailValidator = validate =>
116117 buildValidator([validateNotEmpty, validateEmail], validate);
117118
119+export const buildEmailArrayValidator = () =>
120+ buildValidator([validateUnique, buildArrayValidator(buildEmailValidator())]);
118121// the form validator is the composition of all of the field validators
119122// plus an additional form validator function
120123export const composeValidator = (
src/indigo-react/components/Button.js+7−2
@@ -19,6 +19,7 @@ export default function Button({
1919 background,
2020 type,
2121 children,
22+ center = false,
2223 ...rest
2324}) {
2425 const handleKeyPress = e => {
@@ -60,10 +61,14 @@ export default function Button({
6061 onClick={!disabled && onClick ? onClick : undefined}
6162 {...rest}>
6263 {background}
63− <Grid.Item full as={Flex} justify="between" className="z2">
64+ <Grid.Item
65+ full
66+ as={Flex}
67+ justify="between"
68+ className={cn('z2', { 'flex-center': center })}>
6469 <span>{children}</span>
6570 {type && <button type={type} style={{ display: 'none' }}></button>}
66− <div className={cn('pl4')}>{accessory}</div>
71+ {!center && <div className={cn('pl4')}>{accessory}</div>}
6772 </Grid.Item>
6873 {detail && (
6974 <Grid.Item full as={HelpText} className={cn('z2', detailClassName)}>
src/indigo-react/indigo.css+23−12
@@ -806,18 +806,6 @@ caption,
806806.r-full {
807807 border-radius: 9999px; }
808808
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; }
821809
822810.b1 {
823811 border-style: solid;
@@ -919,6 +907,22 @@ caption,
919907 border-left-style: none;
920908 border-left-width: 0px; }
921909
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+
922926.b-blue4 {
923927 border-color: #190d7b; }
924928
@@ -1578,6 +1582,10 @@ caption,
15781582.justify-around {
15791583 justify-content: space-around; }
15801584
1585+.justify-evenly {
1586+ justify-content: space-evenly;
1587+}
1588+
15811589.flex-center {
15821590 display: flex;
15831591 justify-content: center;
@@ -3607,6 +3615,9 @@ caption,
36073615 .h12-lg {
36083616 height: 288px; } }
36093617
3618+.pointer-hover {
3619+ cursor: pointer; }
3620+
36103621.pointer:hover {
36113622 cursor: pointer; }
36123623
src/lib/routeNames.js+1
@@ -20,5 +20,6 @@ export const ROUTE_NAMES = {
2020 CREATE_GALAXY: Symbol('CREATE_GALAXY'),
2121 ISSUE_CHILD: Symbol('ISSUE_CHILD'),
2222 PARTY_SET_POOL_SIZE: Symbol('PARTY_SET_POOL_SIZE'),
23+ INVITE_COHORT: Symbol('INVITE_COHORT'),
2324 STAR_RELEASE: Symbol('STAR_RELEASE'),
2425};
src/lib/router.js+2
@@ -12,6 +12,7 @@ import Invite from 'views/Invite';
1212import Activate from 'views/Activate';
1313
1414import PartySetPoolSize from 'views/Party/PartySetPoolSize';
15+import InviteCohort from 'views/Invite/Cohort';
1516import AcceptTransfer from 'views/AcceptTransfer';
1617import CancelTransfer from 'views/CancelTransfer';
1718
@@ -33,5 +34,6 @@ export const ROUTES = {
3334 [ROUTE_NAMES.ACCEPT_TRANSFER]: AcceptTransfer,
3435 // TODO: replace this with deep link to AdminCancelTransfer
3536 [ROUTE_NAMES.CANCEL_TRANSFER]: CancelTransfer,
37+ [ROUTE_NAMES.INVITE_COHORT]: InviteCohort,
3638 [ROUTE_NAMES.STAR_RELEASE]: StarRelease,
3739};
src/lib/useBlockWindowClose.js+16
@@ -15,3 +15,19 @@ export default function useBlockWindowClose() {
1515 };
1616 });
1717}
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/useInviter.jsnew file+226
@@ -0,0 +1,226 @@
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+ const planets = await azimuth.delegatedSending.getPlanetsToSend(
75+ _contracts,
76+ point,
77+ numInvites
78+ );
79+
80+ setTxStatus(STATUS.SENDING);
81+ // account for the race condition where invites got used up while we were
82+ // composing our target list
83+ if (planets.length < numInvites) {
84+ // resync invites to the cache, since they're out of date
85+ syncInvites(point);
86+
87+ setTxStatus(STATUS.FAILURE);
88+
89+ return {
90+ errors: {
91+ [FORM_ERROR]:
92+ `Can currently only send ${planets.length} invites. ` +
93+ `Please remove invites until you are within the limit.`,
94+ },
95+ };
96+ }
97+
98+ // NB(shrugs) - must be processed in serial because main thread, etc
99+ let signedInvites = [];
100+ let errorCount = 0;
101+ for (let i = 0; i < numInvites; i++) {
102+ setProgress(x => x + 1);
103+ try {
104+ const planet = planets[i];
105+
106+ const { ticket, owner } = await wg.generateTemporaryTicketAndWallet(
107+ MIN_PLANET
108+ // we're always giving planets, so generate a ticket of the correct size
109+ );
110+
111+ const inviteTx = azimuth.delegatedSending.sendPoint(
112+ _contracts,
113+ point,
114+ planet,
115+ owner.keys.address
116+ );
117+
118+ const signedTx = await signTransaction({
119+ wallet: _wallet,
120+ walletType,
121+ walletHdPath,
122+ networkType,
123+ chainId,
124+ nonce: nonce + i,
125+ // TODO: ^ make a useTransactionSigner to encapsulate this logic
126+ txn: inviteTx,
127+ gasPrice: gasPrice.toFixed(), // expects string gwei
128+ gasLimit: GAS_LIMIT.toFixed(),
129+ });
130+
131+ const rawTx = hexify(signedTx.serialize());
132+
133+ signedInvites.push({ ticket, signedTx, rawTx });
134+ } catch (error) {
135+ console.error(error);
136+ return {
137+ errors: {
138+ [WARNING]: `There was an error while generating wallets.`,
139+ },
140+ };
141+ }
142+ }
143+
144+ const totalCost = toBN(GAS_LIMIT)
145+ .mul(toBN(gasPrice))
146+ .mul(toBN(numInvites));
147+ const tankWasUsed = await tank.ensureFundsFor(
148+ _web3,
149+ point,
150+ _wallet.address,
151+ toWei(totalCost.toString(), 'gwei'),
152+ Object.keys(invites).map(name => invites[name].rawTx),
153+ (address, minBalance, balance) =>
154+ setNeedFunds({ address, minBalance, balance }),
155+ () => setNeedFunds(undefined)
156+ );
157+
158+ let unsentInvites = [];
159+ let confirmedInvites = [];
160+ const txAndMailings = signedInvites.map(async invite => {
161+ try {
162+ const txHash = await sendSignedTransaction(
163+ _web3,
164+ invite.signedTx,
165+ tankWasUsed
166+ );
167+
168+ await waitForTransactionConfirm(_web3, txHash);
169+ confirmedInvites.push(invite);
170+ } catch (error) {
171+ console.error(error);
172+ unsentInvites.push(invite);
173+ return;
174+ }
175+ });
176+
177+ await Promise.all(txAndMailings);
178+
179+ setTxStatus(STATUS.SUCCESS);
180+ setInvites(confirmedInvites);
181+
182+ if (unsentInvites.length > 0) {
183+ return { errors: { [FORM_ERROR]: unsentInvites } };
184+ }
185+
186+ if (errorCount > 0) {
187+ return {
188+ errors: {
189+ [WARNING]: `There ${pluralize(
190+ errorCount,
191+ 'was',
192+ 'were'
193+ )} ${pluralize(
194+ errorCount,
195+ 'error'
196+ )} while generating wallets. You can still send the invites that generated correctly.`,
197+ },
198+ };
199+ }
200+ return { invites: confirmedInvites };
201+ },
202+ [
203+ contracts,
204+ gasPrice,
205+ invites,
206+ networkType,
207+ point,
208+ syncInvites,
209+ wallet,
210+ walletHdPath,
211+ walletType,
212+ web3,
213+ ]
214+ );
215+
216+ return {
217+ progress,
218+ txStatus,
219+ needFunds,
220+ invites,
221+ generateInvites,
222+ resetInvites,
223+ };
224+};
225+
226+export default useInviter;
src/lib/validators.js+5
@@ -108,3 +108,8 @@ export const validateNotNullAddress = validateNotAny([
108108 ETH_ZERO_ADDR,
109109 ETH_ZERO_ADDR_SHORT,
110110]);
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 @@
11import { useCallback } from 'react';
22import { Just, Nothing } from 'folktale/maybe';
33import * as azimuth from 'azimuth-js';
4+import { chain } from 'lodash';
45
56import { useNetwork } from '../network';
67import useSetState from 'lib/useSetState';
@@ -10,6 +11,8 @@ const EMPTY_INVITES = {
1011 availableInvites: Nothing(),
1112 sentInvites: Nothing(),
1213 acceptedInvites: Nothing(),
14+ acceptedPoints: Nothing(),
15+ pendingPoints: Nothing(),
1316};
1417
1518export default function useInvitesStore() {
@@ -42,11 +45,21 @@ export default function useInvitesStore() {
4245 const sentInvites = invitedPoints.length;
4346 const acceptedInvites = activity.filter(i => !!i).length;
4447
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+
4556 addToInvitesCache({
4657 [point]: {
4758 availableInvites: Just(availableInvites),
4859 sentInvites: Just(sentInvites),
4960 acceptedInvites: Just(acceptedInvites),
61+ pendingPoints: Just(pendingPoints),
62+ acceptedPoints: Just(acceptedPoints),
5063 },
5164 });
5265 },
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;
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/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';
23import { Just } from 'folktale/maybe';
3−import { Grid } from 'indigo-react';
4+import { Grid, Flex, Button } from 'indigo-react';
45import { azimuth } from 'azimuth-js';
56
67import { usePointCursor } from 'store/pointCursor';
@@ -11,8 +12,11 @@ import Greeting from 'components/Greeting';
1112import Passport from 'components/Passport';
1213import { ForwardButton, BootUrbitOSButton } from 'components/Buttons';
1314import CopyButton from 'components/CopyButton';
14−import { matchBlinky } from 'components/Blinky';
15+import Blinky, { matchBlinky } from 'components/Blinky';
1516import DownloadSigilButton from 'components/DownloadSigilButton';
17+import BarGraph from 'components/BarGraph';
18+import Chip from 'components/Chip';
19+import InviteSigilList from 'components/InviteSigilList';
1620
1721import * as need from 'lib/need';
1822import useInvites from 'lib/useInvites';
@@ -21,6 +25,8 @@ import useCurrentPermissions from 'lib/useCurrentPermissions';
2125import { useLocalRouter } from 'lib/LocalRouter';
2226import useKeyfileGenerator from 'lib/useKeyfileGenerator';
2327
28+import Inviter from 'views/Invite/Inviter';
29+
2430export default function Point() {
2531 const { pop, push, names } = useLocalRouter();
2632 const { pointCursor } = usePointCursor();
@@ -39,7 +45,28 @@ export default function Point() {
3945 } = useCurrentPermissions();
4046
4147 // fetch the invites for the current cursor
42− const { availableInvites } = useInvites(point);
48+ const invites = useInvites(point);
49+ const {
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
4370
4471 const goAdmin = useCallback(() => push(names.ADMIN), [push, names]);
4572
@@ -47,6 +74,8 @@ export default function Point() {
4774
4875 const goInvite = useCallback(() => push(names.INVITE), [push, names]);
4976
77+ const goCohort = useCallback(() => push(names.INVITE_COHORT), [push, names]);
78+
5079 const goPartiesSetPoolSize = useCallback(
5180 () => push(names.PARTY_SET_POOL_SIZE),
5281 [push, names]
@@ -57,40 +86,27 @@ export default function Point() {
5786 push,
5887 ]);
5988
89+ const isPlanet = azimuth.getPointSize(point) === azimuth.PointSize.Planet;
90+
91+ const [showInviteForm, setShowInviteForm] = useState(false);
92+
6093 const inviteButton = (() => {
61− switch (azimuth.getPointSize(point)) {
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 (
79− <>
80− <Grid.Item
81− full
82− as={ForwardButton}
83− disabled={!isActiveOwner}
84− onClick={goPartiesSetPoolSize}>
85− Manage Invite Pools
86− </Grid.Item>
87− <Grid.Divider />
88− </>
89− );
90− //
91− default:
92− return null;
94+ if (azimuth.getPointSize(point) === azimuth.PointSize.Star) {
95+ return (
96+ <>
97+ <Grid.Item
98+ full
99+ as={ForwardButton}
100+ disabled={!isActiveOwner}
101+ onClick={goPartiesSetPoolSize}>
102+ Manage Invite Pools
103+ </Grid.Item>
104+ <Grid.Divider />
105+ </>
106+ );
93107 }
108+
109+ return null;
94110 })();
95111
96112 const senateButton = (() => {
@@ -124,6 +140,81 @@ export default function Point() {
124140 address={Just(address)}
125141 animationMode={'slide'}
126142 />
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>
127218 <Grid className="pt2">
128219 {inviteButton}
129220 <Grid.Item
src/views/Points.js+1−1
@@ -1,6 +1,6 @@
11import React, { useCallback, useEffect, useMemo } from 'react';
22import { 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';
44import { get } from 'lodash';
55
66import { useHistory } from 'store/history';