groups: new sidebar layout

This commit is contained in:
Liam Fitzgerald 2020-09-17 11:41:51 +10:00
parent d47cfdb57d
commit 4c5bffaef0
18 changed files with 1535 additions and 7 deletions

View File

@ -0,0 +1,19 @@
import { useEffect, RefObject } from "react";
export function useOutsideClick(
ref: RefObject<HTMLElement>,
onClick: () => void
) {
useEffect(() => {
function handleClick(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as any)) {
onClick();
}
}
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
}, [ref.current, onClick]);
}

View File

@ -64,6 +64,9 @@ export function dateToDa(d, mil) {
}
export function deSig(ship) {
if(!ship) {
return null;
}
return ship.replace('~', '');
}
@ -148,6 +151,10 @@ export function cite(ship) {
return `~${patp}`;
}
export function alphabeticalOrder(a,b) {
return a.toLowerCase().localeCompare(b.toLowerCase());
}
export function alphabetiseAssociations(associations) {
const result = {};
Object.keys(associations).sort((a, b) => {
@ -163,7 +170,7 @@ export function alphabetiseAssociations(associations) {
? associations[b].metadata.title
: b.substr(1);
}
return aName.toLowerCase().localeCompare(bName.toLowerCase());
return alphabeticalOrder(aName,bName);
}).map((each) => {
result[each] = associations[each];
});

View File

@ -31,7 +31,7 @@ type MetadataUpdateRemove = {
export type Associations = Record<AppName, AppAssociations>;
type AppAssociations = {
export type AppAssociations = {
[p in Path]: Association;
}

View File

@ -0,0 +1,78 @@
import React from 'react';
import { Box, Col, Row, Text, IconButton, Button, Icon } from '@tlon/indigo-react';
import { Link } from 'react-router-dom';
import { Association, } from "~/types/metadata-update";
import { Dropdown } from "~/views/components/Dropdown";
const GroupSwitcherItem = ({ to, children, ...rest }) => (
<Link to={to}>
<Row {...rest} px={1} mb={2} alignItems="center">
{children}
</Row>
</Link>
);
export function GroupSwitcher(props: { association: Association; baseUrl: string }) {
const { title } = props.association.metadata;
const navTo = (to: string) => `${props.baseUrl}${to}`;
return (
<Box position="sticky" top="0px" p={2}>
<Col bg="white" borderRadius={1} border={1} borderColor="washedGray">
<Row justifyContent="space-between">
<Dropdown
width="220px"
options={
<Col width="100%" alignItems="flex-start">
<Row
alignItems="center"
px={2}
pb={2}
my={2}
borderBottom={1}
borderBottomColor="washedGray"
>
<img
src="/~landscape/img/groups.png"
height="12px"
width="12px"
/>
<Text ml={2}>Switch Groups</Text>
</Row>
<GroupSwitcherItem to={navTo("/popover/participants")}>
<Icon mr={2} icon="Circle" />
<Text> Participants</Text>
</GroupSwitcherItem>
<GroupSwitcherItem to={navTo("/popover/settings")}>
<Icon mr={2} icon="Circle" />
<Text> Settings</Text>
</GroupSwitcherItem>
<GroupSwitcherItem to={navTo("/popover/settings")}>
<Icon mr={2} icon="Circle" />
<Text>Group Settings</Text>
</GroupSwitcherItem>
<GroupSwitcherItem to={navTo("/invites")}>
<Icon mr={2} fill="blue" icon="Circle" />
<Text color="blue">Invite to group</Text>
</GroupSwitcherItem>
</Col>
}
>
<Button width="max-content">
<Box display="flex">
<Box width="max-content">
<Text>{title}</Text>
</Box>
<Icon icon="ChevronSouth" />
</Box>
</Button>
</Dropdown>
<Link to={navTo("/popover/settings")}>
<IconButton icon="MagnifyingGlass" />
</Link>
</Row>
</Col>
</Box>
);
}

View File

@ -0,0 +1,106 @@
import React, { useCallback, useRef, useMemo } from "react";
import { Box, Text, Col, Button, Row } from "@tlon/indigo-react";
import { ShipSearch } from "~/views/components/ShipSearch";
import { Association } from "~/types/metadata-update";
import { Switch, Route, useHistory } from "react-router-dom";
import { Formik, Form } from "formik";
import { AsyncButton } from "~/views/components/AsyncButton";
import { useOutsideClick } from "~/logic/lib/useOutsideClick";
import { FormError } from "~/views/components/FormError";
import { resourceFromPath } from "~/logic/lib/group";
import GlobalApi from "~/logic/api/global";
import { Groups, Rolodex } from "~/types";
interface InvitePopoverProps {
baseUrl: string;
association: Association;
groups: Groups;
contacts: Rolodex;
api: GlobalApi;
}
export function InvitePopover(props: InvitePopoverProps) {
const { baseUrl, api, association } = props;
const relativePath = (p: string) => baseUrl + p;
const { title } = association.metadata;
const innerRef = useRef(null);
const history = useHistory();
const onOutsideClick = useCallback(() => {
history.push(props.baseUrl);
}, [history.push, props.baseUrl]);
useOutsideClick(innerRef, onOutsideClick);
const onSubmit = async ({ ships }: { ships: string[] }, actions) => {
try {
const resource = resourceFromPath(association["group-path"]);
await ships.reduce(
(acc, s) => acc.then(() => api.contacts.invite(resource, `~${s}`)),
Promise.resolve()
);
actions.setStatus({ success: null });
onOutsideClick();
} catch (e) {
console.error(e);
actions.setStatus({ error: e.message });
}
};
return (
<Switch>
<Route path={[relativePath("/invites")]}>
<Box
display="flex"
justifyContent="center"
alignItems="center"
bg="gray"
left="0px"
top="0px"
width="100vw"
height="100vh"
zIndex={4}
position="fixed"
>
<Box
ref={innerRef}
border={1}
borderColor="washedGray"
borderRadius={1}
maxHeight="472px"
width="380px"
bg="white"
>
<Formik initialValues={{ ships: [] }} onSubmit={onSubmit}>
<Form>
<Col p={3}>
<Box mb={2}>
<Text>Invite to </Text>
<Text fontWeight="800">{title}</Text>
</Box>
<ShipSearch
groups={props.groups}
contacts={props.contacts}
id="ships"
label=""
/>
<FormError message="Failed to invite" />
</Col>
<Row
borderTop={1}
borderTopColor="washedGray"
justifyContent="flex-end"
>
<AsyncButton border="none" primary loadingText="Inviting...">
Send
</AsyncButton>
</Row>
</Form>
</Formik>
</Box>
</Box>
</Route>
</Switch>
);
}

View File

@ -0,0 +1,235 @@
import React, { useState, useMemo, SyntheticEvent, ChangeEvent } from "react";
import { Col, Box, Row, Text, Icon, Center, Button } from "@tlon/indigo-react";
import styled from "styled-components";
import _ from "lodash";
import { Contact, Contacts } from "~/types/contact-update";
import { Sigil } from "~/logic/lib/sigil";
import { cite, uxToHex } from "~/logic/lib/util";
import { Group, RoleTags } from "~/types/group-update";
import { roleForShip } from "~/logic/lib/group";
import { Association } from "~/types/metadata-update";
import { useHistory, Link } from "react-router-dom";
import { Dropdown } from "~/views/components/Dropdown";
type Participant = Contact & { patp: string; pending: boolean };
type ParticipantsTabId = "total" | "pending" | "admin";
const searchParticipant = (search: string) => (p: Participant) => {
if (search.length == 0) {
return true;
}
const s = search.toLowerCase();
return p.patp.includes(s) || p.nickname.toLowerCase().includes(search);
};
const UnmanagedInput = styled.input`
width: 100%;
`;
const emptyContact = (patp: string, pending: boolean): Participant => ({
nickname: "",
email: "",
phone: "",
color: "",
avatar: null,
notes: "",
website: "",
patp,
pending,
});
const Tab = ({ selected, id, label, setSelected }) => (
<Box
py={2}
borderBottom={selected === id ? 1 : 0}
borderBottomColor="black"
mr={2}
onClick={() => setSelected(id)}
>
<Text color={selected === id ? "black" : "gray"}>{label}</Text>
</Box>
);
export function Participants(props: {
contacts: Contacts;
group: Group;
association: Association;
}) {
const tabFilters: Record<
ParticipantsTabId,
(p: Participant) => boolean
> = useMemo(
() => ({
total: (p) => !p.pending,
pending: (p) => p.pending,
admin: (p) => props.group.tags?.role?.admin?.has(p.patp),
}),
[props.group]
);
const [filter, setFilter] = useState<ParticipantsTabId>("total");
const [search, _setSearch] = useState("");
const setSearch = (e: ChangeEvent<HTMLInputElement>) => {
_setSearch(e.target.value);
};
const contacts: Participant[] = useMemo(
() =>
_.map(props.contacts, (c, patp) => ({
...c,
patp,
pending: false,
})),
[props.contacts]
);
const members: Participant[] = _.map(Array.from(props.group.members), (m) =>
emptyContact(m, false)
);
const allMembers = _.unionBy(contacts, members, "patp");
const isInvite = "invite" in props.group.policy;
const pending: Participant[] =
"invite" in props.group.policy
? _.map(Array.from(props.group.policy.invite.pending), (m) =>
emptyContact(m, true)
)
: [];
const adminCount = props.group.tags?.role?.admin?.size || 0;
const allSundry = _.unionBy(allMembers, pending, "patp");
const filtered = _.chain(allSundry)
.filter(tabFilters[filter])
.filter(searchParticipant(search))
.value();
return (
<Col height="100%" overflowY="auto" p={2} position="relative">
<Row
bg="white"
border={1}
borderColor="washedGray"
borderRadius={1}
position="sticky"
top="0px"
mb={2}
px={2}
>
<Row>
<Tab
selected={filter}
setSelected={setFilter}
id="total"
label={`${allMembers.length} total`}
/>
{isInvite && (
<Tab
selected={filter}
setSelected={setFilter}
id="pending"
label={`${pending.length} pending`}
/>
)}
<Tab
selected={filter}
setSelected={setFilter}
id="admin"
label={`${adminCount} Admin${adminCount > 1 ? "s" : ""}`}
/>
</Row>
<UnmanagedInput value={search} onChange={setSearch} />
</Row>
<Box
display="grid"
gridAutoRows={["48px 48px 1px", "48px 1px"]}
gridTemplateColumns={["48px 1fr", "48px 1fr 144px"]}
gridRowGap={2}
alignItems="center"
>
{filtered.map((c) => (
<Participant
key={c.patp}
role="admin"
group={props.group}
contact={c}
association={props.association}
/>
))}
</Box>
</Col>
);
}
function Participant(props: {
contact: Participant;
association: Association;
group: Group;
role: RoleTags;
}) {
const history = useHistory();
const { contact, association, group } = props;
const { title } = association.metadata;
const color = uxToHex(contact.color);
const isInvite = "invite" in group.policy;
const role = contact.pending
? "pending"
: roleForShip(group, contact.patp) || "member";
const sendMessage = () => {
history.push(`/~chat/new/dm/${contact.patp}`);
};
return (
<>
<Box>
<Sigil ship={contact.patp} size={32} color={`#${color}`} />
</Box>
<Col>
<Text>{contact.nickname}</Text>
<Text color="gray" fontFamily="mono">
{cite(contact.patp)}
</Text>
</Col>
<Box display="flex" gridColumn={["1 / 3", "auto"]} alignItems="center">
<Col>
<Text mb={1} color="lightGray">
Role
</Text>
<Text>{_.capitalize(role)}</Text>
</Col>
<Dropdown
position="right"
options={
<>
<Button onClick={sendMessage}>
<Text color="green">Send Message</Text>
</Button>
{isInvite && (
<Button onClick={() => {}}>
<Text color="red">Ban from {title}</Text>
</Button>
)}
<Button onSelect={() => {}}>Promote to Admin</Button>
</>
}
>
<Icon mr={2} icon="Ellipsis" />
</Dropdown>
</Box>
<Box
borderBottom={1}
borderBottomColor="washedGray"
gridColumn={["1 / 3", "1 / 4"]}
/>
</>
);
}
function ParticipantMenu(props: {
ourRole?: RoleTags;
theirRole?: RoleTags;
them: string;
}) {
const { ourRole, theirRole } = props;
let options = [];
}

View File

@ -0,0 +1,150 @@
import React, { useRef, useCallback } from "react";
import { Route, Switch, RouteComponentProps, Link } from "react-router-dom";
import { Box, Row, Col, Icon, Text } from "@tlon/indigo-react";
import { useOutsideClick } from "~/logic/lib/useOutsideClick";
import { HoverBoxLink } from "~/views/components/HoverBox";
import { GroupSettings } from "./lib/GroupSettings";
import { Contacts } from "~/types/contact-update";
import { Participants } from "./Participants";
import { Group } from "~/types/group-update";
import { Association } from "~/types/metadata-update";
import GlobalApi from "~/logic/api/global";
import { ContactCard } from "./lib/ContactCard";
import {S3State} from "~/types";
const SidebarItem = ({ selected, icon, text, to }) => {
return (
<HoverBoxLink
to={to}
selected={selected}
bg="white"
bgActive="washedGray"
display="flex"
px={3}
py={1}
>
<Icon icon={icon} />
<Text color={selected ? "black" : "gray"}>{text}</Text>
</HoverBoxLink>
);
};
export function PopoverRoutes(
props: {
baseUrl: string;
contacts: Contacts;
group: Group;
association: Association;
s3: S3State;
api: GlobalApi;
} & RouteComponentProps
) {
const relativeUrl = (url: string) => `${props.baseUrl}/popover${url}`;
const innerRef = useRef(null);
const onOutsideClick = useCallback(() => {
props.history.push(props.baseUrl);
}, [props.history.push, props.baseUrl]);
useOutsideClick(innerRef, onOutsideClick);
return (
<Switch>
<Route
path={[relativeUrl("/:view"), relativeUrl("")]}
render={(routeProps) => {
const { view } = routeProps.match.params;
return (
<Box
px={[3, 7, 8]}
py={[3, 5]}
bg="gray"
left="0px"
top="0px"
width="100vw"
height="100vh"
zIndex={4}
position="fixed"
>
<Box
ref={innerRef}
border={1}
borderColor="washedGray"
borderRadius={1}
width="100%"
height="100%"
bg="white"
>
<Box
display="grid"
gridTemplateRows={["32px 1fr", "100%"]}
gridTemplateColumns={["100%", "200px 1fr"]}
height="100%"
width="100%"
>
<Col
display={!!view ? ["none", "flex"] : "flex"}
py={3}
borderRight={1}
borderRightColor="washedGray"
>
<SidebarItem
icon="Circle"
selected={view === "participants"}
to={relativeUrl("/participants")}
text="Participants"
/>
<SidebarItem
icon="Circle"
selected={view === "settings"}
to={relativeUrl("/settings")}
text="Group Settings"
/>
<SidebarItem
icon="Circle"
selected={view === "profile"}
to={relativeUrl("/profile")}
text="Group Profile"
/>
</Col>
<Box
gridArea={"1 / 1 / 2 / 2"}
p={2}
display={["auto", "none"]}
>
<Link to={!!view ? relativeUrl("") : props.baseUrl}>
<Text>{"<- Back"}</Text>
</Link>
</Box>
<Box overflow="hidden">
{view === "settings" && (
<GroupSettings
group={props.group}
association={props.association}
api={props.api}
/>
)}
{view === "participants" && (
<Participants
group={props.group}
contacts={props.contacts}
association={props.association}
/>
)}
{view === "profile" && (
<ContactCard
contact={props.contacts[window.ship]}
api={props.api}
path={props.association["group-path"]}
s3={props.s3}
/>
)}
</Box>
</Box>
</Box>
</Box>
);
}}
/>
</Switch>
);
}

View File

@ -0,0 +1,124 @@
import React, { useEffect } from "react";
import { AsyncButton } from "../../../../components/AsyncButton";
import * as Yup from "yup";
import {
Box,
Input,
Checkbox,
Col,
InputLabel,
InputCaption,
Button,
Center,
} from "@tlon/indigo-react";
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
import { FormError } from "~/views/components/FormError";
import { Group, GroupPolicy } from "~/types/group-update";
import { Enc } from "~/types/noun";
import { Association } from "~/types/metadata-update";
import GlobalApi from "~/logic/api/global";
import { resourceFromPath } from "~/logic/lib/group";
interface FormSchema {
name: string;
description?: string;
isPrivate: boolean;
}
const formSchema = Yup.object({
name: Yup.string().required("Group must have a name"),
description: Yup.string(),
isPrivate: Yup.boolean(),
});
interface GroupSettingsProps {
group: Group;
association: Association;
api: GlobalApi;
}
export function GroupSettings(props: GroupSettingsProps) {
const { metadata } = props.association;
const currentPrivate = "invite" in props.group.policy;
const initialValues: FormSchema = {
name: metadata.title,
description: metadata.description,
isPrivate: currentPrivate,
};
const onSubmit = async (
values: FormSchema,
actions: FormikHelpers<FormSchema>
) => {
try {
const { name, description, isPrivate } = values;
await props.api.metadata.editGroup(props.association, name, description);
if (isPrivate !== currentPrivate) {
const resource = resourceFromPath(props.association["group-path"]);
const newPolicy: Enc<GroupPolicy> = isPrivate
? { invite: { pending: [] } }
: { open: { banRanks: [], banned: [] } };
const diff = { replace: newPolicy };
await props.api.groups.changePolicy(resource, diff);
}
actions.setStatus({ success: null });
} catch (e) {
console.log(e);
actions.setStatus({ error: e.message });
}
};
const onDelete = async () => {};
return (
<Box height="100%" overflowY="auto">
<Formik
validationSchema={formSchema}
initialValues={initialValues}
onSubmit={onSubmit}
>
<Form style={{ display: "contents" }}>
<Box
maxWidth="300px"
gridTemplateColumns="1fr"
gridAutoRows="auto"
display="grid"
my={3}
mx={4}
>
<Col mb={4}>
<InputLabel>Delete Group</InputLabel>
<InputCaption>
Permanently delete this group. (All current members will no
longer see this group.)
</InputCaption>
<Button onClick={onDelete} mt={1} border error>
Delete this notebook
</Button>
</Col>
<Box mb={4} borderBottom={1} borderBottomColor="washedGray" />
<Input
id="name"
label="Group Name"
caption="The name for your group to be called by"
/>
<Input
id="description"
label="Group Description"
caption="The description of your group"
/>
<Checkbox
id="isPrivate"
label="Private group"
caption="If enabled, users must be invited to join the group"
/>
<AsyncButton primary loadingText="Updating.." border>
Save
</AsyncButton>
<FormError message="Failed to update settings" />
</Box>
</Form>
</Formik>
</Box>
);
}

View File

@ -35,7 +35,7 @@ export function AsyncButton({
}, [status]);
return (
<Button border disabled={!isValid} type="submit" {...rest}>
<Button disabled={!isValid} type="submit" {...rest}>
{isSubmitting ? (
<Spinner awaiting text={loadingText} />
) : success === true ? (

View File

@ -0,0 +1,65 @@
import React, { ReactNode, useState, useRef, useEffect } 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";
interface DropdownProps {
children: ReactNode;
options: ReactNode;
position: "left" | "right";
width?: string;
}
const ClickBox = styled(Box)`
cursor: pointer;
`;
const DropdownOptions = styled(Box)`
z-index: 20;
position: absolute;
`;
export function Dropdown(props: DropdownProps) {
const { children, options } = props;
const dropdownRef = useRef<HTMLElement>(null);
const { pathname } = useLocation();
useEffect(() => {
setOpen(false);
}, [pathname]);
useOutsideClick(dropdownRef, () => {
setOpen(false);
});
const [open, setOpen] = useState(false);
const position = { [props.position]: "0px" };
const align = props.position === "right" ? "flex-end" : "flex-start";
return (
<Box width="min-content" position="relative">
<ClickBox onClick={() => setOpen((o) => !o)}> {children}</ClickBox>
{open && (
<DropdownOptions {...position} ref={dropdownRef}>
<Col
alignItems={align}
width={props.width || "max-content"}
border={1}
borderColor="black"
bg="white"
borderRadius={2}
>
{options}
</Col>
</DropdownOptions>
)}
</Box>
);
}
Dropdown.defaultProps = {
position: "left",
};

View File

@ -0,0 +1,134 @@
import React from "react";
import { Switch, Route, useLocation } from "react-router-dom";
import { Center } from "@tlon/indigo-react";
import { Resource } from "~/views/components/Resource";
import { PopoverRoutes } from "~/views/apps/groups/components/PopoverRoutes";
import { Skeleton } from "~/views/components/Skeleton";
import { Resource as IResource, Groups } from "~/types/group-update";
import { Associations } from "~/types/metadata-update";
import { resourceAsPath } from "~/logic/lib/util";
import { AppName } from "~/types/noun";
import { Contacts, Rolodex } from "~/types/contact-update";
import GlobalApi from "~/logic/api/global";
import { StoreState } from "~/logic/store/type";
import { UnjoinedResource } from "./UnjoinedResource";
import { InvitePopover } from "../apps/groups/components/InvitePopover";
type GroupsPaneProps = StoreState & {
baseUrl: string;
groupPath: string;
api: GlobalApi;
};
export function GroupsPane(props: GroupsPaneProps) {
const { baseUrl, associations, groups, contacts, api, groupPath } = props;
const relativePath = (path: string) => baseUrl + path;
const groupContacts = contacts[groupPath];
const groupAssociation = associations.contacts[groupPath];
const group = groups[groupPath];
const location = useLocation();
const mobileHide = location.pathname === baseUrl;
return (
<Switch>
<Route
path={[
relativePath("/resource/:app/: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 resourceUrl = `${baseUrl}/resource/${app}${resource}`
if (!association) {
return null;
}
return (
<Skeleton
mobileHide
selected={resource}
selectedApp={appName}
selectedGroup={groupPath}
{...props}
baseUrl={resourceUrl}
>
<Resource
{...props}
{...routeProps}
association={association}
baseUrl={baseUrl}
/>
<PopoverRoutes
contacts={groupContacts}
association={groupAssociation}
group={group}
api={api}
s3={props.s3}
{...routeProps}
baseUrl={resourceUrl}
/>
<InvitePopover
api={api}
association={groupAssociation}
baseUrl={resourceUrl}
groups={props.groups}
contacts={props.contacts}
/>
</Skeleton>
);
}}
/>
<Route
path={relativePath("/join/:app/:host/:name")}
render={(routeProps) => {
const { app, host, name } = routeProps.match.params;
const appName = app as AppName;
const appPath = `/${host}/${name}`;
const association = associations[appName][appPath];
return (
<Skeleton mobileHide selectedGroup={groupPath} {...props} baseUrl={baseUrl}>
<UnjoinedResource association={association} />
<PopoverRoutes
contacts={groupContacts}
association={groupAssociation}
group={group}
api={api}
{...routeProps}
baseUrl={baseUrl}
/>
</Skeleton>
);
}}
/>
<Route
path={relativePath("")}
render={(routeProps) => {
return (
<Skeleton selectedGroup={groupPath} {...props} baseUrl={baseUrl}>
<Center display={["none", "auto"]}>
Open something to get started
</Center>
<PopoverRoutes
contacts={groupContacts}
association={groupAssociation}
group={group}
api={api}
{...routeProps}
baseUrl={baseUrl}
/>
</Skeleton>
);
}}
/>
</Switch>
);
}

View File

@ -1,4 +1,6 @@
import styled from 'styled-components';
import React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { Box } from "@tlon/indigo-react";
interface HoverBoxProps {
selected: boolean;
@ -6,11 +8,16 @@ interface HoverBoxProps {
bgActive: string;
}
export const HoverBox = styled(Box)<HoverBoxProps>`
background-color: ${ p => p.selected ? p.theme.colors[p.bgActive] : p.theme.colors[p.bg] };
background-color: ${(p) =>
p.selected ? p.theme.colors[p.bgActive] : p.theme.colors[p.bg]};
pointer: cursor;
&:hover {
background-color: ${ p => p.theme.colors[p.bgActive] };
background-color: ${(p) => p.theme.colors[p.bgActive]};
}
`;
export const HoverBoxLink = ({ to, children, ...rest }) => (
<Link to={to}>
<HoverBox {...rest}>{children}</HoverBox>
</Link>
);

View File

@ -0,0 +1,32 @@
import React from "react";
import { ChatResource } from "~/views/apps/chat/ChatResource";
import { LinkResource } from "~/views/apps/links/LinkResource";
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 } from "react-router-dom";
type ResourceProps = StoreState & {
association: Association;
api: GlobalApi;
baseUrl: string;
} & 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 <LinkResource {...props} />;
}
return null;
}

View File

@ -0,0 +1,153 @@
import React, { useMemo, useCallback } from "react";
import { Box, Text, Row, Col } from "@tlon/indigo-react";
import _ from "lodash";
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 { Rolodex, Groups } from "~/types";
import { HoverBox } from "./HoverBox";
interface InviteSearchProps {
disabled?: boolean;
label: string;
caption?: string;
id: string;
contacts: Rolodex;
groups: Groups;
}
const ClickableText = styled(Text)`
cursor: pointer;
`;
const Candidate = ({ title, detail, selected, onClick }) => (
<HoverBox
display="flex"
justifyContent="space-between"
alignItems="center"
onClick={onClick}
selected={selected}
borderColor="washedGray"
bgActive="washedGray"
bg="white"
color="black"
fontSize={0}
p={1}
width="100%"
>
<Text fontFamily="mono">{cite(title)}</Text>
<Text maxWidth="50%">{detail}</Text>
</HoverBox>
);
export function ShipSearch(props: InviteSearchProps) {
const [{ value }, { error }, { setValue }] = useField<string[]>(props.id);
const onSelect = useCallback(
(s: string) => {
setValue([...value, s]);
},
[setValue, value]
);
const onRemove = useCallback(
(s: string) => {
setValue(value.filter((v) => v !== s));
},
[setValue, value]
);
const [peers, nicknames] = useMemo(() => {
const peerSet = new Set<string>();
const contacts = new Map<string, string[]>();
_.forEach(props.groups, (group, path) => {
if (group.members.size > 0) {
const groupEntries = group.members.values();
for (const member of groupEntries) {
peerSet.add(member);
}
}
const groupContacts = props.contacts[path];
if (groupContacts) {
const groupEntries = group.members.values();
for (const member of groupEntries) {
if (groupContacts[member]) {
if (contacts.has(member)) {
contacts.get(member)?.push(groupContacts[member].nickname);
} else {
contacts.set(member, [groupContacts[member].nickname]);
}
}
}
}
});
return [Array.from(peerSet), contacts] as const;
}, [props.contacts, props.groups]);
const renderCandidate = useCallback(
(s: string, selected: boolean, onSelect: (s: string) => void) => {
const detail = _.uniq(nicknames.get(s)).join(', ');
const onClick = () => {
onSelect(s);
};
return (
<Candidate
title={s}
detail={detail}
selected={selected}
onClick={onClick}
/>
);
},
[nicknames]
);
return (
<Col>
<DropdownSearch<string>
label={props.label}
id={props.id}
placeholder="Search for ships"
caption={props.caption}
candidates={peers}
renderCandidate={renderCandidate}
disabled={false}
search={(s: string, t: string) =>
t.toLowerCase().startsWith(s.toLowerCase())
}
getKey={(s: string) => s}
onSelect={onSelect}
onRemove={onRemove}
renderChoice={({ candidate, onRemove }) => null}
value={undefined}
error={error}
/>
<Row flexWrap="wrap">
{value.map((s) => (
<Box
fontFamily="mono"
px={2}
py={1}
border={1}
borderColor="washedGrey"
color="black"
fontSize={0}
mb={2}
mr={2}
>
<Text fontFamily="mono">{cite(s)}</Text>
<ClickableText ml={2} onClick={() => onRemove(s)} color="black">
x
</ClickableText>
</Box>
))}
</Row>
</Col>
);
}

View File

@ -0,0 +1,175 @@
import React, { ReactNode, useState } from "react";
import {
Box,
Row,
Text,
Icon,
MenuItem as _MenuItem,
} from "@tlon/indigo-react";
import { capitalize } from "lodash";
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 { SidebarItem } from "./SidebarItem";
interface SidebarAppConfig {
name: string;
makeRouteForResource: (appPath: string) => string;
getStatus: (appPath: string) => SidebarItemStatus | undefined;
}
export type SidebarAppConfigs = { [a in AppName]: SidebarAppConfig };
export type SidebarItemStatus =
| "unread"
| "mention"
| "unsubscribed"
| "disconnected"
| "loading";
function ItemGroup(props: {
app: string;
apps: SidebarAppConfigs;
associations: Associations;
selected?: string;
group: string;
}) {
const { selected, apps, associations } = props;
const [open, setOpen] = useState(true);
const toggleOpen = () => setOpen((o) => !o);
const assoc = associations[props.app as AppName];
const items = _.pickBy(
assoc,
(value) =>
value["group-path"] === props.group && value["app-name"] === props.app
);
if (Object.keys(items).length === 0) {
return null;
}
return (
<Box mb={3}>
<Row alignItems="center" onClick={toggleOpen} pl={2} mb={1}>
<Icon
mb="1px"
fill="lightGray"
icon={open ? "TriangleSouth" : "TriangleEast"}
/>
<Text pl={1} color="lightGray">
{capitalize(props.app)}
</Text>
</Row>
{open && <SidebarItems selected={selected} items={items} apps={apps} />}
</Box>
);
}
const apps = ["chat", "publish", "link"];
const GroupItems = (props: {
associations: Associations;
group: string;
apps: SidebarAppConfigs;
selected?: string;
}) => (
<>
{apps.map((app) => (
<ItemGroup app={app} {...props} />
))}
</>
);
function SidebarItems(props: {
apps: SidebarAppConfigs;
items: AppAssociations;
selected?: string;
}) {
const { items, associations, selected } = props;
const ordered = Object.keys(items).sort((a, b) => {
const aAssoc = items[a];
const bAssoc = items[b];
const aTitle = aAssoc?.metadata?.title || b;
const bTitle = bAssoc?.metadata?.title || b;
return alphabeticalOrder(aTitle, bTitle);
});
return (
<>
{ordered.map((path) => {
const assoc = items[path];
return (
<SidebarItem
key={path}
path={path}
selected={path === selected}
association={assoc}
apps={props.apps}
/>
);
})}
</>
);
}
interface SidebarProps {
children: ReactNode;
invites: AppInvites;
api: GlobalApi;
associations: Associations;
selected?: string;
selectedGroup: string;
apps: SidebarAppConfigs;
baseUrl: string;
mobileHide?: boolean;
}
export function Sidebar(props: SidebarProps) {
const { invites, api, associations, selected, apps } = props;
const groupAsssociation = associations.contacts[props.selectedGroup];
const display = props.mobileHide ? ["none", "flex"] : "flex";
if (!groupAsssociation) {
return null;
}
return (
<Box
display={display}
flexDirection="column"
width="100%"
gridRow="1/3"
gridColumn="1/2"
borderRight={1}
borderRightColor="washedGray"
overflowY="auto"
fontSize={0}
bg="white"
position="relative"
>
<GroupSwitcher baseUrl={props.baseUrl} association={groupAsssociation} />
{Object.keys(invites).map((appPath) =>
Object.keys(invites[appPath]).map((uid) => (
<SidebarInvite
key={uid}
invite={props.invites[uid]}
onAccept={() => props.api.invite.accept(appPath, uid)}
onDecline={() => props.api.invite.decline(appPath, uid)}
/>
))
)}
<GroupItems
group={props.selectedGroup}
apps={apps}
selected={selected}
associations={associations || {}}
/>
</Box>
);
}

View File

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

View File

@ -0,0 +1,143 @@
import React, { ReactNode, useEffect } from "react";
import { Box, Text } from "@tlon/indigo-react";
import { Link } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { ChatHookUpdate } from "~/types/chat-hook-update";
import { Inbox } from "~/types/chat-update";
import { Associations } from "~/types/metadata-update";
import { Notebooks } from "~/types/publish-update";
import GlobalApi from "~/logic/api/global";
import { Path, AppName } from "~/types/noun";
import { LinkCollections } from "~/types/link-update";
interface SkeletonProps {
children: ReactNode;
associations: Associations;
chatSynced: ChatHookUpdate | null;
linkListening: Set<Path>;
links: LinkCollections;
notebooks: Notebooks;
inbox: Inbox;
selected?: string;
selectedApp?: AppName;
selectedGroup: string;
baseUrl: string;
mobileHide?: boolean;
api: GlobalApi;
}
const buntAppConfig = (name: string) => ({
name,
getStatus: (s: string) => undefined,
});
export function Skeleton(props: SkeletonProps) {
const chatConfig = {
name: "chat",
getStatus: (s: string) => {
if (!(s in (props.chatSynced || {}))) {
return "unsubscribed";
}
const { config } = props.inbox[s];
if (config.read !== config.length) {
return "unread";
}
return undefined;
},
};
const publishConfig = {
name: "chat",
getStatus: (s: string) => {
const [, host, name] = s.split("/");
const notebook = props.notebooks?.[host]?.[name];
if (!notebook) {
return "unsubscribed";
}
if (notebook["num-unread"]) {
return "unread";
}
return undefined;
},
};
const linkConfig = {
name: "link",
getStatus: (s: string) => {
if (!props.linkListening.has(s)) {
return "unsubscribed";
}
const link = props.links[s];
if (!link) {
return undefined;
}
if (link.unseenCount > 0) {
return "unread";
}
return undefined;
},
};
const config = {
publish: publishConfig,
link: linkConfig,
chat: chatConfig,
};
useEffect(() => {
props.api.publish.fetchNotebooks();
props.subscription.startApp("chat");
props.subscription.startApp("publish");
props.subscription.startApp("link");
props.api.links.getPage("", 0);
}, []);
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
bg="white"
height="100%"
width="100%"
display="grid"
borderRadius={1}
border={[0, 1]}
borderColor={["washedGray", "washedGray"]}
gridTemplateColumns={["1fr", "250px 1fr"]}
gridTemplateRows="32px 1fr"
>
<Sidebar
selected={props.selected}
selectedGroup={props.selectedGroup}
selectedApp={props.selectedApp}
associations={props.associations}
invites={{}}
apps={config}
baseUrl={props.baseUrl}
onlyGroups={[props.selectedGroup]}
mobileHide={props.mobileHide}
></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>{association?.metadata?.title}</Box>
</Box>
{props.children}
</Box>
</Box>
);
}

View File

@ -0,0 +1,32 @@
import React from "react";
import { Association } from "~/types/metadata-update";
import { Box, Text, Button, Col, Center } from "@tlon/indigo-react";
import { Link } from "react-router-dom";
interface UnjoinedResourceProps {
association: Association;
}
export function UnjoinedResource(props: UnjoinedResourceProps) {
const appPath = props.association["app-path"];
const appName = props.association["app-name"];
const { title, description } = props.association.metadata;
const to = `/~${appName}/join${appPath}`;
return (
<Center p={6}>
<Col maxWidth="400px" p={4} border={1} borderColor="washedGray">
<Box mb={4}>
<Text>{title}</Text>
</Box>
<Box mb={4}>
<Text color="gray">{description}</Text>
</Box>
<Link to={to}>
<Button mx="auto" border>
Join Channel
</Button>
</Link>
</Col>
</Center>
);
}