interface: refactor sidebar, improve routing

This commit is contained in:
Liam Fitzgerald 2021-06-15 12:10:48 +10:00
parent c00768442a
commit c90f7bde25
No known key found for this signature in database
GPG Key ID: D390E12C61D1CFFB
11 changed files with 144 additions and 149 deletions

View File

@ -1,6 +1,7 @@
import dark from '@tlon/indigo-dark';
import light from '@tlon/indigo-light';
import Mousetrap from 'mousetrap';
import shallow from 'zustand/shallow';
import 'mousetrap-global-bind';
import * as React from 'react';
import Helmet from 'react-helmet';
@ -12,7 +13,6 @@ import gcpManager from '~/logic/lib/gcpManager';
import { favicon, svgDataURL } from '~/logic/lib/util';
import withState from '~/logic/lib/withState';
import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group';
import useLocalState from '~/logic/state/local';
import useSettingsState from '~/logic/state/settings';
import useGraphState from '~/logic/state/graph';
@ -131,7 +131,7 @@ class App extends React.Component {
render() {
const theme = this.getTheme();
const ourContact = this.props.contacts[`~${this.ship}`] || null;
const { ourContact } = this.props;
return (
<ThemeProvider theme={theme}>
<ShortcutContextProvider>
@ -173,12 +173,38 @@ class App extends React.Component {
);
}
}
const WarmApp = process.env.NODE_ENV === 'production' ? App : hot(App);
const selContacts = s => s.contacts[`~${window.ship}`];
const selLocal = s => [s.set, s.omniboxShown, s.toggleOmnibox];
const selSettings = s => [s.display, s.getAll];
const selGraph = s => s.getShallowChildren;
const selLaunch = s => [s.getRuntimeLag, s.getBaseHash];
const WithApp = React.forwardRef((props, ref) => {
const ourContact = useContactState(selContacts);
const [display, getAll] = useSettingsState(selSettings, shallow);
const [setLocal, omniboxShown, toggleOmnibox] = useLocalState(selLocal);
const getShallowChildren = useGraphState(selGraph);
const [getRuntimeLag, getBaseHash] = useLaunchState(selLaunch, shallow);
return (
<WarmApp
ref={ref}
ourContact={ourContact}
display={display}
getAll={getAll}
set={setLocal}
getShallowChildren={getShallowChildren}
getRuntimeLag={getRuntimeLag}
getBaseHash={getBaseHash}
toggleOmnibox={toggleOmnibox}
omniboxShown={omniboxShown}
/>
);
});
WarmApp.whyDidYouRender = true;
export default WithApp;
export default withState(process.env.NODE_ENV === 'production' ? App : hot(App), [
[useGroupState],
[useContactState],
[useSettingsState, ['display', 'getAll']],
[useLocalState],
[useGraphState, ['getShallowChildren']],
[useLaunchState, ['getRuntimeLag', 'getBaseHash']]
]);

View File

@ -37,7 +37,6 @@ export const Content = (props) => {
history.goBack();
}, [history.goBack]));
const [hasProtocol, setHasProtocol] = useLocalStorageState(
'registeredProtocol', false
);
@ -78,16 +77,9 @@ export const Content = (props) => {
/>
)}
/>
<Route
path='/~landscape'
render={p => (
<Landscape
location={p.location}
match={p.match}
{...props}
/>
)}
/>
<Route path='/~landscape'>
<Landscape />
</Route>
<Route
path="/~profile"
render={ p => (

View File

@ -121,7 +121,6 @@ function stitchInline(a: any, b: any) {
}
const lastParaIdx = a.children.length - 1;
const last = a.children[lastParaIdx];
console.log(last);
if (last?.children) {
const ros = {
...a,

View File

@ -1,4 +1,4 @@
import { AppName, readGroup } from '@urbit/api';
import { readGroup } from '@urbit/api';
import _ from 'lodash';
import React, { useCallback, useEffect } from 'react';
import Helmet from 'react-helmet';
@ -27,10 +27,10 @@ import { Resource } from './Resource';
import { Skeleton } from './Skeleton';
import airlock from '~/logic/api';
type GroupsPaneProps = {
interface GroupsPaneProps {
baseUrl: string;
workspace: Workspace;
};
}
export function GroupsPane(props: GroupsPaneProps) {
const { baseUrl, workspace } = props;
@ -114,8 +114,6 @@ export function GroupsPane(props: GroupsPaneProps) {
string
>;
const appName = app as AppName;
const resource = `/ship/${host}/${name}`;
const association = associations.graph[resource];
const resourceUrl = `${baseUrl}/resource/${app}${resource}`;
@ -129,12 +127,11 @@ export function GroupsPane(props: GroupsPaneProps) {
mobileHide
recentGroups={recentGroups}
selected={resource}
selectedApp={appName}
{...props}
baseUrl={resourceUrl}
>
<Resource
{...props}
workspace={props.workspace}
association={association}
baseUrl={baseUrl}
/>

View File

@ -13,11 +13,11 @@ import { PublishResource } from '~/views/apps/publish/PublishResource';
import { ChannelPopoverRoutes } from './ChannelPopoverRoutes';
import { ResourceSkeleton } from './ResourceSkeleton';
type ResourceProps = {
interface ResourceProps {
association: Association;
baseUrl: string;
workspace: Workspace;
};
}
export function Resource(props: ResourceProps): ReactElement {
const { association } = props;

View File

@ -106,7 +106,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
canWrite = isOwn;
}
const BackLink = () => (
const backLink = (
<Box
borderRight={1}
borderRightColor='gray'
@ -123,7 +123,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
</Box>
);
const Title = () => (
const titleText = (
<Text
mono={urbitOb.isValidPatp(title)}
fontSize={2}
@ -143,7 +143,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
</Text>
);
const Description = () => (
const description = (
<TruncatedText
display={['none','inline']}
mono={workspace === '/messages' && !association?.metadata?.description}
@ -160,9 +160,8 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
</TruncatedText>
);
const ExtraControls = () => {
if (workspace === '/messages' && isOwn && !resource.startsWith('dm-')) {
return (
const extraControls =
(workspace === '/messages' && isOwn && !resource.startsWith('dm-')) ? (
<Dropdown
flexShrink={0}
dropWidth='300px'
@ -186,21 +185,15 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
+ Add Ship
</Text>
</Dropdown>
);
}
if (canWrite) {
return (
) : canWrite ? (
<Link to={resourcePath('/new')}>
<Text bold pr='3' color='blue'>
+ New Post
</Text>
</Link>
);
}
return null;
};
) : null;
const MenuControl = () => (
const menuControl = (
<Link to={`${baseUrl}/settings`}>
<Icon icon='Menu' color='gray' pr={2} />
</Link>
@ -229,9 +222,9 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
width={`calc(100% - ${actionsWidth}px - 16px)`}
flexShrink={0}
>
<BackLink />
<Title />
<Description />
{backLink}
{titleText}
{description}
</Box>
<Box
ml={3}
@ -240,8 +233,8 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
flexShrink={0}
ref={actionsRef}
>
{ExtraControls()}
<MenuControl />
{extraControls}
{menuControl}
</Box>
</Box>
{children}

View File

@ -12,7 +12,7 @@ import { useTutorialModal } from '~/views/components/useTutorialModal';
import { GroupSwitcher } from '../GroupSwitcher';
import { SidebarList } from './SidebarList';
import { SidebarListHeader } from './SidebarListHeader';
import { SidebarAppConfigs, SidebarListConfig } from './types';
import { SidebarListConfig } from './types';
const ScrollbarLessCol = styled(Col)`
scrollbar-width: none !important;
@ -25,8 +25,6 @@ const ScrollbarLessCol = styled(Col)`
interface SidebarProps {
recentGroups: string[];
selected?: string;
selectedGroup?: string;
apps: SidebarAppConfigs;
baseUrl: string;
mobileHide?: boolean;
workspace: Workspace;
@ -84,7 +82,6 @@ export function Sidebar(props: SidebarProps): ReactElement | null {
config={config}
selected={selected}
group={groupPath}
apps={props.apps}
baseUrl={props.baseUrl}
workspace={workspace}
/>

View File

@ -12,9 +12,30 @@ import useContactState, { useContact } from '~/logic/state/contact';
import { getItemTitle, getModuleIcon, uxToHex } from '~/logic/lib/util';
import useGroupState from '~/logic/state/group';
import Dot from '~/views/components/Dot';
import { SidebarAppConfigs } from './types';
import { useHarkDm } from '~/logic/state/hark';
import useHarkState, { useHarkDm } from '~/logic/state/hark';
import useSettingsState from '~/logic/state/settings';
import useGraphState from '~/logic/state/graph';
function useAssociationStatus(resource: string) {
const [, , ship, name] = resource.split('/');
const graphKey = `${ship.slice(1)}/${name}`;
const isSubscribed = useGraphState(s => s.graphKeys.has(graphKey));
const { unreads, notifications } = useHarkState(
s => s.unreads.graph?.[resource]?.['/'] || { unreads: 0, notifications: 0, last: 0 }
);
const hasNotifications =
(typeof notifications === 'number' && notifications > 0) ||
(typeof notifications === 'object' && notifications.length);
const hasUnread =
typeof unreads === 'number' ? unreads > 0 : unreads?.size ?? 0 > 0;
return hasNotifications
? 'notification'
: hasUnread
? 'unread'
: isSubscribed
? undefined
: 'unsubscribed';
}
function SidebarItemBase(props: {
to: string;
@ -36,7 +57,11 @@ function SidebarItemBase(props: {
isSynced = false,
mono = false
} = props;
const color = isSynced ? (hasUnread || hasNotification) ? 'black' : 'gray' : 'lightGray';
const color = isSynced
? hasUnread || hasNotification
? 'black'
: 'gray'
: 'lightGray';
const fontWeight = hasUnread || hasNotification ? '500' : 'normal';
@ -95,17 +120,18 @@ function SidebarItemBase(props: {
);
}
export function SidebarDmItem(props: {
export const SidebarDmItem = React.memo((props: {
ship: string;
selected?: boolean;
workspace: Workspace;
}) {
}) => {
const { ship, selected = false } = props;
const contact = useContact(ship);
const { hideAvatars, hideNicknames } = useSettingsState(s => s.calm);
const title = (!hideNicknames && contact?.nickname)
? contact?.nickname
: (cite(ship) ?? ship);
const { hideAvatars, hideNicknames } = useSettingsState(s => s.calm);
const title =
!hideNicknames && contact?.nickname
? contact?.nickname
: cite(ship) ?? ship;
const { unreads } = useHarkDm(ship) || { unreads: 0 };
const img =
contact?.avatar && !hideAvatars ? (
@ -139,17 +165,15 @@ export function SidebarDmItem(props: {
{img}
</SidebarItemBase>
);
}
});
// eslint-disable-next-line max-lines-per-function
export function SidebarAssociationItem(props: {
export const SidebarAssociationItem = React.memo((props: {
hideUnjoined: boolean;
association: Association;
path: string;
selected: boolean;
apps: SidebarAppConfigs;
workspace: Workspace;
}) {
const { association, path, selected, apps } = props;
}) => {
const { association, selected } = props;
const title = getItemTitle(association) || '';
const appName = association?.['app-name'];
let mod = appName;
@ -158,7 +182,7 @@ export function SidebarAssociationItem(props: {
}
const rid = association?.resource;
const groupPath = association?.group;
const groups = useGroupState(state => state.groups);
const group = useGroupState(state => state.groups[groupPath]);
const { hideNicknames } = useSettingsState(s => s.calm);
const contacts = useContactState(s => s.contacts);
const anchorRef = useRef<HTMLAnchorElement>(null);
@ -167,13 +191,9 @@ export function SidebarAssociationItem(props: {
groupPath === `/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`,
anchorRef
);
const app = apps[appName];
const isUnmanaged = groups?.[groupPath]?.hidden || false;
if (!app) {
return null;
}
const isUnmanaged = group?.hidden || false;
const DM = isUnmanaged && props.workspace?.type === 'messages';
const itemStatus = app.getStatus(path);
const itemStatus = useAssociationStatus(rid);
const hasNotification = itemStatus === 'notification';
const hasUnread = itemStatus === 'unread';
const isSynced = itemStatus !== 'unsubscribed';
@ -194,7 +214,11 @@ export function SidebarAssociationItem(props: {
}
const participantNames = (str: string) => {
const color = isSynced ? (hasUnread || hasNotification) ? 'black' : 'gray' : 'lightGray';
const color = isSynced
? hasUnread || hasNotification
? 'black'
: 'gray'
: 'lightGray';
if (_.includes(str, ',') && _.startsWith(str, '~')) {
const names = _.split(str, ', ');
return names.map((name, idx) => {
@ -209,9 +233,7 @@ export function SidebarAssociationItem(props: {
return (
<Text key={name} mono bold={hasUnread} color={color}>
{name}
<Text color={color}>
{idx + 1 != names.length ? ', ' : null}
</Text>
<Text color={color}>{idx + 1 != names.length ? ', ' : null}</Text>
</Text>
);
} else {
@ -230,9 +252,7 @@ export function SidebarAssociationItem(props: {
hasUnread={hasUnread}
isSynced={isSynced}
title={
DM && !urbitOb.isValidPatp(title)
? participantNames(title)
: title
DM && !urbitOb.isValidPatp(title) ? participantNames(title) : title
}
hasNotification={hasNotification}
>
@ -255,4 +275,4 @@ export function SidebarAssociationItem(props: {
)}
</SidebarItemBase>
);
}
});

View File

@ -6,7 +6,7 @@ import { SidebarAssociationItem, SidebarDmItem } from './SidebarItem';
import useGraphState, { useInbox } from '~/logic/state/graph';
import useHarkState from '~/logic/state/hark';
import { alphabeticalOrder, getResourcePath, modulo } from '~/logic/lib/util';
import { SidebarAppConfigs, SidebarListConfig, SidebarSort } from './types';
import { SidebarListConfig, SidebarSort } from './types';
import { Workspace } from '~/types/workspace';
import useMetadataState from '~/logic/state/metadata';
import { useHistory } from 'react-router';
@ -14,8 +14,7 @@ import { useShortcut } from '~/logic/state/settings';
function sidebarSort(
associations: AppAssociations,
apps: SidebarAppConfigs,
inboxUnreads: Record<string, UnreadStats>
unreads: Record<string, Record<string, UnreadStats>>
): Record<SidebarSort, (a: string, b: string) => number> {
const alphabetical = (a: string, b: string) => {
const aAssoc = associations[a];
@ -29,16 +28,16 @@ function sidebarSort(
const lastUpdated = (a: string, b: string) => {
const aAssoc = associations[a];
const bAssoc = associations[b];
const aAppName = aAssoc?.['app-name'];
const bAppName = bAssoc?.['app-name'];
const aResource = aAssoc.resource;
const bResource = bAssoc.resource;
const aUpdated = a.startsWith('~')
? (inboxUnreads?.[`/${patp2dec(a)}`]?.last || 0)
: (apps[aAppName]?.lastUpdated(a) || 0);
? (unreads?.[`/ship/~${window.ship}/dm-inbox`]?.[`/${patp2dec(a)}`]?.last || 0)
: ((unreads?.[aResource]?.['/']?.last) || 0);
const bUpdated = b.startsWith('~')
? (inboxUnreads?.[`/${patp2dec(b)}`]?.last || 0)
: (apps[bAppName]?.lastUpdated(b) || 0);
? (unreads?.[`/ship/~${window.ship}/dm-inbox`]?.[`/${patp2dec(b)}`]?.last || 0)
: ((unreads?.[bResource]?.['/']?.last) || 0);
return bUpdated - aUpdated || alphabetical(a, b);
};
@ -86,7 +85,6 @@ function getItems(associations: Associations, workspace: Workspace, inbox: Graph
}
export function SidebarList(props: {
apps: SidebarAppConfigs;
config: SidebarListConfig;
baseUrl: string;
group?: string;
@ -96,11 +94,11 @@ export function SidebarList(props: {
const { selected, config, workspace } = props;
const associations = useMetadataState(state => state.associations);
const inbox = useInbox();
const unreads = useHarkState(s => s.unreads.graph?.[`/ship/~${window.ship}/dm-inbox`]);
const unreads = useHarkState(s => s.unreads.graph);
const graphKeys = useGraphState(s => s.graphKeys);
const ordered = getItems(associations, workspace, inbox)
.sort(sidebarSort(associations.graph, props.apps, unreads)[config.sortBy]);
.sort(sidebarSort(associations.graph, unreads)[config.sortBy]);
const history = useHistory();
@ -139,10 +137,8 @@ export function SidebarList(props: {
return pathOrShip.startsWith('/') ? (
<SidebarAssociationItem
key={pathOrShip}
path={pathOrShip}
selected={pathOrShip === selected}
association={associations.graph[pathOrShip]}
apps={props.apps}
hideUnjoined={config.hideUnjoined}
workspace={workspace}
/>

View File

@ -1,39 +1,24 @@
import React, { ReactElement, ReactNode, useCallback, useMemo, useState } from 'react';
import React, { ReactElement, ReactNode, useCallback, useState } from 'react';
import { Sidebar } from './Sidebar/Sidebar';
import { AppName } from '@urbit/api';
import useGraphState from '~/logic/state/graph';
import useHarkState from '~/logic/state/hark';
import { Workspace } from '~/types/workspace';
import { Body } from '~/views/components/Body';
import ErrorBoundary from '~/views/components/ErrorBoundary';
import { useShortcut } from '~/logic/state/settings';
import { useGraphModule } from './Sidebar/Apps';
interface SkeletonProps {
children: ReactNode;
recentGroups: string[];
selected?: string;
selectedApp?: AppName;
baseUrl: string;
mobileHide?: boolean;
workspace: Workspace;
}
export function Skeleton(props: SkeletonProps): ReactElement {
export const Skeleton = React.memo((props: SkeletonProps): ReactElement => {
const [sidebar, setSidebar] = useState(true);
useShortcut('hideSidebar', useCallback(() => {
setSidebar(s => !s);
}, []));
const graphs = useGraphState(state => state.graphs);
const graphKeys = useGraphState(state => state.graphKeys);
const unreads = useHarkState(state => state.unreads);
const graphConfig = useGraphModule(graphKeys, graphs, unreads.graph);
const config = useMemo(
() => ({
graph: graphConfig
}),
[graphConfig]
);
return !sidebar ? (<Body> {props.children} </Body>) : (
<Body
@ -47,7 +32,6 @@ export function Skeleton(props: SkeletonProps): ReactElement {
<Sidebar
recentGroups={props.recentGroups}
selected={props.selected}
apps={config}
baseUrl={props.baseUrl}
mobileHide={props.mobileHide}
workspace={props.workspace}
@ -56,4 +40,4 @@ export function Skeleton(props: SkeletonProps): ReactElement {
{props.children}
</Body>
);
}
});

View File

@ -10,6 +10,7 @@ import { GroupsPane } from './components/GroupsPane';
import { JoinGroup } from './components/JoinGroup';
import { NewGroup } from './components/NewGroup';
import './css/custom.css';
import _ from 'lodash';
moment.updateLocale('en', {
relativeTime : {
@ -32,7 +33,12 @@ moment.updateLocale('en', {
}
});
export default function Landscape(props) {
const makeGroupWorkspace = _.memoize((group: string): Workspace => ({ type: 'group', group }));
const homeWorkspace: Workspace = { type: 'home' };
const messagesWorkspace: Workspace = { type: 'messages' };
export default function Landscape() {
const notificationsCount = useHarkState(s => s.notificationsCount);
return (
@ -49,40 +55,26 @@ export default function Landscape(props) {
} = routeProps.match.params as Record<string, string>;
const groupPath = `/ship/${host}/${name}`;
const baseUrl = `/~landscape${groupPath}`;
const ws: Workspace = { type: 'group', group: groupPath };
const ws: Workspace = makeGroupWorkspace(groupPath);
return (
<GroupsPane workspace={ws} baseUrl={baseUrl} {...props} />
);
}}
/>
<Route path="/~landscape/home"
render={() => {
const ws: Workspace = { type: 'home' };
return (
<GroupsPane workspace={ws} baseUrl="/~landscape/home" {...props} />
);
}}
/>
<Route path="/~landscape/messages"
render={() => {
const ws: Workspace = { type: 'messages' };
return (
<GroupsPane workspace={ws} baseUrl="/~landscape/messages" {...props} />
);
}}
/>
<Route path="/~landscape/new"
render={() => {
return (
<Body>
<Box maxWidth="300px">
<NewGroup />
</Box>
</Body>
<GroupsPane workspace={ws} baseUrl={baseUrl} />
);
}}
/>
<Route path="/~landscape/home">
<GroupsPane workspace={homeWorkspace} baseUrl="/~landscape/home" />
</Route>
<Route path="/~landscape/messages">
<GroupsPane workspace={messagesWorkspace} baseUrl="/~landscape/messages" />
</Route>
<Route path="/~landscape/new">
<Body>
<Box maxWidth="300px">
<NewGroup />
</Box>
</Body>
</Route>
<Route path="/~landscape/join/:ship?/:name?"
render={(routeProps) => {
const { ship, name } = routeProps.match.params;
@ -103,4 +95,3 @@ export default function Landscape(props) {
</>
);
}