mirror of
https://github.com/urbit/shrub.git
synced 2025-01-01 17:16:47 +03:00
interface: flesh out new groups layout
This commit is contained in:
parent
ae778de989
commit
7b2c485587
@ -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>
|
||||
|
1
pkg/interface/src/logic/lib/Home.ts
Normal file
1
pkg/interface/src/logic/lib/Home.ts
Normal file
@ -0,0 +1 @@
|
||||
|
24
pkg/interface/src/logic/lib/workspace.ts
Normal file
24
pkg/interface/src/logic/lib/workspace.ts
Normal 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;
|
||||
}
|
12
pkg/interface/src/types/workspace.ts
Normal file
12
pkg/interface/src/types/workspace.ts
Normal file
@ -0,0 +1,12 @@
|
||||
|
||||
|
||||
interface GroupWorkspace {
|
||||
type: 'group';
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface HomeWorkspace {
|
||||
type: 'home'
|
||||
}
|
||||
|
||||
export type Workspace = HomeWorkspace | GroupWorkspace;
|
@ -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 =
|
||||
|
@ -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>
|
||||
|
@ -198,7 +198,8 @@ function Participant(props: {
|
||||
<Text>{_.capitalize(role)}</Text>
|
||||
</Col>
|
||||
<Dropdown
|
||||
position="right"
|
||||
alignX="right"
|
||||
alignY="top"
|
||||
options={
|
||||
<>
|
||||
<Button onClick={sendMessage}>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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" />
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
70
pkg/interface/src/views/apps/launch/components/Groups.tsx
Normal file
70
pkg/interface/src/views/apps/launch/components/Groups.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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",
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
17
pkg/interface/src/views/components/Portal.tsx
Normal file
17
pkg/interface/src/views/components/Portal.tsx
Normal 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);
|
||||
}
|
@ -173,4 +173,4 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
|
||||
return renderUrl ? this.wrapInLink(url) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -31,7 +31,7 @@ export function SidebarListHeader(props: {
|
||||
</Box>
|
||||
<Dropdown
|
||||
width="200px"
|
||||
position="right"
|
||||
alignY="top"
|
||||
options={
|
||||
<FormikOnBlur initialValues={props.initialValues} onSubmit={onSubmit}>
|
||||
<Col>
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user