Merge pull request #5209 from urbit/lf/app-linking

grid/treaty: link to apps
This commit is contained in:
Liam Fitzgerald 2021-09-10 12:52:18 +10:00 committed by GitHub
commit caea7a7f1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 307 additions and 59 deletions

View File

@ -7,6 +7,7 @@
sovereign=(map desk treaty) sovereign=(map desk treaty)
entente=alliance entente=alliance
=allies:ally =allies:ally
direct=(set [=ship =desk])
== ==
-- --
^- agent:gall ^- agent:gall
@ -95,16 +96,18 @@
[%treaty @ @ ~] [%treaty @ @ ~]
=/ =ship (slav %p i.t.path) =/ =ship (slav %p i.t.path)
=* desk i.t.t.path =* desk i.t.t.path
=/ =treaty ?: =(our.bowl ship)
?: =(our.bowl ship) (~(got by sovereign) desk) :_(this (fact-init:io treaty+!>((~(got by sovereign) desk)))^~)
(~(got by treaties) [ship desk]) ?^ treat=(~(get by treaties) [ship desk])
:_ this :_ this
(fact-init:io treaty+!>(treaty))^~ (fact-init:io treaty+!>(u.treat))^~
?> =(our.bowl src.bowl)
=. direct (~(put in direct) [ship desk])
:_(this (drop ~(safe-watch tr:cc [ship desk])))
:: ::
[%alliance ~] [%alliance ~]
:_ this :_ this
(fact-init:io (alliance-update:cg:cc %ini entente))^~ (fact-init:io (alliance-update:cg:cc %ini entente))^~
:: local :: local
:: ::
[%allies ~] [%allies ~]
@ -196,7 +199,9 @@
:: ::
%watch-ack %watch-ack
?~ p.sign `this ?~ p.sign `this
=. treaties (~(del by treaties) ship desk) =: treaties (~(del by treaties) ship desk)
direct (~(del in direct) ship desk)
==
%- (slog leaf+"Withdrew from treaty {<ship>}/{<desk>}" u.p.sign) %- (slog leaf+"Withdrew from treaty {<ship>}/{<desk>}" u.p.sign)
`this `this
:: ::

View File

