diff --git a/pkg/grid/src/components/AppLink.tsx b/pkg/grid/src/components/AppLink.tsx new file mode 100644 index 000000000..3981ab79b --- /dev/null +++ b/pkg/grid/src/components/AppLink.tsx @@ -0,0 +1,53 @@ +import classNames from 'classnames'; +import React from 'react'; +import { Link, LinkProps } from 'react-router-dom'; +import { Docket } from '@urbit/api'; +import { getAppHref } from '../state/util'; + +export type AppLinkProps = Omit & { + app: Docket; + small?: boolean; + selected?: boolean; + to?: (app: Docket) => LinkProps['to']; +}; + +export const AppLink = ({ + app, + to, + small = false, + selected = false, + className, + ...props +}: AppLinkProps) => { + return ( + +
+ {app.image && ( + + )} +
+
+

{app.title}

+ {app.info && !small &&

{app.info}

} +
+ + ); +}; diff --git a/pkg/grid/src/components/AppList.tsx b/pkg/grid/src/components/AppList.tsx new file mode 100644 index 000000000..2ba5810c5 --- /dev/null +++ b/pkg/grid/src/components/AppList.tsx @@ -0,0 +1,46 @@ +import React, { MouseEvent, useCallback } from 'react'; +import { Docket } from '@urbit/api'; +import { MatchItem } from '../nav/Nav'; +import { useRecentsStore } from '../nav/search/Home'; +import { AppLink, AppLinkProps } from './AppLink'; + +type AppListProps = { + apps: Docket[]; + labelledBy: string; + matchAgainst?: MatchItem; + onClick?: (e: MouseEvent, app: Docket) => void; +} & Omit; + +export function appMatches(target: Docket, match?: MatchItem): boolean { + if (!match) { + return false; + } + + const matchValue = match.display || match.value; + return target.title === matchValue; // TODO: need desk name or something || target.href === matchValue; +} + +export const AppList = ({ apps, labelledBy, matchAgainst, onClick, ...props }: AppListProps) => { + const addRecentApp = useRecentsStore((state) => state.addRecentApp); + const selected = useCallback((app: Docket) => appMatches(app, matchAgainst), [matchAgainst]); + + return ( +
    + {apps.map((app) => ( +
  • + { + addRecentApp(app); + if (onClick) { + onClick(e, app); + } + }} + /> +
  • + ))} +
