interface: clean up publish

This commit is contained in:
Logan Allen 2020-10-06 13:44:21 -05:00
parent 0436598a99
commit ec657cb8f8
29 changed files with 11 additions and 733 deletions

View File

@ -5,7 +5,7 @@ import GlobalApi from "~/logic/api/global";
import { StoreState } from "~/logic/store/type";
import { Association } from "~/types";
import { RouteComponentProps } from "react-router-dom";
import { NotebookRoutes } from "./components/lib/NotebookRoutes";
import { NotebookRoutes } from "./components/NotebookRoutes";
type PublishResourceProps = StoreState & {
association: Association;

View File

@ -1,176 +0,0 @@
import React, { useRef, useEffect } from "react";
import { Route, Switch, useLocation, withRouter } from "react-router-dom";
import { Center, Text } from "@tlon/indigo-react";
import _ from "lodash";
import Helmet from "react-helmet";
import "./css/custom.css";
import { Skeleton } from "./components/skeleton";
import { NewScreen } from "./components/lib/new";
import { JoinScreen } from "./components/lib/Join";
import { StoreState } from "~/logic/store/type";
import GlobalApi from "~/logic/api/global";
import GlobalSubscription from "~/logic/subscription/global";
import { NotebookRoutes } from "./components/lib/NotebookRoutes";
type PublishAppProps = StoreState & {
api: GlobalApi;
ship: string;
subscription: GlobalSubscription;
};
const RouterSkeleton = withRouter(Skeleton);
export default function PublishApp(props: PublishAppProps) {
const unreadTotal = useRef(0);
useEffect(() => {
document.title =
unreadTotal.current > 0
? `(${unreadTotal.current}) OS1 - Publish`
: "OS1 - Publish";
}, [unreadTotal.current]);
useEffect(() => {
props.subscription.startApp("publish");
props.api.publish.fetchNotebooks();
return () => {
props.subscription.stopApp("publish");
};
}, []);
const contacts = props.contacts ? props.contacts : {};
const notebooks = props.notebooks ? props.notebooks : {};
const location = useLocation();
unreadTotal.current = _.chain(notebooks)
.values()
.map(_.values)
.flatten() // flatten into array of notebooks
.map("num-unread")
.reduce((acc, count) => acc + count, 0)
.value();
const {
api,
groups,
sidebarShown,
invites,
associations,
hideNicknames,
hideAvatars,
remoteContentPolicy
} = props;
const active = location.pathname.endsWith("/~publish")
? "sidebar"
: "rightPanel";
return (
<>
<Helmet defer={false}>
<title>{unreadTotal > 0 ? `(${unreadTotal}) ` : ""}OS1 - Publish</title>
</Helmet>
<Route
path={[
"/~publish/notebook/:ship/:notebook/note/:noteId",
"/~publish/notebook/:ship/:notebook/*",
"/~publish/notebook/:ship/:notebook",
"/~publish",
]}
>
<RouterSkeleton
active={active}
sidebarShown={sidebarShown}
invites={invites}
notebooks={notebooks}
associations={associations}
contacts={contacts}
api={api}
>
<Switch>
<Route exact path="/~publish">
<Center width="100%" height="100%">
<Text color="lightGray">
Select or create a notebook to begin.
</Text>
</Center>
</Route>
<Route
exact
path="/~publish/new"
render={(props) => {
return (
<NewScreen
associations={associations}
api={api}
notebooks={notebooks}
groups={groups}
{...props}
/>
);
}}
/>
<Route
exact
path="/~publish/join/:ship/:notebook"
render={(props) => {
const ship = props.match.params.ship || "";
const notebook = props.match.params.notebook || "";
return (
<JoinScreen
notebooks={notebooks}
ship={ship}
book={notebook}
api={api}
{...props}
/>
);
}}
/>
<Route
path="/~publish/notebook/:ship/:notebook"
render={(props) => {
const view = props.match.params.view
? props.match.params.view
: "posts";
const ship = props.match.params.ship || "";
const book = props.match.params.notebook || "";
const bookGroupPath =
notebooks?.[ship]?.[book]?.["subscribers-group-path"];
const notebookContacts =
bookGroupPath in contacts ? contacts[bookGroupPath] : {};
const notebook = notebooks?.[ship]?.[book];
return (
<NotebookRoutes
notebook={notebook}
ship={ship}
book={book}
groups={groups}
contacts={contacts}
notebookContacts={notebookContacts}
sidebarShown={sidebarShown}
api={api}
hideNicknames={hideNicknames}
hideAvatars={hideAvatars}
remoteContentPolicy={remoteContentPolicy}
associations={associations}
{...props}
/>
);
}}
/>
</Switch>
</RouterSkeleton>
</Route>
</>
);
}

View File

@ -1,7 +1,7 @@
import React from "react";
import * as Yup from "yup";
import { Formik, FormikHelpers, Form, useFormikContext } from "formik";
import { AsyncButton } from "../../../../components/AsyncButton";
import { AsyncButton } from "../../../components/AsyncButton";
import { ManagedTextAreaField as TextArea } from "@tlon/indigo-react";
interface FormSchema {

View File

@ -1,5 +1,5 @@
import React, { useEffect } from "react";
import { AsyncButton } from "../../../../components/AsyncButton";
import { AsyncButton } from "../../../components/AsyncButton";
import * as Yup from "yup";
import {
Box,

View File

@ -6,7 +6,7 @@ import {
Row,
Col,
} from "@tlon/indigo-react";
import { AsyncButton } from "../../../../components/AsyncButton";
import { AsyncButton } from "../../../components/AsyncButton";
import { Formik, Form, FormikHelpers } from "formik";
import { MarkdownField } from "./MarkdownField";

View File

@ -1,15 +1,16 @@
import React, { useEffect } from "react";
import { RouteComponentProps, Link, Route, Switch } from "react-router-dom";
import { Box, Text } from "@tlon/indigo-react";
import GlobalApi from "../../../../api/global";
import { PublishContent } from "./PublishContent";
import { Notebook as INotebook } from "../../../../types/publish-update";
import { Groups } from "../../../../types/group-update";
import { Contacts, Rolodex } from "../../../../types/contact-update";
import GlobalApi from "~/api/global";
import { Notebook as INotebook } from "~/types/publish-update";
import { Groups } from "~/types/group-update";
import { Contacts, Rolodex } from "~/types/contact-update";
import { LocalUpdateRemoteContentPolicy, Associations } from "~/types";
import Notebook from "./Notebook";
import NewPost from "./new-post";
import { NoteRoutes } from './NoteRoutes';
import { LocalUpdateRemoteContentPolicy, Associations } from "~/types";
interface NotebookRoutesProps {
api: GlobalApi;

View File

@ -1,45 +0,0 @@
import React, { Component } from 'react';
import { Box, Text } from "@tlon/indigo-react";
import { NotebookItem } from './NotebookItem';
export class GroupItem extends Component {
render() {
const { props } = this;
const association = props.association ? props.association : {};
let title = association['app-path'] ? association['app-path'] : 'Unmanaged Notebooks';
if (association.metadata && association.metadata.title) {
title = association.metadata.title !== ''
? association.metadata.title : title;
}
const groupedBooks = props.groupedBooks ? props.groupedBooks : [];
const first = (props.index === 0) ? 'pt1' : 'pt6';
const notebookItems = groupedBooks.map((each, i) => {
const unreads = props.notebooks[each]['num-unread'] || 0;
let title = each.substr(1);
if (props.notebooks[each].title) {
title = (props.notebooks[each].title !== '')
? props.notebooks[each].title : title;
}
return (
<NotebookItem
key={i}
unreadCount={unreads}
title={title}
path={each}
selected={(props.path === each)}
/>
);
});
return (
<Box className={first}>
<Box fontSize={0} px={3} fontWeight="700" pb={2} color="lightGray">{title}</Box>
{notebookItems}
</Box>
);
}
}
export default GroupItem;

View File

@ -1,54 +0,0 @@
import React, { useCallback, useState, useRef, useEffect } from "react";
import { Col, Text, ErrorLabel } from "@tlon/indigo-react";
import { Spinner } from "~/views/components/Spinner";
import { Notebooks } from "~/types/publish-update";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import { RouteComponentProps } from "react-router-dom";
import { deSig } from "~/logic/lib/util";
interface JoinScreenProps {
api: any; // GlobalApi;
ship: string;
book: string;
notebooks: Notebooks;
}
export function JoinScreen(props: JoinScreenProps & RouteComponentProps) {
const { book, ship, api } = props;
const [error, setError] = useState(false);
const joining = useRef(false);
const waiter = useWaitForProps(props);
const onJoin = useCallback(async () => {
joining.current = true;
try {
await api.publish.subscribeNotebook(deSig(ship), book);
await waiter((p) => !!p.notebooks?.[ship]?.[book]);
props.history.replace(`/~publish/notebook/${ship}/${book}`);
} catch (e) {
console.error(e);
setError(true);
} finally {
joining.current = false;
}
}, [waiter, api, ship, book]);
useEffect(() => {
if (joining.current) {
return;
}
onJoin();
}, [onJoin]);
return (
<Col p={4}>
<Text fontSize={1}>Joining Notebook</Text>
<Spinner awaiting text="Joining..." />
{error && <ErrorLabel>Unable to join notebook</ErrorLabel>}
</Col>
);
}
export default JoinScreen;

View File

@ -1,52 +0,0 @@
import React, { Component } from "react";
import { Box, Text } from "@tlon/indigo-react";
import { Link } from "react-router-dom";
import { HoverBox } from "../../../../components/HoverBox";
interface NotebookItemProps {
selected: boolean;
title: string;
path: string;
unreadCount: number;
}
function UnreadCount(props: { unread: number }) {
return (
<Box
px={1}
fontWeight="700"
py={1}
borderRadius={1}
flexShrink='0'
color="white"
bg="lightGray"
>
{props.unread}
</Box>
);
}
export function NotebookItem(props: NotebookItemProps) {
return (
<Link to={"/~publish/notebook/" + props.path}>
<HoverBox
bg="white"
bgActive="washedGray"
selected={props.selected}
width="100%"
fontSize={0}
px={3}
py={1}
display="flex"
justifyContent="space-between"
alignItems="center"
>
<Box py='1' pr='1'>{props.title}</Box>
{props.unreadCount > 0 && <UnreadCount unread={props.unreadCount} />}
</HoverBox>
</Link>
);
}
export default NotebookItem;

View File

@ -1,128 +0,0 @@
import React, { Component } from "react";
import { Box, Text, Col } from "@tlon/indigo-react";
import { Link } from "react-router-dom";
import SidebarInvite from "~/views/components/Sidebar/SidebarInvite";
import { Welcome } from "./Welcome";
import { GroupItem } from "./GroupItem";
import { alphabetiseAssociations } from "~/logic/lib/util";
export function Sidebar(props: any) {
const sidebarInvites = !(props.invites && props.invites["/publish"])
? null
: Object.keys(props.invites["/publish"]).map((uid) => {
return (
<SidebarInvite
key={uid}
invite={props.invites["/publish"][uid]}
onAccept={() => props.api.invite.accept("/publish", uid)}
onDecline={() => props.api.invite.decline("/publish", uid)}
/>
);
});
const associations =
props.associations && "contacts" in props.associations
? alphabetiseAssociations(props.associations.contacts)
: {};
const notebooks = {};
Object.keys(props.notebooks).map((host) => {
Object.keys(props.notebooks[host]).map((notebook) => {
const title = `${host}/${notebook}`;
notebooks[title] = props.notebooks[host][notebook];
});
});
const groupedNotebooks = {};
Object.keys(notebooks).map((book) => {
const path = notebooks[book]["subscribers-group-path"]
? notebooks[book]["subscribers-group-path"]
: book;
if (path in associations) {
if (groupedNotebooks[path]) {
const array = groupedNotebooks[path];
array.push(book);
groupedNotebooks[path] = array;
} else {
groupedNotebooks[path] = [book];
}
} else {
if (groupedNotebooks["/~/"]) {
const array = groupedNotebooks["/~/"];
array.push(book);
groupedNotebooks["/~/"] = array;
} else {
groupedNotebooks["/~/"] = [book];
}
}
});
const selectedGroups = props.selectedGroups ? props.selectedGroups : [];
const groupedItems = Object.keys(associations)
.map((each, i) => {
const books = groupedNotebooks[each] || [];
if (books.length === 0) return;
if (
selectedGroups.length === 0 &&
groupedNotebooks["/~/"] &&
groupedNotebooks["/~/"].length !== 0
) {
i = i + 1;
}
return (
<GroupItem
key={i}
index={i}
association={associations[each]}
groupedBooks={books}
notebooks={notebooks}
path={props.path}
/>
);
});
if (
selectedGroups.length === 0 &&
groupedNotebooks["/~/"] &&
groupedNotebooks["/~/"].length !== 0
) {
groupedItems.unshift(
<GroupItem
key={"/~/"}
index={0}
association={"/~/"}
groupedBooks={groupedNotebooks["/~/"]}
notebooks={notebooks}
path={props.path}
/>
);
}
const display = props.hidden ? ['none', 'block'] : 'block';
return (
<Col
borderRight={[0, 1]}
borderRightColor={["washedGray", "washedGray"]}
height="100%"
pt={[3, 0]}
overflowY="auto"
display={display}
flexShrink={0}
width={["auto", "250px"]}
>
<Box>
<Link to="/~publish/new" className="green2 pa4 f9 dib">
<Box color="green">New Notebook</Box>
</Link>
</Box>
<Box
className="overflow-y-auto pb1"
>
<Welcome mx={2} />
{sidebarInvites}
{groupedItems}
</Box>
</Col>
);
}
export default Sidebar;

View File

@ -1,32 +0,0 @@
import React from "react";
import { Box } from "@tlon/indigo-react";
import { useLocalStorageState } from "~/logic/lib/useLocalStorageState";
export function Welcome(props: Parameters<typeof Box>[0]) {
const [wasWelcomed, setWasWelcomed] = useLocalStorageState(
"urbit-publish:wasWelcomed",
false
);
if (wasWelcomed) {
return null;
}
return (
<Box {...props} p={2} border={1} >
<Box lineHeight="1.6" fontSize={0}>
Notebooks are for longer-form writing and discussion. Each Notebook is a
collection of Markdown-formatted notes with optional comments.
</Box>
<Box
fontSize={0}
mt={2}
className="f8 pt2 dib pointer bb"
onClick={() => { setWasWelcomed(true) }}
>
Close this
</Box>
</Box>
);
}
export default Welcome;

View File

@ -1,103 +0,0 @@
import React, { useCallback } from "react";
import { Box, ManagedTextInputField as Input, Col } 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";
interface FormSchema {
name: string;
description: string;
group?: string;
}
const formSchema = Yup.object({
name: Yup.string().required("Notebook must have a name"),
description: Yup.string(),
group: Yup.string(),
});
interface NewScreenProps {
api: GlobalApi;
associations: Associations;
notebooks: Notebooks;
groups: Groups;
}
export function NewScreen(props: NewScreenProps & RouteComponentProps) {
const { history } = props;
const waiter = useWaitForProps(props, 5000);
const onSubmit = async (values: FormSchema, actions) => {
const bookId = stringToSymbol(values.name);
try {
const { name, description, group } = values;
await props.api.publish.newBook(bookId, name, description, group);
await waiter((p) => !!p?.notebooks?.[`~${window.ship}`]?.[bookId]);
if (!group) {
await waiter((p) => !!p?.groups?.[`/ship/~${window.ship}/${bookId}`]);
}
actions.setStatus({ success: null });
history.push(`/~publish/notebook/~${window.ship}/${bookId}`);
} catch (e) {
console.error(e);
actions.setStatus({ error: "Notebook creation failed" });
}
};
return (
<Col p={3}>
<Box mb={4} color="black">New Notebook</Box>
<Formik
validationSchema={formSchema}
initialValues={{ name: "", description: "", group: "" }}
onSubmit={onSubmit}
>
<Form>
<Box
display="grid"
gridTemplateRows="auto"
gridRowGap={2}
gridTemplateColumns="300px"
>
<Input
id="name"
label="Name"
caption="Provide a name for your notebook"
placeholder="eg. My Journal"
/>
<Input
id="description"
label="Description"
caption="What's your notebook about?"
placeholder="Notebook description"
/>
<GroupSearch
id="group"
label="Group"
caption="What group is the notebook for?"
associations={props.associations}
/>
<Box justifySelf="start">
<AsyncButton loadingText="Creating..." type="submit" border>
Create Notebook
</AsyncButton>
</Box>
<FormError message="Notebook Creation failed" />
</Box>
</Form>
</Formik>
</Col>
);
}
export default NewScreen;

View File

@ -1,133 +0,0 @@
import React, { useRef, SyntheticEvent, useEffect } from "react";
import { Box, Center } from "@tlon/indigo-react";
import { Sidebar } from "./lib/Sidebar";
import ErrorBoundary from "~/views/components/ErrorBoundary";
import { Notebooks } from "~/types/publish-update";
import { Rolodex } from "~/types/contact-update";
import { Invites } from "~/types/invite-update";
import GlobalApi from "~/logic/api/global";
import { Associations } from "~/types/metadata-update";
import { RouteComponentProps } from "react-router-dom";
type SkeletonProps = RouteComponentProps<{
ship?: string;
notebook?: string;
noteId?: string;
}> & {
notebooks: Notebooks;
invites: Invites;
associations: Associations;
contacts: Rolodex;
api: GlobalApi;
children: React.ReactNode;
};
export function Skeleton(props: SkeletonProps) {
const { api, notebooks } = props;
const { ship, notebook, noteId } = props.match.params;
const scrollRef = useRef<HTMLDivElement>();
const path =
(ship &&
notebook &&
`${props.match.params.ship}/${props.match.params.notebook}`) ||
undefined;
const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollHeight, scrollTop, clientHeight } = e.target as HTMLDivElement;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
if (noteId && notebook && ship) {
const note = notebooks?.[ship]?.[notebook]?.notes?.[noteId];
if (!note || !note.comments) {
return;
}
const loadedComments = note.comments?.length;
const fullyLoaded = note["num-comments"] === loadedComments;
if (distanceFromBottom < 40) {
if (!fullyLoaded) {
api.publish.fetchCommentsPage(
ship,
notebook,
noteId,
loadedComments,
30
);
}
if (!note.read) {
api.publish.publishAction({
read: {
who: ship.slice(1),
book: notebook,
note: noteId,
},
});
}
}
} else if (notebook && ship) {
}
};
// send read immediately if we aren't in a scrollable container
useEffect(() => {
if(!(noteId && notebook && ship)) {
return;
}
const note = notebooks?.[ship]?.[notebook]?.notes?.[noteId];
setTimeout(() => {
const { clientHeight, scrollHeight } = scrollRef.current;
const isScrolling = clientHeight < scrollHeight;
if(!isScrolling && note) {
api.publish.publishAction({
read: {
who: ship.slice(1),
book: notebook,
note: noteId,
},
});
}
}, 1500);
}, [noteId, notebook, ship, notebooks])
const hideSidebar = path || props.location.pathname.endsWith('/new')
const panelDisplay = !hideSidebar ? ["none", "block"] : "block";
return (
<Box height="100%" width="100%" px={[0, 3]} pb={[0, 3]}>
<Box
bg="white"
display="flex"
border={[0, 1]}
borderColor={["washedGray", "washedGray"]}
borderRadius={1}
width="100%"
height="100%"
>
<Sidebar
notebooks={props.notebooks}
contacts={props.contacts}
path={path}
hidden={hideSidebar}
invites={props.invites}
associations={props.associations}
api={props.api}
/>
<Box
ref={scrollRef}
display={panelDisplay}
width="100%"
height="100%"
position="relative"
px={[3, 4]}
fontSize={0}
overflowY="scroll"
onScroll={onScroll}
>
<ErrorBoundary>{props.children}</ErrorBoundary>
</Box>
</Box>
</Box>
);
}
export default Skeleton;