feat: Comments (#4911)

* Comment model

* Framework, model, policy, presenter, api endpoint etc

* Iteration, first pass of UI

* fixes, refactors

* Comment commands

* comment socket support

* typing indicators

* comment component, styling

* wip

* right sidebar resize

* fix: CMD+Enter submit

* Add usePersistedState
fix: Main page scrolling on comment highlight

* drafts

* Typing indicator

* refactor

* policies

* Click thread to highlight
Improve comment timestamps

* padding

* Comment menu v1

* Change comments to use editor

* Basic comment editing

* fix: Hide commenting button when disabled at team level

* Enable opening sidebar without mark

* Move selected comment to location state

* Add comment delete confirmation

* Add comment count to document meta

* fix: Comment sidebar togglable
Add copy link to comment

* stash

* Restore History changes

* Refactor right sidebar to allow for comment animation

* Update to new router best practices

* stash

* Various improvements

* stash

* Handle click outside

* Fix incorrect placeholder in input
fix: Input box appearing on other sessions erroneously

* stash

* fix: Don't leave orphaned child comments

* styling

* stash

* Enable comment toggling again

* Edit styling, merge conflicts

* fix: Cannot navigate from insights to comments

* Remove draft comment mark on click outside

* Fix: Empty comment sidebar, tsc

* Remove public toggle

* fix: All comments are recessed
fix: Comments should not be printed

* fix: Associated mark should be removed on comment delete

* Revert unused changes

* Empty state, basic RTL support

* Create dont toggle comment mark

* Make it feel more snappy

* Highlight active comment in text

* fix animation

* RTL support

* Add reply CTA

* Translations
This commit is contained in:
Tom Moor
2023-02-25 15:03:05 -05:00
committed by GitHub
parent 59e25a0ef0
commit fc8c20149f
89 changed files with 2909 additions and 315 deletions

View File

@@ -0,0 +1,13 @@
import * as React from "react";
import extensions from "@shared/editor/packages/basic";
import Editor, { Props as EditorProps } from "~/components/Editor";
import type { Editor as SharedEditor } from "~/editor";
const CommentEditor = (
props: EditorProps,
ref: React.RefObject<SharedEditor>
) => {
return <Editor extensions={extensions} {...props} ref={ref} />;
};
export default React.forwardRef(CommentEditor);

View File

@@ -0,0 +1,229 @@
import { m } from "framer-motion";
import { action } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { CommentValidation } from "@shared/validations";
import Comment from "~/models/Comment";
import Avatar from "~/components/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import { useDocumentContext } from "~/components/DocumentContext";
import Flex from "~/components/Flex";
import type { Editor as SharedEditor } from "~/editor";
import useCurrentUser from "~/hooks/useCurrentUser";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import CommentEditor from "./CommentEditor";
import { Bubble } from "./CommentThreadItem";
type Props = {
/** The document that the comment will be associated with */
documentId: string;
/** The comment thread that the comment will be associated with */
thread: Comment;
/** Placeholder text to display in the editor */
placeholder?: string;
/** Whether to focus the editor on mount */
autoFocus?: boolean;
/** Whether to render the comment form as standalone, rather than as a reply */
standalone?: boolean;
/** Whether to animate the comment form in and out */
animatePresence?: boolean;
/** The text direction of the editor */
dir?: "rtl" | "ltr";
/** Callback when the user is typing in the editor */
onTyping?: () => void;
/** Callback when the editor is focused */
onFocus?: () => void;
/** Callback when the editor is blurred */
onBlur?: () => void;
/** Callback when the editor is clicked outside of */
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
};
function CommentForm({
documentId,
thread,
onTyping,
onFocus,
onBlur,
onClickOutside,
autoFocus,
standalone,
placeholder,
animatePresence,
dir,
...rest
}: Props) {
const { editor } = useDocumentContext();
const [data, setData] = usePersistedState<Record<string, any> | undefined>(
`draft-${documentId}-${thread.id}`,
undefined
);
const formRef = React.useRef<HTMLFormElement>(null);
const editorRef = React.useRef<SharedEditor>(null);
const [forceRender, setForceRender] = React.useState(0);
const { t } = useTranslation();
const { showToast } = useToasts();
const { comments } = useStores();
const user = useCurrentUser();
const isEmpty = editorRef.current?.isEmpty() ?? true;
useOnClickOutside(formRef, () => {
if (isEmpty && thread.isNew) {
if (thread.id) {
editor?.removeComment(thread.id);
}
thread.delete();
}
});
const handleCreateComment = action(async (event: React.FormEvent) => {
event.preventDefault();
setData(undefined);
setForceRender((s) => ++s);
thread
.save({
documentId,
data,
})
.catch(() => {
thread.isNew = true;
showToast(t("Error creating comment"), { type: "error" });
});
// optimistically update the comment model
thread.isNew = false;
thread.createdBy = user;
});
const handleCreateReply = async (event: React.FormEvent) => {
event.preventDefault();
if (!data) {
return;
}
setData(undefined);
setForceRender((s) => ++s);
try {
await comments.save({
parentCommentId: thread?.id,
documentId,
data,
});
} catch (error) {
showToast(t("Error creating comment"), { type: "error" });
}
};
const handleChange = (
value: (asString: boolean, trim: boolean) => Record<string, any>
) => {
setData(value(false, true));
onTyping?.();
};
const handleSave = () => {
formRef.current?.dispatchEvent(
new Event("submit", { cancelable: true, bubbles: true })
);
};
const handleClickPadding = () => {
if (editorRef.current?.isBlurred) {
editorRef.current?.focusAtStart();
}
};
const handleCancel = () => {
setData(undefined);
setForceRender((s) => ++s);
};
// Focus the editor when it's a new comment just mounted, after a delay as the
// editor is mounted within a fade transition.
React.useEffect(() => {
setTimeout(() => {
if (autoFocus) {
editorRef.current?.focusAtStart();
}
}, 0);
}, [autoFocus]);
const presence = animatePresence
? {
initial: {
opacity: 0,
translateY: 100,
},
animate: {
opacity: 1,
translateY: 0,
transition: {
type: "spring",
bounce: 0.1,
},
},
exit: {
opacity: 0,
translateY: 100,
scale: 0.98,
},
}
: {};
return (
<m.form
ref={formRef}
onSubmit={thread?.isNew ? handleCreateComment : handleCreateReply}
{...presence}
{...rest}
>
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
<Avatar model={user} size={24} style={{ marginTop: 8 }} />
<Bubble
gap={10}
onClick={handleClickPadding}
$lastOfThread
$firstOfAuthor
$firstOfThread={standalone}
column
>
<CommentEditor
key={`${forceRender}`}
ref={editorRef}
onChange={handleChange}
onSave={handleSave}
onFocus={onFocus}
onBlur={onBlur}
maxLength={CommentValidation.maxLength}
placeholder={
placeholder ||
// isNew is only the case for comments that exist in draft state,
// they are marks in the document, but not yet saved to the db.
(thread.isNew ? `${t("Add a comment")}` : `${t("Add a reply")}`)
}
/>
{!isEmpty && (
<Flex justify={dir === "rtl" ? "flex-end" : "flex-start"} gap={8}>
<ButtonSmall type="submit" borderOnHover>
{thread.isNew ? t("Post") : t("Reply")}
</ButtonSmall>
<ButtonSmall onClick={handleCancel} neutral borderOnHover>
{t("Cancel")}
</ButtonSmall>
</Flex>
)}
</Bubble>
</Flex>
</m.form>
);
}
export default observer(CommentForm);

View File

@@ -0,0 +1,232 @@
import { throttle } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import Avatar from "~/components/Avatar";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Typing from "~/components/Typing";
import { WebsocketContext } from "~/components/WebsocketProvider";
import useCurrentUser from "~/hooks/useCurrentUser";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import CommentForm from "./CommentForm";
import CommentThreadItem from "./CommentThreadItem";
type Props = {
/** The document that this comment thread belongs to */
document: Document;
/** The root comment to render */
comment: Comment;
/** Whether the thread is focused */
focused: boolean;
/** Whether the thread is displayed in a recessed/backgrounded state */
recessed: boolean;
};
function useTypingIndicator({
document,
comment,
}: Omit<Props, "focused" | "recessed">): [undefined, () => void] {
const socket = React.useContext(WebsocketContext);
const setIsTyping = React.useMemo(
() =>
throttle(() => {
socket?.emit("typing", {
documentId: document.id,
commentId: comment.id,
});
}, 500),
[socket, document.id, comment.id]
);
return [undefined, setIsTyping];
}
function CommentThread({
comment: thread,
document,
recessed,
focused,
}: Props) {
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
const user = useCurrentUser();
const { t } = useTranslation();
const history = useHistory();
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
const [, setIsTyping] = useTypingIndicator({
document,
comment: thread,
});
const commentsInThread = comments.inThread(thread.id);
useOnClickOutside(topRef, (event) => {
if (
focused &&
!(event.target as HTMLElement).classList.contains("comment")
) {
history.replace({
pathname: window.location.pathname,
state: { commentId: undefined },
});
}
});
const handleClickThread = () => {
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId: thread.id },
});
};
React.useEffect(() => {
if (!focused && autoFocus) {
setAutoFocus(false);
}
}, [focused, autoFocus]);
React.useEffect(() => {
if (focused && topRef.current) {
scrollIntoView(topRef.current, {
scrollMode: "if-needed",
behavior: "smooth",
block: "start",
boundary: (parent) => {
// Prevents body and other parent elements from being scrolled
return parent.id !== "comments";
},
});
setTimeout(() => {
const commentMarkElement = window.document?.getElementById(
`comment-${thread.id}`
);
commentMarkElement?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 0);
}
}, [focused, thread.id]);
return (
<Thread
ref={topRef}
$focused={focused}
$recessed={recessed}
$dir={document.dir}
onClick={handleClickThread}
>
{commentsInThread.map((comment, index) => {
const firstOfAuthor =
index === 0 ||
comment.createdById !== commentsInThread[index - 1].createdById;
const lastOfAuthor =
index === commentsInThread.length - 1 ||
comment.createdById !== commentsInThread[index + 1].createdById;
return (
<CommentThreadItem
comment={comment}
key={comment.id}
firstOfThread={index === 0}
lastOfThread={index === commentsInThread.length - 1 && !focused}
firstOfAuthor={firstOfAuthor}
lastOfAuthor={lastOfAuthor}
previousCommentCreatedAt={commentsInThread[index - 1]?.createdAt}
dir={document.dir}
/>
);
})}
{thread.currentlyTypingUsers
.filter((typing) => typing.id !== user.id)
.map((typing) => (
<Flex gap={8} key={typing.id}>
<Avatar model={typing} size={24} />
<Typing />
</Flex>
))}
<ResizingHeightContainer
hideOverflow={false}
config={{
transition: {
duration: 0.1,
ease: "easeInOut",
},
}}
>
{focused && (
<Fade timing={100}>
<CommentForm
documentId={document.id}
thread={thread}
onTyping={setIsTyping}
standalone={commentsInThread.length === 0}
dir={document.dir}
autoFocus={autoFocus}
/>
</Fade>
)}
</ResizingHeightContainer>
{!focused && !recessed && (
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}</Reply>
)}
</Thread>
);
}
const Reply = styled.button`
border: 0;
padding: 8px;
margin: 0;
background: none;
color: ${(props) => props.theme.textTertiary};
font-size: 14px;
-webkit-appearance: none;
cursor: var(--pointer);
opacity: 0;
transition: opacity 100ms ease-out;
position: absolute;
text-align: left;
width: 100%;
bottom: -30px;
left: 32px;
`;
const Thread = styled.div<{
$focused: boolean;
$recessed: boolean;
$dir?: "rtl" | "ltr";
}>`
margin: 12px 12px 32px;
margin-right: ${(props) => (props.$dir !== "rtl" ? "18px" : "12px")};
margin-left: ${(props) => (props.$dir === "rtl" ? "18px" : "12px")};
position: relative;
transition: opacity 100ms ease-out;
&:hover {
${Reply} {
opacity: 1;
}
}
${(props) =>
props.$recessed &&
css`
opacity: 0.35;
cursor: default;
`}
`;
export default observer(CommentThread);

