mirror of
https://github.com/urbit/shrub.git
synced 2024-12-11 11:02:25 +03:00
Merge pull request #5209 from urbit/lf/app-linking
grid/treaty: link to apps
This commit is contained in:
commit
caea7a7f1f
@ -7,6 +7,7 @@
|
||||
sovereign=(map desk treaty)
|
||||
entente=alliance
|
||||
=allies:ally
|
||||
direct=(set [=ship =desk])
|
||||
==
|
||||
--
|
||||
^- agent:gall
|
||||
@ -95,16 +96,18 @@
|
||||
[%treaty @ @ ~]
|
||||
=/ =ship (slav %p i.t.path)
|
||||
=* desk i.t.t.path
|
||||
=/ =treaty
|
||||
?: =(our.bowl ship) (~(got by sovereign) desk)
|
||||
(~(got by treaties) [ship desk])
|
||||
:_ this
|
||||
(fact-init:io treaty+!>(treaty))^~
|
||||
?: =(our.bowl ship)
|
||||
:_(this (fact-init:io treaty+!>((~(got by sovereign) desk)))^~)
|
||||
?^ treat=(~(get by treaties) [ship desk])
|
||||
:_ this
|
||||
(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 ~]
|
||||
:_ this
|
||||
(fact-init:io (alliance-update:cg:cc %ini entente))^~
|
||||
|
||||
:: local
|
||||
::
|
||||
[%allies ~]
|
||||
@ -196,7 +199,9 @@
|
||||
::
|
||||
%watch-ack
|
||||
?~ 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)
|
||||
`this
|
||||
::
|
||||
|
@ -3,6 +3,8 @@ import Mousetrap from 'mousetrap';
|
||||
import { BrowserRouter, Switch, Route, useHistory } from 'react-router-dom';
|
||||
import { Grid } from './pages/Grid';
|
||||
import useDocketState from './state/docket';
|
||||
import { PermalinkRoutes } from './pages/PermalinkRoutes';
|
||||
import useKilnState from './state/kiln';
|
||||
import { usePreferencesStore } from './nav/preferences/usePreferencesStore';
|
||||
import useContactState from './state/contact';
|
||||
import api from './state/api';
|
||||
@ -40,6 +42,9 @@ const AppRoutes = () => {
|
||||
const { fetchAllies, fetchCharges } = useDocketState.getState();
|
||||
fetchCharges();
|
||||
fetchAllies();
|
||||
const { fetchVats, fetchLag } = useKilnState.getState();
|
||||
fetchVats();
|
||||
fetchLag();
|
||||
useContactState.getState().initialize(api);
|
||||
|
||||
Mousetrap.bind(['command+/', 'ctrl+/'], () => {
|
||||
@ -49,6 +54,7 @@ const AppRoutes = () => {
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/perma" component={PermalinkRoutes} />
|
||||
<Route path={['/leap/:menu', '/']} component={Grid} />
|
||||
</Switch>
|
||||
);
|
||||
|
@ -20,15 +20,13 @@ export const Setting: FC<SettingsProps> = ({ name, on, toggle, className, childr
|
||||
<h3 id={id} className="flex items-center h4 mb-2">
|
||||
{name} {status === 'loading' && <Spinner className="ml-2" />}
|
||||
</h3>
|
||||
<div className="flex">
|
||||
<div className="flex-none mr-2">
|
||||
<Toggle
|
||||
aria-labelledby={id}
|
||||
pressed={on}
|
||||
onPressedChange={call}
|
||||
className="text-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Toggle
|
||||
aria-labelledby={id}
|
||||
pressed={on}
|
||||
onPressedChange={call}
|
||||
className="text-blue-400"
|
||||
/>
|
||||
<div className="flex-1 space-y-6">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
46
pkg/grid/src/logic/useQuery.ts
Normal file
46
pkg/grid/src/logic/useQuery.ts
Normal 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
|
||||
};
|
||||
}
|
@ -5,8 +5,40 @@ import { NotificationPrefs } from './preferences/NotificationPrefs';
|
||||
import { SystemUpdatePrefs } from './preferences/SystemUpdatePrefs';
|
||||
import notificationsSVG from '../assets/notifications.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 matchSub = useCallback(
|
||||
@ -28,36 +60,34 @@ export const SystemPreferences = ({ match }: RouteComponentProps<{ submenu: stri
|
||||
</div>
|
||||
<nav className="border-b-2 border-gray-50">
|
||||
<ul className="font-semibold">
|
||||
<li>
|
||||
<Link
|
||||
to={`${match.url}/notifications`}
|
||||
className={classNames(
|
||||
'flex items-center px-8 py-3 hover:text-black hover:bg-gray-50',
|
||||
matchSub('notifications') && 'text-black bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<img className="w-8 h-8 mr-3" src={notificationsSVG} alt="" />
|
||||
Notifications
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={`${match.url}/system-updates`}
|
||||
className={classNames(
|
||||
'flex items-center px-8 py-3 hover:text-black hover:bg-gray-50',
|
||||
matchSub('system-updates') && 'text-black bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<img className="w-8 h-8 mr-3" src={systemUpdatesSVG} alt="" />
|
||||
System Updates
|
||||
</Link>
|
||||
</li>
|
||||
<SystemPreferencesSection
|
||||
{...props}
|
||||
text="Notifications"
|
||||
icon={notificationsSVG}
|
||||
submenu="notifications"
|
||||
active={matchSub('notifications')}
|
||||
/>
|
||||
<SystemPreferencesSection
|
||||
{...props}
|
||||
text="System Updates"
|
||||
icon={systemUpdatesSVG}
|
||||
submenu="system-updates"
|
||||
active={matchSub('system-updates')}
|
||||
/>
|
||||
<SystemPreferencesSection
|
||||
{...props}
|
||||
text="Interface Settings"
|
||||
icon={systemUpdatesSVG}
|
||||
submenu="interface"
|
||||
active={matchSub('interface')}
|
||||
/>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<section className="flex-1 p-8 text-black">
|
||||
<Switch>
|
||||
<Route path={`${match.url}/system-updates`} component={SystemUpdatePrefs} />
|
||||
<Route path={`${match.url}/interface`} component={InterfacePrefs} />
|
||||
<Route path={[`${match.url}/notifications`, match.url]} component={NotificationPrefs} />
|
||||
</Switch>
|
||||
</section>
|
||||
|
40
pkg/grid/src/nav/preferences/InterfacePrefs.tsx
Normal file
40
pkg/grid/src/nav/preferences/InterfacePrefs.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { AppInfo } from '../../components/AppInfo';
|
||||
import { ShipName } from '../../components/ShipName';
|
||||
import { useCharge, useTreaty } from '../../state/docket';
|
||||
import useDocketState, { useCharge, useTreaty } from '../../state/docket';
|
||||
import { useVat } from '../../state/kiln';
|
||||
import { useLeapStore } from '../Nav';
|
||||
|
||||
@ -13,10 +13,16 @@ export const TreatyInfo = () => {
|
||||
const vat = useVat(desk);
|
||||
const charge = useCharge(desk);
|
||||
|
||||
useEffect(() => {
|
||||
if(!charge) {
|
||||
useDocketState.getState().requestTreaty(host, desk);
|
||||
}
|
||||
}, [host, desk]);
|
||||
|
||||
useEffect(() => {
|
||||
select(
|
||||
<>
|
||||
Apps by <ShipName name={ship} className="font-mono" />: {treaty?.title}
|
||||
{treaty?.title}
|
||||
</>
|
||||
);
|
||||
}, [treaty?.title]);
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { map, omit } from 'lodash';
|
||||
import React, { FunctionComponent, useEffect } from 'react';
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import { Route, RouteComponentProps } from 'react-router-dom';
|
||||
import { MenuState, Nav } from '../nav/Nav';
|
||||
import useDocketState, { useCharges } from '../state/docket';
|
||||
import { useKilnState } from '../state/kiln';
|
||||
import { useCharges } from '../state/docket';
|
||||
import { RemoveApp } from '../tiles/RemoveApp';
|
||||
import { SuspendApp } from '../tiles/SuspendApp';
|
||||
import { Tile } from '../tiles/Tile';
|
||||
@ -17,15 +16,6 @@ export const Grid: FunctionComponent<GridProps> = ({ match }) => {
|
||||
const charges = useCharges();
|
||||
const chargesLoaded = Object.keys(charges).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const { fetchCharges, fetchAllies } = useDocketState.getState();
|
||||
const { fetchVats, fetchLag } = useKilnState.getState();
|
||||
fetchCharges();
|
||||
fetchAllies();
|
||||
fetchVats();
|
||||
fetchLag();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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">
|
||||
|
92
pkg/grid/src/pages/PermalinkRoutes.tsx
Normal file
92
pkg/grid/src/pages/PermalinkRoutes.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -87,7 +87,7 @@ const useDocketState = create<DocketState>((set, get) => ({
|
||||
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 };
|
||||
set((state) => ({
|
||||
treaties: { ...state.treaties, [key]: treaty }
|
||||
|
@ -8,21 +8,24 @@ import { mockVats } from './mock-data';
|
||||
|
||||
interface KilnState {
|
||||
vats: Vats;
|
||||
loaded: boolean;
|
||||
fetchVats: () => Promise<void>;
|
||||
lag: boolean;
|
||||
fetchLag: () => Promise<void>;
|
||||
set: (s: KilnState) => void;
|
||||
}
|
||||
export const useKilnState = create<KilnState>((set) => ({
|
||||
const useKilnState = create<KilnState>((set) => ({
|
||||
vats: useMockData ? mockVats : {},
|
||||
lag: !!useMockData,
|
||||
loaded: false,
|
||||
fetchVats: async () => {
|
||||
if (useMockData) {
|
||||
await fakeRequest({}, 500);
|
||||
set({ loaded: true });
|
||||
return;
|
||||
}
|
||||
const vats = await api.scry<Vats>(getVats);
|
||||
set({ vats });
|
||||
set({ vats, loaded: true });
|
||||
},
|
||||
fetchLag: async () => {
|
||||
const lag = await api.scry<boolean>(scryLag);
|
||||
@ -53,4 +56,9 @@ export function useLag() {
|
||||
return useKilnState(selLag);
|
||||
}
|
||||
|
||||
const selLoaded = (s: KilnState) => s.loaded;
|
||||
export function useKilnLoaded() {
|
||||
return useKilnState(selLoaded);
|
||||
}
|
||||
|
||||
export default useKilnState;
|
||||
|
27
pkg/grid/src/state/local.ts
Normal file
27
pkg/grid/src/state/local.ts
Normal 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);
|
@ -230,7 +230,7 @@ export const mockVat = (desk: string, blockers?: boolean): Vat => ({
|
||||
aeon: 3,
|
||||
desk,
|
||||
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'
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user