mirror of
https://github.com/urbit/shrub.git
synced 2024-12-01 14:42:02 +03:00
groups: new sidebar layout
This commit is contained in:
parent
d47cfdb57d
commit
4c5bffaef0
19
pkg/interface/src/logic/lib/useOutsideClick.ts
Normal file
19
pkg/interface/src/logic/lib/useOutsideClick.ts
Normal 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]);
|
||||
}
|
@ -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];
|
||||
});
|
||||
|
@ -31,7 +31,7 @@ type MetadataUpdateRemove = {
|
||||
|
||||
export type Associations = Record<AppName, AppAssociations>;
|
||||
|
||||
type AppAssociations = {
|
||||
export type AppAssociations = {
|
||||
[p in Path]: Association;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
106
pkg/interface/src/views/apps/groups/components/InvitePopover.tsx
Normal file
106
pkg/interface/src/views/apps/groups/components/InvitePopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
235
pkg/interface/src/views/apps/groups/components/Participants.tsx
Normal file
235
pkg/interface/src/views/apps/groups/components/Participants.tsx
Normal 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 = [];
|
||||
}
|
150
pkg/interface/src/views/apps/groups/components/PopoverRoutes.tsx
Normal file
150
pkg/interface/src/views/apps/groups/components/PopoverRoutes.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 ? (
|
||||
|
65
pkg/interface/src/views/components/Dropdown.tsx
Normal file
65
pkg/interface/src/views/components/Dropdown.tsx
Normal 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",
|
||||
};
|
134
pkg/interface/src/views/components/GroupsPane.tsx
Normal file
134
pkg/interface/src/views/components/GroupsPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
32
pkg/interface/src/views/components/Resource.tsx
Normal file
32
pkg/interface/src/views/components/Resource.tsx
Normal 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;
|
||||
}
|
153
pkg/interface/src/views/components/ShipSearch.tsx
Normal file
153
pkg/interface/src/views/components/ShipSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
175
pkg/interface/src/views/components/Sidebar.tsx
Normal file
175
pkg/interface/src/views/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
68
pkg/interface/src/views/components/SidebarItem.tsx
Normal file
68
pkg/interface/src/views/components/SidebarItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
143
pkg/interface/src/views/components/Skeleton.tsx
Normal file
143
pkg/interface/src/views/components/Skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
32
pkg/interface/src/views/components/UnjoinedResource.tsx
Normal file
32
pkg/interface/src/views/components/UnjoinedResource.tsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user