+ ); +}; diff --git a/pkg/grid/src/components/ProviderLink.tsx b/pkg/grid/src/components/ProviderLink.tsx new file mode 100644 index 000000000..c7c075b6c --- /dev/null +++ b/pkg/grid/src/components/ProviderLink.tsx @@ -0,0 +1,46 @@ +import classNames from 'classnames'; +import React from 'react'; +import { Link, LinkProps } from 'react-router-dom'; +import { Provider } from '@urbit/api'; +import { ShipName } from './ShipName'; + +export type ProviderLinkProps = Omit & { + provider: Provider; + small?: boolean; + selected?: boolean; + to?: (p: Provider) => LinkProps['to']; +}; + +export const ProviderLink = ({ + provider, + to, + selected = false, + small = false, + className, + ...props +}: ProviderLinkProps) => { + return ( + +
+ {/* TODO: Handle sigils */} +
+
+

{provider.nickname || }

+ {provider.status && !small &&

{provider.status}

} +
+ + ); +}; diff --git a/pkg/grid/src/components/ProviderList.tsx b/pkg/grid/src/components/ProviderList.tsx new file mode 100644 index 000000000..f2aa79709 --- /dev/null +++ b/pkg/grid/src/components/ProviderList.tsx @@ -0,0 +1,55 @@ +import React, { MouseEvent, useCallback } from 'react'; +import { Provider } from '@urbit/api'; +import { MatchItem } from '../nav/Nav'; +import { useRecentsStore } from '../nav/search/Home'; +import { ProviderLink, ProviderLinkProps } from './ProviderLink'; + +export type ProviderListProps = { + providers: Provider[]; + labelledBy: string; + matchAgainst?: MatchItem; + onClick?: (e: MouseEvent, p: Provider) => void; +} & Omit; + +export function providerMatches(target: Provider, match?: MatchItem): boolean { + if (!match) { + return false; + } + + const matchValue = match.display || match.value; + return target.nickname === matchValue || target.shipName === matchValue; +} + +export const ProviderList = ({ + providers, + labelledBy, + matchAgainst, + onClick, + ...props +}: ProviderListProps) => { + const addRecentDev = useRecentsStore((state) => state.addRecentDev); + const selected = useCallback( + (provider: Provider) => providerMatches(provider, matchAgainst), + [matchAgainst] + ); + + return ( +
    + {providers.map((p) => ( +
  • + { + addRecentDev(p); + if (onClick) { + onClick(e, p); + } + }} + /> +
  • + ))} +
+ ); +}; diff --git a/pkg/grid/src/components/ShipName.tsx b/pkg/grid/src/components/ShipName.tsx index 99c2a3feb..34f743023 100644 --- a/pkg/grid/src/components/ShipName.tsx +++ b/pkg/grid/src/components/ShipName.tsx @@ -5,16 +5,20 @@ type ShipNameProps = { } & HTMLAttributes; export const ShipName = ({ name, ...props }: ShipNameProps) => { - const parts = name.replace('~', '').split(/[_^-]/); + const parts = name.replace('~', '').split(/([_^-])/); return ( ~ {/* sig */} {parts[0]} - - - {/* hep */} - {parts[1]} + {parts.length > 1 && ( + <> + {parts[1]} + {/* hep */} + {parts[2]} + + )} ); }; diff --git a/pkg/grid/src/nav/Leap.tsx b/pkg/grid/src/nav/Leap.tsx index d69b5886c..d968c7cdd 100644 --- a/pkg/grid/src/nav/Leap.tsx +++ b/pkg/grid/src/nav/Leap.tsx @@ -136,7 +136,7 @@ export const Leap = React.forwardRef(({ menu, dropdown, showClose, className }: return; } - const input = [slugify(getMatch(value)?.value || value)]; + const input = [getMatch(value)?.value || slugify(value)]; if (appsMatch) { input.unshift(match?.params.query || ''); } else { diff --git a/pkg/grid/src/nav/search/AppInfo.tsx b/pkg/grid/src/nav/search/AppInfo.tsx index 8a4b8f546..89f5239e7 100644 --- a/pkg/grid/src/nav/search/AppInfo.tsx +++ b/pkg/grid/src/nav/search/AppInfo.tsx @@ -11,8 +11,10 @@ import { TreatyMeta } from '../../components/TreatyMeta'; import useDocketState, { useCharges, useTreaty } from '../../state/docket'; import { getAppHref } from '../../state/util'; import { useLeapStore } from '../Nav'; +import { useRecentsStore } from './Home'; export const AppInfo = () => { + const addRecentApp = useRecentsStore((state) => state.addRecentApp); const select = useLeapStore((state) => state.select); const { ship, host, desk } = useParams<{ ship: string; host: string; desk: string }>(); const treaty = useTreaty(host, desk); @@ -55,6 +57,7 @@ export const AppInfo = () => { as="a" href={getAppHref(treaty.href)} target={treaty.title || '_blank'} + onClick={() => addRecentApp(treaty)} > Open App diff --git a/pkg/grid/src/nav/search/Apps.tsx b/pkg/grid/src/nav/search/Apps.tsx index 9ed221649..f79ef5cf7 100644 --- a/pkg/grid/src/nav/search/Apps.tsx +++ b/pkg/grid/src/nav/search/Apps.tsx @@ -1,15 +1,22 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { Link, RouteComponentProps } from 'react-router-dom'; +import React, { useEffect, useMemo } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; import fuzzy from 'fuzzy'; import slugify from 'slugify'; -import classNames from 'classnames'; -import { Treaty } from '@urbit/api/docket'; +import { Docket } from '@urbit/api/docket'; import { ShipName } from '../../components/ShipName'; import useDocketState, { useAllyTreaties } from '../../state/docket'; -import { useLeapStore } from '../Nav'; +import { MatchItem, useLeapStore } from '../Nav'; +import { AppList } from '../../components/AppList'; type AppsProps = RouteComponentProps<{ ship: string }>; +export function appMatch(app: Docket): MatchItem { + // TODO: do we need display vs value here, + // will all apps have unique titles? If not, + // what would we use? + return { value: app.title, display: app.title }; +} + export const Apps = ({ match }: AppsProps) => { const { searchInput, selectedMatch, select } = useLeapStore((state) => ({ searchInput: state.searchInput, @@ -49,7 +56,7 @@ export const Apps = ({ match }: AppsProps) => { useEffect(() => { if (results) { useLeapStore.setState({ - matches: results.map((treaty) => ({ value: treaty.desk, display: treaty.title })) + matches: results.map(appMatch) }); } }, [results]); @@ -60,18 +67,6 @@ export const Apps = ({ match }: AppsProps) => { } }, [provider]); - const isSelected = useCallback( - (target: Treaty) => { - if (!selectedMatch) { - return false; - } - - const matchValue = selectedMatch.display || selectedMatch.value; - return target.title === matchValue || target.desk === matchValue; - }, - [selectedMatch] - ); - return (
@@ -83,43 +78,12 @@ export const Apps = ({ match }: AppsProps) => {

{results && ( -
    - {results.map((treaty) => ( -
  • - -
    - {treaty.image && ( - - )} -
    -
    -

    {treaty.title}

    - {treaty.info &&

    {treaty.info}

    } -
    - -
  • - ))} -
+ `${match?.path.replace(':ship', provider)}/${slugify(app.base)}`} + /> )}

That's it!

diff --git a/pkg/grid/src/nav/search/Home.tsx b/pkg/grid/src/nav/search/Home.tsx index 89c2006c8..1d6241887 100644 --- a/pkg/grid/src/nav/search/Home.tsx +++ b/pkg/grid/src/nav/search/Home.tsx @@ -1,35 +1,115 @@ -import { debounce } from 'lodash-es'; -import React, { useCallback, useEffect } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { createNextPath, useLeapStore } from '../Nav'; +import produce from 'immer'; +import create from 'zustand'; +import React, { useEffect } from 'react'; +import { persist } from 'zustand/middleware'; +import { take } from 'lodash-es'; +import { Docket, Provider } from '@urbit/api'; +import { MatchItem, useLeapStore } from '../Nav'; +import { appMatch } from './Apps'; +import { providerMatch } from './Providers'; +import { AppList } from '../../components/AppList'; +import { ProviderList } from '../../components/ProviderList'; +import { AppLink } from '../../components/AppLink'; +import { ShipName } from '../../components/ShipName'; +import { ProviderLink } from '../../components/ProviderLink'; +import { useCharges } from '../../state/docket'; -type HomeProps = RouteComponentProps; +interface RecentsStore { + recentApps: Docket[]; + recentDevs: Provider[]; + addRecentApp: (docket: Docket) => void; + addRecentDev: (dev: Provider) => void; +} -export const Home = ({ match, history }: HomeProps) => { - const searchInput = useLeapStore((s) => s.searchInput); - const { push } = history; - const { path } = match; +export const useRecentsStore = create( + persist( + (set) => ({ + recentApps: [], + recentDevs: [], + addRecentApp: (docket) => { + set( + produce((draft: RecentsStore) => { + const hasApp = draft.recentApps.find((app) => app.href === docket.href); + if (!hasApp) { + draft.recentApps.unshift(docket); + } - const handleSearch = useCallback( - debounce((input: string) => { - push(createNextPath(path, input.trim())); - }, 300), - [path] - ); + draft.recentApps = take(draft.recentApps, 3); + }) + ); + }, + addRecentDev: (dev) => { + set( + produce((draft: RecentsStore) => { + const hasDev = draft.recentDevs.find((p) => p.shipName === dev.shipName); + if (!hasDev) { + draft.recentDevs.unshift(dev); + } + + draft.recentDevs = take(draft.recentDevs, 3); + }) + ); + } + }), + { + whitelist: ['recentApps', 'recentDevs'], + name: 'recents-store' + } + ) +); + +export const Home = () => { + const selectedMatch = useLeapStore((state) => state.selectedMatch); + const { recentApps, recentDevs, addRecentApp, addRecentDev } = useRecentsStore(); + const charges = useCharges(); + const groups = charges?.groups; + const zod = { shipName: '~zod' }; useEffect(() => { - if (searchInput) { - handleSearch(searchInput); - } - }, [searchInput]); + const apps = recentApps.map(appMatch); + const devs = recentDevs.map(providerMatch); + + useLeapStore.setState({ + matches: ([] as MatchItem[]).concat(apps, devs) + }); + }, [recentApps, recentDevs]); return ( -
-

Recent Apps

-
+
+

+ Recent Apps +

+ {recentApps.length === 0 && ( +
+

Apps you use will be listed here, in the order you used them.

+

You can click/tap/keyboard on a listed app to open it.

+ {groups && addRecentApp(groups)} />} +
+ )} + {recentApps.length > 0 && ( + + )}
-

Recent Developers

-
+

+ Recent Developers +

+ {recentDevs.length === 0 && ( +
+

Urbit app developers you search for will be listed here.

+

+ Try out app discovery by visiting below. +

+ addRecentDev(zod)} /> +
+ )} + {recentDevs.length > 0 && ( + + )}
); }; diff --git a/pkg/grid/src/nav/search/Providers.tsx b/pkg/grid/src/nav/search/Providers.tsx index d8d25115b..eeed8439e 100644 --- a/pkg/grid/src/nav/search/Providers.tsx +++ b/pkg/grid/src/nav/search/Providers.tsx @@ -1,13 +1,21 @@ -import classNames from 'classnames'; -import React, { useCallback, useEffect, useMemo } from 'react'; -import { Link, RouteComponentProps } from 'react-router-dom'; +import React, { useEffect, useMemo } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; import fuzzy from 'fuzzy'; -import { ShipName } from '../../components/ShipName'; -import { useLeapStore } from '../Nav'; +import { Provider } from '@urbit/api'; +import { MatchItem, useLeapStore } from '../Nav'; import { useAllies } from '../../state/docket'; +import { ProviderList } from '../../components/ProviderList'; type ProvidersProps = RouteComponentProps<{ ship: string }>; +export function providerMatch(provider: Provider | string): MatchItem { + if (typeof provider === 'string') { + return { value: provider, display: provider }; + } + + return { value: provider.shipName, display: provider.nickname }; +} + export const Providers = ({ match }: ProvidersProps) => { const { selectedMatch, select } = useLeapStore((state) => ({ select: state.select, @@ -30,10 +38,7 @@ export const Providers = ({ match }: ProvidersProps) => { return right - left; }) - .map((el) => { - console.log(el); - return el.original; - }) + .map((el) => ({ shipName: el.original })) : [], [allies, search] ); @@ -47,23 +52,11 @@ export const Providers = ({ match }: ProvidersProps) => { useEffect(() => { if (results) { useLeapStore.setState({ - matches: results.map((p) => ({ value: p, display: p })) + matches: results.map(providerMatch) }); } }, [results]); - const isSelected = useCallback( - (target: string) => { - if (!selectedMatch) { - return false; - } - - const matchValue = selectedMatch.display || selectedMatch.value; - return target === matchValue; - }, - [selectedMatch] - ); - return (
@@ -73,28 +66,7 @@ export const Providers = ({ match }: ProvidersProps) => {

{results && ( -
    - {results.map((p) => ( -
  • - -
    - {/* TODO: Handle sigils */} -
    -
    -

    - -

    -
    - -
  • - ))} -
+ )}

That's it!

diff --git a/pkg/grid/src/tiles/Tile.tsx b/pkg/grid/src/tiles/Tile.tsx index 3cbfa2653..d8ae2c65b 100644 --- a/pkg/grid/src/tiles/Tile.tsx +++ b/pkg/grid/src/tiles/Tile.tsx @@ -5,6 +5,7 @@ import { chadIsRunning, Charge } from '@urbit/api/docket'; import { TileMenu } from './TileMenu'; import { Spinner } from '../components/Spinner'; import { getAppHref } from '../state/util'; +import { useRecentsStore } from '../nav/search/Home'; type TileProps = { charge: Charge; @@ -24,6 +25,7 @@ function getMenuColor(color: string, lightText: boolean, active: boolean): strin } export const Tile: FunctionComponent = ({ charge, desk }) => { + const addRecentApp = useRecentsStore((state) => state.addRecentApp); const { title, color, image, chad, href } = charge; const loading = 'install' in chad; const active = chadIsRunning(chad); @@ -42,6 +44,8 @@ export const Tile: FunctionComponent = ({ charge, desk }) => { !active && 'cursor-default' )} style={{ backgroundColor: active ? color || 'purple' : suspendColor }} + onClick={() => addRecentApp(charge)} + onAuxClick={() => addRecentApp(charge)} >
{loading ? (