View File

@@ -0,0 +1,279 @@
import { differenceInMilliseconds, formatDistanceToNow } from "date-fns";
import { toJS } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { Minute } from "@shared/utils/time";
import Comment from "~/models/Comment";
import Avatar from "~/components/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import { useDocumentContext } from "~/components/DocumentContext";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useBoolean from "~/hooks/useBoolean";
import useToasts from "~/hooks/useToasts";
import CommentMenu from "~/menus/CommentMenu";
import CommentEditor from "./CommentEditor";
/**
* Hook to calculate if we should display a timestamp on a comment
*
* @param createdAt The date the comment was created
* @param previousCreatedAt The date of the previous comment, if any
* @returns boolean if to show timestamp
*/
function useShowTime(
createdAt: string | undefined,
previousCreatedAt: string | undefined
): boolean {
if (!createdAt) {
return false;
}
const previousTimeStamp = previousCreatedAt
? formatDistanceToNow(Date.parse(previousCreatedAt))
: undefined;
const currentTimeStamp = formatDistanceToNow(Date.parse(createdAt));
const msSincePreviousComment = previousCreatedAt
? differenceInMilliseconds(
Date.parse(createdAt),
Date.parse(previousCreatedAt)
)
: 0;
return (
!msSincePreviousComment ||
(msSincePreviousComment > 15 * Minute &&
previousTimeStamp !== currentTimeStamp)
);
}
type Props = {
/** The comment to render */
comment: Comment;
/** The text direction of the editor */
dir?: "rtl" | "ltr";
/** Whether this is the first comment in the thread */
firstOfThread?: boolean;
/** Whether this is the last comment in the thread */
lastOfThread?: boolean;
/** Whether this is the first consecutive comment by this author */
firstOfAuthor?: boolean;
/** Whether this is the last consecutive comment by this author */
lastOfAuthor?: boolean;
/** The date of the previous comment in the thread */
previousCommentCreatedAt?: string;
};
function CommentThreadItem({
comment,
firstOfAuthor,
firstOfThread,
lastOfThread,
dir,
previousCommentCreatedAt,
}: Props) {
const { editor } = useDocumentContext();
const { showToast } = useToasts();
const { t } = useTranslation();
const [forceRender, setForceRender] = React.useState(0);
const [data, setData] = React.useState(toJS(comment.data));
const showAuthor = firstOfAuthor;
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
const [isEditing, setEditing, setReadOnly] = useBoolean();
const formRef = React.useRef<HTMLFormElement>(null);
const handleChange = (value: (asString: boolean) => object) => {
setData(value(false));
};
const handleSave = () => {
formRef.current?.dispatchEvent(
new Event("submit", { cancelable: true, bubbles: true })
);
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
try {
setReadOnly();
await comment.save({
data,
});
} catch (error) {
setEditing();
showToast(t("Error updating comment"), { type: "error" });
}
};
const handleDelete = () => {
editor?.removeComment(comment.id);
};
const handleCancel = () => {
setData(toJS(comment.data));
setReadOnly();
setForceRender((s) => ++s);
};
React.useEffect(() => {
setData(toJS(comment.data));
setForceRender((s) => ++s);
}, [comment.data]);
return (
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
{firstOfAuthor && (
<AvatarSpacer>
<Avatar model={comment.createdBy} size={24} />
</AvatarSpacer>
)}
<Bubble
$firstOfThread={firstOfThread}
$firstOfAuthor={firstOfAuthor}
$lastOfThread={lastOfThread}
$dir={dir}
column
>
{(showAuthor || showTime) && (
<Meta size="xsmall" type="secondary" dir={dir}>
{showAuthor && <em>{comment.createdBy.name}</em>}
{showAuthor && showTime && <> &middot; </>}
{showTime && (
<Time
dateTime={comment.createdAt}
tooltipDelay={500}
addSuffix
shorten
/>
)}
</Meta>
)}
<Body ref={formRef} onSubmit={handleSubmit}>
<StyledCommentEditor
key={`${forceRender}`}
readOnly={!isEditing}
defaultValue={data}
onChange={handleChange}
onSave={handleSave}
autoFocus
/>
{isEditing && (
<Flex align="flex-end" gap={8}>
<ButtonSmall type="submit" borderOnHover>
{t("Save")}
</ButtonSmall>
<ButtonSmall onClick={handleCancel} neutral borderOnHover>
{t("Cancel")}
</ButtonSmall>
</Flex>
)}
</Body>
{!isEditing && (
<Menu
comment={comment}
onEdit={setEditing}
onDelete={handleDelete}
dir={dir}
/>
)}
</Bubble>
</Flex>
);
}
const StyledCommentEditor = styled(CommentEditor)`
${(props) =>
!props.readOnly &&
css`
box-shadow: 0 0 0 2px ${props.theme.accent};
border-radius: 2px;
padding: 2px;
margin: 2px;
margin-bottom: 8px;
`}
`;
const AvatarSpacer = styled(Flex)`
width: 24px;
height: 24px;
margin-top: 4px;
align-items: flex-end;
justify-content: flex-end;
flex-shrink: 0;
flex-direction: column;
`;
const Body = styled.form`
border-radius: 2px;
`;
const Menu = styled(CommentMenu)<{ dir?: "rtl" | "ltr" }>`
position: absolute;
left: ${(props) => (props.dir !== "rtl" ? "auto" : "4px")};
right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")};
top: 4px;
opacity: 0;
transition: opacity 100ms ease-in-out;
&:hover,
&[aria-expanded="true"] {
opacity: 1;
background: ${(props) => props.theme.sidebarActiveBackground};
}
`;
const Meta = styled(Text)`
margin-bottom: 2px;
em {
font-weight: 600;
font-style: normal;
}
`;
export const Bubble = styled(Flex)<{
$firstOfThread?: boolean;
$firstOfAuthor?: boolean;
$lastOfThread?: boolean;
$focused?: boolean;
$dir?: "rtl" | "ltr";
}>`
position: relative;
flex-grow: 1;
font-size: 15px;
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.commentBackground};
min-width: 2em;
margin-bottom: 1px;
padding: 8px 12px;
transition: color 100ms ease-out,
${(props) => props.theme.backgroundTransition};
${({ $lastOfThread }) =>
$lastOfThread &&
"border-bottom-left-radius: 8px; border-bottom-right-radius: 8px"};
${({ $firstOfThread }) =>
$firstOfThread &&
"border-top-left-radius: 8px; border-top-right-radius: 8px"};
margin-left: ${(props) =>
props.$firstOfAuthor || props.$dir === "rtl" ? 0 : 32}px;
margin-right: ${(props) =>
props.$firstOfAuthor || props.$dir !== "rtl" ? 0 : 32}px;
p:last-child {
margin-bottom: 0;
}
&:hover ${Menu} {
opacity: 1;
}
`;
export default observer(CommentThreadItem);

