This commit is contained in:
Liam Fitzgerald 2020-09-30 23:21:01 +10:00
parent 8acabefcc5
commit 04a7d0075d
11 changed files with 126 additions and 514 deletions

View File

@ -9,7 +9,7 @@
"@reach/menu-button": "^0.10.5",
"@reach/tabs": "^0.10.5",
"@tlon/indigo-light": "^1.0.3",
"@tlon/indigo-react": "1.2.6",
"@tlon/indigo-react": "urbit/indigo-react#lf/1.2.8",
"aws-sdk": "^2.726.0",
"classnames": "^2.2.6",
"codemirror": "^5.55.0",

View File

@ -6,16 +6,20 @@ import React, {
useCallback,
} from "react";
import styled from "styled-components";
import _ from 'lodash';
import { Box, Col } from "@tlon/indigo-react";
import { useOutsideClick } from "~/logic/lib/useOutsideClick";
import { useLocation } from "react-router-dom";
import { Portal } from "./Portal";
type AlignY = "top" | "bottom";
type AlignX = "left" | "right";
interface DropdownProps {
children: ReactNode;
options: ReactNode;
alignY: "top" | "bottom";
alignX: "left" | "right";
alignY: AlignY | AlignY[];
alignX: AlignX | AlignX[];
width?: string;
}
@ -31,7 +35,7 @@ const DropdownOptions = styled(Box)`
`;
export function Dropdown(props: DropdownProps) {
const { children, options, alignX, alignY } = props;
const { children, options } = props;
const dropdownRef = useRef<HTMLElement>(null);
const anchorRef = useRef<HTMLElement>(null);
const { pathname } = useLocation();
@ -47,10 +51,34 @@ export function Dropdown(props: DropdownProps) {
bottom: document.documentElement.clientHeight - rect.bottom,
right: document.documentElement.clientWidth - rect.right,
};
const alignX = _.isArray(props.alignX) ? props.alignX : [props.alignX];
const alignY = _.isArray(props.alignY) ? props.alignY : [props.alignY];
let newCoords = {
[alignX]: `${bounds[alignX]}px`,
[alignY]: `${bounds[alignY]}px`,
..._.reduce(
alignX,
(acc, a, idx) => ({
...acc,
[a]: _.zipWith(
[...Array(idx), `${bounds[a]}px`],
acc[a] || [],
(a, b) => a || b || null
),
}),
{}
),
..._.reduce(
alignY,
(acc, a, idx) => ({
...acc,
[a]: _.zipWith(
[...Array(idx), `${bounds[a]}px`],
acc[a] || [],
(a, b) => a || b || null
),
}),
{}
)
};
setCoords(newCoords);
}

View File

