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

@@ -2,6 +2,7 @@ import { AnimatePresence } from "framer-motion";
import { observer, useLocalStore } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import DocumentContext from "~/components/DocumentContext";
import type { DocumentContextValue } from "~/components/DocumentContext";
@@ -16,14 +17,17 @@ import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import {
searchPath,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
matchDocumentInsights,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
const DocumentComments = React.lazy(
() => import("~/scenes/Document/components/Comments")
);
const DocumentHistory = React.lazy(
() => import("~/scenes/Document/components/History")
);
@@ -84,15 +88,21 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
const showInsights = !!matchPath(location.pathname, {
path: matchDocumentInsights,
});
const showComments =
!showInsights &&
!showHistory &&
!ui.commentsCollapsed &&
team?.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence key={ui.activeDocumentId}>
{(showHistory || showInsights) && (
<AnimatePresence>
{(showHistory || showInsights || showComments) && (
<Route path={`/doc/${slug}`}>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showInsights && <DocumentInsights />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
</Route>

View File

@@ -19,15 +19,16 @@ type Props = {
showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
className?: string;
style?: React.CSSProperties;
};
function Avatar(props: Props) {
const { icon, showBorder, model, ...rest } = props;
const { icon, showBorder, model, style, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
return (
<Relative>
<Relative style={style}>
{src && !error ? (
<CircleImg
onError={handleError}
@@ -53,6 +54,7 @@ Avatar.defaultProps = {
const Relative = styled.div`
position: relative;
user-select: none;
flex-shrink: 0;
`;

View File

@@ -0,0 +1,15 @@
import styled from "styled-components";
import Button, { Inner } from "./Button";
const ButtonSmall = styled(Button)`
font-size: 13px;
height: 26px;
${Inner} {
padding: 0 6px;
line-height: 26px;
min-height: 26px;
}
`;
export default ButtonSmall;

View File

@@ -0,0 +1,53 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Comment from "~/models/Comment";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = {
comment: Comment;
onSubmit?: () => void;
};
function CommentDeleteDialog({ comment, onSubmit }: Props) {
const { comments } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const hasChildComments = comments.inThread(comment.id).length > 1;
const handleSubmit = async () => {
try {
await comment.delete();
onSubmit?.();
} catch (err) {
showToast(err.message, { type: "error" });
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Text type="secondary">
{hasChildComments ? (
<Trans>
Are you sure you want to permanently delete this entire comment
thread?
</Trans>
) : (
<Trans>
Are you sure you want to permanently delete this comment?
</Trans>
)}
</Text>
</ConfirmationDialog>
);
}
export default observer(CommentDeleteDialog);

View File

@@ -39,8 +39,8 @@ const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
right: 32px;
margin: 24px;
transform: translateX(-32px);
${breakpoint("tablet")`
display: block;

View File

@@ -1,14 +1,15 @@
import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash";
import { deburr, difference, sortBy } from "lodash";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { useHistory } from "react-router-dom";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { Heading } from "@shared/editor/lib/getHeadings";
import { AttachmentPreset } from "@shared/types";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
@@ -20,11 +21,11 @@ import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import useFocusedComment from "~/hooks/useFocusedComment";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
@@ -51,11 +52,20 @@ export type Props = Optional<
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onHeadingsChange } = props;
const { documents, auth } = useStores();
const {
id,
shareId,
onChange,
onHeadingsChange,
onCreateCommentMark,
onDeleteCommentMark,
} = props;
const { auth, comments, documents } = useStores();
const focusedComment = useFocusedComment();
const { showToast } = useToasts();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const history = useHistory();
const localRef = React.useRef<SharedEditor>();
const preferences = auth.user?.preferences;
const previousHeadings = React.useRef<Heading[] | null>(null);
@@ -63,6 +73,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
activeLinkElement,
setActiveLink,
] = React.useState<HTMLAnchorElement | null>(null);
const previousCommentIds = React.useRef<string[]>();
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
setActiveLink(element);
@@ -125,7 +136,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[documents]
);
const onUploadFile = React.useCallback(
const handleUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
@@ -136,7 +147,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[id]
);
const onClickLink = React.useCallback(
const handleClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
// on page hash
if (isHash(href)) {
@@ -175,7 +186,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
window.open(href, "_blank");
}
},
[shareId]
[history, shareId]
);
const focusAtEnd = React.useCallback(() => {
@@ -223,7 +234,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
);
insertFiles(view, event, pos, files, {
uploadFile: onUploadFile,
uploadFile: handleUploadFile,
onFileUploadStart: props.onFileUploadStart,
onFileUploadStop: props.onFileUploadStop,
onShowToast: showToast,
@@ -236,7 +247,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
props.onFileUploadStart,
props.onFileUploadStop,
dictionary,
onUploadFile,
handleUploadFile,
showToast,
]
);
@@ -265,21 +276,54 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
}
}, [localRef, onHeadingsChange]);
const updateComments = React.useCallback(() => {
if (onCreateCommentMark && onDeleteCommentMark) {
const commentMarks = localRef.current?.getComments();
const commentIds = comments.orderedData.map((c) => c.id);
const commentMarkIds = commentMarks?.map((c) => c.id);
const newCommentIds = difference(
commentMarkIds,
previousCommentIds.current ?? [],
commentIds
);
newCommentIds.forEach((commentId) => {
const mark = commentMarks?.find((c) => c.id === commentId);
if (mark) {
onCreateCommentMark(mark.id, mark.userId);
}
});
const removedCommentIds = difference(
previousCommentIds.current ?? [],
commentMarkIds ?? []
);
removedCommentIds.forEach((commentId) => {
onDeleteCommentMark(commentId);
});
previousCommentIds.current = commentMarkIds;
}
}, [onCreateCommentMark, onDeleteCommentMark, comments.orderedData]);
const handleChange = React.useCallback(
(event) => {
onChange?.(event);
updateHeadings();
updateComments();
},
[onChange, updateHeadings]
[onChange, updateComments, updateHeadings]
);
const handleRefChanged = React.useCallback(
(node: SharedEditor | null) => {
if (node) {
updateHeadings();
updateComments();
}
},
[updateHeadings]
[updateComments, updateHeadings]
);
return (
@@ -287,18 +331,19 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
<>
<LazyLoadedEditor
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={onUploadFile}
uploadFile={handleUploadFile}
onShowToast={showToast}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onHoverLink={handleLinkActive}
onClickLink={onClickLink}
onClickLink={handleClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
focusedCommentId={focusedComment?.id}
/>
{props.bottomPadding && !props.readOnly && (
<ClickablePadding

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
import DocumentMeta from "~/components/DocumentMeta";
import Editor from "~/components/Editor";
import useStores from "~/hooks/useStores";
@@ -30,10 +30,7 @@ function HoverPreviewDocument({ url, children }: Props) {
{children(
<Content to={document.url}>
<Heading>{document.titleWithDefault}</Heading>
<DocumentMetaWithViews
isDraft={document.isDraft}
document={document}
/>
<DocumentMeta document={document} />
<React.Suspense fallback={<div />}>
<Editor

View File

@@ -121,6 +121,8 @@ export type Props = React.InputHTMLAttributes<
margin?: string | number;
error?: string;
icon?: React.ReactNode;
/* Callback is triggered with the CMD+Enter keyboard combo */
onRequestSubmit?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
};
@@ -147,6 +149,20 @@ function Input(
}
};
const handleKeyDown = (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
if (ev.key === "Enter" && ev.metaKey) {
if (this.props.onRequestSubmit) {
this.props.onRequestSubmit(ev);
}
}
if (this.props.onKeyDown) {
this.props.onKeyDown(ev);
}
};
const {
type = "text",
icon,
@@ -180,6 +196,7 @@ function Input(
ref={ref as React.RefObject<HTMLTextAreaElement>}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
{...rest}
/>
@@ -188,6 +205,7 @@ function Input(
ref={ref as React.RefObject<HTMLInputElement>}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
type={type}
{...rest}

View File

@@ -0,0 +1,42 @@
import { m, TargetAndTransition } from "framer-motion";
import * as React from "react";
import useComponentSize from "~/hooks/useComponentSize";
type Props = {
/** The children to render */
children: React.ReactNode;
/** Whether to hide overflow. */
hideOverflow?: boolean;
/** A way to calculate height */
componentSizeCalculation?: "clientRectHeight" | "scrollHeight";
/** Optional animation config. */
config?: TargetAndTransition;
/** Optional styles. */
style?: React.CSSProperties;
};
/**
* Automatically animates the height of a container based on it's contents.
*/
export function ResizingHeightContainer(props: Props) {
const { hideOverflow, children, config, style } = props;
const ref = React.useRef<HTMLDivElement>(null);
const { height } = useComponentSize(ref);
return (
<m.div
animate={{
...config,
height: Math.round(height),
}}
style={{
...style,
overflow: hideOverflow ? "hidden" : "inherit",
position: "relative",
}}
>
<div ref={ref}>{children}</div>
</m.div>
);
}

View File

@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import useWindowSize from "~/hooks/useWindowSize";
import { hideScrollbars } from "~/styles";
type Props = React.HTMLAttributes<HTMLDivElement> & {
shadow?: boolean;
@@ -94,16 +95,7 @@ const Wrapper = styled.div<{
}};
transition: box-shadow 100ms ease-in-out;
${(props) =>
props.$hiddenScrollbars &&
`
-ms-overflow-style: none;
overflow: -moz-scrollbars-none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`}
${(props) => props.$hiddenScrollbars && hideScrollbars()}
`;
export default observer(React.forwardRef(Scrollable));

View File

@@ -35,7 +35,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const { user } = auth;
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const maxWidth = theme.sidebarMaxWidth;

View File

@@ -3,6 +3,8 @@ import styled from "styled-components";
type Props = {
type?: "secondary" | "tertiary" | "danger";
size?: "large" | "small" | "xsmall";
dir?: "ltr" | "rtl" | "auto";
selectable?: boolean;
weight?: "bold" | "normal";
};
@@ -12,6 +14,7 @@ type Props = {
*/
const Text = styled.p<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "left")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
@@ -35,7 +38,7 @@ const Text = styled.p<Props>`
? "normal"
: "inherit"};
white-space: normal;
user-select: none;
user-select: ${(props) => (props.selectable ? "text" : "none")};
`;
export default Text;

41
app/components/Typing.tsx Normal file
View File

@@ -0,0 +1,41 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
/** The size to render the indicator, defaults to 24px */
size?: number;
};
/**
* A component to show an animated typing indicator.
*/
export default function Typing({ size = 24 }: Props) {
return (
<Wrapper height={size} width={size}>
<Circle cx={size / 4} cy={size / 2} r="2" />
<Circle cx={size / 2} cy={size / 2} r="2" />
<Circle cx={size / 1.33333} cy={size / 2} r="2" />
</Wrapper>
);
}
const Wrapper = styled.svg`
fill: ${(props) => props.theme.textTertiary};
@keyframes blink {
50% {
fill: transparent;
}
}
`;
const Circle = styled.circle`
animation: 1s blink infinite;
&:nth-child(2) {
animation-delay: 250ms;
}
&:nth-child(3) {
animation-delay: 500ms;
}
`;

View File

@@ -6,6 +6,7 @@ import * as React from "react";
import { io, Socket } from "socket.io-client";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
@@ -84,6 +85,7 @@ class WebsocketProvider extends React.Component<Props> {
memberships,
policies,
presence,
comments,
views,
subscriptions,
fileOperations,
@@ -261,6 +263,20 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
comments.add(event);
});
this.socket.on("comments.update", (event: PartialWithId<Comment>) => {
comments.add(event);
});
this.socket.on("comments.delete", (event: WebsocketEntityDeletedEvent) => {
comments.inThread(event.modelId).forEach((comment) => {
comments.remove(comment.id);
});
});
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
groups.add(event);
});
@@ -323,6 +339,13 @@ class WebsocketProvider extends React.Component<Props> {
stars.remove(event.modelId);
});
this.socket.on(
"user.typing",
(event: { userId: string; documentId: string; commentId: string }) => {
comments.setTyping(event);
}
);
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on(

View File

@@ -21,6 +21,17 @@ function ToolbarMenu(props: Props) {
const { items } = props;
const { state } = view;
const handleClick = (item: MenuItem) => () => {
if (!item.name) {
return;
}
const attrs =
typeof item.attrs === "function" ? item.attrs(state) : item.attrs;
commands[item.name](attrs);
};
return (
<FlexibleWrapper>
{items.map((item, index) => {
@@ -34,10 +45,7 @@ function ToolbarMenu(props: Props) {
return (
<Tooltip tooltip={item.tooltip} key={index}>
<ToolbarButton
onClick={() => item.name && commands[item.name](item.attrs)}
active={isActive}
>
<ToolbarButton onClick={handleClick(item)} active={isActive}>
{React.cloneElement(item.icon, { color: "currentColor" })}
</ToolbarButton>
</Tooltip>

View File

@@ -1,5 +1,6 @@
/* global File Promise */
import { PluginSimple } from "markdown-it";
import { transparentize } from "polished";
import { baseKeymap } from "prosemirror-commands";
import { dropCursor } from "prosemirror-dropcursor";
import { gapCursor } from "prosemirror-gapcursor";
@@ -15,13 +16,11 @@ import {
import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
import { Decoration, EditorView } from "prosemirror-view";
import * as React from "react";
import { DefaultTheme, ThemeProps } from "styled-components";
import EditorContainer from "@shared/editor/components/Styles";
import styled, { css, DefaultTheme, ThemeProps } from "styled-components";
import Styles from "@shared/editor/components/Styles";
import { EmbedDescriptor } from "@shared/editor/embeds";
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import getHeadings from "@shared/editor/lib/getHeadings";
import getTasks from "@shared/editor/lib/getTasks";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import textBetween from "@shared/editor/lib/textBetween";
import Mark from "@shared/editor/marks/Mark";
@@ -30,6 +29,7 @@ import ReactNode from "@shared/editor/nodes/ReactNode";
import fullExtensionsPackage from "@shared/editor/packages/full";
import { EventType } from "@shared/editor/types";
import { UserPreferences } from "@shared/types";
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
import EventEmitter from "@shared/utils/events";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
@@ -48,16 +48,20 @@ export { default as Extension } from "@shared/editor/lib/Extension";
export type Props = {
/** An optional identifier for the editor context. It is used to persist local settings */
id?: string;
/** The current userId, if any */
userId?: string;
/** The editor content, should only be changed if you wish to reset the content */
value?: string;
/** The initial editor content */
defaultValue: string;
/** The initial editor content as a markdown string or JSON object */
defaultValue: string | object;
/** Placeholder displayed when the editor is empty */
placeholder: string;
/** Extensions to load into the editor */
extensions?: (typeof Node | typeof Mark | typeof Extension | Extension)[];
/** If the editor should be focused on mount */
autoFocus?: boolean;
/** The focused comment, if any */
focusedCommentId?: string;
/** If the editor should not allow editing */
readOnly?: boolean;
/** If the editor should still allow editing checkboxes when it is readOnly */
@@ -85,7 +89,13 @@ export type Props = {
/** Callback when user uses cancel key combo */
onCancel?: () => void;
/** Callback when user changes editor content */
onChange?: (value: () => string | undefined) => void;
onChange?: (value: () => any) => void;
/** Callback when a comment mark is clicked */
onClickCommentMark?: (commentId: string) => void;
/** Callback when a comment mark is created */
onCreateCommentMark?: (commentId: string, userId: string) => void;
/** Callback when a comment mark is removed */
onDeleteCommentMark?: (commentId: string) => void;
/** Callback when a file upload begins */
onFileUploadStart?: () => void;
/** Callback when a file upload ends */
@@ -394,7 +404,7 @@ export class Editor extends React.PureComponent<
});
}
private createState(value?: string) {
private createState(value?: string | object) {
const doc = this.createDocument(value || this.props.defaultValue);
return EditorState.create({
@@ -415,8 +425,13 @@ export class Editor extends React.PureComponent<
});
}
private createDocument(content: string) {
return this.parser.parse(content);
private createDocument(content: string | object) {
// Looks like Markdown
if (typeof content === "string") {
return this.parser.parse(content);
}
return ProsemirrorNode.fromJSON(this.schema, content);
}
private createView() {
@@ -475,10 +490,6 @@ export class Editor extends React.PureComponent<
return view;
}
private dispatchThemeChanged = (event: CustomEvent) => {
this.view.dispatch(this.view.state.tr.setMeta("theme", event.detail));
};
public scrollToAnchor(hash: string) {
if (!hash) {
return;
@@ -497,6 +508,18 @@ export class Editor extends React.PureComponent<
}
}
public value = (asString = true, trim?: boolean) => {
if (asString) {
const content = this.serializer.serialize(this.view.state.doc);
return trim ? content.trim() : content;
}
return (trim
? ProsemirrorHelper.trim(this.view.state.doc)
: this.view.state.doc
).toJSON();
};
private calculateDir = () => {
if (!this.element.current) {
return;
@@ -511,8 +534,106 @@ export class Editor extends React.PureComponent<
}
};
public value = (): string => {
return this.serializer.serialize(this.view.state.doc);
/**
* Focus the editor at the start of the content.
*/
public focusAtStart = () => {
const selection = Selection.atStart(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
this.view.dispatch(transaction);
this.view.focus();
};
/**
* Focus the editor at the end of the content.
*/
public focusAtEnd = () => {
const selection = Selection.atEnd(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
this.view.dispatch(transaction);
this.view.focus();
};
/**
* Returns true if the trimmed content of the editor is an empty string.
*
* @returns True if the editor is empty
*/
public isEmpty = () => {
return ProsemirrorHelper.isEmpty(this.view.state.doc);
};
/**
* Return the headings in the current editor.
*
* @returns A list of headings in the document
*/
public getHeadings = () => {
return ProsemirrorHelper.getHeadings(this.view.state.doc);
};
/**
* Return the tasks/checkmarks in the current editor.
*
* @returns A list of tasks in the document
*/
public getTasks = () => {
return ProsemirrorHelper.getTasks(this.view.state.doc);
};
/**
* Return the comments in the current editor.
*
* @returns A list of comments in the document
*/
public getComments = () => {
return ProsemirrorHelper.getComments(this.view.state.doc);
};
/**
* Remove a specific comment mark from the document.
*
* @param commentId The id of the comment to remove
*/
public removeComment = (commentId: string) => {
const { state, dispatch } = this.view;
let found = false;
state.doc.descendants((node, pos) => {
if (!node.isInline || found) {
return;
}
const mark = node.marks.find(
(mark) =>
mark.type === state.schema.marks.comment &&
mark.attrs.id === commentId
);
if (mark) {
dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark));
found = true;
}
});
};
/**
* Return the plain text content of the current editor.
*
* @returns A string of text
*/
public getPlainText = () => {
const { doc } = this.view.state;
const textSerializers = Object.fromEntries(
Object.entries(this.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
return textBetween(doc, 0, doc.content.size, textSerializers);
};
private dispatchThemeChanged = (event: CustomEvent) => {
this.view.dispatch(this.view.state.tr.setMeta("theme", event.detail));
};
private handleChange = () => {
@@ -520,8 +641,8 @@ export class Editor extends React.PureComponent<
return;
}
this.props.onChange(() => {
return this.view ? this.value() : undefined;
this.props.onChange((asString = true, trim = false) => {
return this.view ? this.value(asString, trim) : undefined;
});
};
@@ -583,60 +704,6 @@ export class Editor extends React.PureComponent<
this.setState({ blockMenuOpen: false });
};
/**
* Focus the editor at the start of the content.
*/
public focusAtStart = () => {
const selection = Selection.atStart(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
this.view.dispatch(transaction);
this.view.focus();
};
/**
* Focus the editor at the end of the content.
*/
public focusAtEnd = () => {
const selection = Selection.atEnd(this.view.state.doc);
const transaction = this.view.state.tr.setSelection(selection);
this.view.dispatch(transaction);
this.view.focus();
};
/**
* Return the headings in the current editor.
*
* @returns A list of headings in the document
*/
public getHeadings = () => {
return getHeadings(this.view.state.doc);
};
/**
* Return the tasks/checkmarks in the current editor.
*
* @returns A list of tasks in the document
*/
public getTasks = () => {
return getTasks(this.view.state.doc);
};
/**
* Return the plain text content of the current editor.
*
* @returns A string of text
*/
public getPlainText = () => {
const { doc } = this.view.state;
const textSerializers = Object.fromEntries(
Object.entries(this.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
return textBetween(doc, 0, doc.content.size, textSerializers);
};
public render() {
const {
dir,
@@ -658,7 +725,6 @@ export class Editor extends React.PureComponent<
className={className}
align="flex-start"
justify="center"
dir={dir}
column
>
<EditorContainer
@@ -667,6 +733,7 @@ export class Editor extends React.PureComponent<
grow={grow}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
focusedCommentId={this.props.focusedCommentId}
ref={this.element}
/>
{!readOnly && this.view && (
@@ -724,6 +791,16 @@ export class Editor extends React.PureComponent<
}
}
const EditorContainer = styled(Styles)<{ focusedCommentId?: string }>`
${(props) =>
props.focusedCommentId &&
css`
#comment-${props.focusedCommentId} {
background: ${transparentize(0.5, props.theme.brand.marine)};
}
`}
`;
const LazyLoadedEditor = React.forwardRef<Editor, Props>(
(props: Props, ref) => {
return (

View File

@@ -11,6 +11,7 @@ import {
TodoListIcon,
InputIcon,
HighlightIcon,
CommentIcon,
ItalicIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
@@ -146,5 +147,12 @@ export default function formattingMenuItems(
attrs: { href: "" },
visible: !isCode,
},
{
name: "comment",
tooltip: dictionary.comment,
icon: <CommentIcon />,
active: isMarkActive(schema.marks.comment),
visible: !isCode,
},
];
}

View File

@@ -19,6 +19,7 @@ export default function useDictionary() {
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
comment: t("Comment"),
copy: t("Copy"),
createLink: t("Create link"),
createLinkError: t("Sorry, an error occurred creating the link"),

View File

@@ -7,6 +7,10 @@ type Options = {
/**
* Measures the width of an emoji character
*
* @param emoji The emoji to measure
* @param options Options to pass to the measurement element
* @returns The width of the emoji in pixels
*/
export default function useEmojiWidth(
emoji: string | undefined,

View File

@@ -0,0 +1,11 @@
import { useLocation } from "react-router-dom";
import useQuery from "~/hooks/useQuery";
import useStores from "./useStores";
export default function useFocusedComment() {
const { comments } = useStores();
const location = useLocation<{ commentId?: string }>();
const query = useQuery();
const focusedCommentId = location.state?.commentId || query.get("commentId");
return focusedCommentId ? comments.get(focusedCommentId) : undefined;
}

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import useEventListener from "./useEventListener";
/**
* Hook to detect clicks outside of a specified element.
*
* @param ref The React ref to the element.
* @param callback The handler to call when a click outside the element is detected.
*/
export default function useOnClickOutside(
ref: React.RefObject<HTMLElement>,
callback?: (event: MouseEvent | TouchEvent) => void
) {
const listener = React.useCallback(
(event: MouseEvent | TouchEvent) => {
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
callback?.(event);
},
[ref, callback]
);
useEventListener("mousedown", listener);
useEventListener("touchstart", listener);
}

View File

@@ -18,7 +18,7 @@ type Options = {
* @param options Options for the hook
* @returns Tuple of the current value and a function to update it
*/
export default function usePersistedState<T extends Primitive>(
export default function usePersistedState<T extends Primitive | object>(
key: string,
defaultValue: T,
options?: Options

82
app/menus/CommentMenu.tsx Normal file
View File

@@ -0,0 +1,82 @@
import copy from "copy-to-clipboard";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Separator from "~/components/ContextMenu/Separator";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { commentPath, urlify } from "~/utils/routeHelpers";
type Props = {
/** The comment to associate with the menu */
comment: Comment;
/** CSS class name */
className?: string;
/** Callback when the "Edit" is selected in the menu */
onEdit: () => void;
/** Callback when the comment has been deleted */
onDelete: () => void;
};
function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
const menu = useMenuState({
modal: true,
});
const { showToast } = useToasts();
const { documents, dialogs } = useStores();
const { t } = useTranslation();
const can = usePolicy(comment.id);
const document = documents.get(comment.documentId);
const handleDelete = React.useCallback(() => {
dialogs.openModal({
title: t("Delete comment"),
isCentered: true,
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
});
}, [dialogs, comment, onDelete, t]);
const handleCopyLink = React.useCallback(() => {
if (document) {
copy(urlify(commentPath(document, comment)));
showToast(t("Link copied"));
}
}, [t, document, comment, showToast]);
return (
<>
<OverflowMenuButton
aria-label={t("Show menu")}
className={className}
{...menu}
/>
<ContextMenu {...menu} aria-label={t("Comment options")}>
{can.update && (
<MenuItem {...menu} onClick={onEdit}>
{t("Edit")}
</MenuItem>
)}
<MenuItem {...menu} onClick={handleCopyLink}>
{t("Copy link")}
</MenuItem>
{can.delete && (
<>
<Separator />
<MenuItem {...menu} onClick={handleDelete} dangerous>
{t("Delete")}
</MenuItem>
</>
)}
</ContextMenu>
</>
);
}
export default observer(CommentMenu);

View File

@@ -1,5 +1,5 @@
import { pick } from "lodash";
import { set, computed, observable } from "mobx";
import { set, observable } from "mobx";
import { getFieldsForModel } from "./decorators/Field";
export default abstract class BaseModel {
@@ -9,6 +9,9 @@ export default abstract class BaseModel {
@observable
isSaving: boolean;
@observable
isNew: boolean;
createdAt: string;
updatedAt: string;
@@ -17,6 +20,7 @@ export default abstract class BaseModel {
constructor(fields: Record<string, any>, store: any) {
this.updateFromJson(fields);
this.isNew = !this.id;
this.store = store;
}
@@ -32,10 +36,19 @@ export default abstract class BaseModel {
params = this.toAPI();
}
const model = await this.store.save({ ...params, id: this.id }, options);
const model = await this.store.save(
{
...params,
id: this.id,
},
{
...options,
isNew: this.isNew,
}
);
// if saving is successful set the new values on the model itself
set(this, { ...params, ...model });
set(this, { ...params, ...model, isNew: false });
this.persistedAttributes = this.toAPI();
@@ -46,7 +59,8 @@ export default abstract class BaseModel {
};
updateFromJson = (data: any) => {
set(this, data);
//const isNew = !data.id && !this.id && this.isNew;
set(this, { ...data, isNew: false });
this.persistedAttributes = this.toAPI();
};
@@ -94,7 +108,9 @@ export default abstract class BaseModel {
if (
// eslint-disable-next-line no-prototype-builtins
this.hasOwnProperty(property) &&
!["persistedAttributes", "store", "isSaving"].includes(property)
!["persistedAttributes", "store", "isSaving", "isNew"].includes(
property
)
) {
output[property] = this[property];
}
@@ -121,15 +137,5 @@ export default abstract class BaseModel {
);
}
/**
* Returns a boolean indicating whether the model has been persisted to db
*
* @returns boolean true if the model has never been persisted
*/
@computed
get isNew(): boolean {
return !this.id;
}
protected persistedAttributes: Partial<BaseModel> = {};
}

66
app/models/Comment.ts Normal file
View File

@@ -0,0 +1,66 @@
import { subSeconds } from "date-fns";
import { computed, observable } from "mobx";
import { now } from "mobx-utils";
import User from "~/models/User";
import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
class Comment extends BaseModel {
/**
* Map to keep track of which users are currently typing a reply in this
* comments thread.
*/
@observable
typingUsers: Map<string, Date> = new Map();
@Field
@observable
id: string;
/**
* The Prosemirror data representing the comment content
*/
@Field
@observable
data: Record<string, any>;
/**
* If this comment is a reply then the parent comment will be set, otherwise
* it is a top thread.
*/
@Field
@observable
parentCommentId: string;
/**
* The document to which this comment belongs.
*/
@Field
@observable
documentId: string;
createdAt: string;
createdBy: User;
createdById: string;
resolvedAt: string;
resolvedBy: User;
updatedAt: string;
/**
* An array of users that are currently typing a reply in this comments thread.
*/
@computed
get currentlyTypingUsers(): User[] {
return Array.from(this.typingUsers.entries())
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
.map(([userId]) => this.store.rootStore.users.get(userId))
.filter(Boolean);
}
}
export default Comment;

View File

@@ -29,6 +29,10 @@ class Team extends BaseModel {
@observable
collaborativeEditing: boolean;
@Field
@observable
commenting: boolean;
@Field
@observable
documentEmbeds: boolean;

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

@@ -1,34 +1,40 @@
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";
import Fade from "./Fade";
type Props = {
/* The document to display meta data for */
document: Document;
isDraft: boolean;
to?: LocationDescriptor;
rtl?: boolean;
};
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const { views } = useStores();
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 insightsUrl = documentInsightsUrl(document);
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 ? (
@@ -46,15 +52,32 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
</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 {
@@ -70,4 +93,4 @@ const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
}
`;
export default observer(DocumentMetaWithViews);
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 = () => {

View File

@@ -60,7 +60,11 @@ export default function DocumentScene(props: Props) {
// no longer be required
if (isActive && !team.collaborativeEditing) {
return (
<SocketPresence documentId={document.id} isEditing={isEditing}>
<SocketPresence
documentId={document.id}
isEditing={isEditing}
presence={!team.collaborativeEditing}
>
<Document document={document} {...rest} />
</SocketPresence>
);

View File

@@ -57,6 +57,25 @@ function Features() {
/>
</SettingRow>
)}
{/* <SettingRow
name={TeamPreference.Commenting}
label={
<Flex align="center">
{t("Commenting")} <Badge>Beta</Badge>
</Flex>
}
description={t(
"When enabled team members can add comments to documents."
)}
>
<Switch
id={TeamPreference.Commenting}
name={TeamPreference.Commenting}
checked={team.getPreference(TeamPreference.Commenting, false)}
disabled={!team.collaborativeEditing}
onChange={handlePreferenceChange}
/>
</SettingRow> */}
{team.avatarUrl && (
<SettingRow
name={TeamPreference.PublicBranding}

View File

@@ -103,12 +103,13 @@ export default abstract class BaseStore<T extends BaseModel> {
save(
params: Partial<T>,
options?: Record<string, string | boolean | number | undefined>
options: Record<string, string | boolean | number | undefined> = {}
): Promise<T> {
if (params.id) {
return this.update(params, options);
const { isNew, ...rest } = options;
if (isNew || !params.id) {
return this.create(params, rest);
}
return this.create(params, options);
return this.update(params, rest);
}
get(id: string): T | undefined {
@@ -171,6 +172,10 @@ export default abstract class BaseStore<T extends BaseModel> {
throw new Error(`Cannot delete ${this.modelName}`);
}
if (item.isNew) {
return this.remove(item.id);
}
this.isSaving = true;
try {

101
app/stores/CommentsStore.ts Normal file
View File

@@ -0,0 +1,101 @@
import invariant from "invariant";
import { filter, orderBy } from "lodash";
import { action, runInAction, computed } from "mobx";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
import BaseStore from "./BaseStore";
import RootStore from "./RootStore";
export default class CommentsStore extends BaseStore<Comment> {
apiEndpoint = "comments";
constructor(rootStore: RootStore) {
super(rootStore, Comment);
}
/**
* Returns a list of comments in a document that are not replies to other
* comments.
*
* @param documentId ID of the document to get comments for
* @returns Array of comments
*/
threadsInDocument(documentId: string): Comment[] {
return this.inDocument(documentId).filter(
(comment) => !comment.parentCommentId
);
}
/**
* Returns a list of comments that are replies to the given comment.
*
* @param commentId ID of the comment to get replies for
* @returns Array of comments
*/
inThread(threadId: string): Comment[] {
return filter(
this.orderedData,
(comment) =>
comment.parentCommentId === threadId ||
(comment.id === threadId && !comment.isNew)
);
}
/**
* Returns a list of comments in a document.
*
* @param documentId ID of the document to get comments for
* @returns Array of comments
*/
inDocument(documentId: string): Comment[] {
return filter(
this.orderedData,
(comment) => comment.documentId === documentId
);
}
@action
setTyping({
commentId,
userId,
}: {
commentId: string;
userId: string;
}): void {
const comment = this.get(commentId);
if (comment) {
comment.typingUsers.set(userId, new Date());
}
}
@action
fetchDocumentComments = async (
documentId: string,
options?: PaginationParams | undefined
): Promise<Document[]> => {
this.isFetching = true;
try {
const res = await client.post(`/comments.list`, {
documentId,
...options,
});
invariant(res && res.data, "Comment list not available");
runInAction("CommentsStore#fetchDocumentComments", () => {
res.data.forEach(this.add);
this.addPolicies(res.policies);
});
return res.data;
} finally {
this.isFetching = false;
}
};
@computed
get orderedData(): Comment[] {
return orderBy(Array.from(this.data.values()), "createdAt", "asc");
}
}

View File

@@ -3,6 +3,7 @@ import AuthStore from "./AuthStore";
import AuthenticationProvidersStore from "./AuthenticationProvidersStore";
import CollectionGroupMembershipsStore from "./CollectionGroupMembershipsStore";
import CollectionsStore from "./CollectionsStore";
import CommentsStore from "./CommentsStore";
import DialogsStore from "./DialogsStore";
import DocumentPresenceStore from "./DocumentPresenceStore";
import DocumentsStore from "./DocumentsStore";
@@ -32,6 +33,7 @@ export default class RootStore {
authenticationProviders: AuthenticationProvidersStore;
collections: CollectionsStore;
collectionGroupMemberships: CollectionGroupMembershipsStore;
comments: CommentsStore;
dialogs: DialogsStore;
documents: DocumentsStore;
events: EventsStore;
@@ -63,6 +65,7 @@ export default class RootStore {
this.auth = new AuthStore(this);
this.collections = new CollectionsStore(this);
this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this);
this.comments = new CommentsStore(this);
this.dialogs = new DialogsStore();
this.documents = new DocumentsStore(this);
this.events = new EventsStore(this);
@@ -92,6 +95,7 @@ export default class RootStore {
// this.auth omitted for reasons...
this.collections.clear();
this.collectionGroupMemberships.clear();
this.comments.clear();
this.documents.clear();
this.events.clear();
this.groups.clear();

View File

@@ -57,9 +57,15 @@ class UiStore {
@observable
sidebarWidth: number;
@observable
sidebarRightWidth: number;
@observable
sidebarCollapsed = false;
@observable
commentsCollapsed = false;
@observable
sidebarIsResizing = false;
@@ -91,6 +97,8 @@ class UiStore {
this.languagePromptDismissed = data.languagePromptDismissed;
this.sidebarCollapsed = !!data.sidebarCollapsed;
this.sidebarWidth = data.sidebarWidth || defaultTheme.sidebarWidth;
this.sidebarRightWidth =
data.sidebarRightWidth || defaultTheme.sidebarWidth;
this.tocVisible = !!data.tocVisible;
this.theme = data.theme || Theme.System;
@@ -153,8 +161,8 @@ class UiStore {
};
@action
setSidebarWidth = (sidebarWidth: number): void => {
this.sidebarWidth = sidebarWidth;
setSidebarWidth = (width: number): void => {
this.sidebarWidth = width;
};
@action
@@ -168,6 +176,21 @@ class UiStore {
this.sidebarCollapsed = false;
};
@action
collapseComments = () => {
this.commentsCollapsed = true;
};
@action
expandComments = () => {
this.commentsCollapsed = false;
};
@action
toggleComments = () => {
this.commentsCollapsed = !this.commentsCollapsed;
};
@action
toggleCollapsedSidebar = () => {
sidebarHidden = false;
@@ -239,6 +262,7 @@ class UiStore {
tocVisible: this.tocVisible,
sidebarCollapsed: this.sidebarCollapsed,
sidebarWidth: this.sidebarWidth,
sidebarRightWidth: this.sidebarRightWidth,
languagePromptDismissed: this.languagePromptDismissed,
theme: this.theme,
};

View File

@@ -38,3 +38,17 @@ export const fadeOnDesktopBackgrounded = () => {
body.backgrounded & { opacity: 0.75; }
`;
};
/**
* Mixin to hide scrollbars.
*
* @returns string of CSS
*/
export const hideScrollbars = () => `
-ms-overflow-style: none;
overflow: -moz-scrollbars-none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;

View File

@@ -136,6 +136,8 @@ declare module "styled-components" {
textDiffDeleted: string;
textDiffDeletedBackground: string;
placeholder: string;
commentBackground: string;
commentActiveBackground: string;
sidebarBackground: string;
sidebarActiveBackground: string;
sidebarControlHoverBackground: string;

View File

@@ -1,6 +1,8 @@
import queryString from "query-string";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import env from "~/env";
export function homePath(): string {
return "/home";
@@ -42,6 +44,10 @@ export function groupSettingsPath(): string {
return "/settings/groups";
}
export function commentPath(document: Document, comment: Comment): string {
return `${documentUrl(document)}?commentId=${comment.id}`;
}
export function collectionUrl(url: string, section?: string): string {
if (section) {
return `${url}/${section}`;
@@ -131,6 +137,10 @@ export function notFoundUrl(): string {
return "/404";
}
export function urlify(path: string): string {
return `${env.URL}${path}`;
}
export const matchDocumentSlug =
":documentSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";

View File

@@ -153,6 +153,11 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "collections.remove_group":
await this.handleCollectionGroupEvent(subscription, event);
return;
case "comments.create":
case "comments.update":
case "comments.delete":
// TODO
return;
case "groups.create":
case "groups.update":
case "groups.delete":

View File

@@ -0,0 +1,31 @@
import { Event } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import { setupTestDatabase } from "@server/test/support";
import commentCreator from "./commentCreator";
setupTestDatabase();
describe("commentCreator", () => {
const ip = "127.0.0.1";
it("should create comment", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await commentCreator({
documentId: document.id,
data: { text: "test" },
user,
ip,
});
const event = await Event.findOne();
expect(comment.documentId).toEqual(document.id);
expect(comment.createdById).toEqual(user.id);
expect(event!.name).toEqual("comments.create");
expect(event!.modelId).toEqual(comment.id);
});
});

View File

@@ -0,0 +1,62 @@
import { Transaction } from "sequelize";
import { Comment, User, Event } from "@server/models";
type Props = {
id?: string;
/** The user creating the comment */
user: User;
/** The comment as data in Prosemirror schema format */
data: Record<string, any>;
/** The document to comment within */
documentId: string;
/** The parent comment we're replying to, if any */
parentCommentId?: string;
/** The IP address of the user creating the comment */
ip: string;
transaction?: Transaction;
};
/**
* This command creates a comment inside a document.
*
* @param Props The properties of the comment to create
* @returns Comment The comment that was created
*/
export default async function commentCreator({
id,
user,
data,
documentId,
parentCommentId,
ip,
transaction,
}: Props): Promise<Comment> {
// TODO: Parse data to validate
const comment = await Comment.create(
{
id,
createdById: user.id,
documentId,
parentCommentId,
data,
},
{ transaction }
);
comment.createdBy = user;
await Event.create(
{
name: "comments.create",
modelId: comment.id,
teamId: user.teamId,
actorId: user.id,
documentId,
ip,
},
{ transaction }
);
return comment;
}

View File

@@ -0,0 +1,38 @@
import { Comment, Event } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import { setupTestDatabase } from "@server/test/support";
import commentDestroyer from "./commentDestroyer";
setupTestDatabase();
describe("commentDestroyer", () => {
const ip = "127.0.0.1";
it("should destroy existing comment", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await Comment.create({
teamId: document.teamId,
documentId: document.id,
data: { text: "test" },
createdById: user.id,
});
await commentDestroyer({
comment,
user,
ip,
});
const count = await Comment.count();
expect(count).toEqual(0);
const event = await Event.findOne();
expect(event!.name).toEqual("comments.delete");
expect(event!.modelId).toEqual(comment.id);
});
});

View File

@@ -0,0 +1,50 @@
import { Transaction } from "sequelize";
import { Event, Comment, User } from "@server/models";
type Props = {
/** The user destroying the comment */
user: User;
/** The comment to destroy */
comment: Comment;
/** The IP address of the user */
ip: string;
transaction?: Transaction;
};
/**
* This command destroys a document comment. This just removes the comment itself and
* does not touch the document
*
* @param Props The properties of the comment to destroy
* @returns void
*/
export default async function commentDestroyer({
user,
comment,
ip,
transaction,
}: Props): Promise<Comment> {
await comment.destroy({ transaction });
// Also destroy any child comments
const childComments = await Comment.findAll({
where: { parentCommentId: comment.id },
transaction,
});
await Promise.all(
childComments.map((childComment) => childComment.destroy({ transaction }))
);
await Event.create(
{
name: "comments.delete",
modelId: comment.id,
teamId: user.teamId,
actorId: user.id,
documentId: comment.documentId,
ip,
},
{ transaction }
);
return comment;
}

View File

@@ -0,0 +1,54 @@
import { Transaction } from "sequelize";
import { Event, Comment, User } from "@server/models";
type Props = {
/** The user updating the comment */
user: User;
/** The user resolving the comment */
resolvedBy?: User;
/** The existing comment */
comment: Comment;
/** The index to comment the document at */
data: Record<string, any>;
/** The IP address of the user creating the comment */
ip: string;
transaction: Transaction;
};
/**
* This command updates a comment.
*
* @param Props The properties of the comment to update
* @returns Comment The updated comment
*/
export default async function commentUpdater({
user,
comment,
data,
resolvedBy,
ip,
transaction,
}: Props): Promise<Comment> {
if (resolvedBy !== undefined) {
comment.resolvedBy = resolvedBy;
}
if (data !== undefined) {
comment.data = data;
}
await comment.save({ transaction });
await Event.create(
{
name: "comments.update",
modelId: comment.id,
teamId: user.teamId,
actorId: user.id,
documentId: comment.documentId,
ip,
},
{ transaction }
);
return comment;
}

View File

@@ -1,8 +1,8 @@
import { Schema } from "prosemirror-model";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import fullPackage from "@shared/editor/packages/full";
import extensionsPackage from "@shared/editor/packages/fullWithComments";
const extensions = new ExtensionManager(fullPackage);
const extensions = new ExtensionManager(extensionsPackage);
export const schema = new Schema({
nodes: extensions.nodes,

View File

@@ -0,0 +1,70 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("comments", {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true
},
data: {
type: Sequelize.JSONB,
allowNull: false
},
documentId: {
type: Sequelize.UUID,
allowNull: false,
onDelete: "cascade",
references: {
model: "documents"
}
},
parentCommentId: {
type: Sequelize.UUID,
allowNull: true,
onDelete: "cascade",
references: {
model: "comments"
}
},
createdById: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users"
}
},
resolvedAt: {
type: Sequelize.DATE,
allowNull: true
},
resolvedById: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: "users"
}
},
createdAt: {
type: Sequelize.DATE,
allowNull: false
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true
}
});
await queryInterface.addIndex("comments", ["documentId"]);
await queryInterface.addIndex("comments", ["createdAt"]);
},
down: async (queryInterface, Sequelize) => {
queryInterface.dropTable("comments");
}
};

72
server/models/Comment.ts Normal file
View File

@@ -0,0 +1,72 @@
import {
DataType,
BelongsTo,
ForeignKey,
Column,
Table,
Scopes,
DefaultScope,
} from "sequelize-typescript";
import Document from "./Document";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
@DefaultScope(() => ({
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
],
}))
@Scopes(() => ({
withDocument: {
include: [
{
model: Document,
as: "document",
required: true,
},
],
},
}))
@Table({ tableName: "comments", modelName: "comment" })
@Fix
class Comment extends ParanoidModel {
@Column(DataType.JSONB)
data: Record<string, any>;
// associations
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
@BelongsTo(() => User, "resolvedById")
resolvedBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
resolvedById: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
@BelongsTo(() => Comment, "parentCommentId")
parentComment: Comment;
@ForeignKey(() => Comment)
@Column(DataType.UUID)
parentCommentId: string;
}
export default Comment;

View File

@@ -12,6 +12,8 @@ export { default as CollectionGroup } from "./CollectionGroup";
export { default as CollectionUser } from "./CollectionUser";
export { default as Comment } from "./Comment";
export { default as Document } from "./Document";
export { default as Event } from "./Event";

View File

@@ -0,0 +1,19 @@
import { Comment, User, Team } from "@server/models";
import { allow } from "./cancan";
allow(User, "createComment", Team, (user, team) => {
if (!team || user.isViewer || user.teamId !== team.id) {
return false;
}
return true;
});
allow(User, ["read", "update", "delete"], Comment, (user, comment) => {
if (!comment) {
return false;
}
if (user.isViewer) {
return false;
}
return user?.id === comment.createdById;
});

View File

@@ -4,6 +4,7 @@ import {
Team,
User,
Collection,
Comment,
Document,
Group,
} from "@server/models";
@@ -12,6 +13,7 @@ import "./apiKey";
import "./attachment";
import "./authenticationProvider";
import "./collection";
import "./comment";
import "./document";
import "./fileOperation";
import "./integration";
@@ -47,9 +49,10 @@ export function serialize(
model: User,
target:
| Attachment
| Collection
| Comment
| FileOperation
| Team
| Collection
| Document
| User
| Group

View File

@@ -0,0 +1,15 @@
import { Comment } from "@server/models";
import presentUser from "./user";
export default function present(comment: Comment) {
return {
id: comment.id,
data: comment.data,
documentId: comment.documentId,
parentCommentId: comment.parentCommentId,
createdBy: presentUser(comment.createdBy),
createdById: comment.createdById,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
}

View File

@@ -4,6 +4,7 @@ import presentAuthenticationProvider from "./authenticationProvider";
import presentAvailableTeam from "./availableTeam";
import presentCollection from "./collection";
import presentCollectionGroupMembership from "./collectionGroupMembership";
import presentComment from "./comment";
import presentDocument from "./document";
import presentEvent from "./event";
import presentFileOperation from "./fileOperation";
@@ -32,6 +33,7 @@ export {
presentAvailableTeam,
presentCollection,
presentCollectionGroupMembership,
presentComment,
presentDocument,
presentEvent,
presentFileOperation,

View File

@@ -2,6 +2,7 @@ import { subHours } from "date-fns";
import { Op } from "sequelize";
import { Server } from "socket.io";
import {
Comment,
Document,
Collection,
FileOperation,
@@ -14,6 +15,7 @@ import {
Subscription,
} from "@server/models";
import {
presentComment,
presentCollection,
presentDocument,
presentFileOperation,
@@ -355,6 +357,35 @@ export default class WebsocketsProcessor {
});
}
case "comments.create":
case "comments.update": {
const comment = await Comment.scope([
"defaultScope",
"withDocument",
]).findByPk(event.modelId);
if (!comment) {
return;
}
return socketio
.to(`collection-${comment.document.collectionId}`)
.emit(event.name, presentComment(comment));
}
case "comments.delete": {
const comment = await Comment.scope([
"defaultScope",
"withDocument",
]).findByPk(event.modelId);
if (!comment) {
return;
}
return socketio
.to(`collection-${comment.document.collectionId}`)
.emit(event.name, {
modelId: event.modelId,
});
}
case "stars.create":
case "stars.update": {
const star = await Star.findByPk(event.modelId);

View File

@@ -0,0 +1,144 @@
import Router from "koa-router";
import { Transaction } from "sequelize";
import commentCreator from "@server/commands/commentCreator";
import commentDestroyer from "@server/commands/commentDestroyer";
import commentUpdater from "@server/commands/commentUpdater";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Document, Comment } from "@server/models";
import { authorize } from "@server/policies";
import { presentComment, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"comments.create",
auth(),
validate(T.CommentsCreateSchema),
transaction(),
async (ctx: APIContext<T.CommentsCreateReq>) => {
const { id, documentId, parentCommentId, data } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const document = await Document.findByPk(documentId, {
userId: user.id,
transaction,
});
authorize(user, "read", document);
const comment = await commentCreator({
id,
data,
parentCommentId,
documentId,
user,
ip: ctx.request.ip,
transaction,
});
ctx.body = {
data: presentComment(comment),
policies: presentPolicies(user, [comment]),
};
}
);
router.post(
"comments.list",
auth(),
pagination(),
validate(T.CollectionsListSchema),
async (ctx: APIContext<T.CollectionsListReq>) => {
const { sort, direction, documentId } = ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(documentId, { userId: user.id });
authorize(user, "read", document);
const comments = await Comment.findAll({
where: { documentId },
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
data: comments.map(presentComment),
policies: presentPolicies(user, comments),
};
}
);
router.post(
"comments.update",
auth(),
validate(T.CommentsUpdateSchema),
transaction(),
async (ctx: APIContext<T.CommentsUpdateReq>) => {
const { id, data } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const comment = await Comment.findByPk(id, {
transaction,
lock: {
level: transaction.LOCK.UPDATE,
of: Comment,
},
});
authorize(user, "update", comment);
await commentUpdater({
user,
comment,
data,
ip: ctx.request.ip,
transaction,
});
ctx.body = {
data: presentComment(comment),
policies: presentPolicies(user, [comment]),
};
}
);
router.post(
"comments.delete",
auth(),
validate(T.CommentsDeleteSchema),
transaction(),
async (ctx: APIContext<T.CommentsDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const comment = await Comment.unscoped().findByPk(id, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
authorize(user, "delete", comment);
await commentDestroyer({
user,
comment,
ip: ctx.request.ip,
transaction,
});
ctx.body = {
success: true,
};
}
);
// router.post("comments.resolve", auth(), async (ctx) => {
// router.post("comments.unresolve", auth(), async (ctx) => {
export default router;

View File

@@ -0,0 +1 @@
export { default } from "./comments";

View File

@@ -0,0 +1,64 @@
import { z } from "zod";
import BaseSchema from "@server/routes/api/BaseSchema";
const CollectionsSortParamsSchema = z.object({
/** Specifies the attributes by which documents will be sorted in the list */
sort: z
.string()
.refine((val) => ["createdAt", "updatedAt"].includes(val))
.default("createdAt"),
/** Specifies the sort order with respect to sort field */
direction: z
.string()
.optional()
.transform((val) => (val !== "ASC" ? "DESC" : val)),
});
export const CommentsCreateSchema = BaseSchema.extend({
body: z.object({
/** Allow creation with a specific ID */
id: z.string().uuid().optional(),
/** Create comment for this document */
documentId: z.string(),
/** Create comment under this parent */
parentCommentId: z.string().uuid().optional(),
/** Create comment with this data */
data: z.any(),
}),
});
export type CommentsCreateReq = z.infer<typeof CommentsCreateSchema>;
export const CommentsUpdateSchema = BaseSchema.extend({
body: z.object({
/** Which comment to update */
id: z.string().uuid(),
/** Update comment with this data */
data: z.any(),
}),
});
export type CommentsUpdateReq = z.infer<typeof CommentsUpdateSchema>;
export const CommentsDeleteSchema = BaseSchema.extend({
body: z.object({
/** Which comment to delete */
id: z.string().uuid(),
}),
});
export type CommentsDeleteReq = z.infer<typeof CommentsDeleteSchema>;
export const CollectionsListSchema = BaseSchema.extend({
body: CollectionsSortParamsSchema.extend({
/** Id of a document to list comments for */
documentId: z.string(),
}),
});
export type CollectionsListReq = z.infer<typeof CollectionsListSchema>;

View File

@@ -14,7 +14,8 @@ import attachments from "./attachments";
import auth from "./auth";
import authenticationProviders from "./authenticationProviders";
import collections from "./collections";
import utils from "./cron";
import comments from "./comments/comments";
import cron from "./cron";
import developer from "./developer";
import documents from "./documents";
import events from "./events";
@@ -67,6 +68,7 @@ router.use("/", authenticationProviders.routes());
router.use("/", events.routes());
router.use("/", users.routes());
router.use("/", collections.routes());
router.use("/", comments.routes());
router.use("/", documents.routes());
router.use("/", pins.routes());
router.use("/", revisions.routes());
@@ -80,7 +82,7 @@ router.use("/", teams.routes());
router.use("/", integrations.routes());
router.use("/", notificationSettings.routes());
router.use("/", attachments.routes());
router.use("/", utils.routes());
router.use("/", cron.routes());
router.use("/", groups.routes());
router.use("/", fileOperationsRoute.routes());

View File

@@ -40,6 +40,8 @@ export const TeamsUpdateSchema = BaseSchema.extend({
publicBranding: z.boolean().optional(),
/** Whether viewers should see download options. */
viewersCanExport: z.boolean().optional(),
/** Whether commenting is enabled */
commenting: z.boolean().optional(),
/** The custom theme for the team. */
customTheme: z
.object({

View File

@@ -284,6 +284,18 @@ async function authenticated(io: IO.Server, socket: SocketWithAuth) {
documentId: event.documentId,
isEditing: event.isEditing,
});
socket.on("typing", async (event) => {
const room = `document-${event.documentId}`;
if (event.documentId && socket.rooms[room]) {
io.to(room).emit("user.typing", {
userId: user.id,
documentId: event.documentId,
commentId: event.commentId,
});
}
});
}
});
}

View File

@@ -279,6 +279,13 @@ export type PinEvent = BaseEvent & {
collectionId?: string;
};
export type CommentEvent = BaseEvent & {
name: "comments.create" | "comments.update" | "comments.delete";
modelId: string;
documentId: string;
actorId: string;
};
export type StarEvent = BaseEvent & {
name: "stars.create" | "stars.update" | "stars.delete";
modelId: string;
@@ -332,6 +339,7 @@ export type Event =
| AuthenticationProviderEvent
| DocumentEvent
| PinEvent
| CommentEvent
| StarEvent
| CollectionEvent
| FileOperationEvent

View File

@@ -0,0 +1,13 @@
import { EditorState, TextSelection } from "prosemirror-state";
import { Dispatch } from "../types";
const collapseSelection = () => (state: EditorState, dispatch?: Dispatch) => {
dispatch?.(
state.tr.setSelection(
TextSelection.create(state.doc, state.tr.selection.from)
)
);
return true;
};
export default collapseSelection;

View File

@@ -567,6 +567,17 @@ h6 {
opacity: 1;
}
.comment {
border-bottom: 2px solid ${transparentize(0.5, props.theme.brand.marine)};
transition: background 100ms ease-in-out;
cursor: pointer;
border-radius: 2px;
&:hover {
background: ${transparentize(0.5, props.theme.brand.marine)};
}
}
.notice-block {
display: flex;
align-items: center;
@@ -1456,6 +1467,11 @@ del[data-operation-index] {
display: none;
}
.comment {
border: 0;
background: none;
}
.page-break {
opacity: 0;
}

View File

@@ -64,7 +64,7 @@ export default class Extension {
commands(_options: {
type?: NodeType | MarkType;
schema: Schema;
}): Record<string, CommandFactory> | CommandFactory {
}): Record<string, CommandFactory> | CommandFactory | undefined {
return {};
}
}

View File

@@ -213,7 +213,7 @@ export default class ExtensionManager {
Object.entries(value).forEach(([commandName, commandValue]) => {
handle(commandName, commandValue);
});
} else {
} else if (value) {
handle(name, value);
}

View File

@@ -1,45 +0,0 @@
import { Node } from "prosemirror-model";
import headingToSlug from "./headingToSlug";
export type Heading = {
title: string;
level: number;
id: string;
};
/**
* Iterates through the document to find all of the headings and their level.
*
* @param doc Prosemirror document node
* @returns Array<Heading>
*/
export default function getHeadings(doc: Node) {
const headings: Heading[] = [];
const previouslySeen = {};
doc.forEach((node) => {
if (node.type.name === "heading") {
// calculate the optimal id
const id = headingToSlug(node);
let name = id;
// check if we've already used it, and if so how many times?
// Make the new id based on that number ensuring that we have
// unique ID's even when headings are identical
if (previouslySeen[id] > 0) {
name = headingToSlug(node, previouslySeen[id]);
}
// record that we've seen this id for the next loop
previouslySeen[id] =
previouslySeen[id] !== undefined ? previouslySeen[id] + 1 : 1;
headings.push({
title: node.textContent,
level: node.attrs.level,
id: name,
});
}
});
return headings;
}

View File

@@ -1,26 +0,0 @@
import { Node as ProsemirrorNode, Mark } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import Node from "../nodes/Node";
export default function getMarkAttrs(state: EditorState, type: Node) {
const { from, to } = state.selection;
let marks: Mark[] = [];
state.doc.nodesBetween(from, to, (node: ProsemirrorNode) => {
marks = [...marks, ...node.marks];
if (node.content) {
node.content.forEach((content) => {
marks = [...marks, ...content.marks];
});
}
});
const mark = marks.find((markItem) => markItem.type.name === type.name);
if (mark) {
return mark.attrs;
}
return {};
}

View File

@@ -1,44 +0,0 @@
import { Node } from "prosemirror-model";
export type Task = {
text: string;
completed: boolean;
};
/**
* Iterates through the document to find all of the tasks and their completion
* state.
*
* @param doc Prosemirror document node
* @returns Array<Task>
*/
export default function getTasks(doc: Node): Task[] {
const tasks: Task[] = [];
doc.descendants((node) => {
if (!node.isBlock) {
return false;
}
if (node.type.name === "checkbox_list") {
node.content.forEach((listItem) => {
let text = "";
listItem.forEach((contentNode) => {
if (contentNode.type.name === "paragraph") {
text += contentNode.textContent;
}
});
tasks.push({
text,
completed: listItem.attrs.checked,
});
});
}
return true;
});
return tasks;
}

View File

@@ -0,0 +1,107 @@
import { toggleMark } from "prosemirror-commands";
import { MarkSpec, MarkType, Schema } from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state";
import { v4 as uuidv4 } from "uuid";
import collapseSelection from "../commands/collapseSelection";
import { Command } from "../lib/Extension";
import chainTransactions from "../lib/chainTransactions";
import isMarkActive from "../queries/isMarkActive";
import { Dispatch } from "../types";
import Mark from "./Mark";
export default class Comment extends Mark {
get name() {
return "comment";
}
get schema(): MarkSpec {
return {
attrs: {
id: {},
userId: {},
},
inclusive: false,
parseDOM: [{ tag: "span.comment" }],
toDOM: (node) => [
"span",
{ class: "comment", id: `comment-${node.attrs.id}` },
],
};
}
keys({ type }: { type: MarkType }): Record<string, Command> {
return this.options.onCreateCommentMark
? {
"Mod-Alt-m": (state: EditorState, dispatch: Dispatch) => {
if (isMarkActive(state.schema.marks.comment)(state)) {
return false;
}
chainTransactions(
toggleMark(type, {
id: uuidv4(),
userId: this.options.userId,
}),
collapseSelection()
)(state, dispatch);
return true;
},
}
: {};
}
commands({ type }: { type: MarkType; schema: Schema }) {
return this.options.onCreateCommentMark
? () => (state: EditorState, dispatch: Dispatch) => {
if (isMarkActive(state.schema.marks.comment)(state)) {
return false;
}
chainTransactions(
toggleMark(type, {
id: uuidv4(),
userId: this.options.userId,
}),
collapseSelection()
)(state, dispatch);
return true;
}
: undefined;
}
toMarkdown() {
return {
open: "",
close: "",
mixable: true,
expelEnclosingWhitespace: true,
};
}
get plugins(): Plugin[] {
return [
new Plugin({
props: {
handleDOMEvents: {
mousedown: (view, event: MouseEvent) => {
if (
!(event.target instanceof HTMLSpanElement) ||
!event.target.classList.contains("comment")
) {
this.options?.onClickCommentMark?.();
return false;
}
const commentId = event.target.id.replace("comment-", "");
this.options?.onClickCommentMark?.(commentId);
return false;
},
},
},
}),
];
}
}

View File

@@ -113,10 +113,6 @@ export default class Link extends Mark {
];
}
commands({ type }: { type: MarkType }) {
return ({ href } = { href: "" }) => toggleMark(type, { href });
}
keys({ type }: { type: MarkType }) {
return {
"Mod-k": (state: EditorState, dispatch: Dispatch) => {

View File

@@ -39,7 +39,12 @@ export default abstract class Mark extends Extension {
return undefined;
}
commands({ type }: { type: MarkType; schema: Schema }): CommandFactory {
return () => toggleMark(type);
commands({
type,
}: {
type: MarkType;
schema: Schema;
}): Record<string, CommandFactory> | CommandFactory | undefined {
return (attrs) => toggleMark(type, attrs);
}
}

View File

@@ -8,7 +8,6 @@ import Strikethrough from "../marks/Strikethrough";
import Underline from "../marks/Underline";
import Doc from "../nodes/Doc";
import Emoji from "../nodes/Emoji";
import HardBreak from "../nodes/HardBreak";
import Image from "../nodes/Image";
import Node from "../nodes/Node";
import Paragraph from "../nodes/Paragraph";
@@ -16,6 +15,7 @@ import Text from "../nodes/Text";
import ClipboardTextSerializer from "../plugins/ClipboardTextSerializer";
import DateTime from "../plugins/DateTime";
import History from "../plugins/History";
import Keys from "../plugins/Keys";
import MaxLength from "../plugins/MaxLength";
import PasteHandler from "../plugins/PasteHandler";
import Placeholder from "../plugins/Placeholder";
@@ -24,7 +24,6 @@ import TrailingNode from "../plugins/TrailingNode";
const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
Doc,
HardBreak,
Paragraph,
Emoji,
Text,
@@ -42,6 +41,7 @@ const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
Placeholder,
MaxLength,
DateTime,
Keys,
ClipboardTextSerializer,
];

View File

@@ -10,6 +10,7 @@ import CheckboxList from "../nodes/CheckboxList";
import CodeBlock from "../nodes/CodeBlock";
import CodeFence from "../nodes/CodeFence";
import Embed from "../nodes/Embed";
import HardBreak from "../nodes/HardBreak";
import Heading from "../nodes/Heading";
import HorizontalRule from "../nodes/HorizontalRule";
import ListItem from "../nodes/ListItem";
@@ -24,11 +25,11 @@ import TableHeadCell from "../nodes/TableHeadCell";
import TableRow from "../nodes/TableRow";
import BlockMenuTrigger from "../plugins/BlockMenuTrigger";
import Folding from "../plugins/Folding";
import Keys from "../plugins/Keys";
import basicPackage from "./basic";
const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
...basicPackage,
HardBreak,
CodeBlock,
CodeFence,
CheckboxList,
@@ -49,7 +50,6 @@ const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
Highlight,
TemplatePlaceholder,
Folding,
Keys,
BlockMenuTrigger,
Math,
MathBlock,

View File

@@ -0,0 +1,13 @@
import Extension from "../lib/Extension";
import Comment from "../marks/Comment";
import Mark from "../marks/Mark";
import Node from "../nodes/Node";
import fullPackage from "./full";
const fullWithCommentsPackage: (
| typeof Node
| typeof Mark
| typeof Extension
)[] = [...fullPackage, Comment];
export default fullWithCommentsPackage;

View File

@@ -98,7 +98,7 @@
"Viewers": "Viewers",
"Im sure Delete": "Im sure Delete",
"Deleting": "Deleting",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description",
@@ -106,6 +106,8 @@
"Expand": "Expand",
"Type a command or search": "Type a command or search",
"Open search from anywhere with the {{ shortcut }} shortcut": "Open search from anywhere with the {{ shortcut }} shortcut",
"Are you sure you want to permanently delete this entire comment thread?": "Are you sure you want to permanently delete this entire comment thread?",
"Are you sure you want to permanently delete this comment?": "Are you sure you want to permanently delete this comment?",
"Server connection lost": "Server connection lost",
"Edits you make will sync once youre online": "Edits you make will sync once youre online",
"Submenu": "Submenu",
@@ -138,10 +140,6 @@
"in": "in",
"nested document": "nested document",
"nested document_plural": "nested documents",
"Viewed by": "Viewed by",
"only you": "only you",
"person": "person",
"people": "people",
"{{ total }} task": "{{ total }} task",
"{{ total }} task_plural": "{{ total }} tasks",
"{{ completed }} task done": "{{ completed }} task done",
@@ -246,6 +244,7 @@
"Code block": "Code block",
"Copied to clipboard": "Copied to clipboard",
"Code": "Code",
"Comment": "Comment",
"Copy": "Copy",
"Create link": "Create link",
"Sorry, an error occurred creating the link": "Sorry, an error occurred creating the link",
@@ -320,10 +319,12 @@
"Group member options": "Group member options",
"Remove": "Remove",
"Export collection": "Export collection",
"Delete collection": "Are you sure you want to delete this collection?",
"Delete collection": "Delete collection",
"Sort in sidebar": "Sort in sidebar",
"Alphabetical sort": "Alphabetical sort",
"Manual sort": "Manual sort",
"Delete comment": "Delete comment",
"Comment options": "Comment options",
"Document options": "Document options",
"Restore": "Restore",
"Choose a collection": "Choose a collection",
@@ -432,9 +433,24 @@
"Add people to {{ collectionName }}": "Add people to {{ collectionName }}",
"Signing in": "Signing in",
"You can safely close this window once the Outline desktop app has opened": "You can safely close this window once the Outline desktop app has opened",
"Error creating comment": "Error creating comment",
"Add a comment": "Add a comment",
"Add a reply": "Add a reply",
"Post": "Post",
"Reply": "Reply",
"Cancel": "Cancel",
"Comments": "Comments",
"No comments yet": "No comments yet",
"Error updating comment": "Error updating comment",
"Document updated by {{userName}}": "Document updated by {{userName}}",
"You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?",
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
"Viewed by": "Viewed by",
"only you": "only you",
"person": "person",
"people": "people",
"{{ count }} comment": "{{ count }} comment",
"{{ count }} comment_plural": "{{ count }} comments",
"Type '/' to insert, or start writing…": "Type '/' to insert, or start writing…",
"Hide contents": "Hide contents",
"Show contents": "Show contents",
@@ -503,7 +519,7 @@
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>one nested document</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>._plural": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested documents</em>.",
"If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
"Archiving": "Archiving",
@@ -522,7 +538,6 @@
"no access": "no access",
"Heads up moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all members of the workspace <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}.": "Heads up moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all members of the workspace <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}.",
"Moving": "Moving",
"Cancel": "Cancel",
"Search documents": "Search documents",
"No documents found for your filters.": "No documents found for your filters.",
"Youve not got any drafts at the moment.": "Youve not got any drafts at the moment.",

View File

@@ -124,6 +124,9 @@ export const buildLightTheme = (input: Partial<Colors>): DefaultTheme => {
backdrop: "rgba(0, 0, 0, 0.2)",
shadow: "rgba(0, 0, 0, 0.2)",
commentBackground: colors.warmGrey,
commentActiveBackground: "#d7e0ea",
modalBackdrop: colors.black10,
modalBackground: colors.white,
modalShadow:
@@ -189,6 +192,9 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
backdrop: "rgba(0, 0, 0, 0.5)",
shadow: "rgba(0, 0, 0, 0.6)",
commentBackground: colors.veryDarkBlue,
commentActiveBackground: colors.black,
modalBackdrop: colors.black50,
modalBackground: "#1f2128",
modalShadow:

View File

@@ -120,6 +120,8 @@ export enum TeamPreference {
PublicBranding = "publicBranding",
/** Whether viewers should see download options. */
ViewersCanExport = "viewersCanExport",
/** Whether users can comment on documents. */
Commenting = "commenting",
/** The custom theme for the team. */
CustomTheme = "customTheme",
}
@@ -128,6 +130,7 @@ export type TeamPreferences = {
[TeamPreference.SeamlessEdit]?: boolean;
[TeamPreference.PublicBranding]?: boolean;
[TeamPreference.ViewersCanExport]?: boolean;
[TeamPreference.Commenting]?: boolean;
[TeamPreference.CustomTheme]?: Partial<CustomTheme>;
};

View File

@@ -0,0 +1,155 @@
import { Node } from "prosemirror-model";
import headingToSlug from "../editor/lib/headingToSlug";
export type Heading = {
/* The heading in plain text */
title: string;
/* The level of the heading */
level: number;
/* The unique id of the heading */
id: string;
};
export type CommentMark = {
/* The unique id of the comment */
id: string;
/* The id of the user who created the comment */
userId: string;
};
export type Task = {
/* The text of the task */
text: string;
/* Whether the task is completed or not */
completed: boolean;
};
export default class ProsemirrorHelper {
/**
* Removes any empty paragraphs from the beginning and end of the document.
*
* @returns True if the editor is empty
*/
static trim(doc: Node) {
const first = doc.firstChild;
const last = doc.lastChild;
const firstIsEmpty =
first?.type.name === "paragraph" && !first.textContent.trim();
const lastIsEmpty =
last?.type.name === "paragraph" && !last.textContent.trim();
const firstIsLast = first === last;
return doc.cut(
firstIsEmpty ? first.nodeSize : 0,
lastIsEmpty && !firstIsLast ? doc.nodeSize - last.nodeSize : undefined
);
}
/**
* Returns true if the trimmed content of the passed document is an empty
* string.
*
* @returns True if the editor is empty
*/
static isEmpty(doc: Node) {
return !doc || doc.textContent.trim() === "";
}
/**
* Iterates through the document to find all of the comments that exist as
* marks.
*
* @param doc Prosemirror document node
* @returns Array<CommentMark>
*/
static getComments(doc: Node): CommentMark[] {
const comments: CommentMark[] = [];
doc.descendants((node) => {
node.marks.forEach((mark) => {
if (mark.type.name === "comment") {
comments.push(mark.attrs as CommentMark);
}
});
return true;
});
return comments;
}
/**
* Iterates through the document to find all of the tasks and their completion
* state.
*
* @param doc Prosemirror document node
* @returns Array<Task>
*/
static getTasks(doc: Node): Task[] {
const tasks: Task[] = [];
doc.descendants((node) => {
if (!node.isBlock) {
return false;
}
if (node.type.name === "checkbox_list") {
node.content.forEach((listItem) => {
let text = "";
listItem.forEach((contentNode) => {
if (contentNode.type.name === "paragraph") {
text += contentNode.textContent;
}
});
tasks.push({
text,
completed: listItem.attrs.checked,
});
});
}
return true;
});
return tasks;
}
/**
* Iterates through the document to find all of the headings and their level.
*
* @param doc Prosemirror document node
* @returns Array<Heading>
*/
static getHeadings(doc: Node) {
const headings: Heading[] = [];
const previouslySeen = {};
doc.forEach((node) => {
if (node.type.name === "heading") {
// calculate the optimal id
const id = headingToSlug(node);
let name = id;
// check if we've already used it, and if so how many times?
// Make the new id based on that number ensuring that we have
// unique ID's even when headings are identical
if (previouslySeen[id] > 0) {
name = headingToSlug(node, previouslySeen[id]);
}
// record that we've seen this id for the next loop
previouslySeen[id] =
previouslySeen[id] !== undefined ? previouslySeen[id] + 1 : 1;
headings.push({
title: node.textContent,
level: node.attrs.level,
id: name,
});
}
});
return headings;
}
}

11
shared/utils/time.ts Normal file
View File

@@ -0,0 +1,11 @@
/** A second in ms */
export const Second = 1000;
/** A minute in ms */
export const Minute = 60 * Second;
/** An hour in ms */
export const Hour = 60 * Minute;
/** A day in ms */
export const Day = 24 * Hour;

View File

@@ -27,6 +27,11 @@ export const CollectionValidation = {
maxNameLength: 100,
};
export const CommentValidation = {
/** The maximum length of a comment */
maxLength: 1000,
};
export const DocumentValidation = {
/** The maximum length of the document title */
maxTitleLength: 100,