From fc8c20149f4df3934f10be992100524f0a762dce Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 25 Feb 2023 15:03:05 -0500 Subject: [PATCH] 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 --- app/components/AuthenticatedLayout.tsx | 16 +- app/components/Avatar/Avatar.tsx | 6 +- app/components/ButtonSmall.ts | 15 + app/components/CommentDeleteDialog.tsx | 53 ++++ app/components/ConnectionStatus.tsx | 2 +- app/components/Editor.tsx | 73 ++++- app/components/HoverPreviewDocument.tsx | 7 +- app/components/Input.tsx | 18 ++ app/components/ResizingHeightContainer.tsx | 42 +++ app/components/Scrollable.tsx | 12 +- app/components/Sidebar/Sidebar.tsx | 1 - app/components/Text.ts | 5 +- app/components/Typing.tsx | 41 +++ app/components/WebsocketProvider.tsx | 23 ++ app/editor/components/ToolbarMenu.tsx | 16 +- app/editor/index.tsx | 223 +++++++++----- app/editor/menus/formatting.tsx | 8 + app/hooks/useDictionary.ts | 1 + app/hooks/useEmojiWidth.ts | 4 + app/hooks/useFocusedComment.ts | 11 + app/hooks/useOnClickOutside.ts | 27 ++ app/hooks/usePersistedState.ts | 2 +- app/menus/CommentMenu.tsx | 82 +++++ app/models/BaseModel.ts | 36 ++- app/models/Comment.ts | 66 +++++ app/models/Team.ts | 4 + .../Document/components/CommentEditor.tsx | 13 + .../Document/components/CommentForm.tsx | 229 ++++++++++++++ .../Document/components/CommentThread.tsx | 232 +++++++++++++++ .../Document/components/CommentThreadItem.tsx | 279 ++++++++++++++++++ app/scenes/Document/components/Comments.tsx | 94 ++++++ app/scenes/Document/components/DataLoader.tsx | 11 +- app/scenes/Document/components/Document.tsx | 23 +- .../Document/components/DocumentMeta.tsx} | 33 ++- app/scenes/Document/components/Editor.tsx | 87 +++++- .../components/KeyboardShortcutsButton.tsx | 1 - .../Document/components/RevisionViewer.tsx | 11 +- .../Document/components/SidebarLayout.tsx | 4 +- .../Document/components/SocketPresence.ts | 19 +- app/scenes/Document/index.tsx | 6 +- app/scenes/Settings/Features.tsx | 19 ++ app/stores/BaseStore.ts | 13 +- app/stores/CommentsStore.ts | 101 +++++++ app/stores/RootStore.ts | 4 + app/stores/UiStore.ts | 28 +- app/styles/index.ts | 14 + app/typings/styled-components.d.ts | 2 + app/utils/routeHelpers.ts | 10 + .../server/tasks/DeliverWebhookTask.ts | 5 + server/commands/commentCreator.test.ts | 31 ++ server/commands/commentCreator.ts | 62 ++++ server/commands/commentDestroyer.test.ts | 38 +++ server/commands/commentDestroyer.ts | 50 ++++ server/commands/commentUpdater.ts | 54 ++++ server/editor/index.ts | 4 +- .../20220305195830-create-comments.js | 70 +++++ server/models/Comment.ts | 72 +++++ server/models/index.ts | 2 + server/policies/comment.ts | 19 ++ server/policies/index.ts | 5 +- server/presenters/comment.ts | 15 + server/presenters/index.ts | 2 + .../queues/processors/WebsocketsProcessor.ts | 31 ++ server/routes/api/comments/comments.ts | 144 +++++++++ server/routes/api/comments/index.ts | 1 + server/routes/api/comments/schema.ts | 64 ++++ server/routes/api/index.ts | 6 +- server/routes/api/teams/schema.ts | 2 + server/services/websockets.ts | 12 + server/types.ts | 8 + shared/editor/commands/collapseSelection.ts | 13 + shared/editor/components/Styles.ts | 16 + shared/editor/lib/Extension.ts | 2 +- shared/editor/lib/ExtensionManager.ts | 2 +- shared/editor/lib/getHeadings.ts | 45 --- shared/editor/lib/getMarkAttrs.ts | 26 -- shared/editor/lib/getTasks.ts | 44 --- shared/editor/marks/Comment.ts | 107 +++++++ shared/editor/marks/Link.tsx | 4 - shared/editor/marks/Mark.ts | 9 +- shared/editor/packages/basic.ts | 4 +- shared/editor/packages/full.ts | 4 +- shared/editor/packages/fullWithComments.ts | 13 + shared/i18n/locales/en_US/translation.json | 31 +- shared/styles/theme.ts | 6 + shared/types.ts | 3 + shared/utils/ProsemirrorHelper.ts | 155 ++++++++++ shared/utils/time.ts | 11 + shared/validations.ts | 5 + 89 files changed, 2909 insertions(+), 315 deletions(-) create mode 100644 app/components/ButtonSmall.ts create mode 100644 app/components/CommentDeleteDialog.tsx create mode 100644 app/components/ResizingHeightContainer.tsx create mode 100644 app/components/Typing.tsx create mode 100644 app/hooks/useFocusedComment.ts create mode 100644 app/hooks/useOnClickOutside.ts create mode 100644 app/menus/CommentMenu.tsx create mode 100644 app/models/Comment.ts create mode 100644 app/scenes/Document/components/CommentEditor.tsx create mode 100644 app/scenes/Document/components/CommentForm.tsx create mode 100644 app/scenes/Document/components/CommentThread.tsx create mode 100644 app/scenes/Document/components/CommentThreadItem.tsx create mode 100644 app/scenes/Document/components/Comments.tsx rename app/{components/DocumentMetaWithViews.tsx => scenes/Document/components/DocumentMeta.tsx} (66%) create mode 100644 app/stores/CommentsStore.ts create mode 100644 server/commands/commentCreator.test.ts create mode 100644 server/commands/commentCreator.ts create mode 100644 server/commands/commentDestroyer.test.ts create mode 100644 server/commands/commentDestroyer.ts create mode 100644 server/commands/commentUpdater.ts create mode 100644 server/migrations/20220305195830-create-comments.js create mode 100644 server/models/Comment.ts create mode 100644 server/policies/comment.ts create mode 100644 server/presenters/comment.ts create mode 100644 server/routes/api/comments/comments.ts create mode 100644 server/routes/api/comments/index.ts create mode 100644 server/routes/api/comments/schema.ts create mode 100644 shared/editor/commands/collapseSelection.ts delete mode 100644 shared/editor/lib/getHeadings.ts delete mode 100644 shared/editor/lib/getMarkAttrs.ts delete mode 100644 shared/editor/lib/getTasks.ts create mode 100644 shared/editor/marks/Comment.ts create mode 100644 shared/editor/packages/fullWithComments.ts create mode 100644 shared/utils/ProsemirrorHelper.ts create mode 100644 shared/utils/time.ts diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index 554b43eb3..acd4cfde4 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -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 = ( - - {(showHistory || showInsights) && ( + + {(showHistory || showInsights || showComments) && ( {showHistory && } {showInsights && } + {showComments && } diff --git a/app/components/Avatar/Avatar.tsx b/app/components/Avatar/Avatar.tsx index 83b56b02c..e7bc55c21 100644 --- a/app/components/Avatar/Avatar.tsx +++ b/app/components/Avatar/Avatar.tsx @@ -19,15 +19,16 @@ type Props = { showBorder?: boolean; onClick?: React.MouseEventHandler; 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 ( - + {src && !error ? ( 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 ( + + + {hasChildComments ? ( + + Are you sure you want to permanently delete this entire comment + thread? + + ) : ( + + Are you sure you want to permanently delete this comment? + + )} + + + ); +} + +export default observer(CommentDeleteDialog); diff --git a/app/components/ConnectionStatus.tsx b/app/components/ConnectionStatus.tsx index 59a3d498c..705716f56 100644 --- a/app/components/ConnectionStatus.tsx +++ b/app/components/ConnectionStatus.tsx @@ -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; diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 7a55d4739..0459e7133 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -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 | 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(); const preferences = auth.user?.preferences; const previousHeadings = React.useRef(null); @@ -63,6 +73,7 @@ function Editor(props: Props, ref: React.RefObject | null) { activeLinkElement, setActiveLink, ] = React.useState(null); + const previousCommentIds = React.useRef(); const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => { setActiveLink(element); @@ -125,7 +136,7 @@ function Editor(props: Props, ref: React.RefObject | 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 | 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 | null) { window.open(href, "_blank"); } }, - [shareId] + [history, shareId] ); const focusAtEnd = React.useCallback(() => { @@ -223,7 +234,7 @@ function Editor(props: Props, ref: React.RefObject | 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 | null) { props.onFileUploadStart, props.onFileUploadStop, dictionary, - onUploadFile, + handleUploadFile, showToast, ] ); @@ -265,21 +276,54 @@ function Editor(props: Props, ref: React.RefObject | 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 | null) { <> {props.bottomPadding && !props.readOnly && ( {document.titleWithDefault} - + }> ) => unknown; onFocus?: (ev: React.SyntheticEvent) => unknown; onBlur?: (ev: React.SyntheticEvent) => unknown; }; @@ -147,6 +149,20 @@ function Input( } }; + const handleKeyDown = ( + ev: React.KeyboardEvent + ) => { + 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} onBlur={handleBlur} onFocus={handleFocus} + onKeyDown={handleKeyDown} hasIcon={!!icon} {...rest} /> @@ -188,6 +205,7 @@ function Input( ref={ref as React.RefObject} onBlur={handleBlur} onFocus={handleFocus} + onKeyDown={handleKeyDown} hasIcon={!!icon} type={type} {...rest} diff --git a/app/components/ResizingHeightContainer.tsx b/app/components/ResizingHeightContainer.tsx new file mode 100644 index 000000000..779eac277 --- /dev/null +++ b/app/components/ResizingHeightContainer.tsx @@ -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(null); + const { height } = useComponentSize(ref); + + return ( + +
{children}
+
+ ); +} diff --git a/app/components/Scrollable.tsx b/app/components/Scrollable.tsx index 3116e5a22..b8b94f588 100644 --- a/app/components/Scrollable.tsx +++ b/app/components/Scrollable.tsx @@ -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 & { 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)); diff --git a/app/components/Sidebar/Sidebar.tsx b/app/components/Sidebar/Sidebar.tsx index 5a3225f1c..8dc3ff339 100644 --- a/app/components/Sidebar/Sidebar.tsx +++ b/app/components/Sidebar/Sidebar.tsx @@ -35,7 +35,6 @@ const Sidebar = React.forwardRef( const previousLocation = usePrevious(location); const { isMenuOpen } = useMenuContext(); const { user } = auth; - const width = ui.sidebarWidth; const collapsed = ui.sidebarIsClosed && !isMenuOpen; const maxWidth = theme.sidebarMaxWidth; diff --git a/app/components/Text.ts b/app/components/Text.ts index 87a899105..af1486277 100644 --- a/app/components/Text.ts +++ b/app/components/Text.ts @@ -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` 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` ? "normal" : "inherit"}; white-space: normal; - user-select: none; + user-select: ${(props) => (props.selectable ? "text" : "none")}; `; export default Text; diff --git a/app/components/Typing.tsx b/app/components/Typing.tsx new file mode 100644 index 000000000..e8b5fcb6c --- /dev/null +++ b/app/components/Typing.tsx @@ -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 ( + + + + + + ); +} + +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; + } +`; diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index 79899313a..a62274b02 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -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 { memberships, policies, presence, + comments, views, subscriptions, fileOperations, @@ -261,6 +263,20 @@ class WebsocketProvider extends React.Component { } ); + this.socket.on("comments.create", (event: PartialWithId) => { + comments.add(event); + }); + + this.socket.on("comments.update", (event: PartialWithId) => { + 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) => { groups.add(event); }); @@ -323,6 +339,13 @@ class WebsocketProvider extends React.Component { 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( diff --git a/app/editor/components/ToolbarMenu.tsx b/app/editor/components/ToolbarMenu.tsx index 0b41e66cf..300549939 100644 --- a/app/editor/components/ToolbarMenu.tsx +++ b/app/editor/components/ToolbarMenu.tsx @@ -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 ( {items.map((item, index) => { @@ -34,10 +45,7 @@ function ToolbarMenu(props: Props) { return ( - item.name && commands[item.name](item.attrs)} - active={isActive} - > + {React.cloneElement(item.icon, { color: "currentColor" })} diff --git a/app/editor/index.tsx b/app/editor/index.tsx index b30e9cb1e..be796cc48 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -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 > {!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( (props: Props, ref) => { return ( diff --git a/app/editor/menus/formatting.tsx b/app/editor/menus/formatting.tsx index b603642d2..0c612a8a1 100644 --- a/app/editor/menus/formatting.tsx +++ b/app/editor/menus/formatting.tsx @@ -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: , + active: isMarkActive(schema.marks.comment), + visible: !isCode, + }, ]; } diff --git a/app/hooks/useDictionary.ts b/app/hooks/useDictionary.ts index 5b9196de5..088d40e71 100644 --- a/app/hooks/useDictionary.ts +++ b/app/hooks/useDictionary.ts @@ -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"), diff --git a/app/hooks/useEmojiWidth.ts b/app/hooks/useEmojiWidth.ts index 5a3ed95bc..123056a2a 100644 --- a/app/hooks/useEmojiWidth.ts +++ b/app/hooks/useEmojiWidth.ts @@ -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, diff --git a/app/hooks/useFocusedComment.ts b/app/hooks/useFocusedComment.ts new file mode 100644 index 000000000..9a6ea6b54 --- /dev/null +++ b/app/hooks/useFocusedComment.ts @@ -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; +} diff --git a/app/hooks/useOnClickOutside.ts b/app/hooks/useOnClickOutside.ts new file mode 100644 index 000000000..0e41d7260 --- /dev/null +++ b/app/hooks/useOnClickOutside.ts @@ -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, + 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); +} diff --git a/app/hooks/usePersistedState.ts b/app/hooks/usePersistedState.ts index eb0837ec5..56fdb0d95 100644 --- a/app/hooks/usePersistedState.ts +++ b/app/hooks/usePersistedState.ts @@ -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( +export default function usePersistedState( key: string, defaultValue: T, options?: Options diff --git a/app/menus/CommentMenu.tsx b/app/menus/CommentMenu.tsx new file mode 100644 index 000000000..c3a18a841 --- /dev/null +++ b/app/menus/CommentMenu.tsx @@ -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: , + }); + }, [dialogs, comment, onDelete, t]); + + const handleCopyLink = React.useCallback(() => { + if (document) { + copy(urlify(commentPath(document, comment))); + showToast(t("Link copied")); + } + }, [t, document, comment, showToast]); + + return ( + <> + + + {can.update && ( + + {t("Edit")} + + )} + + {t("Copy link")} + + {can.delete && ( + <> + + + {t("Delete")} + + + )} + + + ); +} + +export default observer(CommentMenu); diff --git a/app/models/BaseModel.ts b/app/models/BaseModel.ts index 8d0d9b5ec..7176034da 100644 --- a/app/models/BaseModel.ts +++ b/app/models/BaseModel.ts @@ -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, 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 = {}; } diff --git a/app/models/Comment.ts b/app/models/Comment.ts new file mode 100644 index 000000000..f7a35df35 --- /dev/null +++ b/app/models/Comment.ts @@ -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 = new Map(); + + @Field + @observable + id: string; + + /** + * The Prosemirror data representing the comment content + */ + @Field + @observable + data: Record; + + /** + * 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; diff --git a/app/models/Team.ts b/app/models/Team.ts index a3ab9e97c..7e9271a45 100644 --- a/app/models/Team.ts +++ b/app/models/Team.ts @@ -29,6 +29,10 @@ class Team extends BaseModel { @observable collaborativeEditing: boolean; + @Field + @observable + commenting: boolean; + @Field @observable documentEmbeds: boolean; diff --git a/app/scenes/Document/components/CommentEditor.tsx b/app/scenes/Document/components/CommentEditor.tsx new file mode 100644 index 000000000..660fe2f49 --- /dev/null +++ b/app/scenes/Document/components/CommentEditor.tsx @@ -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 +) => { + return ; +}; + +export default React.forwardRef(CommentEditor); diff --git a/app/scenes/Document/components/CommentForm.tsx b/app/scenes/Document/components/CommentForm.tsx new file mode 100644 index 000000000..55f7e5fed --- /dev/null +++ b/app/scenes/Document/components/CommentForm.tsx @@ -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 | undefined>( + `draft-${documentId}-${thread.id}`, + undefined + ); + const formRef = React.useRef(null); + const editorRef = React.useRef(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 + ) => { + 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 ( + + + + + + + {!isEmpty && ( + + + {thread.isNew ? t("Post") : t("Reply")} + + + {t("Cancel")} + + + )} + + + + ); +} + +export default observer(CommentForm); diff --git a/app/scenes/Document/components/CommentThread.tsx b/app/scenes/Document/components/CommentThread.tsx new file mode 100644 index 000000000..943b1e648 --- /dev/null +++ b/app/scenes/Document/components/CommentThread.tsx @@ -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): [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(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 ( + + {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 ( + + ); + })} + + {thread.currentlyTypingUsers + .filter((typing) => typing.id !== user.id) + .map((typing) => ( + + + + + ))} + + + {focused && ( + + + + )} + + {!focused && !recessed && ( + setAutoFocus(true)}>{t("Reply")}… + )} + + ); +} + +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); diff --git a/app/scenes/Document/components/CommentThreadItem.tsx b/app/scenes/Document/components/CommentThreadItem.tsx new file mode 100644 index 000000000..c0698a423 --- /dev/null +++ b/app/scenes/Document/components/CommentThreadItem.tsx @@ -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(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 ( + + {firstOfAuthor && ( + + + + )} + + {(showAuthor || showTime) && ( + + {showAuthor && {comment.createdBy.name}} + {showAuthor && showTime && <> · } + {showTime && ( +