View File

@@ -0,0 +1,94 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import Comment from "~/models/Comment";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import useStores from "~/hooks/useStores";
import CommentForm from "./CommentForm";
import CommentThread from "./CommentThread";
import Sidebar from "./SidebarLayout";
function Comments() {
const { ui, comments, documents } = useStores();
const [newComment] = React.useState(new Comment({}, comments));
const { t } = useTranslation();
const user = useCurrentUser();
const match = useRouteMatch<{ documentSlug: string }>();
const document = documents.getByUrl(match.params.documentSlug);
const focusedComment = useFocusedComment();
if (!document) {
return null;
}
const threads = comments
.threadsInDocument(document.id)
.filter((thread) => !thread.isNew || thread.createdById === user.id);
const hasComments = threads.length > 0;
return (
<Sidebar title={t("Comments")} onClose={ui.collapseComments}>
<Wrapper $hasComments={hasComments}>
{hasComments ? (
threads.map((thread) => (
<CommentThread
key={thread.id}
comment={thread}
document={document}
recessed={!!focusedComment && focusedComment.id !== thread.id}
focused={focusedComment?.id === thread.id}
/>
))
) : (
<NoComments align="center" justify="center" auto>
<Empty>{t("No comments yet")}</Empty>
</NoComments>
)}
<AnimatePresence initial={false}>
{!focusedComment && (
<NewCommentForm
key="new-comment-form"
documentId={document.id}
thread={newComment}
placeholder={`${t("Add a comment")}`}
autoFocus={false}
dir={document.dir}
animatePresence
standalone
/>
)}
</AnimatePresence>
</Wrapper>
</Sidebar>
);
}
const NoComments = styled(Flex)`
padding-bottom: 65px;
height: 100%;
`;
const Wrapper = styled.div<{ $hasComments: boolean }>`
padding-bottom: ${(props) => (props.$hasComments ? "50vh" : "0")};
height: ${(props) => (props.$hasComments ? "auto" : "100%")};
`;
const NewCommentForm = styled(CommentForm)<{ dir?: "ltr" | "rtl" }>`
background: ${(props) => props.theme.background};
position: absolute;
padding: 12px;
padding-right: ${(props) => (props.dir !== "rtl" ? "18px" : "12px")};
padding-left: ${(props) => (props.dir === "rtl" ? "18px" : "12px")};
left: 0;
right: 0;
bottom: 0;
`;
export default observer(Comments);

