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)
`state
=/ =charge (~(got by charges) desk)
?: &(?=(%install -.chad.charge) ?=(%held zest))
`state
?- zest
%live
?. ?=(%glob -.href.docket.charge)

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import { Dialog, DialogContent } from './Dialog';
import { Button } from './Button';
import { useCharges } from '../state/docket';
import { GroupLink } from './GroupLink';
import WayfindingAppLink from './WayfindingAppLink';
interface Group {
title: string;
@ -14,14 +15,6 @@ interface Group {
link: string;
}
interface App {
title: string;
description: string;
image: string;
color: string;
link: string;
}
const groups: Record<string, Group> = {
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() {
const charges = useCharges();
return (
@ -92,26 +56,35 @@ function LandscapeDescription() {
software developer, like ~paldev.
</p>
<div className="mt-8 space-y-2">
<AppLink
<WayfindingAppLink
title="Groups"
description="Build or join Urbit-based communities"
link="/apps/groups"
image={charges.groups?.image || ''}
color={charges.groups?.color || 'bg-gray'}
installed={charges['groups'] ? true : false}
source="~sogryp-dister-dozzod-dozzod"
desk="groups"
/>
<AppLink
<WayfindingAppLink
title="Talk"
description="Simple instant messaging app"
link="/apps/talk"
image={charges.talk?.image || ''}
color={charges.talk?.color || 'bg-blue'}
installed={charges['talk'] ? true : false}
source="~sogryp-dister-dozzod-dozzod"
desk="talk"
/>
<AppLink
<WayfindingAppLink
title="Terminal"
description="Pop open the hood of your urbit"
link="/apps/webterm"
image={charges.webterm?.image || ''}
color={charges.webterm?.color || 'bg-black'}
installed={charges['terminal'] ? true : false}
source="~mister-dister-dozzod-dozzod"
desk="terminal"
/>
</div>
<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;
return (
<div className="mt-5 sm:mt-8 space-y-5 sm:space-y-8">
<Attribute title="Desk Hash" attr="hash">
<div className="mt-5 space-y-5 sm:mt-8 sm:space-y-8">
<Attribute title="Desk Hash" attr="hash" className="break-all">
{pike.hash}
</Attribute>
<Attribute title="Installed into" attr="local-desk">
@ -17,7 +17,8 @@ export function PikeMeta(props: { pike: Pike }) {
</Attribute>
{pike.wefts && pike.wefts.length > 0 ? (
<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>
) : null}
</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,
useImperativeHandle,
useRef,
useEffect
useEffect,
useState,
} from 'react';
import { Link, useHistory, useLocation, useRouteMatch } from 'react-router-dom';
import { Cross } from '../components/icons/Cross';
import { useDebounce } from '../logic/useDebounce';
import { useErrorHandler } from '../logic/useErrorHandler';
import { useMedia } from '../logic/useMedia';
import { MenuState, useLeapStore } from './Nav';
import { MenuState, useAppSearchStore } from './Nav';
function normalizePathEnding(path: string) {
const end = path.length - 1;
@ -37,7 +38,6 @@ type LeapProps = {
menu: MenuState;
dropdown: string;
navOpen: boolean;
systemMenuOpen: boolean;
} & HTMLAttributes<HTMLDivElement>;
function normalizeMatchString(match: string, keepAltChars: boolean): string {
@ -50,19 +50,21 @@ function normalizeMatchString(match: string, keepAltChars: boolean): string {
return normalizedString;
}
export const Leap = React.forwardRef(
({ menu, dropdown, navOpen, systemMenuOpen, className }: LeapProps, ref) => {
export const AppSearch = React.forwardRef(
({ menu, dropdown, navOpen, className }: LeapProps, ref) => {
const { push } = useHistory();
const location = useLocation();
const isMobile = useMedia('(max-width: 639px)');
const deskMatch = useRouteMatch<{ menu?: MenuState; query?: string; desk?: string }>(
`/leap/${menu}/:query?/(apps)?/:desk?`
const deskMatch = useRouteMatch<{
menu?: MenuState;
query?: string;
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);
useImperativeHandle(ref, () => inputRef.current);
const { rawInput, selectedMatch, matches, selection, select } = useLeapStore();
const { rawInput, selectedMatch, matches, selection, select } =
useAppSearchStore();
const handleError = useErrorHandler();
useEffect(() => {
@ -77,16 +79,24 @@ export const Leap = React.forwardRef(
useEffect(() => {
const newMatch = getMatch(rawInput);
if (newMatch && rawInput) {
useLeapStore.setState({ selectedMatch: newMatch });
useAppSearchStore.setState({ selectedMatch: newMatch });
}
}, [rawInput, matches]);
useEffect(() => {
if (menu === 'search') {
inputRef.current?.focus();
} else {
inputRef.current?.blur();
}
}, [menu]);
const toggleSearch = useCallback(() => {
if (selection || menu === 'search') {
return;
}
push('/leap/search');
push('/search');
}, [selection, menu]);
const onFocus = useCallback(
@ -119,7 +129,7 @@ export const Leap = React.forwardRef(
.trim()
.replace('%', '')
.replace(/(~?[\w^_-]{3,13})\//, '$1/apps/$1/');
push(`/leap/${menu}/${normalizedValue}`);
push(`/${menu}/${normalizedValue}`);
},
[menu]
);
@ -130,7 +140,7 @@ export const Leap = React.forwardRef(
return;
}
useLeapStore.setState({ searchInput: input });
useAppSearchStore.setState({ searchInput: input });
navigateByInput(input);
},
300,
@ -139,52 +149,12 @@ export const Leap = React.forwardRef(
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(
handleError((e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
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 matchValue = inputMatch?.value;
@ -192,16 +162,17 @@ export const Leap = React.forwardRef(
inputRef.current.value = matchValue;
const start = matchValue.startsWith(value)
? 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);
useLeapStore.setState({
useAppSearchStore.setState({
rawInput: matchValue,
selectedMatch: inputMatch
selectedMatch: inputMatch,
});
} else {
useLeapStore.setState({
useAppSearchStore.setState({
rawInput: value,
selectedMatch: matches[0]
selectedMatch: matches[0],
});
}
@ -227,7 +198,7 @@ export const Leap = React.forwardRef(
}
push(currentMatch.url);
useLeapStore.setState({ rawInput: '' });
useAppSearchStore.setState({ rawInput: '' });
}),
[deskMatch, selectedMatch]
);
@ -239,7 +210,12 @@ export const Leap = React.forwardRef(
if (deletion && !rawInput && selection) {
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 || '');
push(pathBack);
}
@ -257,14 +233,15 @@ export const Leap = React.forwardRef(
return matchValue === searchValue;
})
: 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 newMatch = matches[index];
useLeapStore.setState({
useAppSearchStore.setState({
rawInput: newMatch.value,
// searchInput: matchValue,
selectedMatch: newMatch
selectedMatch: newMatch,
});
}
}),
@ -302,7 +279,7 @@ export const Leap = React.forwardRef(
id="leap"
type="text"
ref={inputRef}
placeholder={selection ? '' : getPlaceholder()}
placeholder={selection ? '' : 'e.g., ~paldev or ~paldev/pals'}
// TODO: style placeholder text with 100% opacity.
// 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"
@ -320,7 +297,7 @@ export const Leap = React.forwardRef(
</form>
{menu === 'search' && (
<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"
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 { ErrorAlert } from '../components/ErrorAlert';
import { Help } from './Help';
import { Leap } from './Leap';
import { AppSearch } from './AppSearch';
import { Notifications } from './notifications/Notifications';
import { NotificationsLink } from './notifications/NotificationsLink';
import { Search } from './Search';
@ -30,6 +30,8 @@ import { SystemPreferences } from '../preferences/SystemPreferences';
import { useSystemUpdate } from '../logic/useSystemUpdate';
import { Bullet } from '../components/icons/Bullet';
import { Cross } from '../components/icons/Cross';
import MagnifyingGlass16Icon from '../components/icons/MagnifyingGlass16Icon';
import GetApps from './GetApps';
export interface MatchItem {
url: string;
@ -38,7 +40,7 @@ export interface MatchItem {
display?: string;
}
interface LeapStore {
interface AppSearchStore {
rawInput: string;
searchInput: string;
matches: MatchItem[];
@ -47,7 +49,7 @@ interface LeapStore {
select: (selection: React.ReactNode, input?: string) => void;
}
export const useLeapStore = create<LeapStore>((set) => ({
export const useAppSearchStore = create<AppSearchStore>((set) => ({
rawInput: '',
searchInput: '',
matches: [],
@ -61,11 +63,13 @@ export const useLeapStore = create<LeapStore>((set) => ({
}),
}));
window.leap = useLeapStore.getState;
window.appSearch = useAppSearchStore.getState;
export type MenuState =
| 'closed'
| 'search'
| 'get-apps'
| 'app'
| 'notifications'
| 'help-and-support'
| 'system-preferences'
@ -99,7 +103,7 @@ export const SystemPrefsLink = ({
}
return (
<Link to="/leap/system-preferences" className="relative flex-none">
<Link to="/system-preferences" className="relative flex-none">
<Avatar shipName={window.ship} size="nav" />
{systemBlocked && (
<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 }) => {
const { push } = useHistory();
const inputRef = useRef<HTMLInputElement>(null);
const navRef = useRef<HTMLDivElement>(null);
const dialogNavRef = useRef<HTMLDivElement>(null);
const systemMenuOpen = useRouteMatch('/leap/system-preferences');
const { systemBlocked } = useSystemUpdate();
const [dialogContentOpen, setDialogContentOpen] = useState(false);
const select = useLeapStore((state) => state.select);
const select = useAppSearchStore((state) => state.select);
const menuState = menu || 'closed';
const isOpen = menuState !== 'upgrading' && menuState !== 'closed';
const isOpen =
menuState !== 'upgrading' && menuState !== 'closed' && menuState !== 'app';
useEffect(() => {
if (!isOpen) {
@ -172,13 +190,16 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
navOpen={isOpen}
notificationsOpen={menu === 'notifications'}
/>
<Leap
ref={inputRef}
menu={menuState}
dropdown="leap-items"
navOpen={isOpen}
systemMenuOpen={!!systemMenuOpen}
/>
{menuState === 'search' || menuState === 'get-apps' ? (
<AppSearch
ref={inputRef}
menu={menuState}
dropdown="leap-items"
navOpen={isOpen}
/>
) : (
<GetAppsLink />
)}
</Portal.Root>
<div
ref={navRef}
@ -213,13 +234,11 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
role="listbox"
>
<Switch>
<Route path="/leap/notifications" component={Notifications} />
<Route
path="/leap/system-preferences"
component={SystemPreferences}
/>
<Route path="/leap/help-and-support" component={Help} />
<Route path={['/leap/search', '/leap']} component={Search} />
<Route path="/notifications" component={Notifications} />
<Route path="/system-preferences" component={SystemPreferences} />
<Route path="/help-and-support" component={Help} />
<Route path="/get-apps" component={GetApps} />
<Route path={['/search']} component={Search} />
</Switch>
</div>
</DialogContent>

View File

@ -9,7 +9,7 @@ import { usePike } from '../state/kiln';
import { disableDefault, handleDropdownLink } from '../state/util';
import { useMedia } from '../logic/useMedia';
import { Cross } from '../components/icons/Cross';
import { useLeapStore } from './Nav';
import { useAppSearchStore } from './Nav';
type SystemMenuProps = HTMLAttributes<HTMLButtonElement> & {
open: boolean;
@ -33,7 +33,7 @@ export const SystemMenu = ({
const garden = usePike(window.desk);
const hash = garden ? getHash(garden) : null;
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 copyHash = useCallback(

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { RouteComponentProps } from 'react-router-dom';
import fuzzy from 'fuzzy';
import { Provider, deSig } from '@urbit/api';
import * as ob from 'urbit-ob';
import { MatchItem, useLeapStore } from '../Nav';
import { MatchItem, useAppSearchStore } from '../Nav';
import { useAllies, useCharges } from '../../state/docket';
import { ProviderList } from '../../components/ProviderList';
import useContactState from '../../state/contact';
@ -19,7 +19,7 @@ export function providerMatch(provider: Provider | string): MatchItem {
return {
value,
display,
url: `/leap/search/${value}/apps`,
url: `/search/${value}/apps`,
openInNewTab: false
};
}
@ -34,7 +34,7 @@ function fuzzySort(search: string) {
}
export const Providers = ({ match }: ProvidersProps) => {
const selectedMatch = useLeapStore((state) => state.selectedMatch);
const selectedMatch = useAppSearchStore((state) => state.selectedMatch);
const provider = match?.params.ship;
const contacts = useContactState((s) => s.contacts);
const charges = useCharges();
@ -87,7 +87,7 @@ export const Providers = ({ match }: ProvidersProps) => {
useEffect(() => {
if (search) {
useLeapStore.setState({ rawInput: search });
useAppSearchStore.setState({ rawInput: search });
}
}, []);
@ -106,7 +106,7 @@ export const Providers = ({ match }: ProvidersProps) => {
const newProviderMatches = isValidPatp
? [
{
url: `/leap/search/${patp}/apps`,
url: `/search/${patp}/apps`,
value: patp,
display: patp,
openInNewTab: false
@ -114,7 +114,7 @@ export const Providers = ({ match }: ProvidersProps) => {
]
: [];
useLeapStore.setState({
useAppSearchStore.setState({
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 { usePike } from '../../state/kiln';
import { getAppName } from '../../state/util';
import { useLeapStore } from '../Nav';
import { useAppSearchStore } from '../Nav';
export const TreatyInfo = () => {
const select = useLeapStore((state) => state.select);
const select = useAppSearchStore((state) => state.select);
const { host, desk } = useParams<{ host: string; desk: string }>();
const treaty = useTreaty(host, desk);
const pike = usePike(desk);
@ -23,16 +23,23 @@ export const TreatyInfo = () => {
useEffect(() => {
select(<>{name}</>);
useLeapStore.setState({ matches: [] });
useAppSearchStore.setState({ matches: [] });
}, [name]);
if (!treaty) {
// TODO: maybe replace spinner with skeletons
return (
<div className="dialog-inner-container flex justify-center text-black">
<Spinner className="w-10 h-10" />
<Spinner className="h-10 w-10" />
</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 (
<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">
<Nav menu={menu} />
</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';
declare global {
@ -9,7 +14,8 @@ declare global {
}
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;
@ -61,6 +67,15 @@ const api = {
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>) {
if (!client) {
await setupAPI();
@ -74,7 +89,7 @@ const api = {
}
return client.unsubscribe(id);
}
},
} as Urbit | UrbitMock;
export default api;

View File

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

View File

@ -1,4 +1,4 @@
import { useLeapStore } from './nav/Nav';
import { useAppSearchStore } from './nav/Nav';
import { useRecentsStore } from './nav/search/Home';
import useDocketState from './state/docket';
@ -8,7 +8,7 @@ declare global {
desk: string;
recents: typeof useRecentsStore.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: {
DEFAULT: '#008EFF',
soft: '#E5F4FF',
50: '#EFF9FF',
100: '#C8EDFF',
200: '#A0E1FF',