@ -40,9 +40,10 @@ export function GroupsPane(props: GroupsPaneProps) {
const relativePath = (path: string) => baseUrl + path;
const groupPath = getGroupFromWorkspace(workspace);
const groupContacts = groupPath && contacts[groupPath] || undefined;
const groupAssociation = groupPath && associations.contacts[groupPath] || undefined;
const group = groupPath && groups[groupPath] || undefined;
const groupContacts = (groupPath && contacts[groupPath]) || undefined;
const groupAssociation =
(groupPath && associations.contacts[groupPath]) || undefined;
const group = (groupPath && groups[groupPath]) || undefined;
const [recentGroups, setRecentGroups] = useLocalStorageState<string[]>(
"recent-groups",
[]
@ -126,13 +127,14 @@ export function GroupsPane(props: GroupsPaneProps) {
}}
/>
<Route
path={relativePath("/join/:app/:host/:name")}
path={relativePath("/join/:app/(ship)?/:host/:name")}
render={(routeProps) => {
const { app, host, name } = routeProps.match.params;
const appName = app as AppName;
const appPath = `/${host}/${name}`;
const association = associations[appName][appPath];
const resourceUrl = `${baseUrl}/join/${app}/${host}/${name}`;
const isShip = app === "link";
const appPath = `${isShip ? '/ship/' : '/'}${host}/${name}`;
const association = isShip ? associations.graph[appPath] : associations[appName][appPath];
const resourceUrl = `${baseUrl}/join/${app}${appPath}`;
return (
<Skeleton
recentGroups={recentGroups}
@ -141,7 +143,13 @@ export function GroupsPane(props: GroupsPaneProps) {
{...props}
baseUrl={baseUrl}
>
<UnjoinedResource association={association} />
<UnjoinedResource
notebooks={props.notebooks}
inbox={props.inbox}
baseUrl={baseUrl}
api={api}
association={association}
/>
{popovers(routeProps, resourceUrl)}
</Skeleton>
);

View File

@ -144,7 +144,7 @@ export function ShipSearch(props: InviteSearchProps) {
borderColor="washedGrey"
color="black"
fontSize={0}
mb={2}
mt={2}
mr={2}
>
<Text fontFamily="mono">{cite(s)}</Text>

View File

@ -1,201 +0,0 @@
import React, { ReactNode, useState } from "react";
import {
Box,
Row,
Text,
Icon,
MenuItem as _MenuItem,
IconButton,
Button,
} from "@tlon/indigo-react";
import { capitalize } from "lodash";
import { Link } from "react-router-dom";
import { SidebarInvite } from "./SidebarInvite";
import GlobalApi from "~/logic/api/global";
import { AppName } from "~/types/noun";
import { alphabeticalOrder } from "~/logic/lib/util";
import { GroupSwitcher } from "~/views/apps/groups/components/GroupSwitcher";
import { AppInvites, Associations, AppAssociations, Workspace } from "~/types";
import { SidebarItem } from "./SidebarItem";
import {
SidebarListHeader,
SidebarListConfig,
SidebarSort,
} from "./SidebarListHeader";
import { useLocalStorageState } from "~/logic/lib/useLocalStorageState";
import {getGroupFromWorkspace} from "~/logic/lib/workspace";
interface SidebarAppConfig {
name: string;
makeRouteForResource: (appPath: string) => string;
getStatus: (appPath: string) => SidebarItemStatus | undefined;
}
export type SidebarAppConfigs = { [a in AppName]: SidebarAppConfig };
export type SidebarItemStatus =
| "unread"
| "mention"
| "unsubscribed"
| "disconnected"
| "loading";
function sidebarSort(
associations: AppAssociations
): Record<SidebarSort, (a: string, b: string) => number> {
const alphabetical = (a: string, b: string) => {
const aAssoc = associations[a];
const bAssoc = associations[b];
const aTitle = aAssoc?.metadata?.title || b;
const bTitle = bAssoc?.metadata?.title || b;
return alphabeticalOrder(aTitle, bTitle);
};
return {
asc: alphabetical,
desc: (a, b) => alphabetical(b, a),
};
}
const apps = ["chat", "publish", "link"];
function SidebarItems(props: {
apps: SidebarAppConfigs;
config: SidebarListConfig;
associations: Associations;
group?: string;
selected?: string;
}) {
const { selected, group, config } = props;
const associations = {
...props.associations.chat,
...props.associations.publish,
...props.associations.link,
...props.associations.graph,
};
const ordered = Object.keys(associations)
.filter((a) => {
const assoc = associations[a];
console.log(a);
return group
? assoc["group-path"] === group
: !(assoc["group-path"] in props.associations.contacts);
})
.sort(sidebarSort(associations)[config.sortBy]);
return (
<>
{ordered.map((path) => {
const assoc = associations[path];
return (
<SidebarItem
key={path}
path={path}
selected={path === selected}
association={assoc}
apps={props.apps}
hideUnjoined={config.hideUnjoined}
/>
);
})}
</>
);
}
interface SidebarProps {
children: ReactNode;
recentGroups: string[];
invites: AppInvites;
api: GlobalApi;
associations: Associations;
selected?: string;
selectedGroup?: string;
includeUnmanaged?: boolean;
apps: SidebarAppConfigs;
baseUrl: string;
mobileHide?: boolean;
workspace: Workspace;
}
export function Sidebar(props: SidebarProps) {
const { invites, api, associations, selected, apps, workspace } = props;
const groupPath = getGroupFromWorkspace(workspace)
const groupAsssociation =
groupPath && associations.contacts[groupPath];
const display = props.mobileHide ? ["none", "flex"] : "flex";
if (!associations) {
return null;
}
const [config, setConfig] = useLocalStorageState<SidebarListConfig>(
`group-config:${groupPath || "home"}`,
{
sortBy: "asc",
hideUnjoined: false,
}
);
return (
<Box
display={display}
flexDirection="column"
width="100%"
height="100%"
gridRow="1/2"
gridColumn="1/2"
borderRight={1}
borderRightColor="washedGray"
overflowY="auto"
fontSize={0}
bg="white"
position="relative"
>
<GroupSwitcher
associations={associations}
recentGroups={props.recentGroups}
baseUrl={props.baseUrl}
workspace={props.workspace}
/>
{Object.keys(invites).map((appPath) =>
Object.keys(invites[appPath]).map((uid) => (
<SidebarInvite
key={uid}
invite={props.invites[uid]}
onAccept={() => props.api.invite.accept(appPath, uid)}
onDecline={() => props.api.invite.decline(appPath, uid)}
/>
))
)}
<SidebarListHeader initialValues={config} handleSubmit={setConfig} />
<SidebarItems
config={config}
associations={associations}
selected={selected}
group={groupPath}
apps={props.apps}
/>
<Box
display="flex"
justifyContent="center"
position="sticky"
bottom="8px"
width="100%"
my={2}
>
<Link to={`/~groups${props.selectedGroup}/new`}>
<Box
bg="white"
p={2}
borderRadius={1}
border={1}
borderColor="lightGray"
>
+ New Channel
</Box>
</Link>
</Box>
</Box>
);
}

View File

@ -1,32 +0,0 @@
import React, { Component } from 'react';
import { Invite } from '~/types/invite-update';
export class SidebarInvite extends Component<{invite: Invite, onAccept: Function, onDecline: Function}, {}> {
render() {
const { props } = this;
return (
<div className='w-100 bg-white bg-gray0-d pa4 bb b--gray4 b--gray1-d z-5' style={{position: 'sticky', top: 0}}>
<div className='w-100 v-mid'>
<p className="dib f8 mono gray4-d">
{props.invite.text ? props.invite.text : props.invite.path}
</p>
</div>
<a
className="dib pointer pa2 f9 bg-green2 white mt4"
onClick={this.props.onAccept.bind(this)}
>
Accept Invite
</a>
<a
className="dib pointer ml4 pa2 f9 bg-black bg-gray0-d white mt4"
onClick={this.props.onDecline.bind(this)}
>
Decline
</a>
</div>
);
}
}
export default SidebarInvite;

View File

@ -1,106 +0,0 @@
import React from "react";
import { Icon, Row, Box, Text } from "@tlon/indigo-react";
import { Association } from "~/types/metadata-update";
import { SidebarAppConfigs, SidebarItemStatus } from "./Sidebar";
import { HoverBoxLink } from "./HoverBox";
import {Groups} from "~/types";
function SidebarItemIndicator(props: { status?: SidebarItemStatus }) {
switch (props.status) {
case "disconnected":
return <Icon ml={2} fill="red" icon="X" />;
case "unsubscribed":
return <Icon ml={2} icon="Circle" fill="gray" />;
case "mention":
return <Icon ml={2} icon="Circle" />;
case "loading":
return <Icon ml={2} icon="Bullet" />;
default:
return null;
}
}
const getAppIcon = (app: string, module: string) => {
if (app === "graph") {
if (module === "link") {
return "Links";
}
return _.capitalize(module);
}
return _.capitalize(app);
};
export function SidebarItem(props: {
hideUnjoined: boolean;
association: Association;
groups: Groups;
path: string;
selected: boolean;
apps: SidebarAppConfigs;
}) {
const { association, path, selected, apps, groups } = props;
const title = association?.metadata?.title || path;
const appName = association?.["app-name"];
const module = association?.metadata?.module || appName;
const appPath = association?.["app-path"];
const groupPath = association?.["group-path"];
const app = apps[module];
const isUnmanaged = groups?.[groupPath]?.hidden || false;
if (!app) {
return null;
}
const status = app.getStatus(path);
const hasUnread = status === "unread" || status === "mention";
const isSynced = status !== "unsubscribed";
const baseUrl = isUnmanaged ? `/~groups/home` : `/~groups${groupPath}`;
const to = isSynced
? `${baseUrl}/resource/${module}${appPath}`
: `${baseUrl}/join/${module}${appPath}`;
const color = selected ? "black" : isSynced ? "gray" : "lightGray";
if (props.hideUnjoined && !isSynced) {
return null;
}
return (
<HoverBoxLink
to={to}
bg="white"
bgActive="washedGray"
width="100%"
display="flex"
justifyContent="space-between"
alignItems="center"
py={1}
pl={4}
pr={2}
selected={selected}
>
<Row alignItems="center">
<Icon
display="block"
fill="rgba(0,0,0,0)"
stroke={color}
icon={getAppIcon(appName, module)}
/>
<Box
flexShrink={2}
ml={2}
lineHeight="1.33"
fontWeight={hasUnread ? "600" : "400"}
>
<Text color={selected || isSynced ? "black" : "lightGray"}>
{title}
</Text>
</Box>
</Row>
</HoverBoxLink>
);
}

View File

@ -1,67 +0,0 @@
import React, { useCallback } from "react";
import * as Yup from "yup";
import {
Row,
Box,
Icon,
ManagedRadioButtonField as Radio,
ManagedCheckboxField as Checkbox,
Col,
} from "@tlon/indigo-react";
import { FormikOnBlur } from "./FormikOnBlur";
import { Dropdown } from "./Dropdown";
import { FormikHelpers } from "formik";
export type SidebarSort = "asc" | "desc";
export interface SidebarListConfig {
sortBy: SidebarSort;
hideUnjoined: boolean;
}
export function SidebarListHeader(props: {
initialValues: SidebarListConfig;
handleSubmit: (c: SidebarListConfig) => void;
}) {
const onSubmit = useCallback(
(values: SidebarListConfig, actions: FormikHelpers<SidebarListConfig>) => {
props.handleSubmit(values);
actions.setSubmitting(false);
},
[props.handleSubmit]
);
return (
<Row alignItems="center" justifyContent="space-between" py={2} px={3}>
<Box>
{props.initialValues.hideUnjoined ? "Joined Channels" : "All Channels"}
</Box>
<Dropdown
width="200px"
alignY="top"
options={
<FormikOnBlur initialValues={props.initialValues} onSubmit={onSubmit}>
<Col>
<Col borderBottom={1} borderBottomColor="washedGray" p={2}>
<Box color="gray" mt={2} mb={4}>
Sort Order
</Box>
<Radio label="A -> Z" id="asc" name="sortBy" />
<Radio label="Z -> A" id="desc" name="sortBy" />
</Col>
<Col px={2}>
<Checkbox
mt={4}
id="hideUnjoined"
label="Hide Unsubscribed Channels"
/>
</Col>
</Col>
</FormikOnBlur>
}
>
<Icon stroke="gray" icon="Circle" />
</Dropdown>
</Row>
);
}

View File

@ -1,8 +1,8 @@
import React, { ReactNode, useEffect } from "react";
import React, { ReactNode, useEffect, useMemo } from "react";
import { Box, Text } from "@tlon/indigo-react";
import { Link } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { Sidebar } from "./Sidebar/Sidebar";
import { ChatHookUpdate } from "~/types/chat-hook-update";
import { Inbox } from "~/types/chat-update";
import { Associations } from "~/types/metadata-update";
@ -12,14 +12,18 @@ import { Path, AppName } from "~/types/noun";
import { LinkCollections } from "~/types/link-update";
import styled from "styled-components";
import GlobalSubscription from "~/logic/subscription/global";
import {Workspace} from "~/types";
import { Workspace, Groups, Graphs } from "~/types";
import { useChat, usePublish, useLinks } from "./Sidebar/Apps";
import { Body } from "./Body";
interface SkeletonProps {
children: ReactNode;
recentGroups: string[];
groups: Groups;
associations: Associations;
chatSynced: ChatHookUpdate | null;
graphKeys: Set<string>;
graphs: Graphs;
linkListening: Set<Path>;
links: LinkCollections;
notebooks: Notebooks;
@ -32,99 +36,55 @@ interface SkeletonProps {
subscription: GlobalSubscription;
includeUnmanaged: boolean;
workspace: Workspace;
hideSidebar?: boolean;
}
export function Skeleton(props: SkeletonProps) {
const chatConfig = {
name: "chat",
getStatus: (s: string) => {
if (!(s in (props.chatSynced || {}))) {
return "unsubscribed";
}
const mailbox = props?.inbox?.[s];
if(!mailbox) {
return undefined;
}
const { config } = mailbox;
if (config?.read !== config?.length) {
return "unread";
}
return undefined;
},
};
const publishConfig = {
name: "chat",
getStatus: (s: string) => {
const [, host, name] = s.split("/");
const notebook = props.notebooks?.[host]?.[name];
if (!notebook) {
return "unsubscribed";
}
if (notebook["num-unread"]) {
return "unread";
}
return undefined;
},
};
const linkConfig = {
name: "link",
getStatus: (s: string) => {
const [, , host, name] = s.split("/");
const graphKey = `${host.slice(1)}/${name}`;
if (!props.graphKeys.has(graphKey)) {
return "unsubscribed";
}
const link = props.links[s];
if (!link) {
return undefined;
}
if (link.unseenCount > 0) {
return "unread";
}
return undefined;
},
};
const config = {
publish: publishConfig,
link: linkConfig,
chat: chatConfig,
};
const chatConfig = useChat(props.inbox, props.chatSynced);
const publishConfig = usePublish(props.notebooks);
const linkConfig = useLinks(props.graphKeys, props.graphs);
const config = useMemo(
() => ({
publish: publishConfig,
link: linkConfig,
chat: chatConfig,
}),
[publishConfig, linkConfig, chatConfig]
);
useEffect(() => {
props.api.publish.fetchNotebooks();
props.subscription.startApp("chat");
props.subscription.startApp("publish");
props.subscription.startApp("graph");
return () => {
props.subscription.stopApp("chat");
props.subscription.stopApp("publish");
props.subscription.stopApp("graph");
};
}, []);
return (
<Box fontSize={0} px={[0, 3]} pb={[0, 3]} height="100%" width="100%">
<Box
bg="white"
height="100%"
width="100%"
display="grid"
borderRadius={1}
border={[0, 1]}
borderColor={["washedGray", "washedGray"]}
gridTemplateColumns={["1fr", "250px 1fr"]}
gridTemplateRows="1fr"
>
<Body
display="grid"
gridTemplateColumns={["1fr", "250px 1fr"]}
gridTemplateRows="1fr"
>
{!props.hideSidebar && (
<Sidebar
recentGroups={props.recentGroups}
selected={props.selected}
selectedApp={props.selectedApp}
associations={props.associations}
invites={{}}
apps={config}
baseUrl={props.baseUrl}
includeUnmanaged={!props.selectedGroup}
groups={props.groups}
mobileHide={props.mobileHide}
workspace={props.workspace}
></Sidebar>
{props.children}
</Box>
</Box>
)}
{props.children}
</Body>
);
}

View File

@ -51,17 +51,6 @@ const StatusBar = (props) => {
{metaKey}/
</Text>
</StatusBarItem>
<StatusBarItem
onClick={() => props.history.push('/~groups')}
badge={Object.keys(invites).length > 0}>
<img
className='invert-d v-mid'
src='/~landscape/img/groups.png'
height='15'
width='15'
/>
<Text display={["none", "inline"]} ml={2}>Groups</Text>
</StatusBarItem>
<ReconnectButton
connection={props.connection}
subscription={props.subscription}

View File

@ -1,17 +1,52 @@
import React from "react";
import { Association } from "~/types/metadata-update";
import { Box, Text, Button, Col, Center } from "@tlon/indigo-react";
import { Link } from "react-router-dom";
import { Link, useHistory } from "react-router-dom";
import GlobalApi from "~/logic/api/global";
import {useWaitForProps} from "~/logic/lib/useWaitForProps";
import { StatelessAsyncButton as AsyncButton } from './StatelessAsyncButton';
import {Notebooks, Graphs, Inbox} from "~/types";
interface UnjoinedResourceProps {
association: Association;
api: GlobalApi;
baseUrl: string;
notebooks: Notebooks;
graphs: Graphs;
inbox: Inbox;
}
export function UnjoinedResource(props: UnjoinedResourceProps) {
const { api } = props;
const history = useHistory();
const appPath = props.association["app-path"];
const appName = props.association["app-name"];
const { title, description } = props.association.metadata;
const to = `/~${appName}/join${appPath}`;
const { title, description, module } = props.association.metadata;
const waiter = useWaitForProps(props);
const app = module || appName;
const onJoin = async () => {
let ship, name;
switch(app) {
case 'link':
[,,ship,name] = appPath.split('/');
await api.graph.joinGraph(ship, name);
break;
case 'publish':
[,ship,name] = appPath.split('/');
await api.publish.subscribeNotebook(ship.slice(1), name);
await waiter(p => !!p?.notebooks?.[ship]?.[name])
break;
case 'chat':
[,ship,name] = appPath.split('/');
await api.chat.join(ship, appPath, true);
await waiter(p => !!p?.inbox?.[appPath])
break;
default:
throw new Error("Unknown resource type");
}
history.push(`${props.baseUrl}/resource/${app}${appPath}`);
};
return (
<Center p={6}>
<Col maxWidth="400px" p={4} border={1} borderColor="washedGray">
@ -21,11 +56,9 @@ export function UnjoinedResource(props: UnjoinedResourceProps) {
<Box mb={4}>
<Text color="gray">{description}</Text>
</Box>
<Link to={to}>
<Button mx="auto" border>
<AsyncButton onClick={onJoin} mx="auto" border>
Join Channel
</Button>
</Link>
</AsyncButton>
</Col>
</Center>
);