View File

@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
import { NavigationNode } from "@shared/types";
import { NavigationNode, TeamPreference } from "@shared/types";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import Error404 from "~/scenes/Error404";
@@ -45,6 +45,7 @@ function DataLoader({ match, children }: Props) {
ui,
views,
shares,
comments,
documents,
auth,
revisions,
@@ -158,6 +159,12 @@ function DataLoader({ match, children }: Props) {
// Prevents unauthorized request to load share information for the document
// when viewing a public share link
if (can.read) {
if (team?.getPreference(TeamPreference.Commenting)) {
comments.fetchDocumentComments(document.id, {
limit: 100,
});
}
shares.fetch(document.id).catch((err) => {
if (!(err instanceof NotFoundError)) {
throw err;
@@ -165,7 +172,7 @@ function DataLoader({ match, children }: Props) {
});
}
}
}, [can.read, can.update, document, isEditRoute, shares, ui]);
}, [can.read, can.update, document, isEditRoute, comments, team, shares, ui]);
if (error) {
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;

View File

@@ -13,8 +13,8 @@ import {
} from "react-router";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { Heading } from "@shared/editor/lib/getHeadings";
import { NavigationNode } from "@shared/types";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import { parseDomain } from "@shared/utils/domains";
import getTasks from "@shared/utils/getTasks";
import RootStore from "~/stores/RootStore";
@@ -638,19 +638,28 @@ class DocumentScene extends React.Component<Props> {
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
</Container>
{!isShare && (
<Footer>
<KeyboardShortcutsButton />
<ConnectionStatus />
</Footer>
)}
</Background>
{!isShare && (
<>
<KeyboardShortcutsButton />
<ConnectionStatus />
</>
)}
</ErrorBoundary>
);
}
}
const Footer = styled.div`
position: absolute;
width: 100%;
text-align: right;
display: flex;
justify-content: flex-end;
`;
const Background = styled(Container)`
position: relative;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
`;

