Merge pull request #109 from tloncorp/po/app-discovery

app discovery
This commit is contained in:
Hunter Miller 2023-03-16 10:18:53 -05:00 committed by GitHub
commit 9433ab1d51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 545 additions and 207 deletions

View File

@ -389,6 +389,8 @@
?. (~(has by charges) desk) ?. (~(has by charges) desk)
`state `state
=/ =charge (~(got by charges) desk) =/ =charge (~(got by charges) desk)
?: &(?=(%install -.chad.charge) ?=(%held zest))
`state
?- zest ?- zest
%live %live
?. ?=(%glob -.href.docket.charge) ?. ?=(%glob -.href.docket.charge)

View File

@ -16,10 +16,7 @@ import useKilnState from './state/kiln';
import useContactState from './state/contact'; import useContactState from './state/contact';
import api from './state/api'; import api from './state/api';
import { useMedia } from './logic/useMedia'; import { useMedia } from './logic/useMedia';
import { import { useCalm, useSettingsState, useTheme } from './state/settings';
useSettingsState,
useTheme,
} from './state/settings';
import { useBrowserId, useLocalState } from './state/local'; import { useBrowserId, useLocalState } from './state/local';
import { ErrorAlert } from './components/ErrorAlert'; import { ErrorAlert } from './components/ErrorAlert';
import { useErrorHandler } from './logic/useErrorHandler'; import { useErrorHandler } from './logic/useErrorHandler';
@ -56,10 +53,13 @@ const AppRoutes = () => {
const { search } = useLocation(); const { search } = useLocation();
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const browserId = useBrowserId(); const browserId = useBrowserId();
const {
display: { doNotDisturb },
} = useSettingsState.getState();
const { count, unreadNotifications } = useNotifications(); const { count, unreadNotifications } = useNotifications();
useEffect(() => { useEffect(() => {
if ('Notification' in window) { if ('Notification' in window && !doNotDisturb) {
if (count > 0 && Notification.permission === 'granted') { if (count > 0 && Notification.permission === 'granted') {
unreadNotifications.forEach((bin) => { unreadNotifications.forEach((bin) => {
makeBrowserNotification(bin); makeBrowserNotification(bin);
@ -120,7 +120,7 @@ const AppRoutes = () => {
useHarkState.getState().start(); useHarkState.getState().start();
Mousetrap.bind(['command+/', 'ctrl+/'], () => { Mousetrap.bind(['command+/', 'ctrl+/'], () => {
push('/leap/search'); push('/search');
}); });
}), }),
[] []
@ -129,7 +129,7 @@ const AppRoutes = () => {
return ( return (
<Switch> <Switch>
<Route path="/perma" component={PermalinkRoutes} /> <Route path="/perma" component={PermalinkRoutes} />
<Route path={['/leap/:menu', '/']} component={Grid} /> <Route path={['/:menu', '/']} component={Grid} />
</Switch> </Switch>
); );
}; };

View File

@ -18,6 +18,7 @@ type App = ChargeWithDesk | Treaty;
interface AppInfoProps { interface AppInfoProps {
docket: App; docket: App;
pike?: Pike; pike?: Pike;
treatyInfoShip?: string;
className?: string; className?: string;
} }
@ -34,20 +35,25 @@ function getInstallStatus(docket: App): InstallStatus {
return 'uninstalled'; return 'uninstalled';
} }
function getRemoteDesk(docket: App, pike?: Pike) { function getRemoteDesk(docket: App, pike?: Pike, treatyInfoShip?: string) {
if (pike && pike.sync) { if (pike && pike.sync) {
return [pike.sync.ship, pike.sync.desk]; return [pike.sync.ship, pike.sync.desk];
} }
if ('chad' in docket) { if ('chad' in docket) {
return ['', docket.desk]; return [treatyInfoShip ?? '', docket.desk];
} }
const { ship, desk } = docket; const { ship, desk } = docket;
return [ship, desk]; return [ship, desk];
} }
export const AppInfo: FC<AppInfoProps> = ({ docket, pike, className }) => { export const AppInfo: FC<AppInfoProps> = ({
docket,
pike,
className,
treatyInfoShip,
}) => {
const installStatus = getInstallStatus(docket); const installStatus = getInstallStatus(docket);
const [ship, desk] = getRemoteDesk(docket, pike); const [ship, desk] = getRemoteDesk(docket, pike, treatyInfoShip);
const publisher = pike?.sync?.ship ?? ship; const publisher = pike?.sync?.ship ?? ship;
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const treaty = useTreaty(ship, desk); const treaty = useTreaty(ship, desk);
@ -96,7 +102,7 @@ export const AppInfo: FC<AppInfoProps> = ({ docket, pike, className }) => {
</PillButton> </PillButton>
)} )}
{installStatus !== 'installed' && ( {installStatus !== 'installed' && (
<Dialog> <Dialog portal={false}>
<DialogTrigger asChild> <DialogTrigger asChild>
<PillButton variant="alt-primary" disabled={installing}> <PillButton variant="alt-primary" disabled={installing}>
{installing ? ( {installing ? (
@ -126,7 +132,7 @@ export const AppInfo: FC<AppInfoProps> = ({ docket, pike, className }) => {
<DialogClose asChild> <DialogClose asChild>
<Button variant="secondary">Cancel</Button> <Button variant="secondary">Cancel</Button>
</DialogClose> </DialogClose>
<DialogClose asChild onClick={installApp}> <DialogClose asChild>
<Button onClick={installApp}> <Button onClick={installApp}>
Get &ldquo;{getAppName(docket)}&rdquo; Get &ldquo;{getAppName(docket)}&rdquo;
</Button> </Button>

View File

@ -1,16 +1,29 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog';
import type * as Polymorphic from '@radix-ui/react-polymorphic';
import classNames from 'classnames'; import classNames from 'classnames';
export const Dialog: FC<DialogPrimitive.DialogProps> = ({ interface DialogProps extends DialogPrimitive.DialogProps {
portal?: boolean;
}
export const Dialog: FC<DialogProps> = ({
children, children,
portal = true,
...props ...props
}) => { }) => {
return ( return (
<DialogPrimitive.Root {...props}> <DialogPrimitive.Root {...props}>
<DialogPrimitive.Overlay className="fixed top-0 bottom-0 left-0 right-0 z-30 transform-gpu bg-black opacity-30" /> {portal ? (
{children} <DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed top-0 bottom-0 left-0 right-0 z-30 transform-gpu bg-black opacity-30" />
{children}
</DialogPrimitive.Portal>
) : (
<>
<DialogPrimitive.Overlay className="fixed top-0 bottom-0 left-0 right-0 z-30 transform-gpu bg-black opacity-30" />
{children}
</>
)}
</DialogPrimitive.Root> </DialogPrimitive.Root>
); );
}; };

View File

@ -5,6 +5,7 @@ import { Dialog, DialogContent } from './Dialog';
import { Button } from './Button'; import { Button } from './Button';
import { useCharges } from '../state/docket'; import { useCharges } from '../state/docket';
import { GroupLink } from './GroupLink'; import { GroupLink } from './GroupLink';
import WayfindingAppLink from './WayfindingAppLink';
interface Group { interface Group {
title: string; title: string;
@ -14,14 +15,6 @@ interface Group {
link: string; link: string;
} }
interface App {
title: string;
description: string;
image: string;
color: string;
link: string;
}
const groups: Record<string, Group> = { const groups: Record<string, Group> = {
foundation: { foundation: {
title: 'Urbit Foundation', title: 'Urbit Foundation',
@ -46,35 +39,6 @@ const groups: Record<string, Group> = {
}, },
}; };
const AppLink = ({ link, title, description, image, color }: App) => {
return (
<div className="flex items-center justify-between py-2">
<div className="flex items-center space-x-2">
{image !== '' ? (
<img
src={image}
className="h-8 w-8 rounded"
style={{ backgroundColor: color }}
/>
) : (
<div className="h-8 w-8 rounded" style={{ backgroundColor: color }} />
)}
<div className="flex flex-col">
<span className="font-semibold">{title}</span>
{description && (
<span className="text-sm font-semibold text-gray-400">
{description}
</span>
)}
</div>
</div>
<Button variant="alt-primary" as="a" href={link} target="_blank">
Open App
</Button>
</div>
);
};
function LandscapeDescription() { function LandscapeDescription() {
const charges = useCharges(); const charges = useCharges();
return ( return (
@ -92,26 +56,35 @@ function LandscapeDescription() {
software developer, like ~paldev. software developer, like ~paldev.
</p> </p>
<div className="mt-8 space-y-2"> <div className="mt-8 space-y-2">
<AppLink <WayfindingAppLink
title="Groups" title="Groups"
description="Build or join Urbit-based communities" description="Build or join Urbit-based communities"
link="/apps/groups" link="/apps/groups"
image={charges.groups?.image || ''} image={charges.groups?.image || ''}
color={charges.groups?.color || 'bg-gray'} color={charges.groups?.color || 'bg-gray'}
installed={charges['groups'] ? true : false}
source="~sogryp-dister-dozzod-dozzod"
desk="groups"
/> />
<AppLink <WayfindingAppLink
title="Talk" title="Talk"
description="Simple instant messaging app" description="Simple instant messaging app"
link="/apps/talk" link="/apps/talk"
image={charges.talk?.image || ''} image={charges.talk?.image || ''}
color={charges.talk?.color || 'bg-blue'} color={charges.talk?.color || 'bg-blue'}
installed={charges['talk'] ? true : false}
source="~sogryp-dister-dozzod-dozzod"
desk="talk"
/> />
<AppLink <WayfindingAppLink
title="Terminal" title="Terminal"
description="Pop open the hood of your urbit" description="Pop open the hood of your urbit"
link="/apps/webterm" link="/apps/webterm"
image={charges.webterm?.image || ''} image={charges.webterm?.image || ''}
color={charges.webterm?.color || 'bg-black'} color={charges.webterm?.color || 'bg-black'}
installed={charges['terminal'] ? true : false}
source="~mister-dister-dozzod-dozzod"
desk="terminal"
/> />
</div> </div>
<h1 className="my-8 text-2xl font-bold">Where are the people?</h1> <h1 className="my-8 text-2xl font-bold">Where are the people?</h1>

View File

@ -8,8 +8,8 @@ export function PikeMeta(props: { pike: Pike }) {
const pluralUpdates = pike.wefts?.length !== 1; const pluralUpdates = pike.wefts?.length !== 1;
return ( return (
<div className="mt-5 sm:mt-8 space-y-5 sm:space-y-8"> <div className="mt-5 space-y-5 sm:mt-8 sm:space-y-8">
<Attribute title="Desk Hash" attr="hash"> <Attribute title="Desk Hash" attr="hash" className="break-all">
{pike.hash} {pike.hash}
</Attribute> </Attribute>
<Attribute title="Installed into" attr="local-desk"> <Attribute title="Installed into" attr="local-desk">
@ -17,7 +17,8 @@ export function PikeMeta(props: { pike: Pike }) {
</Attribute> </Attribute>
{pike.wefts && pike.wefts.length > 0 ? ( {pike.wefts && pike.wefts.length > 0 ? (
<Attribute attr="next" title="Pending Updates"> <Attribute attr="next" title="Pending Updates">
{pike.wefts.length} update{pluralUpdates ? 's are' : ' is'} pending a System Update {pike.wefts.length} update{pluralUpdates ? 's are' : ' is'} pending a
System Update
</Attribute> </Attribute>
) : null} ) : null}
</div> </div>

View File

@ -0,0 +1,64 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { Button } from './Button';
interface WayfindingAppLinkProps {
title: string;
description: string;
image?: string | null;
color: string;
link: string;
installed: boolean;
source: string;
desk: string;
}
const WayfindingAppLink = ({
link,
title,
description,
image = null,
color,
installed,
source,
desk,
}: WayfindingAppLinkProps) => {
return (
<div className="flex items-center justify-between py-2 space-x-2">
<div className="flex items-center space-x-2">
{image !== null && image !== '' ? (
<img
src={image}
className="h-8 w-8 rounded"
style={{ backgroundColor: color }}
/>
) : (
<div className="h-8 w-8 min-w-8 rounded" style={{ backgroundColor: color }} />
)}
<div className="flex flex-col">
<span className="font-semibold text-gray-800">{title}</span>
{description && (
<span className="text-sm font-semibold text-gray-400">
{description}
</span>
)}
</div>
</div>
{installed ? (
<Button variant="alt-primary" as="a" href={link} target="_blank">
Open App
</Button>
) : (
<NavLink to={`/search/${source}/apps/${source}/${desk}`}>
<Button
variant="alt-primary"
>
Install App
</Button>
</NavLink>
)}
</div>
);
};
export default WayfindingAppLink;

View File

@ -0,0 +1,21 @@
import React from 'react';
export default function MagnifyingGlass16Icon(
props: React.SVGProps<SVGSVGElement>
) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="none"
>
<path
className="fill-current"
fillRule="evenodd"
d="M6.5 7a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm3-5a5 5 0 0 0-4.172 7.757l-.535.536-2 2a1 1 0 1 0 1.414 1.414l2-2 .536-.535A5 5 0 1 0 9.5 2Z"
clipRule="evenodd"
/>
</svg>
);
}

161
ui/src/constants.ts Normal file
View File

@ -0,0 +1,161 @@
export const SECTIONS = {
SELECTS: 'Tlon Selects',
PALS: 'Powered by Pals',
DEV: 'Develop on Urbit',
USEFUL: 'Make Urbit Useful',
FUN: 'Make Urbit Fun',
};
export const APPS = [
{
title: 'pals',
description: 'friendlist for peer discovery',
color: '#99D3BD',
link: '/apps/pals',
source: '~paldev',
section: SECTIONS.SELECTS,
desk: 'pals',
},
{
title: 'Terminal',
description: "A web interface to your Urbit's command line",
color: '#233D34',
link: '/apps/webterm',
section: SECTIONS.SELECTS,
desk: 'webterm',
source: '~mister-dister-dozzod-dozzod',
},
{
title: 'face',
description: 'see your friends',
color: '#3B5998',
link: '/apps/face',
source: '~paldev',
section: SECTIONS.PALS,
desk: 'face',
},
{
title: 'rumors',
description: 'Anonymous gossip from friends of friends',
color: '#BB77DD',
link: '/apps/rumors',
source: '~paldev',
section: SECTIONS.PALS,
desk: 'rumors',
},
{
title: 'Contacts',
description: 'Contact book',
color: '#338899',
link: '/apps/whom',
section: SECTIONS.PALS,
source: '~holnes',
desk: 'whom',
},
{
title: "sc'o're",
description: "leaderboard for groups' ['o']s, as seen on tv!",
color: '#FFFF00',
link: '/apps/scooore',
source: '~paldev',
section: SECTIONS.PALS,
desk: 'scooore',
},
{
title: 'Docs',
description: 'User and developer documentation for Urbit apps',
color: '#FFCF00',
link: '/apps/docs',
section: SECTIONS.DEV,
desk: 'docs',
source: '~pocwet',
},
{
title: 'Quorum',
description:
'A choral explanations app (a la Stack Overflow or Quora) for Urbit',
color: '#F2F2F2',
link: '/apps/quorom',
section: SECTIONS.DEV,
desk: 'quorum',
source: '~dister-dister-sidnum-ladrut',
image: 'https://ladrut.xyz/quorum/quorum-logo.png',
},
{
title: 'cliff',
description: 'filesystem explorer',
color: '#E39871',
link: '/apps/cliff',
section: SECTIONS.DEV,
desk: 'cliff',
source: '~paldev',
},
{
title: 'silo',
description: 'An S3 storage manager',
color: '#4F46E5',
link: '/apps/silo',
section: SECTIONS.USEFUL,
source: '~dister-nocsyx-lassul',
desk: 'silo',
},
{
title: 'hodl',
description: 'A portfolio for all that you hodl',
color: '#B8A3D1',
link: '/apps/hodl',
section: SECTIONS.USEFUL,
desk: 'hodl',
source: '~hodler-datder-sonnet',
image:
'https://user-images.githubusercontent.com/16504501/194947852-8802fd63-5954-4ce8-b147-2072bd929242.png',
},
{
title: 'Goals',
description: 'Urbit task manager.',
color: '#EEDFC9',
link: '/apps/goals',
section: SECTIONS.USEFUL,
desk: 'gol-cli',
source: '~dister-dozzod-niblyx-malnus',
},
{
title: '%blog',
description: 'a tool for publishing',
color: '#573C7C',
link: '/apps/blog',
section: SECTIONS.USEFUL,
desk: 'blog',
source: '~hanrut-sillet-dachus-tiprel',
},
{
title: 'Board',
description: 'A tapestry of boards',
color: '#9E34EB',
link: '/apps/board',
section: SECTIONS.FUN,
desk: 'board',
source: '~dister-hanfel-dovned',
},
{
title: 'DukeBox',
description: 'Emulated DOS games on Urbit',
color: '#209AFA',
link: '/apps/dukebox',
section: SECTIONS.FUN,
desk: 'dukebox',
source: '~tagrev-lacmur-lomped-firser',
image:
'https://cdn.pixabay.com/photo/2012/04/13/11/41/joystick-32023_960_720.png',
},
{
title: 'radio',
description: 'an app for urbit disc jockeys',
color: '#FF0000',
link: '/apps/radio',
section: SECTIONS.FUN,
desk: 'radio',
source: '~nodmyn-dosrux',
image: 'https://0x0.st/o4--.png',
},
];

View File

@ -8,14 +8,15 @@ import React, {
useCallback, useCallback,
useImperativeHandle, useImperativeHandle,
useRef, useRef,
useEffect useEffect,
useState,
} from 'react'; } from 'react';
import { Link, useHistory, useLocation, useRouteMatch } from 'react-router-dom'; import { Link, useHistory, useLocation, useRouteMatch } from 'react-router-dom';
import { Cross } from '../components/icons/Cross'; import { Cross } from '../components/icons/Cross';
import { useDebounce } from '../logic/useDebounce'; import { useDebounce } from '../logic/useDebounce';
import { useErrorHandler } from '../logic/useErrorHandler'; import { useErrorHandler } from '../logic/useErrorHandler';
import { useMedia } from '../logic/useMedia'; import { useMedia } from '../logic/useMedia';
import { MenuState, useLeapStore } from './Nav'; import { MenuState, useAppSearchStore } from './Nav';
function normalizePathEnding(path: string) { function normalizePathEnding(path: string) {
const end = path.length - 1; const end = path.length - 1;
@ -37,7 +38,6 @@ type LeapProps = {
menu: MenuState; menu: MenuState;
dropdown: string; dropdown: string;
navOpen: boolean; navOpen: boolean;
systemMenuOpen: boolean;
} & HTMLAttributes<HTMLDivElement>; } & HTMLAttributes<HTMLDivElement>;
function normalizeMatchString(match: string, keepAltChars: boolean): string { function normalizeMatchString(match: string, keepAltChars: boolean): string {
@ -50,19 +50,21 @@ function normalizeMatchString(match: string, keepAltChars: boolean): string {
return normalizedString; return normalizedString;
} }
export const Leap = React.forwardRef( export const AppSearch = React.forwardRef(
({ menu, dropdown, navOpen, systemMenuOpen, className }: LeapProps, ref) => { ({ menu, dropdown, navOpen, className }: LeapProps, ref) => {
const { push } = useHistory(); const { push } = useHistory();
const location = useLocation(); const deskMatch = useRouteMatch<{
const isMobile = useMedia('(max-width: 639px)'); menu?: MenuState;
const deskMatch = useRouteMatch<{ menu?: MenuState; query?: string; desk?: string }>( query?: string;
`/leap/${menu}/:query?/(apps)?/:desk?` desk?: string;
}>(`/${menu}/:query?/(apps)?/:desk?`);
const appsMatch = useRouteMatch(
`/${menu}/${deskMatch?.params.query}/apps`
); );
const systemPrefMatch = useRouteMatch<{ submenu: string }>(`/leap/system-preferences/:submenu`);
const appsMatch = useRouteMatch(`/leap/${menu}/${deskMatch?.params.query}/apps`);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inputRef.current); useImperativeHandle(ref, () => inputRef.current);
const { rawInput, selectedMatch, matches, selection, select } = useLeapStore(); const { rawInput, selectedMatch, matches, selection, select } =
useAppSearchStore();
const handleError = useErrorHandler(); const handleError = useErrorHandler();
useEffect(() => { useEffect(() => {
@ -77,16 +79,24 @@ export const Leap = React.forwardRef(
useEffect(() => { useEffect(() => {
const newMatch = getMatch(rawInput); const newMatch = getMatch(rawInput);
if (newMatch && rawInput) { if (newMatch && rawInput) {
useLeapStore.setState({ selectedMatch: newMatch }); useAppSearchStore.setState({ selectedMatch: newMatch });
} }
}, [rawInput, matches]); }, [rawInput, matches]);
useEffect(() => {
if (menu === 'search') {
inputRef.current?.focus();
} else {
inputRef.current?.blur();
}
}, [menu]);
const toggleSearch = useCallback(() => { const toggleSearch = useCallback(() => {
if (selection || menu === 'search') { if (selection || menu === 'search') {
return; return;
} }
push('/leap/search'); push('/search');
}, [selection, menu]); }, [selection, menu]);
const onFocus = useCallback( const onFocus = useCallback(
@ -119,7 +129,7 @@ export const Leap = React.forwardRef(
.trim() .trim()
.replace('%', '') .replace('%', '')
.replace(/(~?[\w^_-]{3,13})\//, '$1/apps/$1/'); .replace(/(~?[\w^_-]{3,13})\//, '$1/apps/$1/');
push(`/leap/${menu}/${normalizedValue}`); push(`/${menu}/${normalizedValue}`);
}, },
[menu] [menu]
); );
@ -130,7 +140,7 @@ export const Leap = React.forwardRef(
return; return;
} }
useLeapStore.setState({ searchInput: input }); useAppSearchStore.setState({ searchInput: input });
navigateByInput(input); navigateByInput(input);
}, },
300, 300,
@ -139,52 +149,12 @@ export const Leap = React.forwardRef(
const handleSearch = useCallback(debouncedSearch, [deskMatch]); const handleSearch = useCallback(debouncedSearch, [deskMatch]);
const matchSystemPrefs = useCallback(
(target: string) => {
if (isMobile) {
return false;
}
if (!systemPrefMatch && target === 'system-updates') {
return true;
}
return systemPrefMatch?.params.submenu === target;
},
[location, systemPrefMatch]
);
const getPlaceholder = () => {
if (systemMenuOpen) {
switch (true) {
case matchSystemPrefs('system-updates'):
return 'My Urbit: About';
case matchSystemPrefs('help'):
return 'My Urbit: Help';
case matchSystemPrefs('security'):
return 'My Urbit: Security';
case matchSystemPrefs('notifications'):
return 'My Urbit: Notifications';
case matchSystemPrefs('privacy'):
return 'My Urbit: Attention & Privacy';
case matchSystemPrefs('appearance'):
return 'My Urbit: Appearance';
case matchSystemPrefs('shortcuts'):
return 'My Urbit: Shortcuts';
case matchSystemPrefs('interface'):
return 'My Urbit: Interface Settings';
default:
return 'Settings';
}
}
return 'Search';
};
const onChange = useCallback( const onChange = useCallback(
handleError((e: ChangeEvent<HTMLInputElement>) => { handleError((e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
const value = input.value.trim(); const value = input.value.trim();
const isDeletion = (e.nativeEvent as InputEvent).inputType === 'deleteContentBackward'; const isDeletion =
(e.nativeEvent as InputEvent).inputType === 'deleteContentBackward';
const inputMatch = getMatch(value); const inputMatch = getMatch(value);
const matchValue = inputMatch?.value; const matchValue = inputMatch?.value;
@ -192,16 +162,17 @@ export const Leap = React.forwardRef(
inputRef.current.value = matchValue; inputRef.current.value = matchValue;
const start = matchValue.startsWith(value) const start = matchValue.startsWith(value)
? value.length ? value.length
: matchValue.substring(0, matchValue.indexOf(value)).length + value.length; : matchValue.substring(0, matchValue.indexOf(value)).length +
value.length;
inputRef.current.setSelectionRange(start, matchValue.length); inputRef.current.setSelectionRange(start, matchValue.length);
useLeapStore.setState({ useAppSearchStore.setState({
rawInput: matchValue, rawInput: matchValue,
selectedMatch: inputMatch selectedMatch: inputMatch,
}); });
} else { } else {
useLeapStore.setState({ useAppSearchStore.setState({
rawInput: value, rawInput: value,
selectedMatch: matches[0] selectedMatch: matches[0],
}); });
} }
@ -227,7 +198,7 @@ export const Leap = React.forwardRef(
} }
push(currentMatch.url); push(currentMatch.url);
useLeapStore.setState({ rawInput: '' }); useAppSearchStore.setState({ rawInput: '' });
}), }),
[deskMatch, selectedMatch] [deskMatch, selectedMatch]
); );
@ -239,7 +210,12 @@ export const Leap = React.forwardRef(
if (deletion && !rawInput && selection) { if (deletion && !rawInput && selection) {
e.preventDefault(); e.preventDefault();
select(null, appsMatch && !appsMatch.isExact ? undefined : deskMatch?.params.query); select(
null,
appsMatch && !appsMatch.isExact
? undefined
: deskMatch?.params.query
);
const pathBack = createPreviousPath(deskMatch?.url || ''); const pathBack = createPreviousPath(deskMatch?.url || '');
push(pathBack); push(pathBack);
} }
@ -257,14 +233,15 @@ export const Leap = React.forwardRef(
return matchValue === searchValue; return matchValue === searchValue;
}) })
: 0; : 0;
const unsafeIndex = e.key === 'ArrowUp' ? currentIndex - 1 : currentIndex + 1; const unsafeIndex =
e.key === 'ArrowUp' ? currentIndex - 1 : currentIndex + 1;
const index = (unsafeIndex + matches.length) % matches.length; const index = (unsafeIndex + matches.length) % matches.length;
const newMatch = matches[index]; const newMatch = matches[index];
useLeapStore.setState({ useAppSearchStore.setState({
rawInput: newMatch.value, rawInput: newMatch.value,
// searchInput: matchValue, // searchInput: matchValue,
selectedMatch: newMatch selectedMatch: newMatch,
}); });
} }
}), }),
@ -302,7 +279,7 @@ export const Leap = React.forwardRef(
id="leap" id="leap"
type="text" type="text"
ref={inputRef} ref={inputRef}
placeholder={selection ? '' : getPlaceholder()} placeholder={selection ? '' : 'e.g., ~paldev or ~paldev/pals'}
// TODO: style placeholder text with 100% opacity. // TODO: style placeholder text with 100% opacity.
// Not immediately clear how to do this within tailwind. // Not immediately clear how to do this within tailwind.
className="outline-none h-full w-full flex-1 bg-transparent px-2 text-lg text-gray-800 sm:text-base" className="outline-none h-full w-full flex-1 bg-transparent px-2 text-lg text-gray-800 sm:text-base"
@ -320,7 +297,7 @@ export const Leap = React.forwardRef(
</form> </form>
{menu === 'search' && ( {menu === 'search' && (
<Link <Link
to="/" to="/get-apps"
className="circle-button default-ring absolute top-1/2 right-2 h-8 w-8 flex-none -translate-y-1/2 text-gray-600" className="circle-button default-ring absolute top-1/2 right-2 h-8 w-8 flex-none -translate-y-1/2 text-gray-600"
onClick={() => select(null)} onClick={() => select(null)}
> >

58
ui/src/nav/GetApps.tsx Normal file
View File

@ -0,0 +1,58 @@
import React from 'react';
import WayfindingAppLink from '../components/WayfindingAppLink';
import { useCharges } from '../state/docket';
import { APPS, SECTIONS } from '../constants';
export default function GetApps() {
const charges = useCharges();
return (
<div className="flex h-full flex-col space-y-8 overflow-y-scroll p-8">
<h1 className="text-xl font-bold text-gray-800">Find Urbit Apps</h1>
<div className="flex flex-col space-y-3">
<h2 className="font-semibold text-gray-800">
Find Urbit App Developers
</h2>
<span>
Use the search field above to find apps or ships hosting apps.
</span>
</div>
{Object.entries(SECTIONS).map(([key, name]) => (
<div key={key} className="flex flex-col space-y-2">
<h2 className="text-lg font-bold text-gray-800">{name}</h2>
<div className="flex flex-col space-y-2 px-2">
{APPS.map((app) => {
if (app.section === name) {
return (
<WayfindingAppLink
key={app.desk}
title={
charges[app.desk] ? charges[app.desk].title : app.title
}
description={
charges[app.desk]
? charges[app.desk]?.info ?? app.description
: app.description
}
color={
charges[app.desk] ? charges[app.desk].color : app.color
}
image={
charges[app.desk]
? charges[app.desk].image
: app.image ?? ''
}
link={charges[app.desk] ? app.link : ''}
installed={charges[app.desk] ? true : false}
source={app.source}
desk={app.desk}
/>
);
}
})}
</div>
</div>
))}
</div>
);
}

View File

@ -22,7 +22,7 @@ import { Avatar } from '../components/Avatar';
import { Dialog } from '../components/Dialog'; import { Dialog } from '../components/Dialog';
import { ErrorAlert } from '../components/ErrorAlert'; import { ErrorAlert } from '../components/ErrorAlert';
import { Help } from './Help'; import { Help } from './Help';
import { Leap } from './Leap'; import { AppSearch } from './AppSearch';
import { Notifications } from './notifications/Notifications'; import { Notifications } from './notifications/Notifications';
import { NotificationsLink } from './notifications/NotificationsLink'; import { NotificationsLink } from './notifications/NotificationsLink';
import { Search } from './Search'; import { Search } from './Search';
@ -30,6 +30,8 @@ import { SystemPreferences } from '../preferences/SystemPreferences';
import { useSystemUpdate } from '../logic/useSystemUpdate'; import { useSystemUpdate } from '../logic/useSystemUpdate';
import { Bullet } from '../components/icons/Bullet'; import { Bullet } from '../components/icons/Bullet';
import { Cross } from '../components/icons/Cross'; import { Cross } from '../components/icons/Cross';
import MagnifyingGlass16Icon from '../components/icons/MagnifyingGlass16Icon';
import GetApps from './GetApps';
export interface MatchItem { export interface MatchItem {
url: string; url: string;
@ -38,7 +40,7 @@ export interface MatchItem {
display?: string; display?: string;
} }
interface LeapStore { interface AppSearchStore {
rawInput: string; rawInput: string;
searchInput: string; searchInput: string;
matches: MatchItem[]; matches: MatchItem[];
@ -47,7 +49,7 @@ interface LeapStore {
select: (selection: React.ReactNode, input?: string) => void; select: (selection: React.ReactNode, input?: string) => void;
} }
export const useLeapStore = create<LeapStore>((set) => ({ export const useAppSearchStore = create<AppSearchStore>((set) => ({
rawInput: '', rawInput: '',
searchInput: '', searchInput: '',
matches: [], matches: [],
@ -61,11 +63,13 @@ export const useLeapStore = create<LeapStore>((set) => ({
}), }),
})); }));
window.leap = useLeapStore.getState; window.appSearch = useAppSearchStore.getState;
export type MenuState = export type MenuState =
| 'closed' | 'closed'
| 'search' | 'search'
| 'get-apps'
| 'app'
| 'notifications' | 'notifications'
| 'help-and-support' | 'help-and-support'
| 'system-preferences' | 'system-preferences'
@ -99,7 +103,7 @@ export const SystemPrefsLink = ({
} }
return ( return (
<Link to="/leap/system-preferences" className="relative flex-none"> <Link to="/system-preferences" className="relative flex-none">
<Avatar shipName={window.ship} size="nav" /> <Avatar shipName={window.ship} size="nav" />
{systemBlocked && ( {systemBlocked && (
<Bullet <Bullet
@ -111,18 +115,32 @@ export const SystemPrefsLink = ({
); );
}; };
export const GetAppsLink = () => {
return (
<Link
to="/get-apps"
className="flex h-9 w-[150px] items-center justify-center space-x-2 rounded-lg bg-blue-soft px-3 py-2.5"
>
<MagnifyingGlass16Icon className="h-4 w-4 fill-current text-blue" />
<span className="whitespace-nowrap font-semibold text-blue">
Get Urbit Apps
</span>
</Link>
);
};
export const Nav: FunctionComponent<NavProps> = ({ menu }) => { export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
const { push } = useHistory(); const { push } = useHistory();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const navRef = useRef<HTMLDivElement>(null); const navRef = useRef<HTMLDivElement>(null);
const dialogNavRef = useRef<HTMLDivElement>(null); const dialogNavRef = useRef<HTMLDivElement>(null);
const systemMenuOpen = useRouteMatch('/leap/system-preferences');
const { systemBlocked } = useSystemUpdate(); const { systemBlocked } = useSystemUpdate();
const [dialogContentOpen, setDialogContentOpen] = useState(false); const [dialogContentOpen, setDialogContentOpen] = useState(false);
const select = useLeapStore((state) => state.select); const select = useAppSearchStore((state) => state.select);
const menuState = menu || 'closed'; const menuState = menu || 'closed';
const isOpen = menuState !== 'upgrading' && menuState !== 'closed'; const isOpen =
menuState !== 'upgrading' && menuState !== 'closed' && menuState !== 'app';
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@ -172,13 +190,16 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
navOpen={isOpen} navOpen={isOpen}
notificationsOpen={menu === 'notifications'} notificationsOpen={menu === 'notifications'}
/> />
<Leap {menuState === 'search' || menuState === 'get-apps' ? (
ref={inputRef} <AppSearch
menu={menuState} ref={inputRef}
dropdown="leap-items" menu={menuState}
navOpen={isOpen} dropdown="leap-items"
systemMenuOpen={!!systemMenuOpen} navOpen={isOpen}
/> />
) : (
<GetAppsLink />
)}
</Portal.Root> </Portal.Root>
<div <div
ref={navRef} ref={navRef}
@ -213,13 +234,11 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
role="listbox" role="listbox"
> >
<Switch> <Switch>
<Route path="/leap/notifications" component={Notifications} /> <Route path="/notifications" component={Notifications} />
<Route <Route path="/system-preferences" component={SystemPreferences} />
path="/leap/system-preferences" <Route path="/help-and-support" component={Help} />
component={SystemPreferences} <Route path="/get-apps" component={GetApps} />
/> <Route path={['/search']} component={Search} />
<Route path="/leap/help-and-support" component={Help} />
<Route path={['/leap/search', '/leap']} component={Search} />
</Switch> </Switch>
</div> </div>
</DialogContent> </DialogContent>

View File

@ -9,7 +9,7 @@ import { usePike } from '../state/kiln';
import { disableDefault, handleDropdownLink } from '../state/util'; import { disableDefault, handleDropdownLink } from '../state/util';
import { useMedia } from '../logic/useMedia'; import { useMedia } from '../logic/useMedia';
import { Cross } from '../components/icons/Cross'; import { Cross } from '../components/icons/Cross';
import { useLeapStore } from './Nav'; import { useAppSearchStore } from './Nav';
type SystemMenuProps = HTMLAttributes<HTMLButtonElement> & { type SystemMenuProps = HTMLAttributes<HTMLButtonElement> & {
open: boolean; open: boolean;
@ -33,7 +33,7 @@ export const SystemMenu = ({
const garden = usePike(window.desk); const garden = usePike(window.desk);
const hash = garden ? getHash(garden) : null; const hash = garden ? getHash(garden) : null;
const isMobile = useMedia('(max-width: 639px)'); const isMobile = useMedia('(max-width: 639px)');
const select = useLeapStore((s) => s.select); const select = useAppSearchStore((s) => s.select);
const clearSelection = useCallback(() => select(null), [select]); const clearSelection = useCallback(() => select(null), [select]);
const copyHash = useCallback( const copyHash = useCallback(

View File

@ -2,7 +2,7 @@ import classNames from 'classnames';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Link, LinkProps } from 'react-router-dom'; import { Link, LinkProps } from 'react-router-dom';
import { Cross } from '../../components/icons/Cross'; import { Cross } from '../../components/icons/Cross';
import { useLeapStore } from '../Nav'; import { useAppSearchStore } from '../Nav';
import { SettingsState, useSettingsState } from '../../state/settings'; import { SettingsState, useSettingsState } from '../../state/settings';
import BellIcon from '../../components/icons/BellIcon'; import BellIcon from '../../components/icons/BellIcon';
import { useNotifications } from './useNotifications'; import { useNotifications } from './useNotifications';
@ -32,12 +32,12 @@ export const NotificationsLink = ({ navOpen, notificationsOpen }: NotificationsL
const dnd = useSettingsState(selDnd); const dnd = useSettingsState(selDnd);
const { count } = useNotifications(); const { count } = useNotifications();
const state = getNotificationsState(notificationsOpen, count, dnd); const state = getNotificationsState(notificationsOpen, count, dnd);
const select = useLeapStore((s) => s.select); const select = useAppSearchStore((s) => s.select);
const clearSelection = useCallback(() => select(null), [select]); const clearSelection = useCallback(() => select(null), [select]);
return ( return (
<Link <Link
to={state === 'open' ? '/' : '/leap/notifications'} to={state === 'open' ? '/' : '/notifications'}
className={classNames( className={classNames(
'relative z-50 flex-none circle-button h4 default-ring', 'relative z-50 flex-none circle-button h4 default-ring',
navOpen && 'text-opacity-60', navOpen && 'text-opacity-60',

View File

@ -4,7 +4,7 @@ import fuzzy from 'fuzzy';
import { Treaty } from '@urbit/api'; import { Treaty } from '@urbit/api';
import { ShipName } from '../../components/ShipName'; import { ShipName } from '../../components/ShipName';
import { useAllyTreaties } from '../../state/docket'; import { useAllyTreaties } from '../../state/docket';
import { useLeapStore } from '../Nav'; import { useAppSearchStore } from '../Nav';
import { AppList } from '../../components/AppList'; import { AppList } from '../../components/AppList';
import { addRecentDev } from './Home'; import { addRecentDev } from './Home';
import { Spinner } from '../../components/Spinner'; import { Spinner } from '../../components/Spinner';
@ -12,7 +12,7 @@ import { Spinner } from '../../components/Spinner';
type AppsProps = RouteComponentProps<{ ship: string }>; type AppsProps = RouteComponentProps<{ ship: string }>;
export const Apps = ({ match }: AppsProps) => { export const Apps = ({ match }: AppsProps) => {
const { searchInput, selectedMatch, select } = useLeapStore((state) => ({ const { searchInput, selectedMatch, select } = useAppSearchStore((state) => ({
searchInput: state.searchInput, searchInput: state.searchInput,
select: state.select, select: state.select,
selectedMatch: state.selectedMatch selectedMatch: state.selectedMatch
@ -61,7 +61,7 @@ export const Apps = ({ match }: AppsProps) => {
useEffect(() => { useEffect(() => {
if (results) { if (results) {
useLeapStore.setState({ useAppSearchStore.setState({
matches: results.map((r) => ({ matches: results.map((r) => ({
url: getAppPath(r), url: getAppPath(r),
openInNewTab: false, openInNewTab: false,

View File

@ -3,7 +3,7 @@ import create from 'zustand';
import _ from 'lodash'; import _ from 'lodash';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { MatchItem, useLeapStore } from '../Nav'; import { MatchItem, useAppSearchStore } from '../Nav';
import { providerMatch } from './Providers'; import { providerMatch } from './Providers';
import { AppList } from '../../components/AppList'; import { AppList } from '../../components/AppList';
import { ProviderList } from '../../components/ProviderList'; import { ProviderList } from '../../components/ProviderList';
@ -88,7 +88,7 @@ function getApps(desks: string[], charges: ChargesWithDesks) {
} }
export const Home = () => { export const Home = () => {
const selectedMatch = useLeapStore((state) => state.selectedMatch); const selectedMatch = useAppSearchStore((state) => state.selectedMatch);
const { recentApps, recentDevs } = useRecentsStore(); const { recentApps, recentDevs } = useRecentsStore();
const charges = useCharges(); const charges = useCharges();
const groups = charges?.landscape; const groups = charges?.landscape;
@ -108,7 +108,7 @@ export const Home = () => {
})); }));
const devs = recentDevs.map(providerMatch); const devs = recentDevs.map(providerMatch);
useLeapStore.setState({ useAppSearchStore.setState({
matches: ([] as MatchItem[]).concat(appMatches, devs) matches: ([] as MatchItem[]).concat(appMatches, devs)
}); });
}, [recentApps, recentDevs]); }, [recentApps, recentDevs]);

View File

@ -3,7 +3,7 @@ import { RouteComponentProps } from 'react-router-dom';
import fuzzy from 'fuzzy'; import fuzzy from 'fuzzy';
import { Provider, deSig } from '@urbit/api'; import { Provider, deSig } from '@urbit/api';
import * as ob from 'urbit-ob'; import * as ob from 'urbit-ob';
import { MatchItem, useLeapStore } from '../Nav'; import { MatchItem, useAppSearchStore } from '../Nav';
import { useAllies, useCharges } from '../../state/docket'; import { useAllies, useCharges } from '../../state/docket';
import { ProviderList } from '../../components/ProviderList'; import { ProviderList } from '../../components/ProviderList';
import useContactState from '../../state/contact'; import useContactState from '../../state/contact';
@ -19,7 +19,7 @@ export function providerMatch(provider: Provider | string): MatchItem {
return { return {
value, value,
display, display,
url: `/leap/search/${value}/apps`, url: `/search/${value}/apps`,
openInNewTab: false openInNewTab: false
}; };
} }
@ -34,7 +34,7 @@ function fuzzySort(search: string) {
} }
export const Providers = ({ match }: ProvidersProps) => { export const Providers = ({ match }: ProvidersProps) => {
const selectedMatch = useLeapStore((state) => state.selectedMatch); const selectedMatch = useAppSearchStore((state) => state.selectedMatch);
const provider = match?.params.ship; const provider = match?.params.ship;
const contacts = useContactState((s) => s.contacts); const contacts = useContactState((s) => s.contacts);
const charges = useCharges(); const charges = useCharges();
@ -87,7 +87,7 @@ export const Providers = ({ match }: ProvidersProps) => {
useEffect(() => { useEffect(() => {
if (search) { if (search) {
useLeapStore.setState({ rawInput: search }); useAppSearchStore.setState({ rawInput: search });
} }
}, []); }, []);
@ -106,7 +106,7 @@ export const Providers = ({ match }: ProvidersProps) => {
const newProviderMatches = isValidPatp const newProviderMatches = isValidPatp
? [ ? [
{ {
url: `/leap/search/${patp}/apps`, url: `/search/${patp}/apps`,
value: patp, value: patp,
display: patp, display: patp,
openInNewTab: false openInNewTab: false
@ -114,7 +114,7 @@ export const Providers = ({ match }: ProvidersProps) => {
] ]
: []; : [];
useLeapStore.setState({ useAppSearchStore.setState({
matches: ([] as MatchItem[]).concat(appMatches, providerMatches, newProviderMatches) matches: ([] as MatchItem[]).concat(appMatches, providerMatches, newProviderMatches)
}); });
} }

View File

@ -5,10 +5,10 @@ import { Spinner } from '../../components/Spinner';
import useDocketState, { useCharge, useTreaty } from '../../state/docket'; import useDocketState, { useCharge, useTreaty } from '../../state/docket';
import { usePike } from '../../state/kiln'; import { usePike } from '../../state/kiln';
import { getAppName } from '../../state/util'; import { getAppName } from '../../state/util';
import { useLeapStore } from '../Nav'; import { useAppSearchStore } from '../Nav';
export const TreatyInfo = () => { export const TreatyInfo = () => {
const select = useLeapStore((state) => state.select); const select = useAppSearchStore((state) => state.select);
const { host, desk } = useParams<{ host: string; desk: string }>(); const { host, desk } = useParams<{ host: string; desk: string }>();
const treaty = useTreaty(host, desk); const treaty = useTreaty(host, desk);
const pike = usePike(desk); const pike = usePike(desk);
@ -23,16 +23,23 @@ export const TreatyInfo = () => {
useEffect(() => { useEffect(() => {
select(<>{name}</>); select(<>{name}</>);
useLeapStore.setState({ matches: [] }); useAppSearchStore.setState({ matches: [] });
}, [name]); }, [name]);
if (!treaty) { if (!treaty) {
// TODO: maybe replace spinner with skeletons // TODO: maybe replace spinner with skeletons
return ( return (
<div className="dialog-inner-container flex justify-center text-black"> <div className="dialog-inner-container flex justify-center text-black">
<Spinner className="w-10 h-10" /> <Spinner className="h-10 w-10" />
</div> </div>
); );
} }
return <AppInfo className="dialog-inner-container" docket={charge || treaty} pike={pike} />; return (
<AppInfo
treatyInfoShip={treaty.ship}
className="dialog-inner-container"
docket={charge || treaty}
pike={pike}
/>
);
}; };

View File

@ -44,7 +44,7 @@ export const Grid: FunctionComponent = () => {
return ( return (
<div className="flex h-screen w-full flex-col"> <div className="flex h-screen w-full flex-col">
{/* !disableWayfinding && <LandscapeWayfinding /> */} {!disableWayfinding && <LandscapeWayfinding />}
<header className="fixed bottom-0 left-0 z-30 flex w-full justify-center px-4 sm:sticky sm:bottom-auto sm:top-0"> <header className="fixed bottom-0 left-0 z-30 flex w-full justify-center px-4 sm:sticky sm:bottom-auto sm:top-0">
<Nav menu={menu} /> <Nav menu={menu} />
</header> </header>

View File

@ -1,4 +1,9 @@
import Urbit, { PokeInterface, Scry, SubscriptionRequestInterface, Thread } from '@urbit/http-api'; import Urbit, {
PokeInterface,
Scry,
SubscriptionRequestInterface,
Thread,
} from '@urbit/http-api';
import type UrbitMock from '@tloncorp/mock-http-api'; import type UrbitMock from '@tloncorp/mock-http-api';
declare global { declare global {
@ -9,7 +14,8 @@ declare global {
} }
export const IS_MOCK = import.meta.env.MODE === 'mock'; export const IS_MOCK = import.meta.env.MODE === 'mock';
const URL = (import.meta.env.VITE_MOCK_URL || import.meta.env.VITE_VERCEL_URL) as string; const URL = (import.meta.env.VITE_MOCK_URL ||
import.meta.env.VITE_VERCEL_URL) as string;
let client = undefined as unknown as Urbit | UrbitMock; let client = undefined as unknown as Urbit | UrbitMock;
@ -61,6 +67,15 @@ const api = {
return client.subscribe(params); return client.subscribe(params);
}, },
async subscribeOnce<T>(app: string, path: string, timeout?: number) {
if (!client) {
await setupAPI();
}
const clientPoke = await client.subscribeOnce<T>(app, path, timeout);
return clientPoke;
},
async thread<Return, T>(params: Thread<T>) { async thread<Return, T>(params: Thread<T>) {
if (!client) { if (!client) {
await setupAPI(); await setupAPI();
@ -74,7 +89,7 @@ const api = {
} }
return client.unsubscribe(id); return client.unsubscribe(id);
} },
} as Urbit | UrbitMock; } as Urbit | UrbitMock;
export default api; export default api;

View File

@ -20,7 +20,7 @@ import {
ChargeUpdate, ChargeUpdate,
kilnRevive, kilnRevive,
kilnSuspend, kilnSuspend,
allyShip allyShip,
} from '@urbit/api'; } from '@urbit/api';
import api from './api'; import api from './api';
import { normalizeUrbitColor } from './util'; import { normalizeUrbitColor } from './util';
@ -65,11 +65,14 @@ const useDocketState = create<DocketState>((set, get) => ({
fetchCharges: async () => { fetchCharges: async () => {
const charg = (await api.scry<ChargeUpdateInitial>(scryCharges)).initial; const charg = (await api.scry<ChargeUpdateInitial>(scryCharges)).initial;
const charges = Object.entries(charg).reduce((obj: ChargesWithDesks, [key, value]) => { const charges = Object.entries(charg).reduce(
// eslint-disable-next-line no-param-reassign (obj: ChargesWithDesks, [key, value]) => {
obj[key] = normalizeDocket(value as ChargeWithDesk, key); // eslint-disable-next-line no-param-reassign
return obj; obj[key] = normalizeDocket(value as ChargeWithDesk, key);
}, {}); return obj;
},
{}
);
set({ charges }); set({ charges });
}, },
@ -79,7 +82,8 @@ const useDocketState = create<DocketState>((set, get) => ({
return allies; return allies;
}, },
fetchAllyTreaties: async (ally: string) => { fetchAllyTreaties: async (ally: string) => {
let treaties = (await api.scry<TreatyUpdateIni>(scryAllyTreaties(ally))).ini; let treaties = (await api.scry<TreatyUpdateIni>(scryAllyTreaties(ally)))
.ini;
treaties = normalizeDockets(treaties); treaties = normalizeDockets(treaties);
set((s) => ({ treaties: { ...s.treaties, ...treaties } })); set((s) => ({ treaties: { ...s.treaties, ...treaties } }));
return treaties; return treaties;
@ -95,7 +99,7 @@ const useDocketState = create<DocketState>((set, get) => ({
const result = await api.subscribeOnce('treaty', `/treaty/${key}`, 20000); const result = await api.subscribeOnce('treaty', `/treaty/${key}`, 20000);
const treaty = { ...normalizeDocket(result, desk), ship }; const treaty = { ...normalizeDocket(result, desk), ship };
set((state) => ({ set((state) => ({
treaties: { ...state.treaties, [key]: treaty } treaties: { ...state.treaties, [key]: treaty },
})); }));
return treaty; return treaty;
}, },
@ -104,16 +108,18 @@ const useDocketState = create<DocketState>((set, get) => ({
if (!treaty) { if (!treaty) {
throw new Error('Bad install'); throw new Error('Bad install');
} }
set((state) => addCharge(state, desk, { ...treaty, chad: { install: null } })); set((state) =>
addCharge(state, desk, { ...treaty, chad: { install: null } })
);
return api.poke(docketInstall(ship, desk)); await api.poke(docketInstall(ship, desk));
}, },
uninstallDocket: async (desk: string) => { uninstallDocket: async (desk: string) => {
set((state) => delCharge(state, desk)); set((state) => delCharge(state, desk));
await api.poke({ await api.poke({
app: 'docket', app: 'docket',
mark: 'docket-uninstall', mark: 'docket-uninstall',
json: desk json: desk,
}); });
}, },
toggleDocket: async (desk: string) => { toggleDocket: async (desk: string) => {
@ -139,28 +145,38 @@ const useDocketState = create<DocketState>((set, get) => ({
return api.poke(allyShip(ship)); return api.poke(allyShip(ship));
}, },
set set,
})); }));
function normalizeDocket<T extends Docket>(docket: T, desk: string): T { function normalizeDocket<T extends Docket>(docket: T, desk: string): T {
return { return {
...docket, ...docket,
desk, desk,
color: normalizeUrbitColor(docket.color) color: normalizeUrbitColor(docket.color),
}; };
} }
function normalizeDockets<T extends Docket>(dockets: Record<string, T>): Record<string, T> { function normalizeDockets<T extends Docket>(
return Object.entries(dockets).reduce((obj: Record<string, T>, [key, value]) => { dockets: Record<string, T>
const [, desk] = key.split('/'); ): Record<string, T> {
// eslint-disable-next-line no-param-reassign return Object.entries(dockets).reduce(
obj[key] = normalizeDocket(value, desk); (obj: Record<string, T>, [key, value]) => {
return obj; const [, desk] = key.split('/');
}, {}); // eslint-disable-next-line no-param-reassign
obj[key] = normalizeDocket(value, desk);
return obj;
},
{}
);
} }
function addCharge(state: DocketState, desk: string, charge: Charge) { function addCharge(state: DocketState, desk: string, charge: Charge) {
return { charges: { ...state.charges, [desk]: normalizeDocket(charge as ChargeWithDesk, desk) } }; return {
charges: {
...state.charges,
[desk]: normalizeDocket(charge as ChargeWithDesk, desk),
},
};
} }
function delCharge(state: DocketState, desk: string) { function delCharge(state: DocketState, desk: string) {
@ -184,7 +200,7 @@ api.subscribe({
return { charges: state.charges }; return { charges: state.charges };
}); });
} },
}); });
api.subscribe({ api.subscribe({
@ -203,7 +219,7 @@ api.subscribe({
draft.treaties = { ...draft.treaties, ...treaties }; draft.treaties = { ...draft.treaties, ...treaties };
} }
}); });
} },
}); });
api.subscribe({ api.subscribe({
@ -216,7 +232,7 @@ api.subscribe({
draft.allies[ship] = alliance; draft.allies[ship] = alliance;
} }
}); });
} },
}); });
const selCharges = (s: DocketState) => { const selCharges = (s: DocketState) => {
@ -259,7 +275,9 @@ export function useAllyTreaties(ship: string) {
if (isAllied) { if (isAllied) {
setStatus('loading'); setStatus('loading');
try { try {
const newTreaties = await useDocketState.getState().fetchAllyTreaties(ship); const newTreaties = await useDocketState
.getState()
.fetchAllyTreaties(ship);
if (Object.keys(newTreaties).length > 0) { if (Object.keys(newTreaties).length > 0) {
setTreaties(newTreaties); setTreaties(newTreaties);
@ -303,7 +321,7 @@ export function useAllyTreaties(ship: string) {
return { return {
isAllied, isAllied,
treaties, treaties,
status status,
}; };
} }
@ -322,7 +340,9 @@ export function useTreaty(host: string, desk: string) {
export function allyForTreaty(ship: string, desk: string) { export function allyForTreaty(ship: string, desk: string) {
const ref = `${ship}/${desk}`; const ref = `${ship}/${desk}`;
const { allies } = useDocketState.getState(); const { allies } = useDocketState.getState();
const ally = Object.entries(allies).find(([, allied]) => allied.includes(ref))?.[0]; const ally = Object.entries(allies).find(([, allied]) =>
allied.includes(ref)
)?.[0];
return ally; return ally;
} }

View File

@ -1,4 +1,4 @@
import { useLeapStore } from './nav/Nav'; import { useAppSearchStore } from './nav/Nav';
import { useRecentsStore } from './nav/search/Home'; import { useRecentsStore } from './nav/search/Home';
import useDocketState from './state/docket'; import useDocketState from './state/docket';
@ -8,7 +8,7 @@ declare global {
desk: string; desk: string;
recents: typeof useRecentsStore.getState; recents: typeof useRecentsStore.getState;
docket: typeof useDocketState.getState; docket: typeof useDocketState.getState;
leap: typeof useLeapStore.getState; appSearch: typeof useAppSearchStore.getState;
} }
} }

View File

@ -66,6 +66,7 @@ const base = new Theme().addColors({
}, },
blue: { blue: {
DEFAULT: '#008EFF', DEFAULT: '#008EFF',
soft: '#E5F4FF',
50: '#EFF9FF', 50: '#EFF9FF',
100: '#C8EDFF', 100: '#C8EDFF',
200: '#A0E1FF', 200: '#A0E1FF',