@ -3,6 +3,8 @@ import Mousetrap from 'mousetrap';
import { BrowserRouter, Switch, Route, useHistory } from 'react-router-dom'; import { BrowserRouter, Switch, Route, useHistory } from 'react-router-dom';
import { Grid } from './pages/Grid'; import { Grid } from './pages/Grid';
import useDocketState from './state/docket'; import useDocketState from './state/docket';
import { PermalinkRoutes } from './pages/PermalinkRoutes';
import useKilnState from './state/kiln';
import { usePreferencesStore } from './nav/preferences/usePreferencesStore'; import { usePreferencesStore } from './nav/preferences/usePreferencesStore';
import useContactState from './state/contact'; import useContactState from './state/contact';
import api from './state/api'; import api from './state/api';
@ -40,6 +42,9 @@ const AppRoutes = () => {
const { fetchAllies, fetchCharges } = useDocketState.getState(); const { fetchAllies, fetchCharges } = useDocketState.getState();
fetchCharges(); fetchCharges();
fetchAllies(); fetchAllies();
const { fetchVats, fetchLag } = useKilnState.getState();
fetchVats();
fetchLag();
useContactState.getState().initialize(api); useContactState.getState().initialize(api);
Mousetrap.bind(['command+/', 'ctrl+/'], () => { Mousetrap.bind(['command+/', 'ctrl+/'], () => {
@ -49,6 +54,7 @@ const AppRoutes = () => {
return ( return (
<Switch> <Switch>
<Route path="/perma" component={PermalinkRoutes} />
<Route path={['/leap/:menu', '/']} component={Grid} /> <Route path={['/leap/:menu', '/']} component={Grid} />
</Switch> </Switch>
); );

View File

@ -20,15 +20,13 @@ export const Setting: FC<SettingsProps> = ({ name, on, toggle, className, childr
<h3 id={id} className="flex items-center h4 mb-2"> <h3 id={id} className="flex items-center h4 mb-2">
{name} {status === 'loading' && <Spinner className="ml-2" />} {name} {status === 'loading' && <Spinner className="ml-2" />}
</h3> </h3>
<div className="flex"> <div className="flex items-center space-x-2">
<div className="flex-none mr-2"> <Toggle
<Toggle aria-labelledby={id}
aria-labelledby={id} pressed={on}
pressed={on} onPressedChange={call}
onPressedChange={call} className="text-blue-400"
className="text-blue-400" />
/>
</div>
<div className="flex-1 space-y-6">{children}</div> <div className="flex-1 space-y-6">{children}</div>
</div> </div>
</section> </section>

View File

@ -0,0 +1,46 @@
import _ from 'lodash';
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
function mergeQuery(search: URLSearchParams, added: Record<string, string>) {
_.forIn(added, (v, k) => {
if (v) {
search.append(k, v);
} else {
search.delete(k);
}
});
}
export function useQuery() {
const { search, pathname } = useLocation();
const query = useMemo(() => new URLSearchParams(search), [search]);
const appendQuery = useCallback(
(added: Record<string, string>) => {
const q = new URLSearchParams(search);
mergeQuery(q, added);
return q.toString();
},
[search]
);
const toQuery = useCallback(
(params: Record<string, string>, path = pathname) => {
const q = new URLSearchParams(search);
mergeQuery(q, params);
return {
pathname: path,
search: q.toString()
};
},
[search, pathname]
);
return {
query,
appendQuery,
toQuery
};
}

View File

@ -5,8 +5,40 @@ import { NotificationPrefs } from './preferences/NotificationPrefs';
import { SystemUpdatePrefs } from './preferences/SystemUpdatePrefs'; import { SystemUpdatePrefs } from './preferences/SystemUpdatePrefs';
import notificationsSVG from '../assets/notifications.svg'; import notificationsSVG from '../assets/notifications.svg';
import systemUpdatesSVG from '../assets/system-updates.svg'; import systemUpdatesSVG from '../assets/system-updates.svg';
import { InterfacePrefs } from './preferences/InterfacePrefs';
export const SystemPreferences = ({ match }: RouteComponentProps<{ submenu: string }>) => { interface SystemPreferencesSectionProps extends RouteComponentProps<{ submenu: string }> {
submenu: string;
active: boolean;
text: string;
icon?: string;
}
function SystemPreferencesSection({
match,
submenu,
active,
icon,
text
}: SystemPreferencesSectionProps) {
return (
<li>
<Link
to={`${match.url}/${submenu}`}
className={classNames(
'flex items-center px-5 py-3 hover:text-black hover:bg-gray-100',
active && 'text-black bg-gray-100'
)}
>
{icon ? <img className="w-8 h-8 mr-3" src={icon} alt="" /> : null}
{text}
</Link>
</li>
);
}
export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }>) => {
const { match } = props;
const subMatch = useRouteMatch<{ submenu: string }>(`${match.url}/:submenu`); const subMatch = useRouteMatch<{ submenu: string }>(`${match.url}/:submenu`);
const matchSub = useCallback( const matchSub = useCallback(
@ -28,36 +60,34 @@ export const SystemPreferences = ({ match }: RouteComponentProps<{ submenu: stri
</div> </div>
<nav className="border-b-2 border-gray-50"> <nav className="border-b-2 border-gray-50">
<ul className="font-semibold"> <ul className="font-semibold">
<li> <SystemPreferencesSection
<Link {...props}
to={`${match.url}/notifications`} text="Notifications"
className={classNames( icon={notificationsSVG}
'flex items-center px-8 py-3 hover:text-black hover:bg-gray-50', submenu="notifications"
matchSub('notifications') && 'text-black bg-gray-50' active={matchSub('notifications')}
)} />
> <SystemPreferencesSection
<img className="w-8 h-8 mr-3" src={notificationsSVG} alt="" /> {...props}
Notifications text="System Updates"
</Link> icon={systemUpdatesSVG}
</li> submenu="system-updates"
<li> active={matchSub('system-updates')}
<Link />
to={`${match.url}/system-updates`} <SystemPreferencesSection
className={classNames( {...props}
'flex items-center px-8 py-3 hover:text-black hover:bg-gray-50', text="Interface Settings"
matchSub('system-updates') && 'text-black bg-gray-50' icon={systemUpdatesSVG}
)} submenu="interface"
> active={matchSub('interface')}
<img className="w-8 h-8 mr-3" src={systemUpdatesSVG} alt="" /> />
System Updates
</Link>
</li>
</ul> </ul>
</nav> </nav>
</aside> </aside>
<section className="flex-1 p-8 text-black"> <section className="flex-1 p-8 text-black">
<Switch> <Switch>
<Route path={`${match.url}/system-updates`} component={SystemUpdatePrefs} /> <Route path={`${match.url}/system-updates`} component={SystemUpdatePrefs} />
<Route path={`${match.url}/interface`} component={InterfacePrefs} />
<Route path={[`${match.url}/notifications`, match.url]} component={NotificationPrefs} /> <Route path={[`${match.url}/notifications`, match.url]} component={NotificationPrefs} />
</Switch> </Switch>
</section> </section>

View File

@ -0,0 +1,40 @@
import React from 'react';
import { Setting } from '../../components/Setting';
import { ShipName } from '../../components/ShipName';
import { useProtocolHandling, setLocalState } from '../../state/local';
export function InterfacePrefs() {
const protocolHandling = useProtocolHandling();
const toggleProtoHandling = async () => {
if (!protocolHandling && window?.navigator?.registerProtocolHandler) {
try {
window.navigator.registerProtocolHandler('web+urbitgraph', '/apps/grid/perma?ext=%s', 'Urbit Links');
setLocalState((s) => {
s.protocolHandling = true;
});
} catch (e) {
console.error(e);
}
} else if (protocolHandling && window.navigator?.unregisterProtocolHandler) {
try {
window.navigator.unregisterProtocolHandler('web+urbitgraph', '/apps/grid/perma?ext=%s');
setLocalState((s) => {
s.protocolHandling = false;
});
} catch (e) {
console.error(e);
}
}
};
return (
<>
<h2 className="h3 mb-7">Interface Settings</h2>
<Setting on={protocolHandling} toggle={toggleProtoHandling} name="Handle Urbit links">
<p>Automatically open urbit links with this urbit</p>
</Setting>
<div className="space-y-3"> </div>
</>
);
}

View File

@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { AppInfo } from '../../components/AppInfo'; import { AppInfo } from '../../components/AppInfo';
import { ShipName } from '../../components/ShipName'; import { ShipName } from '../../components/ShipName';
import { useCharge, useTreaty } from '../../state/docket'; import useDocketState, { useCharge, useTreaty } from '../../state/docket';
import { useVat } from '../../state/kiln'; import { useVat } from '../../state/kiln';
import { useLeapStore } from '../Nav'; import { useLeapStore } from '../Nav';
@ -13,10 +13,16 @@ export const TreatyInfo = () => {
const vat = useVat(desk); const vat = useVat(desk);
const charge = useCharge(desk); const charge = useCharge(desk);
useEffect(() => {
if(!charge) {
useDocketState.getState().requestTreaty(host, desk);
}
}, [host, desk]);
useEffect(() => { useEffect(() => {
select( select(
<> <>
Apps by <ShipName name={ship} className="font-mono" />: {treaty?.title} {treaty?.title}
</> </>
); );
}, [treaty?.title]); }, [treaty?.title]);

View File

@ -1,9 +1,8 @@
import { map, omit } from 'lodash'; import { map, omit } from 'lodash';
import React, { FunctionComponent, useEffect } from 'react'; import React, { FunctionComponent } from 'react';
import { Route, RouteComponentProps } from 'react-router-dom'; import { Route, RouteComponentProps } from 'react-router-dom';
import { MenuState, Nav } from '../nav/Nav'; import { MenuState, Nav } from '../nav/Nav';
import useDocketState, { useCharges } from '../state/docket'; import { useCharges } from '../state/docket';
import { useKilnState } from '../state/kiln';
import { RemoveApp } from '../tiles/RemoveApp'; import { RemoveApp } from '../tiles/RemoveApp';
import { SuspendApp } from '../tiles/SuspendApp'; import { SuspendApp } from '../tiles/SuspendApp';
import { Tile } from '../tiles/Tile'; import { Tile } from '../tiles/Tile';
@ -17,15 +16,6 @@ export const Grid: FunctionComponent<GridProps> = ({ match }) => {
const charges = useCharges(); const charges = useCharges();
const chargesLoaded = Object.keys(charges).length > 0; const chargesLoaded = Object.keys(charges).length > 0;
useEffect(() => {
const { fetchCharges, fetchAllies } = useDocketState.getState();
const { fetchVats, fetchLag } = useKilnState.getState();
fetchCharges();
fetchAllies();
fetchVats();
fetchLag();
}, []);
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<header className="fixed sm:sticky bottom-0 sm:bottom-auto sm:top-0 left-0 z-30 flex justify-center w-full px-4 bg-white"> <header className="fixed sm:sticky bottom-0 sm:bottom-auto sm:top-0 left-0 z-30 flex justify-center w-full px-4 bg-white">

View File

@ -0,0 +1,92 @@
import React, { useEffect } from 'react';
import { Switch, Route, Redirect, RouteComponentProps } from 'react-router-dom';
import { Spinner } from '../components/Spinner';
import { useQuery } from '../logic/useQuery';
import { useCharge } from '../state/docket';
import useKilnState, { useKilnLoaded, useVat } from '../state/kiln';
import { getAppHref } from '../state/util';
function getDeskByForeignRef(ship: string, desk: string): string | undefined {
const { vats } = useKilnState.getState();
console.log(ship, desk);
const found = Object.entries(vats).find(
([d, vat]) => vat.arak.ship === ship && vat.arak.desk === desk
);
return !!found ? found[0] : undefined;
}
interface AppLinkProps
extends RouteComponentProps<{
ship: string;
desk: string;
link: string;
}> {}
function AppLink(props: AppLinkProps) {
const { ship, desk, link = '' } = props.match.params;
const ourDesk = getDeskByForeignRef(ship, desk);
if (ourDesk) {
return <AppLinkRedirect desk={ourDesk} link={link} />;
}
return <AppLinkNotFound {...props} />;
}
function AppLinkNotFound(props: AppLinkProps) {
const { ship, desk } = props.match.params;
return (<Redirect to={`/leap/search/direct/apps/${ship}/${desk}`} />);
}
function AppLinkInvalid(props: AppLinkProps) {
return (
<div>
<h4>Link was malformed</h4>
<p>The link you tried to follow was invalid</p>
</div>
);
}
function AppLinkRedirect({ desk, link }: { desk: string; link: string }) {
const vat = useVat(desk);
const charge = useCharge(desk);
useEffect(() => {
const query = new URLSearchParams({
'grid-link': encodeURIComponent(`/${link}`)
});
const url = `${getAppHref(charge.href)}?${query.toString()}`;
window.open(url, desk);
}, []);
return <Redirect to="/" />;
}
const LANDSCAPE_SHIP = '~zod';
const LANDSCAPE_DESK = 'groups';
function LandscapeLink(props: RouteComponentProps<{ link: string }>) {
const { link } = props.match.params;
return <Redirect to={`/perma/${LANDSCAPE_SHIP}/${LANDSCAPE_DESK}/${link}`} />;
}
export function PermalinkRoutes() {
const loaded = useKilnLoaded();
const { query } = useQuery();
if (query.has('ext')) {
const ext = query.get('ext')!;
const url = `/perma${ext.slice(16)}`;
return <Redirect to={url} />;
}
if (!loaded) {
return <Spinner />;
}
return (
<Switch>
<Route path="/perma/group/:link+" component={LandscapeLink} />
<Route path="/perma/:ship/:desk/:link*" component={AppLink} />
<Route path="/" component={AppLinkInvalid} />
</Switch>
);
}

View File

@ -87,7 +87,7 @@ const useDocketState = create<DocketState>((set, get) => ({
return treaties[key]; return treaties[key];
} }
const result = await api.subscribeOnce('docket', `/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 }

View File

@ -8,21 +8,24 @@ import { mockVats } from './mock-data';
interface KilnState { interface KilnState {
vats: Vats; vats: Vats;
loaded: boolean;
fetchVats: () => Promise<void>; fetchVats: () => Promise<void>;
lag: boolean; lag: boolean;
fetchLag: () => Promise<void>; fetchLag: () => Promise<void>;
set: (s: KilnState) => void; set: (s: KilnState) => void;
} }
export const useKilnState = create<KilnState>((set) => ({ const useKilnState = create<KilnState>((set) => ({
vats: useMockData ? mockVats : {}, vats: useMockData ? mockVats : {},
lag: !!useMockData, lag: !!useMockData,
loaded: false,
fetchVats: async () => { fetchVats: async () => {
if (useMockData) { if (useMockData) {
await fakeRequest({}, 500); await fakeRequest({}, 500);
set({ loaded: true });
return; return;
} }
const vats = await api.scry<Vats>(getVats); const vats = await api.scry<Vats>(getVats);
set({ vats }); set({ vats, loaded: true });
}, },
fetchLag: async () => { fetchLag: async () => {
const lag = await api.scry<boolean>(scryLag); const lag = await api.scry<boolean>(scryLag);
@ -53,4 +56,9 @@ export function useLag() {
return useKilnState(selLag); return useKilnState(selLag);
} }
const selLoaded = (s: KilnState) => s.loaded;
export function useKilnLoaded() {
return useKilnState(selLoaded);
}
export default useKilnState; export default useKilnState;

View File

@ -0,0 +1,27 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import produce from 'immer';
interface LocalState {
protocolHandling: boolean;
set: (f: (s: LocalState) => void) => void;
}
export const useLocalState = create<LocalState>(
persist(
(set, get) => ({
set: (f) => set(produce(get(), f)),
protocolHandling: false
}),
{
name: 'grid-local'
}
)
);
const selProtocolHandling = (s: LocalState) => s.protocolHandling;
export function useProtocolHandling() {
return useLocalState(selProtocolHandling);
}
export const setLocalState = (f: (s: LocalState) => void) => useLocalState.getState().set(f);

View File

@ -230,7 +230,7 @@ export const mockVat = (desk: string, blockers?: boolean): Vat => ({
aeon: 3, aeon: 3,
desk, desk,
next: blockers ? [{ aeon: 3, weft: { name: 'zuse', kelvin: 419 } }] : [], next: blockers ? [{ aeon: 3, weft: { name: 'zuse', kelvin: 419 } }] : [],
ship: '~dopzod' ship: '~zod'
}, },
hash: '0vh.lhfn6.julg1.fs52d.g2lqj.q5kp0.2o7j3.2bljl.jdm34.hd46v.9uv5v' hash: '0vh.lhfn6.julg1.fs52d.g2lqj.q5kp0.2o7j3.2bljl.jdm34.hd46v.9uv5v'
}); });