View File

@@ -0,0 +1,96 @@
import { LocationDescriptor } from "history";
import { observer, useObserver } from "mobx-react";
import { CommentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { TeamPreference } from "@shared/types";
import Document from "~/models/Document";
import DocumentMeta from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
import useStores from "~/hooks/useStores";
import { documentUrl, documentInsightsUrl } from "~/utils/routeHelpers";
type Props = {
/* The document to display meta data for */
document: Document;
isDraft: boolean;
to?: LocationDescriptor;
rtl?: boolean;
};
function TitleDocumentMeta({ to, isDraft, document, ...rest }: Props) {
const { auth, views, comments, ui } = useStores();
const { t } = useTranslation();
const { team } = auth;
const match = useRouteMatch();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
const viewsLoadedOnMount = React.useRef(totalViewers > 0);
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
const insightsUrl = documentInsightsUrl(document);
const commentsCount = comments.inDocument(document.id).length;
return (
<Meta document={document} to={to} replace {...rest}>
{totalViewers && !isDraft ? (
<Wrapper>
&nbsp;&nbsp;
<Link
to={match.url === insightsUrl ? documentUrl(document) : insightsUrl}
>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</Link>
</Wrapper>
) : null}
{team?.getPreference(TeamPreference.Commenting) && (
<>
&nbsp;&nbsp;
<CommentLink to={documentUrl(document)} onClick={ui.toggleComments}>
<CommentIcon color="currentColor" size={18} />
{commentsCount
? t("{{ count }} comment", { count: commentsCount })
: t("Comment")}
</CommentLink>
</>
)}
</Meta>
);
}
const CommentLink = styled(Link)`
display: inline-flex;
align-items: center;
`;
const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
margin: -12px 0 2em 0;
font-size: 14px;
position: relative;
user-select: none;
z-index: 1;
a {
color: inherit;
&:hover {
text-decoration: underline;
}
}
@media print {
display: none;
}
`;
export default observer(TitleDocumentMeta);

View File

@@ -2,13 +2,15 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { useRouteMatch } from "react-router-dom";
import fullPackage from "@shared/editor/packages/full";
import { useHistory, useRouteMatch } from "react-router-dom";
import fullWithCommentsPackage from "@shared/editor/packages/fullWithComments";
import { TeamPreference } from "@shared/types";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { RefHandle } from "~/components/ContentEditable";
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
import Editor, { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex";
import useStores from "~/hooks/useStores";
import {
documentHistoryUrl,
documentUrl,
@@ -16,6 +18,7 @@ import {
} from "~/utils/routeHelpers";
import { useDocumentContext } from "../../../components/DocumentContext";
import MultiplayerEditor from "./AsyncMultiplayerEditor";
import DocumentMeta from "./DocumentMeta";
import EditableTitle from "./EditableTitle";
type Props = Omit<EditorProps, "extensions"> & {
@@ -34,12 +37,15 @@ type Props = Omit<EditorProps, "extensions"> & {
/**
* The main document editor includes an editable title with metadata below it,
* and support for hover previews of internal links.
* and support for commenting.
*/
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const titleRef = React.useRef<RefHandle>(null);
const { t } = useTranslation();
const match = useRouteMatch();
const { ui, comments, auth } = useStores();
const { user, team } = auth;
const history = useHistory();
const {
document,
onChangeTitle,
@@ -77,9 +83,64 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[focusAtStart, ref]
);
const handleClickComment = React.useCallback(
(commentId?: string) => {
if (commentId) {
ui.expandComments();
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId },
});
} else {
history.replace({
pathname: window.location.pathname,
});
}
},
[ui, history]
);
// Create a Comment model in local store when a comment mark is created, this
// acts as a local draft before submission.
const handleDraftComment = React.useCallback(
(commentId: string, createdById: string) => {
if (comments.get(commentId) || createdById !== user?.id) {
return;
}
const comment = new Comment(
{
documentId: props.id,
createdAt: new Date(),
createdById,
},
comments
);
comment.id = commentId;
comments.add(comment);
ui.expandComments();
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
state: { commentId },
});
},
[comments, user?.id, props.id, ui, history]
);
// Soft delete the Comment model when associated mark is totally removed.
const handleRemoveComment = React.useCallback(
async (commentId: string) => {
const comment = comments.get(commentId);
if (comment?.isNew) {
await comment?.delete();
}
},
[comments]
);
const { setEditor } = useDocumentContext();
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
return (
@@ -95,7 +156,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
placeholder={t("Untitled")}
/>
{!shareId && (
<DocumentMetaWithViews
<DocumentMeta
isDraft={isDraft}
document={document}
to={
@@ -115,7 +176,19 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
scrollTo={decodeURIComponent(window.location.hash)}
readOnly={readOnly}
shareId={shareId}
extensions={fullPackage}
userId={user?.id}
onClickCommentMark={handleClickComment}
onCreateCommentMark={
team?.getPreference(TeamPreference.Commenting)
? handleDraftComment
: undefined
}
onDeleteCommentMark={
team?.getPreference(TeamPreference.Commenting)
? handleRemoveComment
: undefined
}
extensions={fullWithCommentsPackage}
bottomPadding={`calc(50vh - ${childRef.current?.offsetHeight || 0}px)`}
{...rest}
/>

View File

@@ -38,7 +38,6 @@ const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
right: 0;
margin: 24px;
${breakpoint("tablet")`

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import EditorContainer from "@shared/editor/components/Styles";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
import DocumentMeta from "~/components/DocumentMeta";
import { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex";
import { documentUrl } from "~/utils/routeHelpers";
@@ -20,18 +20,13 @@ type Props = Omit<EditorProps, "extensions"> & {
* Displays revision HTML pre-rendered on the server.
*/
function RevisionViewer(props: Props) {
const { document, isDraft, shareId, children, revision } = props;
const { document, shareId, children, revision } = props;
return (
<Flex auto column>
<h1 dir={revision.dir}>{revision.title}</h1>
{!shareId && (
<DocumentMetaWithViews
isDraft={isDraft}
document={document}
to={documentUrl(document)}
rtl={revision.rtl}
/>
<DocumentMeta document={document} to={documentUrl(document)} />
)}
<EditorContainer
dangerouslySetInnerHTML={{ __html: revision.html }}

View File

@@ -31,7 +31,9 @@ function SidebarLayout({ title, onClose, children }: Props) {
/>
</Tooltip>
</Header>
<Scrollable topShadow>{children}</Scrollable>
<Scrollable hiddenScrollbars topShadow>
{children}
</Scrollable>
</>
);
}

View File

@@ -5,6 +5,7 @@ import { WebsocketContext } from "~/components/WebsocketProvider";
type Props = {
documentId: string;
isEditing: boolean;
presence: boolean;
};
export default class SocketPresence extends React.Component<Props> {
@@ -12,14 +13,16 @@ export default class SocketPresence extends React.Component<Props> {
previousContext: typeof WebsocketContext;
editingInterval: ReturnType<typeof setInterval>;
editingInterval: ReturnType<typeof setInterval> | undefined;
componentDidMount() {
this.editingInterval = setInterval(() => {
if (this.props.isEditing) {
this.emitPresence();
}
}, USER_PRESENCE_INTERVAL);
this.editingInterval = this.props.presence
? setInterval(() => {
if (this.props.isEditing) {
this.emitPresence();
}
}, USER_PRESENCE_INTERVAL)
: undefined;
this.setupOnce();
}
@@ -39,7 +42,9 @@ export default class SocketPresence extends React.Component<Props> {
this.context.off("authenticated", this.emitJoin);
}
clearInterval(this.editingInterval);
if (this.editingInterval) {
clearInterval(this.editingInterval);
}
}
setupOnce = () => {