on master help sign in
star-ops: Requests UI and UX

- new Requests view - add #adopt, #reject to roller utils - add #adoptPoint, #rejectPoint to useRoller - add useOnClickOutside hook - remove legacy Residents view

commit81aa3a5e4cc8d80fbe077c97cfa519239597fc11 authortomholford <tomholford@users.noreply.github.com> dateSat 13 Nov 2021, 20:59 (5 years ago) parent137116f
12 files changed+263−327
src/lib/router.js−1 src/lib/useOnClickOutside.ts+34 src/lib/useRoller.ts+51−2 src/lib/utils/roller.ts+45−1 src/views/Ops/RequestRow.tsx+47 src/views/Ops/Requests.scss+17−1 src/views/Ops/Requests.tsx+25−16 src/views/Ops/ResidentRow.tsx+17−6 src/views/Ops/Residents.scss+25−25 src/views/Ops/Residents.tsx+1 src/views/Point/Point.tsx+1−3 src/views/Point/Residents.js−272
src/lib/router.js−1
@@ -14,7 +14,6 @@ import Activate from 'views/Activate';
1414import Point from 'views/Point/Point';
1515import UrbitOS from 'views/Point/UrbitOS';
1616import UrbitID from 'views/Point/UrbitID';
17−// import Residents from 'views/Point/Residents';
1817import { Residents } from 'views/Ops/Residents';
1918import MigrateL2 from 'views/Point/MigrateL2.tsx';
2019import IssueChild from 'views/Point/IssueChild';
src/lib/useOnClickOutside.tsnew file+34
@@ -0,0 +1,34 @@
1+// https://usehooks-ts.com/react-hook/use-on-click-outside
2+import { RefObject, useEffect } from 'react';
3+
4+type AnyEvent = MouseEvent | TouchEvent;
5+
6+function useOnClickOutside<T extends HTMLElement = HTMLElement>(
7+ ref: RefObject<T>,
8+ handler: (event: AnyEvent) => void
9+): void {
10+ useEffect(() => {
11+ const listener = (event: AnyEvent) => {
12+ const el = ref?.current;
13+
14+ // Do nothing if clicking ref's element or descendent elements
15+ if (!el || el.contains(event.target as Node)) {
16+ return;
17+ }
18+
19+ handler(event);
20+ };
21+
22+ document.addEventListener(`mousedown`, listener);
23+ document.addEventListener(`touchstart`, listener);
24+
25+ return () => {
26+ document.removeEventListener(`mousedown`, listener);
27+ document.removeEventListener(`touchstart`, listener);
28+ };
29+
30+ // Reload only if ref or handler changes
31+ }, [ref, handler]);
32+}
33+
34+export default useOnClickOutside;
src/lib/useRoller.ts+51−2
@@ -6,9 +6,14 @@ import SecureLS from 'secure-ls';
66import { Invite } from 'lib/types/Invite';
77import { convertToInt } from './convertToInt';
88import { isDevelopment, isRopsten } from './flags';
9−import { detach, generateInviteWallet, getPendingSpawns } from './utils/roller';
9+import {
10+ adopt,
11+ detach,
12+ generateInviteWallet,
13+ getPendingSpawns,
14+ reject,
15+} from './utils/roller';
1016import { ROLLER_HOSTS } from './constants';
11−import { patp2dec } from './patp2dec';
1217
1318import { useNetwork } from 'store/network';
1419import { usePointCursor } from 'store/pointCursor';
@@ -637,6 +642,27 @@ export default function useRoller() {
637642 [api, pointCursor, wallet, web3, walletType]
638643 );
639644
645+ const adoptPoint = useCallback(
646+ async (point: Ship) => {
647+ const sponsor = need.point(pointCursor);
648+ const _wallet = wallet.getOrElse(null);
649+
650+ const sponsorInfo = await api.getPoint(sponsor);
651+ const ownership = sponsorInfo.ownership!;
652+ const proxy = getManagerProxy(ownership, _wallet.address);
653+
654+ if (proxy === undefined)
655+ throw new Error("Error: Address doesn't match expected proxy");
656+
657+ const nonce = await api.getNonce({ ship: sponsor, proxy });
658+ const txHash = await adopt(api, _wallet, sponsor, proxy, nonce, point);
659+ const pendingTx = await api.getPendingTx(txHash);
660+
661+ return pendingTx;
662+ },
663+ [api, pointCursor, wallet]
664+ );
665+
640666 const kickPoint = useCallback(
641667 async (point: Ship) => {
642668 const sponsor = need.point(pointCursor);
@@ -658,6 +684,27 @@ export default function useRoller() {
658684 [api, pointCursor, wallet]
659685 );
660686
687+ const rejectPoint = useCallback(
688+ async (point: Ship) => {
689+ const sponsor = need.point(pointCursor);
690+ const _wallet = wallet.getOrElse(null);
691+
692+ const sponsorInfo = await api.getPoint(sponsor);
693+ const ownership = sponsorInfo.ownership!;
694+ const proxy = getManagerProxy(ownership, _wallet.address);
695+
696+ if (proxy === undefined)
697+ throw new Error("Error: Address doesn't match expected proxy");
698+
699+ const nonce = await api.getNonce({ ship: sponsor, proxy });
700+ const txHash = await reject(api, _wallet, sponsor, proxy, nonce, point);
701+ const pendingTx = await api.getPendingTx(txHash);
702+
703+ return pendingTx;
704+ },
705+ [api, pointCursor, wallet]
706+ );
707+
661708 const transferPoint = useCallback(
662709 async (address: EthAddress, reset?: boolean) => {
663710 const _point = need.point(pointCursor);
@@ -721,6 +768,7 @@ export default function useRoller() {
721768 }, [nextBatchTime, getPendingTransactions, currentL2]); // eslint-disable-line react-hooks/exhaustive-deps
722769
723770 return {
771+ adoptPoint,
724772 api,
725773 performL2Reticket,
726774 config,
@@ -729,6 +777,7 @@ export default function useRoller() {
729777 getPendingTransactions,
730778 generateInviteCodes,
731779 kickPoint,
780+ rejectPoint,
732781 transferPoint,
733782 setProxyAddress,
734783 configureNetworkingKeys,
src/lib/utils/roller.ts+45−1
@@ -189,6 +189,28 @@ export const transferPointRequest = async (
189189 return api.transferPoint(sig, from, wallet.address, data);
190190};
191191
192+export const adopt = async (
193+ api: RollerRPCAPI,
194+ _wallet: any,
195+ sponsor: Ship,
196+ proxy: string,
197+ nonce: number,
198+ ship: Ship
199+) => {
200+ const from = {
201+ ship: sponsor, // ship that is adopting the planet
202+ proxy,
203+ };
204+
205+ const data = {
206+ ship, // ship to adopt
207+ };
208+
209+ const hash = await api.getUnsignedTx(nonce, from, 'detach', data);
210+ const sSig = signTransactionHash(hash, _wallet.privateKey);
211+ return api.adopt(sSig, from, _wallet.address, data);
212+};
213+
192214export const detach = async (
193215 api: RollerRPCAPI,
194216 _wallet: any,
@@ -198,7 +220,7 @@ export const detach = async (
198220 ship: Ship
199221) => {
200222 const from = {
201− ship: sponsor, //ship that is detaching the planet
223+ ship: sponsor, // ship that is detaching the planet
202224 proxy,
203225 };
204226
@@ -211,6 +233,28 @@ export const detach = async (
211233 return api.detach(sSig, from, _wallet.address, data);
212234};
213235
236+export const reject = async (
237+ api: RollerRPCAPI,
238+ _wallet: any,
239+ sponsor: Ship,
240+ proxy: string,
241+ nonce: number,
242+ ship: Ship
243+) => {
244+ const from = {
245+ ship: sponsor, // ship that is rejecting the planet
246+ proxy,
247+ };
248+
249+ const data = {
250+ ship, // ship to reject
251+ };
252+
253+ const hash = await api.getUnsignedTx(nonce, from, 'detach', data);
254+ const sSig = signTransactionHash(hash, _wallet.privateKey);
255+ return api.reject(sSig, from, _wallet.address, data);
256+};
257+
214258const proxyType = (proxy: Proxy) => {
215259 switch (proxy) {
216260 case 'manage':
src/views/Ops/RequestRow.tsxnew file+47
@@ -0,0 +1,47 @@
1+import ob from 'urbit-ob';
2+import { Box, Button } from '@tlon/indigo-react';
3+import { Ship } from '@urbit/roller-api';
4+import Sigil from 'components/Sigil';
5+import { useCallback } from 'react';
6+
7+interface RequestRowProps {
8+ point: Ship;
9+ onAccept: (point: Ship) => Promise<void>;
10+ onReject: (point: Ship) => Promise<void>;
11+}
12+
13+export const RequestRow = ({ point, onAccept, onReject }: RequestRowProps) => {
14+ const handleAcceptClick = useCallback(async () => {
15+ await onAccept(point);
16+ }, [onAccept, point]);
17+
18+ const handleRejectClick = useCallback(async () => {
19+ await onReject(point);
20+ }, [onReject, point]);
21+
22+ return (
23+ <li key={point}>
24+ <Box className={'point-wrapper'}>
25+ <Box className="sigil">
26+ <Box className="sigil-container">
27+ <Sigil
28+ icon
29+ patp={ob.patp(point)}
30+ size={16}
31+ colors={['#000000', '#FFFFFF']}
32+ />
33+ </Box>
34+ </Box>
35+ <Box className={'patp'}>{ob.patp(point)}</Box>
36+ </Box>
37+ <Box className="button-wrapper">
38+ <Button className="primary" onClick={handleAcceptClick}>
39+ Accept
40+ </Button>
41+ <Button className="secondary" onClick={handleRejectClick}>
42+ Reject
43+ </Button>
44+ </Box>
45+ </li>
46+ );
47+};
src/views/Ops/Requests.scss+17−1
@@ -34,14 +34,30 @@
3434 li {
3535 list-style: none;
3636 margin-bottom: 16px;
37− height: 24px;
37+ height: 32px;
3838 display: flex;
3939 flex-flow: row nowrap;
40+ justify-content: space-between;
41+ align-items: center;
4042
4143 &:first {
4244 margin-top: unset;
4345 }
4446
47+ .point-wrapper {
48+ display: flex;
49+ flex-flow: row nowrap;
50+ }
51+
52+
53+ .button-wrapper {
54+ cursor: pointer;
55+ display: flex;
56+ flex-flow: row nowrap;
57+ justify-content: center;
58+ position: relative;
59+ }
60+
4561 div.sigil {
4662 height: 24px;
4763 width: 24px;
src/views/Ops/Requests.tsx+25−16
@@ -1,5 +1,4 @@
11import * as need from 'lib/need';
2−import ob from 'urbit-ob';
32import { Box, LoadingSpinner, Row } from '@tlon/indigo-react';
43
54import Window from 'components/L2/Window/Window';
@@ -12,15 +11,15 @@ import { usePointCursor } from 'store/pointCursor';
1211import { useCallback, useEffect, useState } from 'react';
1312
1413import './Requests.scss';
15−import Sigil from 'components/Sigil';
1614import useRoller from 'lib/useRoller';
1715import { Ship } from '@urbit/roller-api';
16+import { RequestRow } from './RequestRow';
1817
1918export const Requests = () => {
2019 const { pop }: any = useLocalRouter();
2120 const { pointCursor } = usePointCursor();
2221 const point = need.point(pointCursor);
23− const { api } = useRoller();
22+ const { api, adoptPoint, rejectPoint } = useRoller();
2423 const [requestingPoints, setRequestingPoints] = useState<Ship[]>([]);
2524 const [loading, setLoading] = useState<boolean>(false);
2625
@@ -36,6 +35,24 @@ export const Requests = () => {
3635 setLoading(false);
3736 }, [api, point]);
3837
38+ const handleAcceptClick = useCallback(
39+ async (point: Ship) => {
40+ setLoading(true);
41+ await adoptPoint(point);
42+ await fetchRequests();
43+ },
44+ [adoptPoint, fetchRequests]
45+ );
46+
47+ const handleRejectClick = useCallback(
48+ async (point: Ship) => {
49+ setLoading(true);
50+ await rejectPoint(point);
51+ await fetchRequests();
52+ },
53+ [rejectPoint, fetchRequests]
54+ );
55+
3956 useEffect(() => {
4057 fetchRequests();
4158 }, [fetchRequests]);
@@ -65,19 +82,11 @@ export const Requests = () => {
6582 ) : requestingPoints.length > 0 ? (
6683 <ul className="requests-list">
6784 {requestingPoints.map(sp => (
68− <li>
69− <Box className="sigil">
70− <Box className="sigil-container">
71− <Sigil
72− icon
73− patp={ob.patp(sp)}
74− size={16}
75− colors={['#000000', '#FFFFFF']}
76− />
77− </Box>
78− </Box>
79− <Box className={'patp'}>{ob.patp(sp)}</Box>
80− </li>
85+ <RequestRow
86+ point={sp}
87+ onAccept={handleAcceptClick}
88+ onReject={handleRejectClick}
89+ />
8190 ))}
8291 </ul>
8392 ) : (
src/views/Ops/ResidentRow.tsx+17−6
@@ -1,8 +1,9 @@
11import ob from 'urbit-ob';
2−import { Box, Icon, Text} from '@tlon/indigo-react';
2+import { Box, Icon, Text } from '@tlon/indigo-react';
33import { Ship } from '@urbit/roller-api';
44import Sigil from 'components/Sigil';
5−import { useCallback, useState } from 'react';
5+import { useCallback, useRef, useState } from 'react';
6+import useOnClickOutside from 'lib/useOnClickOutside';
67
78interface ResidentRowProps {
89 point: Ship;
@@ -11,6 +12,14 @@ interface ResidentRowProps {
1112
1213export const ResidentRow = ({ point, onKick }: ResidentRowProps) => {
1314 const [showDropdown, setShowDropdown] = useState<boolean>(false);
15+ const dropdownRef = useRef<HTMLDivElement | null>(null);
16+
17+ const handleClickOutside = useCallback((event: MouseEvent | TouchEvent) => {
18+ event.stopImmediatePropagation();
19+ setShowDropdown(false);
20+ }, []);
21+
22+ useOnClickOutside(dropdownRef, handleClickOutside);
1423
1524 const handleKickClick = useCallback(async () => {
1625 setShowDropdown(false);
@@ -32,10 +41,12 @@ export const ResidentRow = ({ point, onKick }: ResidentRowProps) => {
3241 </Box>
3342 <Box className={'patp'}>{ob.patp(point)}</Box>
3443 </Box>
35− <Box
36− className="button-wrapper"
37− onClick={() => setShowDropdown(!showDropdown)}>
38− <Icon icon="Ellipsis" size="18px" />
44+ <Box className="button-wrapper" ref={dropdownRef}>
45+ <Icon
46+ icon="Ellipsis"
47+ size="18px"
48+ onClick={() => setShowDropdown(!showDropdown)}
49+ />
3950 {showDropdown && (
4051 <Box className={'kick-dropdown'} onClick={handleKickClick}>
4152 <Text>Kick</Text>
src/views/Ops/Residents.scss+25−25
@@ -51,35 +51,35 @@
5151 .button-wrapper {
5252 cursor: pointer;
5353 display: flex;
54− flex-flow: column nowrap;
54+ flex-flow: row nowrap;
5555 justify-content: center;
5656 position: relative;
57+ }
5758
58− .kick-dropdown {
59− z-index: 9999;
60− display: flex;
61− flex-direction: column;
62− align-items: flex-start;
63− justify-content: center;
64− width: 100px;
65− height: 48px;
66− background-color: #fff;
67− border: 1px solid rgba(0, 0, 0, 0.2);
68− box-sizing: border-box;
69− border-radius: 4px;
70− position: absolute;
71− top: 28px;
72− right: 0px;
73− padding: 16px;
59+ .kick-dropdown {
60+ z-index: 9999;
61+ display: flex;
62+ flex-direction: column;
63+ align-items: flex-start;
64+ justify-content: center;
65+ width: 100px;
66+ height: 48px;
67+ background-color: #fff;
68+ border: 1px solid rgba(0, 0, 0, 0.2);
69+ box-sizing: border-box;
70+ border-radius: 4px;
71+ position: absolute;
72+ top: 28px;
73+ right: 0px;
74+ padding: 16px;
7475
75− span {
76− font-family: Inter;
77− font-size: 14px;
78− font-style: normal;
79− font-weight: 500;
80− line-height: 16px;
81− color: rgba(255, 65, 54, 1);
82− }
76+ span {
77+ font-family: Inter;
78+ font-size: 14px;
79+ font-style: normal;
80+ font-weight: 500;
81+ line-height: 16px;
82+ color: rgba(255, 65, 54, 1);
8383 }
8484 }
8585
src/views/Ops/Residents.tsx+1
@@ -37,6 +37,7 @@ export const Residents = () => {
3737
3838 const handleKick = useCallback(
3939 async (point: Ship) => {
40+ setLoading(true);
4041 await kickPoint(point);
4142 await fetchResidents();
4243 },
src/views/Point/Point.tsx+1−3
@@ -139,8 +139,6 @@ export default function Point() {
139139
140140 const goUrbitID = useCallback(() => push(names.URBIT_ID), [push, names]);
141141
142− const goResidents = useCallback(() => push(names.RESIDENTS), [push, names]);
143−
144142 const goOps = useCallback(() => push(names.OPS), [push, names]);
145143
146144 const planet = isPlanet(point);
@@ -225,7 +223,7 @@ export default function Point() {
225223 {isParent && (
226224 <Grid.Item full as={Flex} justify="between">
227225 <Card
228− icon={(<Icon icon="BootNode" />) as any}
226+ icon={(<Icon icon="ShipActivated" />) as any}
229227 title={`${isStar(point) ? 'Star' : 'Galaxy'} Ops`}
230228 subtitle={
231229 isStar(point)
src/views/Point/Residents.jsdeleted−272
@@ -1,272 +0,0 @@
1−import React, { useState, useEffect, useCallback, useMemo } from 'react';
2−import cn from 'classnames';
3−import { Grid, H4, HelpText } from 'indigo-react';
4−import { Just, Nothing } from 'folktale/maybe';
5−import * as ob from 'urbit-ob';
6−
7−import View from 'components/View';
8−import Tabs from 'components/Tabs';
9−import Crumbs from 'components/Crumbs';
10−import Sigil from 'components/Sigil';
11−import Blinky from 'components/Blinky';
12−import NavHeader from 'components/NavHeader';
13−import { ForwardButton } from 'components/Buttons';
14−
15−import { useLocalRouter } from 'lib/LocalRouter';
16−import useCurrentPointName from 'lib/useCurrentPointName';
17−import useCurrentPermissions from 'lib/useCurrentPermissions';
18−import * as need from 'lib/need';
19−
20−import { usePointCursor } from 'store/pointCursor';
21−import { usePointCache } from 'store/pointCache';
22−import Footer from 'components/Footer';
23−import { isGalaxy } from 'lib/utils/point';
24−
25−const NAMES = {
26− REQUESTS: 'REQUESTS',
27− ALL: 'ALL',
28−};
29−
30−const OPTIONS = [
31− { text: 'Requests', value: NAMES.REQUESTS },
32− { text: 'All', value: NAMES.ALL },
33−];
34−
35−const VIEWS = {
36− [NAMES.REQUESTS]: Tab,
37− [NAMES.ALL]: Tab,
38−};
39−
40−const PAGE_SIZE = 10;
41−
42−function Tab({ className, points, onAccept, onDecline, page, setPage }) {
43− const start = page * PAGE_SIZE;
44− const end = page * PAGE_SIZE + PAGE_SIZE;
45− const pointsCount = points.map(ps => ps.length).getOrElse(0);
46− const maxPage = Math.ceil(pointsCount / PAGE_SIZE) - 1;
47− const hasNext = page < maxPage;
48− const hasPrev = page > 0;
49− const _points = points.map(ps => ps.slice(start, end)).getOrElse([]);
50−
51− const onNext = useCallback(() => {
52− setPage(p => p + 1);
53− }, [setPage]);
54−
55− const onPrev = useCallback(() => {
56− setPage(p => p - 1);
57− }, [setPage]);
58−
59− if (Nothing.hasInstance(points)) {
60− return <Grid className={className}></Grid>;
61− }
62−
63− return (
64− <Grid className={cn('mt2', className)}>
65− {_points.length === 0 && (
66− <Grid.Item full as={HelpText} className="mt8 t-center">
67− {Just.hasInstance(points) ? (
68− 'No points to display'
69− ) : (
70− <>
71− <Blinky /> Loading...
72− </>
73− )}
74− </Grid.Item>
75− )}
76−
77− {_points.map(point => (
78− <Resident
79− key={point}
80− point={point}
81− onAccept={onAccept}
82− onDecline={onDecline}
83− />
84− ))}
85− <Grid.Item as={Footer}>
86− <Grid className="pb9">
87− {hasPrev && (
88− <Grid.Item
89− className="pointer underline mt4"
90− fourth={1}
91− onClick={onPrev}>
92− {'<-'}
93− </Grid.Item>
94− )}
95− {maxPage > 0 && (
96− <Grid.Item className="mt4 t-center gray3" cols={[4, 10]}>
97− <span className="black">Page {page + 1}</span> of {maxPage + 1}
98− </Grid.Item>
99− )}
100− {hasNext && (
101− <Grid.Item
102− className="pointer underline t-right mt4"
103− fourth={4}
104− onClick={onNext}>
105− {'->'}
106− </Grid.Item>
107− )}
108− </Grid>
109− </Grid.Item>
110− </Grid>
111− );
112−}
113−
114−function Resident({ point, onAccept, onDecline }) {
115− const patp = ob.patp(point);
116− const sigilSize = 50;
117−
118− const isRequest = onAccept && onDecline;
119−
120− return (
121− <>
122− <Grid.Item className="flex-row justify-center align-center" cols={[1, 3]}>
123− <div
124− style={{
125− display: 'inline-block',
126− height: `${sigilSize}px`,
127− width: `${sigilSize}px`,
128− }}>
129− <Sigil patp={patp} size={25} colors={['#FFFFFF', '#000000']} />
130− </div>
131− </Grid.Item>
132− <Grid.Item className="flex-row align-center mono" cols={[3, 7]}>
133− {patp}{' '}
134− </Grid.Item>
135− {isRequest && (
136− <>
137− <Grid.Item
138− className="flex-row-r align-center underline green3 pointer"
139− cols={[7, 10]}
140− onClick={() => onAccept(point)}>
141− Accept
142− </Grid.Item>
143− <Grid.Item
144− className="flex-row-r align-center underline red4 pointer"
145− cols={[10, 13]}
146− onClick={() => onDecline(point)}>
147− Decline
148− </Grid.Item>
149− </>
150− )}
151− <Grid.Divider />
152− </>
153− );
154−}
155−
156−export default function Residents() {
157− const { pop, push, names } = useLocalRouter();
158− const name = useCurrentPointName();
159−
160− const { pointCursor } = usePointCursor();
161− const point = need.point(pointCursor);
162− const { getResidents, syncResidents } = usePointCache();
163−
164− const goBack = useCallback(() => pop(), [pop]);
165− useEffect(() => {
166− syncResidents(point);
167− }, [syncResidents, point]);
168−
169− const { residents, requests } = getResidents(point);
170−
171− const [currentTab, _setCurrentTab] = useState(NAMES.ALL);
172− const [page, setPage] = useState(0);
173−
174− const goSenate = useCallback(() => push(names.SENATE), [push, names]);
175−
176− const goIssuePoint = useCallback(() => push(names.ISSUE_CHILD), [
177− names.ISSUE_CHILD,
178− push,
179− ]);
180−
181− const setCurrentTab = useCallback(
182− tab => {
183− setPage(0);
184− _setCurrentTab(tab);
185− },
186− [setPage, _setCurrentTab]
187− );
188−
189− const isRequests = useMemo(() => currentTab === NAMES.REQUESTS, [currentTab]);
190−
191− const _onAccept = useCallback(
192− adoptee =>
193− push(names.ADOPT, {
194− adoptee,
195− denied: false,
196− }),
197− [push, names]
198− );
199− const onAccept = isRequests && _onAccept;
200−
201− const _onDecline = useCallback(
202− adoptee =>
203− push(names.ADOPT, {
204− adoptee,
205− denied: true,
206− }),
207− [push, names]
208− );
209− const onDecline = isRequests && _onDecline;
210−
211− const points = isRequests ? requests : residents;
212− const { canSpawn, canVote, isStar } = useCurrentPermissions();
213−
214− const senateButton = (() => {
215− if (!isGalaxy(point)) {
216− return null;
217− }
218− return (
219− <>
220− <Grid.Item
221− full
222− as={ForwardButton}
223− disabled={!canVote}
224− onClick={goSenate}>
225− Senate
226− </Grid.Item>
227− <Grid.Divider />
228− </>
229− );
230− })();
231−
232− return (
233− <View pop={pop} inset>
234− <NavHeader>
235− <Crumbs
236− routes={[{ text: name, action: goBack }, { text: 'Residents' }]}
237− />
238− </NavHeader>
239− <Grid>
240− <Grid.Item full as={H4} className="mt4">
241− Residents
242− </Grid.Item>
243− <Grid.Divider />
244− <Grid.Item
245− full
246− as={ForwardButton}
247− disabled={!canSpawn}
248− onClick={goIssuePoint}>
249− Spawn {isStar ? ' Planets' : ' Stars'}
250− </Grid.Item>
251− <Grid.Divider />
252− {senateButton}
253− <Grid.Item
254− full
255− className="mt1"
256− as={Tabs}
257− // Tabs
258− views={VIEWS}
259− options={OPTIONS}
260− currentTab={currentTab}
261− onTabChange={setCurrentTab}
262− // Props for tab
263− points={points}
264− onAccept={onAccept}
265− onDecline={onDecline}
266− page={page}
267− setPage={setPage}
268− />
269− </Grid>
270− </View>
271− );
272−}