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

@@ -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