interface: flesh out new groups layout

This commit is contained in:
Liam Fitzgerald 2020-09-25 10:42:56 +10:00
parent ae778de989
commit 7b2c485587
24 changed files with 963 additions and 209 deletions

View File

@ -21,6 +21,7 @@
</head>
<body>
<div id="root"></div>
<div id="portal-root"></div>
<script src="/~landscape/js/channel.js"></script>
<script src="/~landscape/js/session.js"></script>
<script src="/~landscape/js/bundle/index.df81e597349a655b83f2.js"></script>

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,24 @@
import { Associations, Workspace } from "~/types";
export function getTitleFromWorkspace(
associations: Associations,
workspace: Workspace
) {
switch (workspace.type) {
case "home":
return "Home";
case "group":
const association = associations.contacts[workspace.group];
return association?.metadata?.title || "";
}
}
export function getGroupFromWorkspace(
workspace: Workspace
): string | undefined {
if (workspace.type === "group") {
return workspace.group;
}
return undefined;
}

View File

@ -0,0 +1,12 @@
interface GroupWorkspace {
type: 'group';
group: string;
}
interface HomeWorkspace {
type: 'home'
}
export type Workspace = HomeWorkspace | GroupWorkspace;

View File

@ -21,6 +21,7 @@ import {Resource} from '~/views/components/Resource';
import {PopoverRoutes} from './components/PopoverRoutes';
import {UnjoinedResource} from '~/views/components/UnjoinedResource';
import {GroupsPane} from '~/views/components/GroupsPane';
import {Workspace} from '~/types';
type GroupsAppProps = StoreState & {
@ -288,12 +289,20 @@ export default class GroupsApp extends Component<GroupsAppProps, {}> {
const { host, name } = routeProps.match.params as Record<string, string>;
const groupPath = `/ship/${host}/${name}`;
const baseUrl = `/~groups${groupPath}`;
const ws: Workspace = { type: 'group', group: groupPath };
return (
<GroupsPane baseUrl={baseUrl} groupPath={groupPath} {...props} />
<GroupsPane workspace={ws} baseUrl={baseUrl} {...props} />
)
}}/>
<Route path="/~groups/home"
render={routeProps => {
const ws: Workspace = { type: 'home' };
return (<GroupsPane workspace={ws} baseUrl="/~groups/home" {...props} />);
}}
/>
<Route exact path="/~groups/view/ship/:ship/:group/:contact"
render={(props) => {
const groupPath =

View File

@ -14,6 +14,8 @@ import { Link } from "react-router-dom";
import { Association, Associations } from "~/types/metadata-update";
import { Dropdown } from "~/views/components/Dropdown";
import {Workspace} from "~/types";
import {getTitleFromWorkspace} from "~/logic/lib/workspace";
const GroupSwitcherItem = ({ to, children, bottom = false, ...rest }) => (
<Link to={to}>
@ -65,12 +67,13 @@ function RecentGroups(props: { recent: string[]; associations: Associations }) {
}
export function GroupSwitcher(props: {
association: Association;
associations: Associations;
workspace: Workspace;
baseUrl: string;
recentGroups: string[];
}) {
const { title } = props.association.metadata;
const { associations, workspace } = props;
const title = getTitleFromWorkspace(associations, workspace)
const navTo = (to: string) => `${props.baseUrl}${to}`;
return (
<Box position="sticky" top="0px" p={2}>
@ -83,7 +86,8 @@ export function GroupSwitcher(props: {
>
<Row alignItems="center" justifyContent="space-between">
<Dropdown
width="200px"
width="220px"
alignY="top"
options={
<Col bg="white" width="100%" alignItems="stretch">
<GroupSwitcherItem to="">
@ -100,23 +104,27 @@ export function GroupSwitcher(props: {
recent={props.recentGroups}
associations={props.associations}
/>
<GroupSwitcherItem to={navTo("/popover/participants")}>
<Icon mr={2} fill="none" stroke="gray" icon="CircleDot" />
<Text> Participants</Text>
</GroupSwitcherItem>
<GroupSwitcherItem to={navTo("/popover/settings")}>
<Icon mr={2} fill="none" stroke="gray" icon="Gear" />
<Text> Settings</Text>
</GroupSwitcherItem>
<GroupSwitcherItem bottom to={navTo("/invites")}>
<Icon
mr={2}
fill="rgba(0,0,0,0)"
stroke="blue"
icon="CreateGroup"
/>
<Text color="blue">Invite to group</Text>
</GroupSwitcherItem>
{workspace.type === 'group' && (
<>
<GroupSwitcherItem to={navTo("/popover/participants")}>
<Icon mr={2} fill="none" stroke="gray" icon="CircleDot" />
<Text> Participants</Text>
</GroupSwitcherItem>
<GroupSwitcherItem to={navTo("/popover/settings")}>
<Icon mr={2} fill="none" stroke="gray" icon="Gear" />
<Text> Settings</Text>
</GroupSwitcherItem>
<GroupSwitcherItem bottom to={navTo("/invites")}>
<Icon
mr={2}
fill="rgba(0,0,0,0)"
stroke="blue"
icon="CreateGroup"
/>
<Text color="blue">Invite to group</Text>
</GroupSwitcherItem>
</>
)}
</Col>
}
>
@ -128,17 +136,21 @@ export function GroupSwitcher(props: {
</Box>
</Dropdown>
<Row collapse pr={1} justifyContent="flex-end" alignItems="center">
<Link to={navTo("/invites")}>
<Icon
display="block"
fill="rgba(0,0,0,0)"
stroke="blue"
icon="CreateGroup"
/>
</Link>
<Link to={navTo("/popover/settings")}>
<Icon display="block" ml={2} icon="Gear" />
</Link>
{ workspace.type === 'group' && (
<>
<Link to={navTo("/invites")}>
<Icon
display="block"
fill="rgba(0,0,0,0)"
stroke="blue"
icon="CreateGroup"
/>
</Link>
<Link to={navTo("/popover/settings")}>
<Icon display="block" ml={2} icon="Gear" />
</Link>
</>
)}
</Row>
</Row>
</Col>

View File

@ -198,7 +198,8 @@ function Participant(props: {
<Text>{_.capitalize(role)}</Text>
</Col>
<Dropdown
position="right"
alignX="right"
alignY="top"
options={
<>
<Button onClick={sendMessage}>

View File

@ -0,0 +1,109 @@
import React, { useCallback } from "react";
import { Link } from "react-router-dom";
import { Icon, Row, Col, Button, Text, Box } from "@tlon/indigo-react";
import { Dropdown } from "~/views/components/Dropdown";
import { Association } from "~/types";
import GlobalApi from "~/logic/api/global";
const ChannelMenuItem = ({
icon,
stroke = undefined,
children,
bottom = false,
}) => (
<Row
alignItems="center"
borderBottom={bottom ? 0 : 1}
borderBottomColor="lightGray"
px={2}
py={1}
>
<Icon fill={stroke} icon={icon} />
{children}
</Row>
);
interface ChannelMenuProps {
association: Association;
api: GlobalApi;
}
export function ChannelMenu(props: ChannelMenuProps) {
const { association, api } = props;
const { metadata } = association;
const app = metadata.module || association["app-name"];
const baseUrl = `/~groups${association?.["group-path"]}/resource/${app}${association["app-path"]}`;
const appPath = association["app-path"];
const [, ship, name] = appPath.startsWith('/ship/') ? appPath.slice(5).split("/") : appPath.split('/');
const isOurs = ship.slice(1) === window.ship;
const onUnsubscribe = useCallback(async () => {
const app = metadata.module || association["app-name"];
switch (app) {
case "chat":
await api.chat.delete(appPath);
break;
case "publish":
await api.publish.unsubscribeNotebook(ship.slice(1), name);
break;
case "link":
await api.graph.leaveGraph(ship.slice(1), name);
break;
default:
throw new Error("Invalid app name");
}
}, [api, association]);
const onDelete = useCallback(async () => {
const app = metadata.module || association["app-name"];
switch (app) {
case "chat":
await api.chat.delete(appPath);
break;
case "publish":
await api.publish.delBook(name);
break;
case "link":
await api.graph.deleteGraph(name);
break;
default:
throw new Error("Invalid app name");
}
}, [api, association]);
return (
<Dropdown
options={
<Col>
<ChannelMenuItem icon="Gear">
<Link to={`${baseUrl}/settings`}>
<Box fontSize={0} p="2">
Channel Settings
</Box>
</Link>
</ChannelMenuItem>
{isOurs ? (
<ChannelMenuItem stroke="red" bottom icon="TrashCan">
<Button error onClick={onDelete}>
Delete Channel
</Button>
</ChannelMenuItem>
) : (
<ChannelMenuItem bottom icon="ArrowEast">
<Button error={isOurs} onClick={onUnsubscribe}>
Unsubscribe from Channel
</Button>
</ChannelMenuItem>
)}
</Col>
}
alignX="right"
alignY="top"
width="250px"
>
<Icon icon="Menu" stroke="gray" />
</Dropdown>
);
}

View File

@ -0,0 +1,110 @@
import React, { useEffect } from "react";
import { AsyncButton } from "../../../../components/AsyncButton";
import * as Yup from "yup";
import {
Box,
Input,
Checkbox,
Col,
InputLabel,
InputCaption,
Button,
Center,
Text,
} from "@tlon/indigo-react";
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
import GlobalApi from "~/logic/api/global";
import { FormError } from "~/views/components/FormError";
import { ColorInput } from "~/views/components/ColorInput";
import { Association } from "~/types";
interface FormSchema {
title: string;
description: string;
color: string;
}
interface ChannelSettingsProps {
association: Association;
api: GlobalApi;
}
export function ChannelSettings(props: ChannelSettingsProps) {
const { api, association } = props;
const { metadata } = association;
const initialValues: FormSchema = {
title: metadata?.title || "",
description: metadata?.description || "",
color: metadata?.color || "0x0",
};
const onSubmit = async (
values: FormSchema,
actions: FormikHelpers<FormSchema>
) => {
try {
const app = association["app-name"];
const resource = association["app-path"];
const group = association["group-path"];
const date = metadata["date-created"];
const { title, description, color } = values;
await api.metadata.metadataAdd(
app,
resource,
group,
title,
description,
date,
color.slice(2),
metadata.module
);
actions.setStatus({ success: null });
} catch (e) {
console.log(e);
actions.setStatus({ error: e.message });
}
};
return (
<Box overflowY="auto" p={4}>
<Formik initialValues={initialValues} onSubmit={onSubmit}>
<Form style={{ display: "contents" }}>
<Box
display="grid"
gridTemplateColumns="100%"
maxWidth="512px"
gridAutoRows="auto"
width="100%"
>
<Col mb={3}>
<Text fontWeight="bold">Channel Host Settings</Text>
<InputCaption>
Adjust channel settings, only available for channel's hosts
</InputCaption>
</Col>
<Input
id="title"
label="Title"
caption="Change the title of this channel"
/>
<Input
id="description"
label="Change description"
caption="Change the description of this channel"
/>
<ColorInput
id="color"
label="Color"
caption="Change the color of this channel"
/>
<AsyncButton primary loadingText="Updating.." border>
Save
</AsyncButton>
<FormError message="Failed to update settings" />
</Box>
</Form>
</Formik>
</Box>
);
}

View File

@ -93,7 +93,7 @@ export function GroupSettings(props: GroupSettingsProps) {
longer see this group.)
</InputCaption>
<Button onClick={onDelete} mt={1} border error>
Delete this notebook
Delete this group
</Button>
</Col>
<Box mb={4} borderBottom={1} borderBottomColor="washedGray" />

View File

@ -0,0 +1,164 @@
import React, { useCallback } from "react";
import { Box, Input, Col, InputLabel, Radio, Text } from "@tlon/indigo-react";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import GlobalApi from "~/logic/api/global";
import { AsyncButton } from "~/views/components/AsyncButton";
import { FormError } from "~/views/components/FormError";
import { RouteComponentProps } from "react-router-dom";
import { stringToSymbol } from "~/logic/lib/util";
import GroupSearch from "~/views/components/GroupSearch";
import { Associations } from "~/types/metadata-update";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import { Notebooks } from "~/types/publish-update";
import { Groups } from "~/types/group-update";
import { ShipSearch } from "~/views/components/ShipSearch";
import { Rolodex } from "~/types";
interface FormSchema {
name: string;
description: string;
ships: string[];
type: "chat" | "publish" | "links";
}
const formSchema = Yup.object({
name: Yup.string().required("Channel must have a name"),
description: Yup.string(),
ships: Yup.array(Yup.string()),
type: Yup.string().required("Must choose channel type"),
});
interface NewChannelProps {
api: GlobalApi;
associations: Associations;
contacts: Rolodex;
groups: Groups;
group?: string;
}
const EMPTY_INVITE_POLICY = { invite: { pending: [] } };
export function NewChannel(props: NewChannelProps & RouteComponentProps) {
const { history, api, group } = props;
const waiter = useWaitForProps(props, 5000);
const onSubmit = async (values: FormSchema, actions) => {
const resId: string = stringToSymbol(values.name);
try {
const { name, description, type, ships } = values;
switch (type) {
case "chat":
const appPath = `/~${window.ship}/${resId}`;
const groupPath = group || `/ship${appPath}`;
await api.chat.create(
name,
description,
appPath,
groupPath,
EMPTY_INVITE_POLICY,
ships.map(s => `~${s}`),
true,
false
);
break;
case "publish":
await props.api.publish.newBook(resId, name, description, group);
break;
case "links":
if (group) {
await api.graph.createManagedGraph(
resId,
name,
description,
group,
"link"
);
} else {
await api.graph.createUnmanagedGraph(
resId,
name,
description,
EMPTY_INVITE_POLICY,
"link"
);
}
break;
default:
console.log("fallthrough");
}
if (!group) {
await waiter((p) => !!p?.groups?.[`/ship/~${window.ship}/${resId}`]);
}
actions.setStatus({ success: null });
} catch (e) {
console.error(e);
actions.setStatus({ error: "Channel creation failed" });
}
};
return (
<Col overflowY="auto" p={3}>
<Box fontWeight="bold" mb={4} color="black">
New Channel
</Box>
<Formik
validationSchema={formSchema}
initialValues={{
type: "chat",
name: "",
description: "",
group: "",
ships: [],
}}
onSubmit={onSubmit}
>
<Form>
<Box
display="grid"
gridTemplateRows="auto"
gridRowGap={2}
gridTemplateColumns="300px"
>
<Box mb={2}>Channel Type</Box>
<Radio label="Chat" id="chat" name="type" />
<Radio label="Notebook" id="publish" name="type" />
<Radio label="Collection" id="links" name="type" />
<Input
id="name"
label="Name"
caption="Provide a name for your channel"
placeholder="eg. My Channel"
/>
<Input
id="description"
label="Description"
caption="What's your channel about?"
placeholder="Channel description"
/>
<ShipSearch
groups={props.groups}
contacts={props.contacts}
id="ships"
label="Invitees"
/>
<Box justifySelf="start">
<AsyncButton
primary
loadingText="Creating..."
type="submit"
border
>
Create Channel
</AsyncButton>
</Box>
<FormError message="Channel creation failed" />
</Box>
</Form>
</Formik>
</Col>
);
}

View File

@ -0,0 +1,83 @@
import React, { useCallback, ReactNode } from "react";
import { Row, Box, Col, Text } from "@tlon/indigo-react";
import styled from "styled-components";
import { Link } from "react-router-dom";
import { ChatResource } from "~/views/apps/chat/ChatResource";
import { PublishResource } from "~/views/apps/publish/PublishResource";
import { Association } from "~/types/metadata-update";
import { StoreState } from "~/logic/store/type";
import GlobalApi from "~/logic/api/global";
import { RouteComponentProps, Route, Switch } from "react-router-dom";
import { ChannelSettings } from "../apps/groups/components/lib/ChannelSettings";
import { ChannelMenu } from "./ChannelMenu";
const TruncatedBox = styled(Box)`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
type ResourceSkeletonProps = {
association: Association;
api: GlobalApi;
baseUrl: string;
children: ReactNode;
atRoot?: boolean;
title?: string;
};
export function ResourceSkeleton(props: ResourceSkeletonProps) {
const { association, api, children, atRoot } = props;
const app = association?.metadata?.module || association["app-name"];
const appPath = association["app-path"];
const selectedGroup = association["group-path"];
const title = props.title || association?.metadata?.title;
return (
<Col width="100%" height="100%" overflowY="hidden">
<Box
p={2}
display="flex"
alignItems="center"
borderBottom={1}
borderBottomColor="washedGray"
>
{atRoot ? (
<Box
borderRight={1}
borderRightColor="gray"
pr={2}
mr={2}
display={["block", "none"]}
>
<Link to={`/~groups${selectedGroup}`}> {"<- Back"}</Link>
</Box>
) : (
<Box
color="blue"
borderRight={1}
borderRightColor="gray"
pr={2}
mr={2}
>
<Link to={`/~groups${selectedGroup}/resource/${app}${appPath}`}>
<Text color="blue">Go back to channel</Text>
</Link>
</Box>
)}
<Box mr={2}>{title}</Box>
{atRoot && (
<>
<TruncatedBox maxWidth="50%" flexShrink={1} color="gray">
{association?.metadata?.description}
</TruncatedBox>
<Box flexGrow={1} />
<ChannelMenu association={association} api={api} />
</>
)}
</Box>
{children}
</Col>
);
}

View File

@ -0,0 +1,70 @@
import React from "react";
import { Box } from "@tlon/indigo-react";
import { Link } from "react-router-dom";
import { useLocalStorageState } from "~/logic/lib/useLocalStorageState";
import { Associations, Association } from "~/types";
import { alphabeticalOrder } from "~/logic/lib/util";
interface GroupsProps {
associations: Associations;
}
// Sort by recent, then by channel size? Should probably sort
// by num unreads when notif-store drops
const sortGroups = (_assocs: Associations, recent: string[]) => (
a: Association,
b: Association
) => {
return alphabeticalOrder(a.metadata.title, b.metadata.title);
//
const aRecency = recent.findIndex((r) => a["group-path"] === r);
const bRecency = recent.findIndex((r) => b["group-path"] === r);
const diff =
((aRecency !== -1 || bRecency !== -1) && bRecency - aRecency) || 0;
if (diff !== 0) {
return diff;
}
};
export default function Groups(props: GroupsProps & Parameters<typeof Box>[0]) {
const { associations, ...boxProps } = props;
const [recentGroups, setRecentGroups] = useLocalStorageState<string[]>(
"recent-groups",
[]
);
const groups = Object.values(props?.associations?.contacts || {}).sort(
sortGroups(props.associations, recentGroups)
);
return (
<Box
display="grid"
gridAutoRows="124px"
gridAutoColumns="124px"
gridGap={3}
p={2}
width="100%"
gridAutoFlow="column"
{...boxProps}
>
{groups.map((group) => (
<Link to={`/~groups${group["group-path"]}`}>
<Box
height="100%"
width="100%"
bg="white"
border={1}
borderRadius={1}
borderColor="lightGray"
p={2}
fontSize={0}
>
{group.metadata.title}
</Box>
</Link>
))}
</Box>
);
}

View File

@ -1,13 +1,21 @@
import React, { ReactNode, useState, useRef, useEffect } from "react";
import React, {
ReactNode,
useState,
useRef,
useEffect,
useCallback,
} from "react";
import styled from "styled-components";
import { Box, Col } from "@tlon/indigo-react";
import { useOutsideClick } from "~/logic/lib/useOutsideClick";
import { useLocation } from "react-router-dom";
import { Portal } from "./Portal";
interface DropdownProps {
children: ReactNode;
options: ReactNode;
position: "left" | "right";
alignY: "top" | "bottom";
alignX: "left" | "right";
width?: string;
}
@ -15,16 +23,56 @@ const ClickBox = styled(Box)`
cursor: pointer;
`;
const DropdownOptions = styled(Box)<{ pos: string }>`
const DropdownOptions = styled(Box)`
z-index: 20;
position: absolute;
${(p) => p.pos}: -1px;
position: fixed;
transition: left 0.05s, top 0.05s, right 0.05s, bottom 0.05s;
transition-timing-function: ease;
`;
export function Dropdown(props: DropdownProps) {
const { children, options } = props;
const { children, options, alignX, alignY } = props;
const dropdownRef = useRef<HTMLElement>(null);
const anchorRef = useRef<HTMLElement>(null);
const { pathname } = useLocation();
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({});
const updatePos = useCallback(() => {
const rect = anchorRef.current?.getBoundingClientRect();
if (rect) {
const bounds = {
top: rect.top,
left: rect.left,
bottom: document.documentElement.clientHeight - rect.bottom,
right: document.documentElement.clientWidth - rect.right,
};
let newCoords = {
[alignX]: `${bounds[alignX]}px`,
[alignY]: `${bounds[alignY]}px`,
};
setCoords(newCoords);
}
}, [setCoords, anchorRef.current]);
useEffect(() => {
if (!open) {
return;
}
const interval = setInterval(updatePos, 100);
return () => {
clearInterval(interval);
};
}, [updatePos, open]);
const onOpen = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
updatePos();
setOpen(true);
},
[setOpen, updatePos]
);
useEffect(() => {
setOpen(false);
@ -34,31 +82,31 @@ export function Dropdown(props: DropdownProps) {
setOpen(false);
});
const [open, setOpen] = useState(false);
const align = props.position === "right" ? "flex-end" : "flex-start";
return (
<Box position={open ? "relative" : "static"}>
<ClickBox onClick={() => setOpen((o) => !o)}> {children}</ClickBox>
<Box flexShrink={0} position={open ? "relative" : "static"}>
<ClickBox ref={anchorRef} onClick={onOpen}>
{children}
</ClickBox>
{open && (
<DropdownOptions pos={props.position} ref={dropdownRef}>
<Col
alignItems={align}
width={props.width || "max-content"}
border={1}
borderColor="lightGray"
bg="white"
borderRadius={2}
>
{options}
</Col>
</DropdownOptions>
<Portal>
<DropdownOptions {...coords} ref={dropdownRef}>
<Col
width={props.width || "max-content"}
border={1}
borderColor="lightGray"
bg="white"
borderRadius={2}
>
{options}
</Col>
</DropdownOptions>
</Portal>
)}
</Box>
);
}
Dropdown.defaultProps = {
position: "left",
alignX: "left",
alignY: "bottom",
};

View File

@ -26,6 +26,8 @@ interface RenderChoiceProps<C> {
interface DropdownSearchProps<C> {
label: string;
id: string;
// check if entry is exact match
isExact: (s: string) => C | undefined;
// Options for dropdown
candidates: C[];
// Present options in dropdown
@ -115,18 +117,17 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
[setQuery]
);
const dropdown = useMemo(
() =>
_.take(options, 5).map((o, idx) =>
props.renderCandidate(
o,
!_.isUndefined(selected) &&
props.getKey(o) === props.getKey(selected),
onSelect
)
),
[options, props.getKey, props.renderCandidate, selected]
);
const dropdown = useMemo(() => {
const first = props.isExact(query);
const opts = first ? [first, ...options] : options;
return _.take(opts, 5).map((o, idx) =>
props.renderCandidate(
o,
!_.isUndefined(selected) && props.getKey(o) === props.getKey(selected),
onSelect
)
);
}, [options, props.getKey, props.renderCandidate, selected]);
return (
<Box position="relative">
@ -145,7 +146,7 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
autocomplete="off"
/>
)}
{options.length !== 0 && query.length !== 0 && (
{dropdown.length !== 0 && query.length !== 0 && (
<Box
mt={1}
border={1}

View File

@ -5,7 +5,7 @@ import {
useLocation,
RouteComponentProps,
} from "react-router-dom";
import { Center } from "@tlon/indigo-react";
import { Center, Box } from "@tlon/indigo-react";
import _ from "lodash";
import { Resource } from "~/views/components/Resource";
@ -22,74 +22,87 @@ import { StoreState } from "~/logic/store/type";
import { UnjoinedResource } from "./UnjoinedResource";
import { InvitePopover } from "../apps/groups/components/InvitePopover";
import { useLocalStorageState } from "~/logic/lib/useLocalStorageState";
import { NewChannel } from "../apps/groups/components/lib/NewChannel";
import "~/views/apps/links/css/custom.css";
import "~/views/apps/publish/css/custom.css";
import { Workspace } from "~/types";
import { getGroupFromWorkspace } from "~/logic/lib/workspace";
type GroupsPaneProps = StoreState & {
baseUrl: string;
groupPath: string;
workspace: Workspace;
api: GlobalApi;
};
export function GroupsPane(props: GroupsPaneProps) {
const { baseUrl, associations, groups, contacts, api, groupPath } = props;
const { baseUrl, associations, groups, contacts, api, workspace } = props;
const relativePath = (path: string) => baseUrl + path;
const groupPath = getGroupFromWorkspace(workspace);
const groupContacts = contacts[groupPath];
const groupAssociation = associations.contacts[groupPath];
const group = groups[groupPath];
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",
[]
);
useEffect(() => {
if (!groupPath) {
if (workspace.type !== "group") {
return;
}
setRecentGroups((gs) => _.uniq([groupPath, ...gs]));
}, [groupPath]);
setRecentGroups((gs) => _.uniq([workspace.group, ...gs]));
}, [workspace]);
if(!groupAssociation) {
if (!associations) {
return null;
}
const popovers = (routeProps: RouteComponentProps, baseUrl: string) => (
<>
<PopoverRoutes
contacts={groupContacts}
association={groupAssociation}
group={group}
api={api}
s3={props.s3}
{...routeProps}
baseUrl={baseUrl}
/>
<InvitePopover
api={api}
association={groupAssociation}
baseUrl={baseUrl}
groups={props.groups}
contacts={props.contacts}
/>
</>
);
const popovers = (routeProps: RouteComponentProps, baseUrl: string) =>
(groupPath && (
<>
<PopoverRoutes
contacts={groupContacts || {}}
association={groupAssociation!}
group={group!}
api={api}
s3={props.s3}
{...routeProps}
baseUrl={baseUrl}
/>
<InvitePopover
api={api}
association={groupAssociation!}
baseUrl={baseUrl}
groups={props.groups}
contacts={props.contacts}
/>
</>
)) ||
null;
return (
<Switch>
<Route
path={[relativePath("/resource/:app/:host/:name")]}
path={[relativePath("/resource/:app/(ship)?/:host/:name")]}
render={(routeProps) => {
const { app, host, name } = routeProps.match.params as Record<
string,
string
>;
const resource = `/${host}/${name}`;
const appName = app as AppName;
const association = associations[appName][resource];
const isShip = app === "link";
const resource = `${isShip ? "/ship" : ""}/${host}/${name}`;
const association =
appName === "link"
? associations.graph[resource]
: associations[appName][resource];
const resourceUrl = `${baseUrl}/resource/${app}${resource}`;
if (!association) {
return null;
return <Box>Loading</Box>;
}
return (
@ -98,7 +111,6 @@ export function GroupsPane(props: GroupsPaneProps) {
recentGroups={recentGroups}
selected={resource}
selectedApp={appName}
selectedGroup={groupPath}
{...props}
baseUrl={resourceUrl}
>
@ -126,7 +138,6 @@ export function GroupsPane(props: GroupsPaneProps) {
recentGroups={recentGroups}
mobileHide
selected={appPath}
selectedGroup={groupPath}
{...props}
baseUrl={baseUrl}
>
@ -136,16 +147,30 @@ export function GroupsPane(props: GroupsPaneProps) {
);
}}
/>
<Route
path={relativePath("/new")}
render={(routeProps) => {
const newUrl = `${baseUrl}/new`;
return (
<Skeleton recentGroups={recentGroups} {...props} baseUrl={baseUrl}>
<NewChannel
{...routeProps}
api={api}
associations={associations}
groups={groups}
group={groupPath}
contacts={props.contacts}
/>
{popovers(routeProps, baseUrl)}
</Skeleton>
);
}}
/>
<Route
path={relativePath("")}
render={(routeProps) => {
return (
<Skeleton
recentGroups={recentGroups}
selectedGroup={groupPath}
{...props}
baseUrl={baseUrl}
>
<Skeleton recentGroups={recentGroups} {...props} baseUrl={baseUrl}>
<Center display={["none", "auto"]}>
Open something to get started
</Center>

View File

@ -0,0 +1,17 @@
import { useEffect, ReactNode, useMemo } from "react";
import { createPortal } from "react-dom";
export function Portal(props: { children: ReactNode }) {
const root = document.getElementById("portal-root");
const el = useMemo(() => document.createElement("div"), []);
useEffect(() => {
root?.appendChild(el);
return () => {
root?.removeChild(el);
};
}, [root, el]);
return createPortal(props.children, el);
}

View File

@ -173,4 +173,4 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
return renderUrl ? this.wrapInLink(url) : null;
}
}
}
}

View File

@ -1,12 +1,24 @@
import React from "react";
import React, { useCallback } from "react";
import { Row, Box, Col } from "@tlon/indigo-react";
import styled from "styled-components";
import { Link } from "react-router-dom";
import { ChatResource } from "~/views/apps/chat/ChatResource";
import { PublishResource } from "~/views/apps/publish/PublishResource";
import { LinkResource } from "~/views/apps/links/LinkResource";
import { Association } from "~/types/metadata-update";
import { StoreState } from "~/logic/store/type";
import GlobalApi from "~/logic/api/global";
import { RouteComponentProps } from "react-router-dom";
import { RouteComponentProps, Route, Switch } from "react-router-dom";
import { ChannelSettings } from "../apps/groups/components/lib/ChannelSettings";
import { ResourceSkeleton } from "../apps/groups/components/lib/ResourceSkeleton";
const TruncatedBox = styled(Box)`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
type ResourceProps = StoreState & {
association: Association;
@ -15,17 +27,44 @@ type ResourceProps = StoreState & {
} & RouteComponentProps;
export function Resource(props: ResourceProps) {
const { association } = props;
const app = association["app-name"];
if (app === "chat") {
return <ChatResource {...props} />;
}
if (app === "publish") {
return <PublishResource {...props} />;
}
if (app === 'link') {
return null; //return <LinkResource {...props} />;
}
return null;
const { association, api } = props;
const app = association.metadata.module || association["app-name"];
const appPath = association["app-path"];
const selectedGroup = association["group-path"];
const relativePath = (p: string) =>
`${props.baseUrl}/resource/${app}${appPath}${p}`;
const skelProps = { api, association };
return (
<Switch>
<Route
path={relativePath("/settings")}
render={(routeProps) => {
const title = `Channel Settings: ${association?.metadata?.title}`;
return (
<ResourceSkeleton
baseUrl={props.baseUrl}
{...skelProps}
title={title}
>
<ChannelSettings api={api} association={association} />
</ResourceSkeleton>
);
}}
/>
<Route
path={relativePath("")}
render={(routeProps) => (
<ResourceSkeleton baseUrl={props.baseUrl} {...skelProps} atRoot>
{app === "chat" ? (
<ChatResource {...props} />
) : app === "publish" ? (
<PublishResource {...props} />
) : (
<LinkResource {...props} />
)}
</ResourceSkeleton>
)}
/>
</Switch>
);
}

View File

@ -1,12 +1,13 @@
import React, { useMemo, useCallback } from "react";
import { Box, Text, Row, Col } from "@tlon/indigo-react";
import _ from "lodash";
import ob from "urbit-ob";
import { useField } from "formik";
import styled from "styled-components";
import { DropdownSearch } from "./DropdownSearch";
import { Associations, Association } from "~/types/metadata-update";
import { cite } from '~/logic/lib/util';
import { cite, deSig } from "~/logic/lib/util";
import { Rolodex, Groups } from "~/types";
import { HoverBox } from "./HoverBox";
@ -91,7 +92,7 @@ export function ShipSearch(props: InviteSearchProps) {
const renderCandidate = useCallback(
(s: string, selected: boolean, onSelect: (s: string) => void) => {
const detail = _.uniq(nicknames.get(s)).join(', ');
const detail = _.uniq(nicknames.get(s)).join(", ");
const onClick = () => {
onSelect(s);
};
@ -113,6 +114,11 @@ export function ShipSearch(props: InviteSearchProps) {
<DropdownSearch<string>
label={props.label}
id={props.id}
isExact={(s) => {
const ship = `~${deSig(s)}`;
const result = ob.isValidPatp(ship);
return result ? deSig(s) : undefined;
}}
placeholder="Search for ships"
caption={props.caption}
candidates={peers}

View File

@ -6,15 +6,17 @@ import {
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 } from "~/types";
import { AppInvites, Associations, AppAssociations, Workspace } from "~/types";
import { SidebarItem } from "./SidebarItem";
import {
SidebarListHeader,
@ -22,6 +24,7 @@ import {
SidebarSort,
} from "./SidebarListHeader";
import { useLocalStorageState } from "~/logic/lib/useLocalStorageState";
import {getGroupFromWorkspace} from "~/logic/lib/workspace";
interface SidebarAppConfig {
name: string;
@ -62,7 +65,7 @@ function SidebarItems(props: {
apps: SidebarAppConfigs;
config: SidebarListConfig;
associations: Associations;
group: string;
group?: string;
selected?: string;
}) {
const { selected, group, config } = props;
@ -70,12 +73,16 @@ function SidebarItems(props: {
...props.associations.chat,
...props.associations.publish,
...props.associations.link,
...props.associations.graph,
};
const ordered = Object.keys(associations)
.filter((a) => {
const assoc = associations[a];
return assoc["group-path"] === group;
console.log(a);
return group
? assoc["group-path"] === group
: !(assoc["group-path"] in props.associations.contacts);
})
.sort(sidebarSort(associations)[config.sortBy]);
@ -105,25 +112,26 @@ interface SidebarProps {
api: GlobalApi;
associations: Associations;
selected?: string;
selectedGroup: string;
selectedGroup?: string;
includeUnmanaged?: boolean;
apps: SidebarAppConfigs;
baseUrl: string;
mobileHide?: boolean;
workspace: Workspace;
}
export function Sidebar(props: SidebarProps) {
const { invites, api, associations, selected, apps } = props;
const groupAsssociation = associations.contacts[props.selectedGroup];
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 (!groupAsssociation) {
return null;
}
if (!associations) {
return null;
}
const [config, setConfig] = useLocalStorageState<SidebarListConfig>(
`group-config:${props.selectedGroup}`,
`group-config:${groupPath || "home"}`,
{
sortBy: "asc",
hideUnjoined: false,
@ -134,7 +142,8 @@ export function Sidebar(props: SidebarProps) {
display={display}
flexDirection="column"
width="100%"
gridRow="1/3"
height="100%"
gridRow="1/2"
gridColumn="1/2"
borderRight={1}
borderRightColor="washedGray"
@ -147,7 +156,7 @@ export function Sidebar(props: SidebarProps) {
associations={associations}
recentGroups={props.recentGroups}
baseUrl={props.baseUrl}
association={groupAsssociation}
workspace={props.workspace}
/>
{Object.keys(invites).map((appPath) =>
Object.keys(invites[appPath]).map((uid) => (
@ -164,9 +173,29 @@ export function Sidebar(props: SidebarProps) {
config={config}
associations={associations}
selected={selected}
group={props.selectedGroup}
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

@ -6,6 +6,7 @@ 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) {
@ -22,9 +23,12 @@ function SidebarItemIndicator(props: { status?: SidebarItemStatus }) {
}
}
const getAppIcon = (app: string) => {
if (app === "link") {
return "Links";
const getAppIcon = (app: string, module: string) => {
if (app === "graph") {
if (module === "link") {
return "Links";
}
return _.capitalize(module);
}
return _.capitalize(app);
};
@ -32,28 +36,36 @@ const getAppIcon = (app: string) => {
export function SidebarItem(props: {
hideUnjoined: boolean;
association: Association;
groups: Groups;
path: string;
selected: boolean;
apps: SidebarAppConfigs;
}) {
const { association, path, selected, apps } = props;
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[appName];
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
? `/~groups${groupPath}/resource/${appName}${appPath}`
: `/~groups${groupPath}/join/${appName}${appPath}`;
? `${baseUrl}/resource/${module}${appPath}`
: `${baseUrl}/join/${module}${appPath}`;
const color = selected ? 'black' : isSynced ? 'gray' : 'lightGray';
const color = selected ? "black" : isSynced ? "gray" : "lightGray";
if(props.hideUnjoined && !isSynced) {
if (props.hideUnjoined && !isSynced) {
return null;
}
@ -72,9 +84,19 @@ export function SidebarItem(props: {
selected={selected}
>
<Row alignItems="center">
<Icon display="block" fill="rgba(0,0,0,0)" stroke={color} icon={getAppIcon(appName)} />
<Box flexShrink={2} ml={2} lineHeight="1.33" fontWeight={hasUnread ? "600" : "400"}>
<Text color={selected || isSynced ? 'black' : 'lightGray'}>
<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>

View File

@ -31,7 +31,7 @@ export function SidebarListHeader(props: {
</Box>
<Dropdown
width="200px"
position="right"
alignY="top"
options={
<FormikOnBlur initialValues={props.initialValues} onSubmit={onSubmit}>
<Col>

View File

@ -11,34 +11,29 @@ import GlobalApi from "~/logic/api/global";
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";
interface SkeletonProps {
children: ReactNode;
recentGroups: string[];
associations: Associations;
chatSynced: ChatHookUpdate | null;
graphKeys: Set<string>;
linkListening: Set<Path>;
links: LinkCollections;
notebooks: Notebooks;
inbox: Inbox;
selected?: string;
selectedApp?: AppName;
selectedGroup: string;
baseUrl: string;
mobileHide?: boolean;
api: GlobalApi;
subscription: GlobalSubscription;
includeUnmanaged: boolean;
workspace: Workspace;
}
const buntAppConfig = (name: string) => ({
name,
getStatus: (s: string) => undefined,
});
const TruncatedBox = styled(Box)`
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`;
export function Skeleton(props: SkeletonProps) {
const chatConfig = {
name: "chat",
@ -46,8 +41,12 @@ export function Skeleton(props: SkeletonProps) {
if (!(s in (props.chatSynced || {}))) {
return "unsubscribed";
}
const { config } = props.inbox[s];
if (config.read !== config.length) {
const mailbox = props?.inbox?.[s];
if(!mailbox) {
return undefined;
}
const { config } = mailbox;
if (config?.read !== config?.length) {
return "unread";
}
return undefined;
@ -70,7 +69,10 @@ export function Skeleton(props: SkeletonProps) {
const linkConfig = {
name: "link",
getStatus: (s: string) => {
if (!props.linkListening.has(s)) {
const [, , host, name] = s.split("/");
const graphKey = `${host.slice(1)}/${name}`;
if (!props.graphKeys.has(graphKey)) {
return "unsubscribed";
}
const link = props.links[s];
@ -93,15 +95,9 @@ export function Skeleton(props: SkeletonProps) {
props.api.publish.fetchNotebooks();
props.subscription.startApp("chat");
props.subscription.startApp("publish");
props.subscription.startApp("link");
props.api.links.getPage("", 0);
props.subscription.startApp("graph");
}, []);
const association =
props.selected && props.selectedApp
? props.associations?.[props.selectedApp]?.[props.selected]
: null;
return (
<Box fontSize={0} px={[0, 3]} pb={[0, 3]} height="100%" width="100%">
<Box
@ -113,45 +109,20 @@ export function Skeleton(props: SkeletonProps) {
border={[0, 1]}
borderColor={["washedGray", "washedGray"]}
gridTemplateColumns={["1fr", "250px 1fr"]}
gridTemplateRows="32px 1fr"
gridTemplateRows="1fr"
>
<Sidebar
recentGroups={props.recentGroups}
selected={props.selected}
selectedGroup={props.selectedGroup}
selectedApp={props.selectedApp}
associations={props.associations}
invites={{}}
apps={config}
baseUrl={props.baseUrl}
onlyGroups={[props.selectedGroup]}
includeUnmanaged={!props.selectedGroup}
mobileHide={props.mobileHide}
workspace={props.workspace}
></Sidebar>
<Box
p={2}
display="flex"
alignItems="center"
borderBottom={1}
borderBottomColor="washedGray"
>
<Box
borderRight={1}
borderRightColor="gray"
pr={2}
mr={2}
display={["block", "none"]}
>
<Link to={`/~groups${props.selectedGroup}`}> {"<- Back"}</Link>
</Box>
<Box mr={2}>{association?.metadata?.title}</Box>
<TruncatedBox
maxWidth="50%"
flexShrink={1}
color="gray"
>
{association?.metadata?.description}
</TruncatedBox>
</Box>
{props.children}
</Box>
</Box>