mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-01 03:23:09 +03:00
Merge pull request #5201 from urbit/hm/grid-real-ship-display
grid: real ship display
This commit is contained in:
commit
cbad68dd21
24373
pkg/grid/package-lock.json
generated
24373
pkg/grid/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,7 @@
|
|||||||
"@radix-ui/react-polymorphic": "^0.0.13",
|
"@radix-ui/react-polymorphic": "^0.0.13",
|
||||||
"@radix-ui/react-portal": "^0.0.15",
|
"@radix-ui/react-portal": "^0.0.15",
|
||||||
"@radix-ui/react-toggle": "^0.0.10",
|
"@radix-ui/react-toggle": "^0.0.10",
|
||||||
|
"@tlon/sigil-js": "^1.4.3",
|
||||||
"@urbit/api": "^1.4.0",
|
"@urbit/api": "^1.4.0",
|
||||||
"@urbit/http-api": "^1.3.1",
|
"@urbit/http-api": "^1.3.1",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
@ -26,24 +27,24 @@
|
|||||||
"color2k": "^1.2.4",
|
"color2k": "^1.2.4",
|
||||||
"fuzzy": "^0.1.3",
|
"fuzzy": "^0.1.3",
|
||||||
"immer": "^9.0.5",
|
"immer": "^9.0.5",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.1",
|
"moment": "^2.29.1",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
"postcss-import": "^14.0.2",
|
"postcss-import": "^14.0.2",
|
||||||
"query-string": "^7.0.1",
|
"query-string": "^7.0.1",
|
||||||
"react": "^17.0.0",
|
"react": "^16.0.0",
|
||||||
"react-dom": "^17.0.0",
|
"react-dom": "^16.0.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"slugify": "^1.6.0",
|
"slugify": "^1.6.0",
|
||||||
"zustand": "^3.5.7"
|
"zustand": "^3.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/aspect-ratio": "^0.2.1",
|
"@tailwindcss/aspect-ratio": "^0.2.1",
|
||||||
"@types/lodash-es": "^4.17.4",
|
"@types/lodash": "^4.14.172",
|
||||||
"@types/mousetrap": "^1.6.8",
|
"@types/mousetrap": "^1.6.8",
|
||||||
"@types/node": "^16.7.9",
|
"@types/node": "^16.7.9",
|
||||||
"@types/react": "^17.0.0",
|
"@types/react": "^16.0.0",
|
||||||
"@types/react-dom": "^17.0.0",
|
"@types/react-dom": "^16.0.0",
|
||||||
"@types/react-router-dom": "^5.1.8",
|
"@types/react-router-dom": "^5.1.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.26.1",
|
"@typescript-eslint/eslint-plugin": "^4.26.1",
|
||||||
"@typescript-eslint/parser": "^4.26.1",
|
"@typescript-eslint/parser": "^4.26.1",
|
||||||
|
@ -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 useContactState from './state/contact';
|
||||||
|
import api from './state/api';
|
||||||
|
|
||||||
const AppRoutes = () => {
|
const AppRoutes = () => {
|
||||||
const { push } = useHistory();
|
const { push } = useHistory();
|
||||||
@ -13,6 +15,7 @@ const AppRoutes = () => {
|
|||||||
const { fetchAllies, fetchCharges } = useDocketState.getState();
|
const { fetchAllies, fetchCharges } = useDocketState.getState();
|
||||||
fetchCharges();
|
fetchCharges();
|
||||||
fetchAllies();
|
fetchAllies();
|
||||||
|
useContactState.getState().initialize(api);
|
||||||
|
|
||||||
Mousetrap.bind(['command+/', 'ctrl+/'], () => {
|
Mousetrap.bind(['command+/', 'ctrl+/'], () => {
|
||||||
push('/leap/search');
|
push('/leap/search');
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { capitalize } from 'lodash-es';
|
import { capitalize } from 'lodash';
|
||||||
|
|
||||||
interface AttributeProps {
|
interface AttributeProps {
|
||||||
attr: string;
|
attr: string;
|
||||||
|
85
pkg/grid/src/components/Avatar.tsx
Normal file
85
pkg/grid/src/components/Avatar.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { sigil, reactRenderer } from '@tlon/sigil-js';
|
||||||
|
import { deSig, Contact } from '@urbit/api';
|
||||||
|
|
||||||
|
export type AvatarSizes = 'xs' | 'small' | 'default';
|
||||||
|
|
||||||
|
interface AvatarProps extends Contact {
|
||||||
|
shipName: string;
|
||||||
|
size: AvatarSizes;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarMeta {
|
||||||
|
classes: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap: Record<AvatarSizes, AvatarMeta> = {
|
||||||
|
xs: { classes: 'w-6 h-6 rounded', size: 12 },
|
||||||
|
small: { classes: 'w-8 h-8 rounded-lg', size: 16 },
|
||||||
|
default: { classes: 'w-12 h-12 rounded-lg', size: 24 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const foregroundFromBackground = (background: string): 'black' | 'white' => {
|
||||||
|
const rgb = {
|
||||||
|
r: parseInt(background.slice(1, 3), 16),
|
||||||
|
g: parseInt(background.slice(3, 5), 16),
|
||||||
|
b: parseInt(background.slice(5, 7), 16)
|
||||||
|
};
|
||||||
|
const brightness = (299 * rgb.r + 587 * rgb.g + 114 * rgb.b) / 1000;
|
||||||
|
const whiteBrightness = 255;
|
||||||
|
|
||||||
|
return whiteBrightness - brightness < 50 ? 'black' : 'white';
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyContact: Contact = {
|
||||||
|
nickname: '',
|
||||||
|
bio: '',
|
||||||
|
status: '',
|
||||||
|
color: '#000000',
|
||||||
|
avatar: null,
|
||||||
|
cover: null,
|
||||||
|
groups: [],
|
||||||
|
'last-updated': 0
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Avatar = ({ size, className, ...ship }: AvatarProps) => {
|
||||||
|
const { shipName, color, avatar } = { ...emptyContact, ...ship };
|
||||||
|
const { classes, size: sigilSize } = sizeMap[size];
|
||||||
|
const foregroundColor = foregroundFromBackground(color);
|
||||||
|
const sigilElement = useMemo(() => {
|
||||||
|
if (shipName.match(/[_^]/)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sigil({
|
||||||
|
patp: deSig(shipName) || 'zod',
|
||||||
|
renderer: reactRenderer,
|
||||||
|
size: sigilSize,
|
||||||
|
icon: true,
|
||||||
|
colors: [color, foregroundColor]
|
||||||
|
});
|
||||||
|
}, [shipName, color, foregroundColor]);
|
||||||
|
|
||||||
|
if (avatar) {
|
||||||
|
return <img className={classNames('', classes)} src={avatar} alt="" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'flex-none relative bg-black rounded-lg',
|
||||||
|
classes,
|
||||||
|
size === 'xs' && 'p-1.5',
|
||||||
|
size === 'small' && 'p-2',
|
||||||
|
size === 'default' && 'p-3',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{sigilElement}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,12 +1,13 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, LinkProps } from 'react-router-dom';
|
import { Link, LinkProps } from 'react-router-dom';
|
||||||
import { Provider } from '@urbit/api';
|
import { Contact, Provider } from '@urbit/api';
|
||||||
import { ShipName } from './ShipName';
|
import { ShipName } from './ShipName';
|
||||||
|
import { Avatar, AvatarSizes } from './Avatar';
|
||||||
|
|
||||||
export type ProviderLinkProps = Omit<LinkProps, 'to'> & {
|
export type ProviderLinkProps = Omit<LinkProps, 'to'> & {
|
||||||
provider: Provider;
|
provider: { shipName: string } & Contact;
|
||||||
small?: boolean;
|
size?: AvatarSizes;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
to?: (p: Provider) => LinkProps['to'];
|
to?: (p: Provider) => LinkProps['to'];
|
||||||
};
|
};
|
||||||
@ -15,7 +16,7 @@ export const ProviderLink = ({
|
|||||||
provider,
|
provider,
|
||||||
to,
|
to,
|
||||||
selected = false,
|
selected = false,
|
||||||
small = false,
|
size = 'default',
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ProviderLinkProps) => {
|
}: ProviderLinkProps) => {
|
||||||
@ -29,17 +30,10 @@ export const ProviderLink = ({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<Avatar size={size} {...provider} />
|
||||||
className={classNames(
|
|
||||||
'flex-none relative bg-black rounded-lg',
|
|
||||||
small ? 'w-8 h-8' : 'w-12 h-12'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* TODO: Handle sigils */}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-black">
|
<div className="flex-1 text-black">
|
||||||
<p className="font-mono">{provider.nickname || <ShipName name={provider.shipName} />}</p>
|
<p className="font-mono">{provider.nickname || <ShipName name={provider.shipName} />}</p>
|
||||||
{provider.status && !small && <p className="font-normal">{provider.status}</p>}
|
{provider.status && size === 'default' && <p className="font-normal">{provider.status}</p>}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import React, { MouseEvent, useCallback } from 'react';
|
import React, { MouseEvent, useCallback } from 'react';
|
||||||
import { Provider } from '@urbit/api';
|
import { Contact, Provider } from '@urbit/api';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { MatchItem } from '../nav/Nav';
|
import { MatchItem } from '../nav/Nav';
|
||||||
import { useRecentsStore } from '../nav/search/Home';
|
import { useRecentsStore } from '../nav/search/Home';
|
||||||
import { ProviderLink, ProviderLinkProps } from './ProviderLink';
|
import { ProviderLink, ProviderLinkProps } from './ProviderLink';
|
||||||
|
|
||||||
export type ProviderListProps = {
|
export type ProviderListProps = {
|
||||||
providers: Provider[];
|
providers: ({ shipName: string } & Contact)[];
|
||||||
labelledBy: string;
|
labelledBy: string;
|
||||||
matchAgainst?: MatchItem;
|
matchAgainst?: MatchItem;
|
||||||
onClick?: (e: MouseEvent<HTMLAnchorElement>, p: Provider) => void;
|
onClick?: (e: MouseEvent<HTMLAnchorElement>, p: Provider) => void;
|
||||||
@ -28,7 +28,7 @@ export const ProviderList = ({
|
|||||||
matchAgainst,
|
matchAgainst,
|
||||||
onClick,
|
onClick,
|
||||||
listClass,
|
listClass,
|
||||||
small = false,
|
size = 'default',
|
||||||
...props
|
...props
|
||||||
}: ProviderListProps) => {
|
}: ProviderListProps) => {
|
||||||
const addRecentDev = useRecentsStore((state) => state.addRecentDev);
|
const addRecentDev = useRecentsStore((state) => state.addRecentDev);
|
||||||
@ -39,18 +39,18 @@ export const ProviderList = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
className={classNames(small ? 'space-y-4' : 'space-y-8', listClass)}
|
className={classNames(size !== 'default' ? 'space-y-4' : 'space-y-8', listClass)}
|
||||||
aria-labelledby={labelledBy}
|
aria-labelledby={labelledBy}
|
||||||
>
|
>
|
||||||
{providers.map((p) => (
|
{providers.map((p) => (
|
||||||
<li key={p.shipName} id={p.shipName} role="option" aria-selected={selected(p)}>
|
<li key={p.shipName} id={p.shipName} role="option" aria-selected={selected(p)}>
|
||||||
<ProviderLink
|
<ProviderLink
|
||||||
{...props}
|
{...props}
|
||||||
small={small}
|
size={size}
|
||||||
provider={p}
|
provider={p}
|
||||||
selected={selected(p)}
|
selected={selected(p)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
addRecentDev(p);
|
addRecentDev(p.shipName);
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick(e, p);
|
onClick(e, p);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { debounce, DebounceSettings } from 'lodash-es';
|
import { debounce, DebounceSettings } from 'lodash';
|
||||||
import { useRef, useEffect, useCallback } from 'react';
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
import { useIsMounted } from './useIsMounted';
|
import { useIsMounted } from './useIsMounted';
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { pick } from 'lodash-es';
|
import { pick } from 'lodash';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { kilnBump } from '@urbit/api/hood';
|
import { kilnBump } from '@urbit/api/hood';
|
||||||
import { AppList } from '../../components/AppList';
|
import { AppList } from '../../components/AppList';
|
||||||
|
@ -2,8 +2,7 @@ import produce from 'immer';
|
|||||||
import create from 'zustand';
|
import create from 'zustand';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { take } from 'lodash-es';
|
import { take } from 'lodash';
|
||||||
import { Provider } from '@urbit/api';
|
|
||||||
import { MatchItem, useLeapStore } from '../Nav';
|
import { MatchItem, useLeapStore } from '../Nav';
|
||||||
import { providerMatch } from './Providers';
|
import { providerMatch } from './Providers';
|
||||||
import { AppList } from '../../components/AppList';
|
import { AppList } from '../../components/AppList';
|
||||||
@ -13,12 +12,13 @@ import { ShipName } from '../../components/ShipName';
|
|||||||
import { ProviderLink } from '../../components/ProviderLink';
|
import { ProviderLink } from '../../components/ProviderLink';
|
||||||
import { DocketWithDesk, useCharges } from '../../state/docket';
|
import { DocketWithDesk, useCharges } from '../../state/docket';
|
||||||
import { getAppHref } from '../../state/util';
|
import { getAppHref } from '../../state/util';
|
||||||
|
import useContactState from '../../state/contact';
|
||||||
|
|
||||||
export interface RecentsStore {
|
export interface RecentsStore {
|
||||||
recentApps: DocketWithDesk[];
|
recentApps: DocketWithDesk[];
|
||||||
recentDevs: Provider[];
|
recentDevs: string[];
|
||||||
addRecentApp: (app: DocketWithDesk) => void;
|
addRecentApp: (app: DocketWithDesk) => void;
|
||||||
addRecentDev: (dev: Provider) => void;
|
addRecentDev: (ship: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useRecentsStore = create<RecentsStore>(
|
export const useRecentsStore = create<RecentsStore>(
|
||||||
@ -41,7 +41,7 @@ export const useRecentsStore = create<RecentsStore>(
|
|||||||
addRecentDev: (dev) => {
|
addRecentDev: (dev) => {
|
||||||
set(
|
set(
|
||||||
produce((draft: RecentsStore) => {
|
produce((draft: RecentsStore) => {
|
||||||
const hasDev = draft.recentDevs.find((p) => p.shipName === dev.shipName);
|
const hasDev = draft.recentDevs.includes(dev);
|
||||||
if (!hasDev) {
|
if (!hasDev) {
|
||||||
draft.recentDevs.unshift(dev);
|
draft.recentDevs.unshift(dev);
|
||||||
}
|
}
|
||||||
@ -60,7 +60,7 @@ export const useRecentsStore = create<RecentsStore>(
|
|||||||
|
|
||||||
window.recents = useRecentsStore.getState;
|
window.recents = useRecentsStore.getState;
|
||||||
|
|
||||||
export function addRecentDev(dev: Provider) {
|
export function addRecentDev(dev: string) {
|
||||||
return useRecentsStore.getState().addRecentDev(dev);
|
return useRecentsStore.getState().addRecentDev(dev);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,7 +73,9 @@ export const Home = () => {
|
|||||||
const { recentApps, recentDevs } = useRecentsStore();
|
const { recentApps, recentDevs } = useRecentsStore();
|
||||||
const charges = useCharges();
|
const charges = useCharges();
|
||||||
const groups = charges?.groups;
|
const groups = charges?.groups;
|
||||||
const zod = { shipName: '~zod' };
|
const contacts = useContactState((s) => s.contacts);
|
||||||
|
const zod = { shipName: '~zod', ...contacts['~zod'] };
|
||||||
|
const providerList = recentDevs.map((d) => ({ shipName: d, ...contacts[d] }));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const apps = recentApps.map((app) => ({
|
const apps = recentApps.map((app) => ({
|
||||||
@ -122,18 +124,26 @@ export const Home = () => {
|
|||||||
{recentDevs.length === 0 && (
|
{recentDevs.length === 0 && (
|
||||||
<div className="min-h-[150px] p-6 rounded-xl bg-gray-100">
|
<div className="min-h-[150px] p-6 rounded-xl bg-gray-100">
|
||||||
<p className="mb-4">Urbit app developers you search for will be listed here.</p>
|
<p className="mb-4">Urbit app developers you search for will be listed here.</p>
|
||||||
<p className="mb-6">
|
{zod && (
|
||||||
Try out app discovery by visiting <ShipName name="~zod" /> below.
|
<>
|
||||||
</p>
|
<p className="mb-6">
|
||||||
<ProviderLink provider={zod} small onClick={() => addRecentDev(zod)} />
|
Try out app discovery by visiting <ShipName name="~zod" /> below.
|
||||||
|
</p>
|
||||||
|
<ProviderLink
|
||||||
|
provider={zod}
|
||||||
|
size="small"
|
||||||
|
onClick={() => addRecentDev(zod.shipName)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{recentDevs.length > 0 && (
|
{recentDevs.length > 0 && (
|
||||||
<ProviderList
|
<ProviderList
|
||||||
providers={recentDevs}
|
providers={providerList}
|
||||||
labelledBy="recent-devs"
|
labelledBy="recent-devs"
|
||||||
matchAgainst={selectedMatch}
|
matchAgainst={selectedMatch}
|
||||||
small
|
size="small"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,6 +5,7 @@ import { Provider } from '@urbit/api';
|
|||||||
import { MatchItem, useLeapStore } from '../Nav';
|
import { MatchItem, useLeapStore } from '../Nav';
|
||||||
import { useAllies } from '../../state/docket';
|
import { useAllies } from '../../state/docket';
|
||||||
import { ProviderList } from '../../components/ProviderList';
|
import { ProviderList } from '../../components/ProviderList';
|
||||||
|
import useContactState from '../../state/contact';
|
||||||
|
|
||||||
type ProvidersProps = RouteComponentProps<{ ship: string }>;
|
type ProvidersProps = RouteComponentProps<{ ship: string }>;
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ export function providerMatch(provider: Provider | string): MatchItem {
|
|||||||
export const Providers = ({ match }: ProvidersProps) => {
|
export const Providers = ({ match }: ProvidersProps) => {
|
||||||
const selectedMatch = useLeapStore((state) => state.selectedMatch);
|
const selectedMatch = useLeapStore((state) => state.selectedMatch);
|
||||||
const provider = match?.params.ship;
|
const provider = match?.params.ship;
|
||||||
|
const contacts = useContactState((s) => s.contacts);
|
||||||
const allies = useAllies();
|
const allies = useAllies();
|
||||||
const search = provider || '';
|
const search = provider || '';
|
||||||
const results = useMemo(
|
const results = useMemo(
|
||||||
@ -39,10 +41,11 @@ export const Providers = ({ match }: ProvidersProps) => {
|
|||||||
|
|
||||||
return right - left;
|
return right - left;
|
||||||
})
|
})
|
||||||
.map((el) => ({ shipName: el.original }))
|
.map((el) => ({ shipName: el.original, ...contacts[el.original] }))
|
||||||
: [],
|
: [],
|
||||||
[allies, search]
|
[allies, search, contacts]
|
||||||
);
|
);
|
||||||
|
|
||||||
const count = results?.length;
|
const count = results?.length;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { map, omit } from 'lodash-es';
|
import { map, omit } from 'lodash';
|
||||||
import React, { FunctionComponent, useEffect } from 'react';
|
import React, { FunctionComponent, useEffect } 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';
|
||||||
|
192
pkg/grid/src/state/base.ts
Normal file
192
pkg/grid/src/state/base.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
import { applyPatches, Patch, produceWithPatches, setAutoFreeze, enablePatches } from 'immer';
|
||||||
|
import { compose } from 'lodash/fp';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import create, { GetState, SetState, UseStore } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import Urbit, { SubscriptionRequestInterface } from '@urbit/http-api';
|
||||||
|
import { Poke } from '@urbit/api';
|
||||||
|
import api from './api';
|
||||||
|
|
||||||
|
setAutoFreeze(false);
|
||||||
|
enablePatches();
|
||||||
|
|
||||||
|
export const stateSetter = <T extends Record<string, unknown>>(
|
||||||
|
fn: (state: Readonly<T & BaseState<T>>) => void,
|
||||||
|
set: (newState: T & BaseState<T>) => void,
|
||||||
|
get: () => T & BaseState<T>
|
||||||
|
): void => {
|
||||||
|
const old = get();
|
||||||
|
const [state] = produceWithPatches(old, fn) as readonly [T & BaseState<T>, any, Patch[]];
|
||||||
|
// console.log(patches);
|
||||||
|
set(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const optStateSetter = <T extends Record<string, unknown>>(
|
||||||
|
fn: (state: T & BaseState<T>) => void,
|
||||||
|
set: (newState: T & BaseState<T>) => void,
|
||||||
|
get: () => T & BaseState<T>
|
||||||
|
): string => {
|
||||||
|
const old = get();
|
||||||
|
const id = _.uniqueId();
|
||||||
|
const [state, , patches] = produceWithPatches(old, fn) as readonly [
|
||||||
|
T & BaseState<T>,
|
||||||
|
any,
|
||||||
|
Patch[]
|
||||||
|
];
|
||||||
|
set({ ...state, patches: { ...state.patches, [id]: patches } });
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reduceState = <S extends Record<string, unknown>, U>(
|
||||||
|
state: UseStore<S & BaseState<S>>,
|
||||||
|
data: U,
|
||||||
|
reducers: ((data: U, state: S & BaseState<S>) => S & BaseState<S>)[]
|
||||||
|
): void => {
|
||||||
|
const reducer = compose(reducers.map((r) => (sta) => r(data, sta)));
|
||||||
|
state.getState().set((s) => {
|
||||||
|
reducer(s);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reduceStateN = <S extends Record<string, unknown>, U>(
|
||||||
|
state: S & BaseState<S>,
|
||||||
|
data: U,
|
||||||
|
reducers: ((data: U, state: S & BaseState<S>) => S & BaseState<S>)[]
|
||||||
|
): void => {
|
||||||
|
const reducer = compose(reducers.map((r) => (sta) => r(data, sta)));
|
||||||
|
state.set(reducer);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const optReduceState = <S extends Record<string, unknown>, U>(
|
||||||
|
state: UseStore<S & BaseState<S>>,
|
||||||
|
data: U,
|
||||||
|
reducers: ((data: U, state: S & BaseState<S>) => BaseState<S> & S)[]
|
||||||
|
): string => {
|
||||||
|
const reducer = compose(reducers.map((r) => (sta) => r(data, sta)));
|
||||||
|
return state.getState().optSet((s) => {
|
||||||
|
reducer(s);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* eslint-disable-next-line import/no-mutable-exports */
|
||||||
|
export let stateStorageKeys: string[] = [];
|
||||||
|
|
||||||
|
export const stateStorageKey = (stateName: string) => {
|
||||||
|
stateName = `Landscape${stateName}State`;
|
||||||
|
stateStorageKeys = [...new Set([...stateStorageKeys, stateName])];
|
||||||
|
return stateName;
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).clearStates = () => {
|
||||||
|
stateStorageKeys.forEach((key) => {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BaseState<StateType extends Record<string, unknown>> {
|
||||||
|
rollback: (id: string) => void;
|
||||||
|
patches: {
|
||||||
|
[id: string]: Patch[];
|
||||||
|
};
|
||||||
|
set: (fn: (state: StateType & BaseState<StateType>) => void) => void;
|
||||||
|
addPatch: (id: string, ...patch: Patch[]) => void;
|
||||||
|
removePatch: (id: string) => void;
|
||||||
|
optSet: (fn: (state: StateType & BaseState<StateType>) => void) => string;
|
||||||
|
initialize: (api: Urbit) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubscription(
|
||||||
|
app: string,
|
||||||
|
path: string,
|
||||||
|
e: (data: any) => void
|
||||||
|
): SubscriptionRequestInterface {
|
||||||
|
const request = {
|
||||||
|
app,
|
||||||
|
path,
|
||||||
|
event: e,
|
||||||
|
err: () => {},
|
||||||
|
quit: () => {}
|
||||||
|
};
|
||||||
|
// TODO: err, quit handling (resubscribe?)
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createState = <T extends Record<string, unknown>>(
|
||||||
|
name: string,
|
||||||
|
properties: T | ((set: SetState<T & BaseState<T>>, get: GetState<T & BaseState<T>>) => T),
|
||||||
|
blacklist: (keyof BaseState<T> | keyof T)[] = [],
|
||||||
|
subscriptions: ((
|
||||||
|
set: SetState<T & BaseState<T>>,
|
||||||
|
get: GetState<T & BaseState<T>>
|
||||||
|
) => SubscriptionRequestInterface)[] = []
|
||||||
|
): UseStore<T & BaseState<T>> =>
|
||||||
|
create<T & BaseState<T>>(
|
||||||
|
persist<T & BaseState<T>>(
|
||||||
|
(set, get) => ({
|
||||||
|
initialize: async (airlock: Urbit) => {
|
||||||
|
await Promise.all(subscriptions.map((sub) => airlock.subscribe(sub(set, get))));
|
||||||
|
},
|
||||||
|
set: (fn) => stateSetter(fn, set, get),
|
||||||
|
optSet: (fn) => {
|
||||||
|
return optStateSetter(fn, set, get);
|
||||||
|
},
|
||||||
|
patches: {},
|
||||||
|
addPatch: (id: string, patch: Patch[]) => {
|
||||||
|
set((s) => ({ ...s, patches: { ...s.patches, [id]: patch } }));
|
||||||
|
},
|
||||||
|
removePatch: (id: string) => {
|
||||||
|
set((s) => ({ ...s, patches: _.omit(s.patches, id) }));
|
||||||
|
},
|
||||||
|
rollback: (id: string) => {
|
||||||
|
set((state) => {
|
||||||
|
const applying = state.patches[id];
|
||||||
|
return { ...applyPatches(state, applying), patches: _.omit(state.patches, id) };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
...(typeof properties === 'function' ? (properties as any)(set, get) : properties)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
blacklist,
|
||||||
|
name: stateStorageKey(name)
|
||||||
|
// version: process.env.LANDSCAPE_SHORTHASH as any
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function doOptimistically<A, S extends Record<string, unknown>>(
|
||||||
|
state: UseStore<S & BaseState<S>>,
|
||||||
|
action: A,
|
||||||
|
call: (a: A) => Promise<any>,
|
||||||
|
reduce: ((a: A, fn: S & BaseState<S>) => S & BaseState<S>)[]
|
||||||
|
) {
|
||||||
|
let num: string | undefined;
|
||||||
|
try {
|
||||||
|
num = optReduceState(state, action, reduce);
|
||||||
|
await call(action);
|
||||||
|
state.getState().removePatch(num);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (num) {
|
||||||
|
state.getState().rollback(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pokeOptimisticallyN<A, S extends Record<string, unknown>>(
|
||||||
|
state: UseStore<S & BaseState<S>>,
|
||||||
|
poke: Poke<any>,
|
||||||
|
reduce: ((a: A, fn: S & BaseState<S>) => S & BaseState<S>)[]
|
||||||
|
) {
|
||||||
|
let num: string | undefined;
|
||||||
|
try {
|
||||||
|
num = optReduceState(state, poke.json, reduce);
|
||||||
|
await api.poke(poke);
|
||||||
|
state.getState().removePatch(num);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (num) {
|
||||||
|
state.getState().rollback(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
134
pkg/grid/src/state/contact.ts
Normal file
134
pkg/grid/src/state/contact.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
import { Contact, ContactEditFieldPrim, ContactUpdate, deSig, Patp, Rolodex } from '@urbit/api';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { BaseState, createState, createSubscription, reduceStateN } from './base';
|
||||||
|
import { useMockData } from './util';
|
||||||
|
import { mockContacts } from './mock-data';
|
||||||
|
|
||||||
|
export interface BaseContactState {
|
||||||
|
contacts: Rolodex;
|
||||||
|
isContactPublic: boolean;
|
||||||
|
nackedContacts: Set<Patp>;
|
||||||
|
[ref: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactState = BaseContactState & BaseState<BaseContactState>;
|
||||||
|
|
||||||
|
const initial = (json: ContactUpdate, state: ContactState): ContactState => {
|
||||||
|
const data = _.get(json, 'initial', false);
|
||||||
|
if (data) {
|
||||||
|
state.contacts = data.rolodex;
|
||||||
|
state.isContactPublic = data['is-public'];
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = (json: ContactUpdate, state: ContactState): ContactState => {
|
||||||
|
const data = _.get(json, 'add', false);
|
||||||
|
if (data) {
|
||||||
|
state.contacts[data.ship] = data.contact;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (json: ContactUpdate, state: ContactState): ContactState => {
|
||||||
|
const data = _.get(json, 'remove', false);
|
||||||
|
if (data && data.ship in state.contacts) {
|
||||||
|
delete state.contacts[data.ship];
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const edit = (json: ContactUpdate, state: ContactState): ContactState => {
|
||||||
|
const data = _.get(json, 'edit', false);
|
||||||
|
const ship = `~${deSig(data.ship)}`;
|
||||||
|
if (data && ship in state.contacts) {
|
||||||
|
const [field] = Object.keys(data['edit-field']);
|
||||||
|
if (!field) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = data['edit-field'][field];
|
||||||
|
if (field === 'add-group') {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
state.contacts[ship].groups.push(`/ship/${Object.values(value).join('/')}`);
|
||||||
|
} else if (!state.contacts[ship].groups.includes(value)) {
|
||||||
|
state.contacts[ship].groups.push(value);
|
||||||
|
}
|
||||||
|
} else if (field === 'remove-group') {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
state.contacts[ship].groups = state.contacts[ship].groups.filter(
|
||||||
|
(g) => g !== `/ship/${Object.values(value).join('/')}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state.contacts[ship].groups = state.contacts[ship].groups.filter((g) => g !== value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const k = field as ContactEditFieldPrim;
|
||||||
|
state.contacts[ship][k] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPublic = (json: ContactUpdate, state: ContactState): ContactState => {
|
||||||
|
const data = _.get(json, 'set-public', state.isContactPublic);
|
||||||
|
state.isContactPublic = data;
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reduceNacks = (
|
||||||
|
json: { resource?: { res: string } },
|
||||||
|
state: ContactState
|
||||||
|
): ContactState => {
|
||||||
|
const data = json?.resource;
|
||||||
|
if (data) {
|
||||||
|
state.nackedContacts.add(`~${data.res}`);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reduce = [initial, add, remove, edit, setPublic];
|
||||||
|
|
||||||
|
const useContactState = createState<BaseContactState>(
|
||||||
|
'Contact',
|
||||||
|
{
|
||||||
|
contacts: {},
|
||||||
|
nackedContacts: new Set(),
|
||||||
|
isContactPublic: false
|
||||||
|
},
|
||||||
|
['nackedContacts'],
|
||||||
|
[
|
||||||
|
(set, get) =>
|
||||||
|
createSubscription('contact-pull-hook', '/nacks', (e) => {
|
||||||
|
const data = e?.resource;
|
||||||
|
if (data) {
|
||||||
|
reduceStateN(get(), data, [reduceNacks]);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
(set, get) =>
|
||||||
|
createSubscription('contact-store', '/all', (e) => {
|
||||||
|
const data = _.get(e, 'contact-update', false);
|
||||||
|
if (data) {
|
||||||
|
reduceStateN(get(), data, reduce);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
useContactState.setState({ contacts: mockContacts });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContact(ship: string) {
|
||||||
|
return useContactState(
|
||||||
|
useCallback((s) => s.contacts[`~${deSig(ship)}`] as Contact | null, [ship])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOurContact() {
|
||||||
|
return useContact(`~${window.ship}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useContactState;
|
@ -1,7 +1,7 @@
|
|||||||
import create from 'zustand';
|
import create from 'zustand';
|
||||||
import produce from 'immer';
|
import produce from 'immer';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { omit, pick } from 'lodash-es';
|
import { omit, pick } from 'lodash';
|
||||||
import {
|
import {
|
||||||
Allies,
|
Allies,
|
||||||
Charge,
|
Charge,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import _ from 'lodash-es';
|
import _ from 'lodash';
|
||||||
|
import { Contact, Contacts } from '@urbit/api';
|
||||||
import { Allies, Charges, DocketHrefGlob, Treaties, Treaty } from '@urbit/api/docket';
|
import { Allies, Charges, DocketHrefGlob, Treaties, Treaty } from '@urbit/api/docket';
|
||||||
import { Vat, Vats } from '@urbit/api/hood';
|
import { Vat, Vats } from '@urbit/api/hood';
|
||||||
import systemUrl from '../assets/system.png';
|
import systemUrl from '../assets/system.png';
|
||||||
@ -155,6 +156,51 @@ export const mockAllies: Allies = [
|
|||||||
'~nalrys'
|
'~nalrys'
|
||||||
].reduce((acc, val) => ({ ...acc, [val]: charter }), {});
|
].reduce((acc, val) => ({ ...acc, [val]: charter }), {});
|
||||||
|
|
||||||
|
const contact: Contact = {
|
||||||
|
nickname: '',
|
||||||
|
bio: '',
|
||||||
|
status: '',
|
||||||
|
color: '#000000',
|
||||||
|
avatar: null,
|
||||||
|
cover: null,
|
||||||
|
groups: [],
|
||||||
|
'last-updated': 0
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockContacts: Contacts = {
|
||||||
|
'~zod': {
|
||||||
|
...contact,
|
||||||
|
nickname: 'Tlon Corporation'
|
||||||
|
},
|
||||||
|
'~nocsyx-lassul': {
|
||||||
|
...contact,
|
||||||
|
status: 'technomancing an electron wrapper for urbit',
|
||||||
|
color: '#4c00ff'
|
||||||
|
},
|
||||||
|
'~nachus-hollyn': {
|
||||||
|
...contact,
|
||||||
|
avatar: 'https://i.pinimg.com/originals/20/62/59/2062590a440f717a2ae1065ad8e8a4c7.gif'
|
||||||
|
},
|
||||||
|
'~nalbel_litzod': {
|
||||||
|
...contact,
|
||||||
|
nickname: 'Queen'
|
||||||
|
},
|
||||||
|
'~litmus^ritten': {
|
||||||
|
...contact
|
||||||
|
},
|
||||||
|
'~nalput_litzod': {
|
||||||
|
...contact
|
||||||
|
},
|
||||||
|
'~nalrex_bannus': {
|
||||||
|
...contact,
|
||||||
|
status: 'Script, command and inspect your Urbit. Use TUI applications'
|
||||||
|
},
|
||||||
|
'~nalrys': {
|
||||||
|
...contact,
|
||||||
|
status: 'hosting coming soon'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const mockNotification: BasicNotification = {
|
export const mockNotification: BasicNotification = {
|
||||||
type: 'basic',
|
type: 'basic',
|
||||||
time: '',
|
time: '',
|
||||||
|
@ -23,3 +23,10 @@ export function getAppHref(href: DocketHref) {
|
|||||||
export function disableDefault<T extends Event>(e: T): void {
|
export function disableDefault<T extends Event>(e: T): void {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deSig(ship: string): string {
|
||||||
|
if (!ship) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return ship.replace('~', '');
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user