Merge pull request #5201 from urbit/hm/grid-real-ship-display

grid: real ship display
This commit is contained in:
Liam Fitzgerald 2021-09-09 09:28:41 +10:00 committed by GitHub
commit cbad68dd21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 12730 additions and 12210 deletions

24373
pkg/grid/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@
"@radix-ui/react-polymorphic": "^0.0.13",
"@radix-ui/react-portal": "^0.0.15",
"@radix-ui/react-toggle": "^0.0.10",
"@tlon/sigil-js": "^1.4.3",
"@urbit/api": "^1.4.0",
"@urbit/http-api": "^1.3.1",
"classnames": "^2.3.1",
@ -26,24 +27,24 @@
"color2k": "^1.2.4",
"fuzzy": "^0.1.3",
"immer": "^9.0.5",
"lodash-es": "^4.17.21",
"lodash": "^4.17.21",
"moment": "^2.29.1",
"mousetrap": "^1.6.5",
"postcss-import": "^14.0.2",
"query-string": "^7.0.1",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"react-router-dom": "^5.2.0",
"slugify": "^1.6.0",
"zustand": "^3.5.7"
},
"devDependencies": {
"@tailwindcss/aspect-ratio": "^0.2.1",
"@types/lodash-es": "^4.17.4",
"@types/lodash": "^4.14.172",
"@types/mousetrap": "^1.6.8",
"@types/node": "^16.7.9",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react": "^16.0.0",
"@types/react-dom": "^16.0.0",
"@types/react-router-dom": "^5.1.8",
"@typescript-eslint/eslint-plugin": "^4.26.1",
"@typescript-eslint/parser": "^4.26.1",

View File

@ -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 useContactState from './state/contact';
import api from './state/api';
const AppRoutes = () => {
const { push } = useHistory();
@ -13,6 +15,7 @@ const AppRoutes = () => {
const { fetchAllies, fetchCharges } = useDocketState.getState();
fetchCharges();
fetchAllies();
useContactState.getState().initialize(api);
Mousetrap.bind(['command+/', 'ctrl+/'], () => {
push('/leap/search');

View File

@ -1,6 +1,6 @@
import React from 'react';
import cn from 'classnames';
import { capitalize } from 'lodash-es';
import { capitalize } from 'lodash';
interface AttributeProps {
attr: string;

View 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>
);
};

View File

@ -1,12 +1,13 @@
import classNames from 'classnames';
import React from 'react';
import { Link, LinkProps } from 'react-router-dom';
import { Provider } from '@urbit/api';
import { Contact, Provider } from '@urbit/api';
import { ShipName } from './ShipName';
import { Avatar, AvatarSizes } from './Avatar';
export type ProviderLinkProps = Omit<LinkProps, 'to'> & {
provider: Provider;
small?: boolean;
provider: { shipName: string } & Contact;
size?: AvatarSizes;
selected?: boolean;
to?: (p: Provider) => LinkProps['to'];
};
@ -15,7 +16,7 @@ export const ProviderLink = ({
provider,
to,
selected = false,
small = false,
size = 'default',
className,
...props
}: ProviderLinkProps) => {
@ -29,17 +30,10 @@ export const ProviderLink = ({
)}
{...props}
>
<div
className={classNames(
'flex-none relative bg-black rounded-lg',
small ? 'w-8 h-8' : 'w-12 h-12'
)}
>
{/* TODO: Handle sigils */}
</div>
<Avatar size={size} {...provider} />
<div className="flex-1 text-black">
<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>
</Link>
);

View File

@ -1,12 +1,12 @@
import React, { MouseEvent, useCallback } from 'react';
import { Provider } from '@urbit/api';
import { Contact, Provider } from '@urbit/api';
import classNames from 'classnames';
import { MatchItem } from '../nav/Nav';
import { useRecentsStore } from '../nav/search/Home';
import { ProviderLink, ProviderLinkProps } from './ProviderLink';
export type ProviderListProps = {
providers: Provider[];
providers: ({ shipName: string } & Contact)[];
labelledBy: string;
matchAgainst?: MatchItem;
onClick?: (e: MouseEvent<HTMLAnchorElement>, p: Provider) => void;
@ -28,7 +28,7 @@ export const ProviderList = ({
matchAgainst,
onClick,
listClass,
small = false,
size = 'default',
...props
}: ProviderListProps) => {
const addRecentDev = useRecentsStore((state) => state.addRecentDev);
@ -39,18 +39,18 @@ export const ProviderList = ({
return (
<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}
>
{providers.map((p) => (
<li key={p.shipName} id={p.shipName} role="option" aria-selected={selected(p)}>
<ProviderLink
{...props}
small={small}
size={size}
provider={p}
selected={selected(p)}
onClick={(e) => {
addRecentDev(p);
addRecentDev(p.shipName);
if (onClick) {
onClick(e, p);
}

View File

@ -1,4 +1,4 @@
import { debounce, DebounceSettings } from 'lodash-es';
import { debounce, DebounceSettings } from 'lodash';
import { useRef, useEffect, useCallback } from 'react';
import { useIsMounted } from './useIsMounted';

View File

@ -1,4 +1,4 @@
import { pick } from 'lodash-es';
import { pick } from 'lodash';
import React, { useCallback } from 'react';
import { kilnBump } from '@urbit/api/hood';
import { AppList } from '../../components/AppList';

View File

@ -2,8 +2,7 @@ import produce from 'immer';
import create from 'zustand';
import React, { useEffect } from 'react';
import { persist } from 'zustand/middleware';
import { take } from 'lodash-es';
import { Provider } from '@urbit/api';
import { take } from 'lodash';
import { MatchItem, useLeapStore } from '../Nav';
import { providerMatch } from './Providers';
import { AppList } from '../../components/AppList';
@ -13,12 +12,13 @@ import { ShipName } from '../../components/ShipName';
import { ProviderLink } from '../../components/ProviderLink';
import { DocketWithDesk, useCharges } from '../../state/docket';
import { getAppHref } from '../../state/util';
import useContactState from '../../state/contact';
export interface RecentsStore {
recentApps: DocketWithDesk[];
recentDevs: Provider[];
recentDevs: string[];
addRecentApp: (app: DocketWithDesk) => void;
addRecentDev: (dev: Provider) => void;
addRecentDev: (ship: string) => void;
}
export const useRecentsStore = create<RecentsStore>(
@ -41,7 +41,7 @@ export const useRecentsStore = create<RecentsStore>(
addRecentDev: (dev) => {
set(
produce((draft: RecentsStore) => {
const hasDev = draft.recentDevs.find((p) => p.shipName === dev.shipName);
const hasDev = draft.recentDevs.includes(dev);
if (!hasDev) {
draft.recentDevs.unshift(dev);
}
@ -60,7 +60,7 @@ export const useRecentsStore = create<RecentsStore>(
window.recents = useRecentsStore.getState;
export function addRecentDev(dev: Provider) {
export function addRecentDev(dev: string) {
return useRecentsStore.getState().addRecentDev(dev);
}
@ -73,7 +73,9 @@ export const Home = () => {
const { recentApps, recentDevs } = useRecentsStore();
const charges = useCharges();
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(() => {
const apps = recentApps.map((app) => ({
@ -122,18 +124,26 @@ export const Home = () => {
{recentDevs.length === 0 && (
<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-6">
Try out app discovery by visiting <ShipName name="~zod" /> below.
</p>
<ProviderLink provider={zod} small onClick={() => addRecentDev(zod)} />
{zod && (
<>
<p className="mb-6">
Try out app discovery by visiting <ShipName name="~zod" /> below.
</p>
<ProviderLink
provider={zod}
size="small"
onClick={() => addRecentDev(zod.shipName)}
/>
</>
)}
</div>
)}
{recentDevs.length > 0 && (
<ProviderList
providers={recentDevs}
providers={providerList}
labelledBy="recent-devs"
matchAgainst={selectedMatch}
small
size="small"
/>
)}
</div>

View File

@ -5,6 +5,7 @@ import { Provider } from '@urbit/api';
import { MatchItem, useLeapStore } from '../Nav';
import { useAllies } from '../../state/docket';
import { ProviderList } from '../../components/ProviderList';
import useContactState from '../../state/contact';
type ProvidersProps = RouteComponentProps<{ ship: string }>;
@ -23,6 +24,7 @@ export function providerMatch(provider: Provider | string): MatchItem {
export const Providers = ({ match }: ProvidersProps) => {
const selectedMatch = useLeapStore((state) => state.selectedMatch);
const provider = match?.params.ship;
const contacts = useContactState((s) => s.contacts);
const allies = useAllies();
const search = provider || '';
const results = useMemo(
@ -39,10 +41,11 @@ export const Providers = ({ match }: ProvidersProps) => {
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;
useEffect(() => {

View File

@ -1,4 +1,4 @@
import { map, omit } from 'lodash-es';
import { map, omit } from 'lodash';
import React, { FunctionComponent, useEffect } from 'react';
import { Route, RouteComponentProps } from 'react-router-dom';
import { MenuState, Nav } from '../nav/Nav';

192
pkg/grid/src/state/base.ts Normal file
View 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);
}
}
}

View 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;

View File

@ -1,7 +1,7 @@
import create from 'zustand';
import produce from 'immer';
import { useCallback, useEffect } from 'react';
import { omit, pick } from 'lodash-es';
import { omit, pick } from 'lodash';
import {
Allies,
Charge,

View File

@ -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 { Vat, Vats } from '@urbit/api/hood';
import systemUrl from '../assets/system.png';
@ -155,6 +156,51 @@ export const mockAllies: Allies = [
'~nalrys'
].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 = {
type: 'basic',
time: '',

View File

@ -23,3 +23,10 @@ export function getAppHref(href: DocketHref) {
export function disableDefault<T extends Event>(e: T): void {
e.preventDefault();
}
export function deSig(ship: string): string {
if (!ship) {
return '';
}
return ship.replace('~', '');
}