diff --git a/.eslintrc b/.eslintrc index 65c2d868e..fcc8e7b8f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -41,6 +41,7 @@ "@typescript-eslint/no-shadow": [ "warn", { + "allow": ["transaction"], "hoist": "all", "ignoreTypeValueShadow": true } @@ -139,4 +140,4 @@ "typescript": {} } } -} \ No newline at end of file +} diff --git a/app/actions/definitions/comments.tsx b/app/actions/definitions/comments.tsx new file mode 100644 index 000000000..9a89393d2 --- /dev/null +++ b/app/actions/definitions/comments.tsx @@ -0,0 +1,86 @@ +import { DoneIcon, TrashIcon } from "outline-icons"; +import * as React from "react"; +import { toast } from "sonner"; +import stores from "~/stores"; +import Comment from "~/models/Comment"; +import CommentDeleteDialog from "~/components/CommentDeleteDialog"; +import history from "~/utils/history"; +import { createAction } from ".."; +import { DocumentSection } from "../sections"; + +export const deleteCommentFactory = ({ + comment, + onDelete, +}: { + comment: Comment; + onDelete: () => void; +}) => + createAction({ + name: ({ t }) => `${t("Delete")}…`, + analyticsName: "Delete comment", + section: DocumentSection, + icon: , + keywords: "trash", + dangerous: true, + visible: () => stores.policies.abilities(comment.id).delete, + perform: ({ t, event }) => { + event?.preventDefault(); + event?.stopPropagation(); + + stores.dialogs.openModal({ + title: t("Delete comment"), + content: , + }); + }, + }); + +export const resolveCommentFactory = ({ + comment, + onResolve, +}: { + comment: Comment; + onResolve: () => void; +}) => + createAction({ + name: ({ t }) => t("Mark as resolved"), + analyticsName: "Resolve thread", + section: DocumentSection, + icon: , + visible: () => stores.policies.abilities(comment.id).resolve, + perform: async ({ t }) => { + await comment.resolve(); + + history.replace({ + ...history.location, + state: null, + }); + + onResolve(); + toast.success(t("Thread resolved")); + }, + }); + +export const unresolveCommentFactory = ({ + comment, + onUnresolve, +}: { + comment: Comment; + onUnresolve: () => void; +}) => + createAction({ + name: ({ t }) => t("Mark as unresolved"), + analyticsName: "Unresolve thread", + section: DocumentSection, + icon: , + visible: () => stores.policies.abilities(comment.id).unresolve, + perform: async () => { + await comment.unresolve(); + + history.replace({ + ...history.location, + state: null, + }); + + onUnresolve(); + }, + }); diff --git a/app/components/ContextMenu/Template.tsx b/app/components/ContextMenu/Template.tsx index fd874ffaa..cb4bf7c19 100644 --- a/app/components/ContextMenu/Template.tsx +++ b/app/components/ContextMenu/Template.tsx @@ -30,6 +30,7 @@ type Props = Omit & { actions?: (Action | MenuSeparator | MenuHeading)[]; context?: Partial; items?: TMenuItem[]; + showIcons?: boolean; }; const Disclosure = styled(ExpandedIcon)` @@ -98,7 +99,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] { }); } -function Template({ items, actions, context, ...menu }: Props) { +function Template({ items, actions, context, showIcons, ...menu }: Props) { const ctx = useActionContext({ isContextMenu: true, }); @@ -124,7 +125,8 @@ function Template({ items, actions, context, ...menu }: Props) { if ( iconIsPresentInAnyMenuItem && item.type !== "separator" && - item.type !== "heading" + item.type !== "heading" && + showIcons !== false ) { item.icon = item.icon || ; } @@ -138,7 +140,7 @@ function Template({ items, actions, context, ...menu }: Props) { key={index} disabled={item.disabled} selected={item.selected} - icon={item.icon} + icon={showIcons !== false ? item.icon : undefined} {...menu} > {item.title} @@ -156,7 +158,7 @@ function Template({ items, actions, context, ...menu }: Props) { selected={item.selected} level={item.level} target={item.href.startsWith("#") ? undefined : "_blank"} - icon={item.icon} + icon={showIcons !== false ? item.icon : undefined} {...menu} > {item.title} @@ -174,7 +176,7 @@ function Template({ items, actions, context, ...menu }: Props) { selected={item.selected} dangerous={item.dangerous} key={index} - icon={item.icon} + icon={showIcons !== false ? item.icon : undefined} {...menu} > {item.title} @@ -190,7 +192,12 @@ function Template({ items, actions, context, ...menu }: Props) { id={`${item.title}-${index}`} templateItems={item.items} parentMenuState={menu} - title={} + title={ + <Title + title={item.title} + icon={showIcons !== false ? item.icon : undefined} + /> + } {...menu} /> ); diff --git a/app/editor/index.tsx b/app/editor/index.tsx index d8a848c33..27ea8d007 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -640,27 +640,56 @@ export class Editor extends React.PureComponent< public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc); /** - * Remove a specific comment mark from the document. + * Remove all marks related to a specific comment 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) { + if (!node.isInline) { return; } const mark = node.marks.find( - (mark) => - mark.type === state.schema.marks.comment && - mark.attrs.id === commentId + (m) => m.type === state.schema.marks.comment && m.attrs.id === commentId ); if (mark) { dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark)); - found = true; + } + }); + }; + + /** + * Update all marks related to a specific comment in the document. + * + * @param commentId The id of the comment to remove + * @param attrs The attributes to update + */ + public updateComment = (commentId: string, attrs: { resolved: boolean }) => { + const { state, dispatch } = this.view; + + state.doc.descendants((node, pos) => { + if (!node.isInline) { + return; + } + + const mark = node.marks.find( + (m) => m.type === state.schema.marks.comment && m.attrs.id === commentId + ); + + if (mark) { + const from = pos; + const to = pos + node.nodeSize; + const newMark = state.schema.marks.comment.create({ + ...mark.attrs, + ...attrs, + }); + dispatch( + state.tr.removeMark(from, to, mark).addMark(from, to, newMark) + ); } }); }; @@ -808,6 +837,7 @@ const EditorContainer = styled(Styles)<{ css` #comment-${props.focusedCommentId} { background: ${transparentize(0.5, props.theme.brand.marine)}; + border-bottom: 2px solid ${props.theme.commentMarkBackground}; } `} diff --git a/app/editor/menus/formatting.tsx b/app/editor/menus/formatting.tsx index d0bae86d5..fe6551d9c 100644 --- a/app/editor/menus/formatting.tsx +++ b/app/editor/menus/formatting.tsx @@ -209,7 +209,7 @@ export default function formattingMenuItems( tooltip: dictionary.comment, icon: <CommentIcon />, label: isCodeBlock ? dictionary.comment : undefined, - active: isMarkActive(schema.marks.comment), + active: isMarkActive(schema.marks.comment, { resolved: false }), visible: !isMobile || !isEmpty, }, { diff --git a/app/menus/CommentMenu.tsx b/app/menus/CommentMenu.tsx index bbab6aae6..71f97f1e3 100644 --- a/app/menus/CommentMenu.tsx +++ b/app/menus/CommentMenu.tsx @@ -1,16 +1,22 @@ import copy from "copy-to-clipboard"; import { observer } from "mobx-react"; +import { CopyIcon, EditIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useMenuState } from "reakit/Menu"; import { toast } from "sonner"; import EventBoundary from "@shared/components/EventBoundary"; 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 Template from "~/components/ContextMenu/Template"; +import { actionToMenuItem } from "~/actions"; +import { + deleteCommentFactory, + resolveCommentFactory, + unresolveCommentFactory, +} from "~/actions/definitions/comments"; +import useActionContext from "~/hooks/useActionContext"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { commentPath, urlify } from "~/utils/routeHelpers"; @@ -24,24 +30,26 @@ type Props = { onEdit: () => void; /** Callback when the comment has been deleted */ onDelete: () => void; + /** Callback when the comment has been updated */ + onUpdate: (attrs: { resolved: boolean }) => void; }; -function CommentMenu({ comment, onEdit, onDelete, className }: Props) { +function CommentMenu({ + comment, + onEdit, + onDelete, + onUpdate, + className, +}: Props) { const menu = useMenuState({ modal: true, }); - const { documents, dialogs } = useStores(); + const { documents } = useStores(); const { t } = useTranslation(); const can = usePolicy(comment); + const context = useActionContext({ isContextMenu: true }); const document = documents.get(comment.documentId); - const handleDelete = React.useCallback(() => { - dialogs.openModal({ - title: t("Delete comment"), - content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />, - }); - }, [dialogs, comment, onDelete, t]); - const handleCopyLink = React.useCallback(() => { if (document) { copy(urlify(commentPath(document, comment))); @@ -58,24 +66,46 @@ function CommentMenu({ comment, onEdit, onDelete, className }: Props) { {...menu} /> </EventBoundary> - <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> - </> - )} + <Template + {...menu} + items={[ + { + type: "button", + title: `${t("Edit")}…`, + icon: <EditIcon />, + onClick: onEdit, + visible: can.update, + }, + actionToMenuItem( + resolveCommentFactory({ + comment, + onResolve: () => onUpdate({ resolved: true }), + }), + context + ), + actionToMenuItem( + unresolveCommentFactory({ + comment, + onUnresolve: () => onUpdate({ resolved: false }), + }), + context + ), + { + type: "button", + icon: <CopyIcon />, + title: t("Copy link"), + onClick: handleCopyLink, + }, + { + type: "separator", + }, + actionToMenuItem( + deleteCommentFactory({ comment, onDelete }), + context + ), + ]} + /> </ContextMenu> </> ); diff --git a/app/models/Comment.ts b/app/models/Comment.ts index 8fc2c3660..afb0a22f3 100644 --- a/app/models/Comment.ts +++ b/app/models/Comment.ts @@ -3,6 +3,7 @@ import { computed, observable } from "mobx"; import { now } from "mobx-utils"; import type { ProsemirrorData } from "@shared/types"; import User from "~/models/User"; +import Document from "./Document"; import Model from "./base/Model"; import Field from "./decorators/Field"; import Relation from "./decorators/Relation"; @@ -34,7 +35,7 @@ class Comment extends Model { */ @Field @observable - parentCommentId: string; + parentCommentId: string | null; /** * The comment that this comment is a reply to. @@ -43,33 +44,86 @@ class Comment extends Model { parentComment?: Comment; /** - * The document to which this comment belongs. + * The document ID to which this comment belongs. */ @Field @observable documentId: string; + /** + * The document that this comment belongs to. + */ + @Relation(() => Document, { onDelete: "cascade" }) + document: Document; + + /** + * The user who created this comment. + */ @Relation(() => User) createdBy: User; + /** + * The ID of the user who created this comment. + */ createdById: string; + /** + * The date and time that this comment was resolved, if it has been resolved. + */ @observable resolvedAt: string; + /** + * The user who resolved this comment, if it has been resolved. + */ @Relation(() => User) - resolvedBy: User; + resolvedBy: User | null; + + /** + * The ID of the user who resolved this comment, if it has been resolved. + */ + resolvedById: string | null; /** * An array of users that are currently typing a reply in this comments thread. */ @computed - get currentlyTypingUsers(): User[] { + public 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) as User[]; } + + /** + * Whether the comment is resolved + */ + @computed + public get isResolved() { + return !!this.resolvedAt; + } + + /** + * Whether the comment is a reply to another comment. + */ + @computed + public get isReply() { + return !!this.parentCommentId; + } + + /** + * Resolve the comment + */ + public resolve() { + return this.store.rootStore.comments.resolve(this.id); + } + + /** + * Unresolve the comment + */ + public unresolve() { + return this.store.rootStore.comments.unresolve(this.id); + } } export default Comment; diff --git a/app/scenes/Document/components/CommentForm.tsx b/app/scenes/Document/components/CommentForm.tsx index 0cb297f00..e1f82d6bf 100644 --- a/app/scenes/Document/components/CommentForm.tsx +++ b/app/scenes/Document/components/CommentForm.tsx @@ -106,6 +106,7 @@ function CommentForm({ thread ?? new Comment( { + createdAt: new Date().toISOString(), documentId, data: draft, }, @@ -139,6 +140,7 @@ function CommentForm({ const comment = new Comment( { + createdAt: new Date().toISOString(), parentCommentId: thread?.id, documentId, data: draft, diff --git a/app/scenes/Document/components/CommentThread.tsx b/app/scenes/Document/components/CommentThread.tsx index 61e345b49..4269e4b07 100644 --- a/app/scenes/Document/components/CommentThread.tsx +++ b/app/scenes/Document/components/CommentThread.tsx @@ -2,7 +2,7 @@ import throttle from "lodash/throttle"; import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useHistory } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; import scrollIntoView from "smooth-scroll-into-view-if-needed"; import styled, { css } from "styled-components"; import breakpoint from "styled-components-breakpoint"; @@ -70,6 +70,7 @@ function CommentThread({ const user = useCurrentUser(); const { t } = useTranslation(); const history = useHistory(); + const location = useLocation(); const [autoFocus, setAutoFocus] = React.useState(thread.isNew); const [, setIsTyping] = useTypingIndicator({ document, @@ -92,7 +93,8 @@ function CommentThread({ !(event.target as HTMLElement).classList.contains("comment") ) { history.replace({ - pathname: window.location.pathname, + search: location.search, + pathname: location.pathname, state: { commentId: undefined }, }); } @@ -100,7 +102,8 @@ function CommentThread({ const handleClickThread = () => { history.replace({ - pathname: window.location.pathname.replace(/\/history$/, ""), + search: location.search, + pathname: location.pathname.replace(/\/history$/, ""), state: { commentId: thread.id }, }); }; @@ -177,6 +180,7 @@ function CommentThread({ highlightedText={index === 0 ? highlightedText : undefined} comment={comment} onDelete={() => editor?.removeComment(comment.id)} + onUpdate={(attrs) => editor?.updateComment(comment.id, attrs)} key={comment.id} firstOfThread={index === 0} lastOfThread={index === commentsInThread.length - 1 && !draft} diff --git a/app/scenes/Document/components/CommentThreadItem.tsx b/app/scenes/Document/components/CommentThreadItem.tsx index aeda32d28..73a9a7b93 100644 --- a/app/scenes/Document/components/CommentThreadItem.tsx +++ b/app/scenes/Document/components/CommentThreadItem.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import styled, { css } from "styled-components"; import breakpoint from "styled-components-breakpoint"; +import EventBoundary from "@shared/components/EventBoundary"; import { s } from "@shared/styles"; import { ProsemirrorData } from "@shared/types"; import { dateToRelative } from "@shared/utils/date"; @@ -76,6 +77,8 @@ type Props = { canReply: boolean; /** Callback when the comment has been deleted */ onDelete: () => void; + /** Callback when the comment has been updated */ + onUpdate: (attrs: { resolved: boolean }) => void; /** Text to highlight at the top of the comment */ highlightedText?: string; }; @@ -89,6 +92,7 @@ function CommentThreadItem({ previousCommentCreatedAt, canReply, onDelete, + onUpdate, highlightedText, }: Props) { const { t } = useTranslation(); @@ -97,7 +101,9 @@ function CommentThreadItem({ const showAuthor = firstOfAuthor; const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt); const showEdited = - comment.updatedAt && comment.updatedAt !== comment.createdAt; + comment.updatedAt && + comment.updatedAt !== comment.createdAt && + !comment.isResolved; const [isEditing, setEditing, setReadOnly] = useBoolean(); const formRef = React.useRef<HTMLFormElement>(null); @@ -198,14 +204,17 @@ function CommentThreadItem({ </Flex> )} </Body> - {!isEditing && ( - <Menu - comment={comment} - onEdit={setEditing} - onDelete={onDelete} - dir={dir} - /> - )} + <EventBoundary> + {!isEditing && ( + <Menu + comment={comment} + onEdit={setEditing} + onDelete={onDelete} + onUpdate={onUpdate} + dir={dir} + /> + )} + </EventBoundary> </Bubble> </Flex> ); diff --git a/app/scenes/Document/components/Comments.tsx b/app/scenes/Document/components/Comments.tsx index fe485232c..cf5675e37 100644 --- a/app/scenes/Document/components/Comments.tsx +++ b/app/scenes/Document/components/Comments.tsx @@ -1,19 +1,25 @@ import { AnimatePresence } from "framer-motion"; import { observer } from "mobx-react"; +import { DoneIcon } from "outline-icons"; +import queryString from "query-string"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useRouteMatch } from "react-router-dom"; -import styled from "styled-components"; +import { useHistory, useLocation, useRouteMatch } from "react-router-dom"; +import styled, { css } from "styled-components"; import { ProsemirrorData } from "@shared/types"; +import Button from "~/components/Button"; import Empty from "~/components/Empty"; import Flex from "~/components/Flex"; import Scrollable from "~/components/Scrollable"; +import Tooltip from "~/components/Tooltip"; import useCurrentUser from "~/hooks/useCurrentUser"; import useFocusedComment from "~/hooks/useFocusedComment"; import useKeyDown from "~/hooks/useKeyDown"; import usePersistedState from "~/hooks/usePersistedState"; import usePolicy from "~/hooks/usePolicy"; +import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; +import { bigPulse } from "~/styles/animations"; import CommentForm from "./CommentForm"; import CommentThread from "./CommentThread"; import Sidebar from "./SidebarLayout"; @@ -22,7 +28,11 @@ function Comments() { const { ui, comments, documents } = useStores(); const { t } = useTranslation(); const user = useCurrentUser(); + const location = useLocation(); + const history = useHistory(); const match = useRouteMatch<{ documentSlug: string }>(); + const params = useQuery(); + const [pulse, setPulse] = React.useState(false); const document = documents.getByUrl(match.params.documentSlug); const focusedComment = useFocusedComment(); const can = usePolicy(document); @@ -34,18 +44,75 @@ function Comments() { undefined ); + const viewingResolved = params.get("resolved") === ""; + const resolvedThreads = document + ? comments.resolvedThreadsInDocument(document.id) + : []; + const resolvedThreadsCount = resolvedThreads.length; + + React.useEffect(() => { + setPulse(true); + const timeout = setTimeout(() => setPulse(false), 250); + + return () => { + clearTimeout(timeout); + setPulse(false); + }; + }, [resolvedThreadsCount]); + if (!document) { return null; } - const threads = comments - .threadsInDocument(document.id) - .filter((thread) => !thread.isNew || thread.createdById === user.id); + const threads = ( + viewingResolved + ? resolvedThreads + : comments.unresolvedThreadsInDocument(document.id) + ).filter((thread) => thread.createdById === user.id); const hasComments = threads.length > 0; + const toggleViewingResolved = () => { + history.push({ + search: queryString.stringify({ + ...queryString.parse(location.search), + resolved: viewingResolved ? undefined : "", + }), + pathname: location.pathname, + }); + }; + return ( <Sidebar - title={t("Comments")} + title={ + <Flex align="center" justify="space-between" auto> + {viewingResolved ? ( + <React.Fragment key="resolved"> + <span>{t("Resolved comments")}</span> + <Tooltip delay={500} content={t("View comments")}> + <ResolvedButton + neutral + borderOnHover + icon={<DoneIcon />} + onClick={toggleViewingResolved} + /> + </Tooltip> + </React.Fragment> + ) : ( + <React.Fragment> + <span>{t("Comments")}</span> + <Tooltip delay={250} content={t("View resolved comments")}> + <ResolvedButton + neutral + borderOnHover + icon={<DoneIcon outline />} + onClick={toggleViewingResolved} + $pulse={pulse} + /> + </Tooltip> + </React.Fragment> + )} + </Flex> + } onClose={() => ui.collapseComments(document?.id)} scrollable={false} > @@ -68,13 +135,17 @@ function Comments() { )) ) : ( <NoComments align="center" justify="center" auto> - <PositionedEmpty>{t("No comments yet")}</PositionedEmpty> + <PositionedEmpty> + {viewingResolved + ? t("No resolved comments") + : t("No comments yet")} + </PositionedEmpty> </NoComments> )} </Wrapper> </Scrollable> <AnimatePresence initial={false}> - {!focusedComment && can.comment && ( + {!focusedComment && can.comment && !viewingResolved && ( <NewCommentForm draft={draft} onSaveDraft={onSaveDraft} @@ -91,6 +162,14 @@ function Comments() { ); } +const ResolvedButton = styled(Button)<{ $pulse: boolean }>` + ${(props) => + props.$pulse && + css` + animation: ${bigPulse} 250ms 1; + `} +`; + const PositionedEmpty = styled(Empty)` position: absolute; top: calc(50vh - 30px); diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 72b222ac8..28bfc2171 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -186,7 +186,7 @@ function DataLoader({ match, children }: Props) { // when viewing a public share link if (can.read && !document.isDeleted) { if (team.getPreference(TeamPreference.Commenting)) { - void comments.fetchPage({ + void comments.fetchAll({ documentId: document.id, limit: 100, }); diff --git a/app/scenes/Document/components/DocumentMeta.tsx b/app/scenes/Document/components/DocumentMeta.tsx index 5af9535c9..3a47259bb 100644 --- a/app/scenes/Document/components/DocumentMeta.tsx +++ b/app/scenes/Document/components/DocumentMeta.tsx @@ -37,7 +37,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) { const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade; const insightsPath = documentInsightsPath(document); - const commentsCount = comments.filter({ documentId: document.id }).length; + const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id); return ( <Meta document={document} revision={revision} to={to} replace {...rest}> diff --git a/app/scenes/Document/components/SidebarLayout.tsx b/app/scenes/Document/components/SidebarLayout.tsx index 3e8ec263d..ff6982960 100644 --- a/app/scenes/Document/components/SidebarLayout.tsx +++ b/app/scenes/Document/components/SidebarLayout.tsx @@ -13,7 +13,7 @@ import useMobile from "~/hooks/useMobile"; import { draggableOnDesktop } from "~/styles"; import { fadeIn } from "~/styles/animations"; -type Props = React.HTMLAttributes<HTMLDivElement> & { +type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & { /* The title of the sidebar */ title: React.ReactNode; /* The content of the sidebar */ diff --git a/app/scenes/KeyboardShortcuts.tsx b/app/scenes/KeyboardShortcuts.tsx index 645e35193..463243e70 100644 --- a/app/scenes/KeyboardShortcuts.tsx +++ b/app/scenes/KeyboardShortcuts.tsx @@ -114,6 +114,19 @@ function KeyboardShortcuts() { }, ], }, + { + title: t("Collaboration"), + items: [ + { + shortcut: ( + <> + <Key symbol>{metaDisplay}</Key> + <Key>Alt</Key> + <Key>m</Key> + </> + ), + label: t("Comment"), + }, + ], + }, { title: t("Formatting"), items: [ diff --git a/app/stores/CommentsStore.ts b/app/stores/CommentsStore.ts index f529476a2..2ada84fe0 100644 --- a/app/stores/CommentsStore.ts +++ b/app/stores/CommentsStore.ts @@ -1,6 +1,8 @@ +import invariant from "invariant"; import orderBy from "lodash/orderBy"; import { action, computed } from "mobx"; import Comment from "~/models/Comment"; +import { client } from "~/utils/ApiClient"; import RootStore from "./RootStore"; import Store from "./base/Store"; @@ -29,12 +31,54 @@ export default class CommentsStore extends Store<Comment> { threadsInDocument(documentId: string): Comment[] { return this.filter( (comment: Comment) => - comment.documentId === documentId && !comment.parentCommentId + comment.documentId === documentId && + !comment.parentCommentId && + (!comment.isNew || + comment.createdById === this.rootStore.auth.currentUserId) ); } /** - * Returns a list of comments that are replies to the given comment. + * Returns a list of resolved 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 + */ + resolvedThreadsInDocument(documentId: string): Comment[] { + return this.threadsInDocument(documentId).filter( + (comment: Comment) => comment.isResolved === true + ); + } + + /** + * 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 + */ + unresolvedThreadsInDocument(documentId: string): Comment[] { + return this.threadsInDocument(documentId).filter( + (comment: Comment) => comment.isResolved === false + ); + } + + /** + * Returns the total number of unresolbed comments in the given document. + * + * @param documentId ID of the document to get comments for + * @returns A number of comments + */ + unresolvedCommentsInDocumentCount(documentId: string): number { + return this.unresolvedThreadsInDocument(documentId).reduce( + (memo, thread) => memo + this.inThread(thread.id).length, + 0 + ); + } + + /** + * Returns a list of comments that includes the given thread ID and any of it's replies. * * @param commentId ID of the comment to get replies for * @returns Array of comments @@ -46,6 +90,40 @@ export default class CommentsStore extends Store<Comment> { ); } + /** + * Resolve a comment thread with the given ID. + * + * @param id ID of the comment to resolve + * @returns Resolved comment + */ + @action + resolve = async (id: string): Promise<Comment> => { + const res = await client.post("/comments.resolve", { + id, + }); + invariant(res?.data, "Comment not available"); + this.addPolicies(res.policies); + this.add(res.data); + return this.data.get(res.data.id) as Comment; + }; + + /** + * Unresolve a comment thread with the given ID. + * + * @param id ID of the comment to unresolve + * @returns Unresolved comment + */ + @action + unresolve = async (id: string): Promise<Comment> => { + const res = await client.post("/comments.unresolve", { + id, + }); + invariant(res?.data, "Comment not available"); + this.addPolicies(res.policies); + this.add(res.data); + return this.data.get(res.data.id) as Comment; + }; + @action setTyping({ commentId, diff --git a/app/styles/animations.ts b/app/styles/animations.ts index 4e0d6875d..c00c085c3 100644 --- a/app/styles/animations.ts +++ b/app/styles/animations.ts @@ -116,6 +116,12 @@ export const pulse = keyframes` 100% { transform: scale(1); } `; +export const bigPulse = keyframes` + 0% { transform: scale(1); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } +`; + /** * The duration of the sidebar appearing animation in ms */ diff --git a/package.json b/package.json index b7f439f5e..44aa535bf 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "node-fetch": "2.7.0", "nodemailer": "^6.9.14", "octokit": "^3.2.1", - "outline-icons": "^3.4.1", + "outline-icons": "^3.5.0", "oy-vey": "^0.12.1", "passport": "^0.7.0", "passport-google-oauth2": "^0.2.0", diff --git a/server/middlewares/feature.ts b/server/middlewares/feature.ts new file mode 100644 index 000000000..8f4f3fe9e --- /dev/null +++ b/server/middlewares/feature.ts @@ -0,0 +1,19 @@ +import { Next } from "koa"; +import { TeamPreference } from "@shared/types"; +import { ValidationError } from "@server/errors"; +import { APIContext } from "@server/types"; + +/** + * Middleware to check if a feature is enabled for the team. + * + * @param preference The preference to check + * @returns The middleware function + */ +export function feature(preference: TeamPreference) { + return async function featureEnabledMiddleware(ctx: APIContext, next: Next) { + if (!ctx.state.auth.user.team.getPreference(preference)) { + throw ValidationError(`${preference} is currently disabled`); + } + return next(); + }; +} diff --git a/server/models/Comment.ts b/server/models/Comment.ts index 7857d1eb6..d13089e28 100644 --- a/server/models/Comment.ts +++ b/server/models/Comment.ts @@ -13,6 +13,7 @@ import type { ProsemirrorData } from "@shared/types"; import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; import { CommentValidation } from "@shared/validations"; import { schema } from "@server/editor"; +import { ValidationError } from "@server/errors"; import Document from "./Document"; import User from "./User"; import ParanoidModel from "./base/ParanoidModel"; @@ -26,6 +27,11 @@ import TextLength from "./validators/TextLength"; as: "createdBy", paranoid: false, }, + { + model: User, + as: "resolvedBy", + paranoid: false, + }, ], })) @Table({ tableName: "comments", modelName: "comment" }) @@ -54,12 +60,15 @@ class Comment extends ParanoidModel< @Column(DataType.UUID) createdById: string; + @Column(DataType.DATE) + resolvedAt: Date | null; + @BelongsTo(() => User, "resolvedById") - resolvedBy: User; + resolvedBy: User | null; @ForeignKey(() => User) @Column(DataType.UUID) - resolvedById: string; + resolvedById: string | null; @BelongsTo(() => Document, "documentId") document: Document; @@ -75,6 +84,51 @@ class Comment extends ParanoidModel< @Column(DataType.UUID) parentCommentId: string; + // methods + + /** + * Resolve the comment. Note this does not save the comment to the database. + * + * @param resolvedBy The user who resolved the comment + */ + public resolve(resolvedBy: User) { + if (this.isResolved) { + throw ValidationError("Comment is already resolved"); + } + if (this.parentCommentId) { + throw ValidationError("Cannot resolve a reply"); + } + + this.resolvedById = resolvedBy.id; + this.resolvedBy = resolvedBy; + this.resolvedAt = new Date(); + } + + /** + * Unresolve the comment. Note this does not save the comment to the database. + */ + public unresolve() { + if (!this.isResolved) { + throw ValidationError("Comment is not resolved"); + } + + this.resolvedById = null; + this.resolvedBy = null; + this.resolvedAt = null; + } + + /** + * Whether the comment is resolved + */ + public get isResolved() { + return !!this.resolvedAt; + } + + /** + * Convert the comment data to plain text + * + * @returns The plain text representation of the comment data + */ public toPlainText() { const node = Node.fromJSON(schema, this.data); return ProsemirrorHelper.toPlainText(node, schema); diff --git a/server/onerror.ts b/server/onerror.ts index dd5865b31..ad6848ee8 100644 --- a/server/onerror.ts +++ b/server/onerror.ts @@ -73,6 +73,10 @@ export default function onerror(app: Koa) { requestErrorHandler(err, this); if (!(err instanceof InternalError)) { + if (env.ENVIRONMENT === "test") { + // eslint-disable-next-line no-console + console.error(err); + } err = InternalError(); } } diff --git a/server/policies/comment.ts b/server/policies/comment.ts index 7d411fe28..14abf24e4 100644 --- a/server/policies/comment.ts +++ b/server/policies/comment.ts @@ -8,6 +8,22 @@ allow(User, "read", Comment, (actor, comment) => isTeamModel(actor, comment?.createdBy) ); +allow(User, "resolve", Comment, (actor, comment) => + and( + isTeamModel(actor, comment?.createdBy), + comment?.parentCommentId === null, + comment?.resolvedById === null + ) +); + +allow(User, "unresolve", Comment, (actor, comment) => + and( + isTeamModel(actor, comment?.createdBy), + comment?.parentCommentId === null, + comment?.resolvedById !== null + ) +); + allow(User, ["update", "delete"], Comment, (actor, comment) => and( isTeamModel(actor, comment?.createdBy), diff --git a/server/presenters/comment.ts b/server/presenters/comment.ts index a2509ab74..9267fe7a6 100644 --- a/server/presenters/comment.ts +++ b/server/presenters/comment.ts @@ -9,6 +9,9 @@ export default function present(comment: Comment) { parentCommentId: comment.parentCommentId, createdBy: presentUser(comment.createdBy), createdById: comment.createdById, + resolvedAt: comment.resolvedAt, + resolvedBy: comment.resolvedBy ? presentUser(comment.resolvedBy) : null, + resolvedById: comment.resolvedById, createdAt: comment.createdAt, updatedAt: comment.updatedAt, }; diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index 5a7da6c43..dfa9edd45 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -424,6 +424,7 @@ export default class WebsocketsProcessor { case "comments.delete": { const comment = await Comment.findByPk(event.modelId, { + paranoid: false, include: [ { model: Document.scope(["withoutState", "withDrafts"]), diff --git a/server/routes/api/comments/__snapshots__/comments.test.ts.snap b/server/routes/api/comments/__snapshots__/comments.test.ts.snap index dfb06a20a..549f072d4 100644 --- a/server/routes/api/comments/__snapshots__/comments.test.ts.snap +++ b/server/routes/api/comments/__snapshots__/comments.test.ts.snap @@ -26,3 +26,30 @@ exports[`#comments.list should require authentication 1`] = ` "status": 401, } `; + +exports[`#comments.resolve should require authentication 1`] = ` +{ + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#comments.unresolve should require authentication 1`] = ` +{ + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#comments.update should require authentication 1`] = ` +{ + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; diff --git a/server/routes/api/comments/comments.test.ts b/server/routes/api/comments/comments.test.ts index 394d1d95c..cea2c5e34 100644 --- a/server/routes/api/comments/comments.test.ts +++ b/server/routes/api/comments/comments.test.ts @@ -1,8 +1,10 @@ +import { CommentStatusFilter } from "@shared/types"; import { buildAdmin, buildCollection, buildComment, buildDocument, + buildResolvedComment, buildTeam, buildUser, } from "@server/test/factories"; @@ -10,6 +12,73 @@ import { getTestServer } from "@server/test/support"; const server = getTestServer(); +describe("#comments.info", () => { + it("should require authentication", async () => { + const res = await server.post("/api/comments.info"); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should return comment info", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const user2 = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + const comment = await buildComment({ + userId: user2.id, + documentId: document.id, + }); + const res = await server.post("/api/comments.info", { + body: { + token: user.getJwtToken(), + id: comment.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).toEqual(comment.id); + expect(body.data.data).toEqual(comment.data); + expect(body.policies.length).toEqual(1); + expect(body.policies[0].abilities.read).toEqual(true); + expect(body.policies[0].abilities.update).toEqual(false); + expect(body.policies[0].abilities.delete).toEqual(false); + }); + + it("should return comment info for admin", async () => { + const team = await buildTeam(); + const user = await buildAdmin({ teamId: team.id }); + const user2 = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + const comment = await buildComment({ + userId: user2.id, + documentId: document.id, + }); + const res = await server.post("/api/comments.info", { + body: { + token: user.getJwtToken(), + id: comment.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.id).toEqual(comment.id); + expect(body.data.data).toEqual(comment.data); + expect(body.policies.length).toEqual(1); + expect(body.policies[0].abilities.read).toEqual(true); + expect(body.policies[0].abilities.update).toEqual(true); + expect(body.policies[0].abilities.delete).toEqual(true); + }); +}); + describe("#comments.list", () => { it("should require authentication", async () => { const res = await server.post("/api/comments.list"); @@ -29,6 +98,10 @@ describe("#comments.list", () => { userId: user.id, documentId: document.id, }); + await buildResolvedComment(user, { + userId: user.id, + documentId: document.id, + }); const res = await server.post("/api/comments.list", { body: { token: user.getJwtToken(), @@ -38,13 +111,14 @@ describe("#comments.list", () => { const body = await res.json(); expect(res.status).toEqual(200); - expect(body.data.length).toEqual(1); - expect(body.data[0].id).toEqual(comment.id); - expect(body.policies.length).toEqual(1); + expect(body.data.length).toEqual(2); + expect(body.data[1].id).toEqual(comment.id); + expect(body.policies.length).toEqual(2); expect(body.policies[0].abilities.read).toEqual(true); + expect(body.policies[1].abilities.read).toEqual(true); }); - it("should return all comments for a collection", async () => { + it("should return unresolved comments for a collection", async () => { const team = await buildTeam(); const user = await buildUser({ teamId: team.id }); const collection = await buildCollection({ @@ -75,7 +149,71 @@ describe("#comments.list", () => { expect(body.policies[0].abilities.read).toEqual(true); }); - it("should return all comments", async () => { + it("should return unresolved comments for a parentCommentId", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + const comment = await buildComment({ + userId: user.id, + documentId: document.id, + }); + const childComment = await buildComment({ + userId: user.id, + documentId: document.id, + parentCommentId: comment.id, + }); + const res = await server.post("/api/comments.list", { + body: { + token: user.getJwtToken(), + parentCommentId: comment.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(childComment.id); + expect(body.policies.length).toEqual(1); + expect(body.policies[0].abilities.read).toEqual(true); + }); + + it("should return resolved comments for a statusFilter", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + await buildComment({ + userId: user.id, + documentId: document.id, + }); + const resolved = await buildResolvedComment(user, { + userId: user.id, + documentId: document.id, + }); + const res = await server.post("/api/comments.list", { + body: { + token: user.getJwtToken(), + documentId: document.id, + statusFilter: [CommentStatusFilter.Resolved], + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(resolved.id); + expect(body.policies.length).toEqual(1); + expect(body.policies[0].abilities.read).toEqual(true); + expect(body.policies[0].abilities.unresolve).toEqual(true); + expect(body.policies[0].abilities.resolve).toEqual(false); + }); + + it("should return all unresolved comments", async () => { const team = await buildTeam(); const user = await buildUser({ teamId: team.id }); const collection1 = await buildCollection({ @@ -310,65 +448,37 @@ describe("#comments.create", () => { }); }); -describe("#comments.info", () => { +describe("#comments.update", () => { it("should require authentication", async () => { - const res = await server.post("/api/comments.info"); + const res = await server.post("/api/comments.update"); const body = await res.json(); expect(res.status).toEqual(401); expect(body).toMatchSnapshot(); }); - it("should return comment info", async () => { + it("should update an existing comment", async () => { const team = await buildTeam(); const user = await buildUser({ teamId: team.id }); - const user2 = await buildUser({ teamId: team.id }); const document = await buildDocument({ userId: user.id, teamId: user.teamId, }); + const comment = await buildComment({ - userId: user2.id, + userId: user.id, documentId: document.id, }); - const res = await server.post("/api/comments.info", { + + const res = await server.post("/api/comments.update", { body: { token: user.getJwtToken(), id: comment.id, + data: comment.data, }, }); const body = await res.json(); expect(res.status).toEqual(200); - expect(body.data.id).toEqual(comment.id); - expect(body.data.data).toEqual(comment.data); - expect(body.policies.length).toEqual(1); - expect(body.policies[0].abilities.read).toEqual(true); - expect(body.policies[0].abilities.update).toEqual(false); - expect(body.policies[0].abilities.delete).toEqual(false); - }); - - it("should return comment info for admin", async () => { - const team = await buildTeam(); - const user = await buildAdmin({ teamId: team.id }); - const user2 = await buildUser({ teamId: team.id }); - const document = await buildDocument({ - userId: user.id, - teamId: user.teamId, - }); - const comment = await buildComment({ - userId: user2.id, - documentId: document.id, - }); - const res = await server.post("/api/comments.info", { - body: { - token: user.getJwtToken(), - id: comment.id, - }, - }); - const body = await res.json(); - - expect(res.status).toEqual(200); - expect(body.data.id).toEqual(comment.id); expect(body.data.data).toEqual(comment.data); expect(body.policies.length).toEqual(1); expect(body.policies[0].abilities.read).toEqual(true); @@ -376,3 +486,115 @@ describe("#comments.info", () => { expect(body.policies[0].abilities.delete).toEqual(true); }); }); + +describe("#comments.resolve", () => { + it("should require authentication", async () => { + const res = await server.post("/api/comments.resolve"); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should allow resolving a comment thread", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + const comment = await buildComment({ + userId: user.id, + documentId: document.id, + }); + + const res = await server.post("/api/comments.resolve", { + body: { + token: user.getJwtToken(), + id: comment.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.resolvedAt).toBeTruthy(); + expect(body.data.resolvedById).toEqual(user.id); + expect(body.data.resolvedBy.id).toEqual(user.id); + expect(body.policies.length).toEqual(1); + expect(body.policies[0].abilities.read).toEqual(true); + expect(body.policies[0].abilities.update).toEqual(true); + expect(body.policies[0].abilities.delete).toEqual(true); + expect(body.policies[0].abilities.unresolve).toEqual(true); + expect(body.policies[0].abilities.resolve).toEqual(false); + }); + + it("should not allow resolving a child comment", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + const parentComment = await buildComment({ + userId: user.id, + documentId: document.id, + }); + + const comment = await buildComment({ + userId: user.id, + documentId: document.id, + parentCommentId: parentComment.id, + }); + + const res = await server.post("/api/comments.resolve", { + body: { + token: user.getJwtToken(), + id: comment.id, + }, + }); + expect(res.status).toEqual(403); + }); +}); + +describe("#comments.unresolve", () => { + it("should require authentication", async () => { + const res = await server.post("/api/comments.unresolve"); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should allow unresolving a comment", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + const comment = await buildResolvedComment(user, { + userId: user.id, + documentId: document.id, + }); + + const res = await server.post("/api/comments.unresolve", { + body: { + token: user.getJwtToken(), + id: comment.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.resolvedAt).toEqual(null); + expect(body.data.resolvedBy).toEqual(null); + expect(body.data.resolvedById).toEqual(null); + expect(body.policies.length).toEqual(1); + expect(body.policies[0].abilities.read).toEqual(true); + expect(body.policies[0].abilities.update).toEqual(true); + expect(body.policies[0].abilities.delete).toEqual(true); + expect(body.policies[0].abilities.resolve).toEqual(true); + expect(body.policies[0].abilities.unresolve).toEqual(false); + }); +}); diff --git a/server/routes/api/comments/comments.ts b/server/routes/api/comments/comments.ts index 78b7d1cdc..0ca6142eb 100644 --- a/server/routes/api/comments/comments.ts +++ b/server/routes/api/comments/comments.ts @@ -1,16 +1,15 @@ -import { Next } from "koa"; import Router from "koa-router"; -import { FindOptions, Op } from "sequelize"; -import { TeamPreference } from "@shared/types"; +import { FindOptions, Op, WhereOptions } from "sequelize"; +import { CommentStatusFilter, TeamPreference } from "@shared/types"; import commentCreator from "@server/commands/commentCreator"; import commentDestroyer from "@server/commands/commentDestroyer"; import commentUpdater from "@server/commands/commentUpdater"; -import { ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; +import { feature } from "@server/middlewares/feature"; import { rateLimiter } from "@server/middlewares/rateLimiter"; import { transaction } from "@server/middlewares/transaction"; import validate from "@server/middlewares/validate"; -import { Document, Comment, Collection } from "@server/models"; +import { Document, Comment, Collection, Event } from "@server/models"; import { authorize } from "@server/policies"; import { presentComment, presentPolicies } from "@server/presenters"; import { APIContext } from "@server/types"; @@ -24,7 +23,7 @@ router.post( "comments.create", rateLimiter(RateLimiterStrategy.TenPerMinute), auth(), - checkCommentingEnabled(), + feature(TeamPreference.Commenting), validate(T.CommentsCreateSchema), transaction(), async (ctx: APIContext<T.CommentsCreateReq>) => { @@ -58,7 +57,7 @@ router.post( router.post( "comments.info", auth(), - checkCommentingEnabled(), + feature(TeamPreference.Commenting), validate(T.CommentsInfoSchema), async (ctx: APIContext<T.CommentsInfoReq>) => { const { id } = ctx.input.body; @@ -67,14 +66,11 @@ router.post( const comment = await Comment.findByPk(id, { rejectOnEmpty: true, }); + const document = await Document.findByPk(comment.documentId, { + userId: user.id, + }); authorize(user, "read", comment); - - if (comment.documentId) { - const document = await Document.findByPk(comment.documentId, { - userId: user.id, - }); - authorize(user, "read", document); - } + authorize(user, "read", document); ctx.body = { data: presentComment(comment), @@ -87,13 +83,45 @@ router.post( "comments.list", auth(), pagination(), - checkCommentingEnabled(), + feature(TeamPreference.Commenting), validate(T.CommentsListSchema), async (ctx: APIContext<T.CommentsListReq>) => { - const { sort, direction, documentId, collectionId } = ctx.input.body; + const { + sort, + direction, + documentId, + parentCommentId, + statusFilter, + collectionId, + } = ctx.input.body; const { user } = ctx.state.auth; + const statusQuery = []; + + if (statusFilter?.includes(CommentStatusFilter.Resolved)) { + statusQuery.push({ resolvedById: { [Op.not]: null } }); + } + if (statusFilter?.includes(CommentStatusFilter.Unresolved)) { + statusQuery.push({ resolvedById: null }); + } + + const where: WhereOptions<Comment> = { + [Op.and]: [], + }; + if (documentId) { + // @ts-expect-error ignore + where[Op.and].push({ documentId }); + } + if (parentCommentId) { + // @ts-expect-error ignore + where[Op.and].push({ parentCommentId }); + } + if (statusQuery.length) { + // @ts-expect-error ignore + where[Op.and].push({ [Op.or]: statusQuery }); + } const params: FindOptions<Comment> = { + where, order: [[sort, direction]], offset: ctx.state.pagination.offset, limit: ctx.state.pagination.limit, @@ -103,12 +131,7 @@ router.post( if (documentId) { const document = await Document.findByPk(documentId, { userId: user.id }); authorize(user, "read", document); - comments = await Comment.findAll({ - where: { - documentId: document.id, - }, - ...params, - }); + comments = await Comment.findAll(params); } else if (collectionId) { const collection = await Collection.findByPk(collectionId); authorize(user, "read", collection); @@ -153,7 +176,7 @@ router.post( router.post( "comments.update", auth(), - checkCommentingEnabled(), + feature(TeamPreference.Commenting), validate(T.CommentsUpdateSchema), transaction(), async (ctx: APIContext<T.CommentsUpdateReq>) => { @@ -194,7 +217,7 @@ router.post( router.post( "comments.delete", auth(), - checkCommentingEnabled(), + feature(TeamPreference.Commenting), validate(T.CommentsDeleteSchema), transaction(), async (ctx: APIContext<T.CommentsDeleteReq>) => { @@ -226,19 +249,98 @@ router.post( } ); -function checkCommentingEnabled() { - return async function checkCommentingEnabledMiddleware( - ctx: APIContext, - next: Next - ) { - if (!ctx.state.auth.user.team.getPreference(TeamPreference.Commenting)) { - throw ValidationError("Commenting is currently disabled"); - } - return next(); - }; -} +router.post( + "comments.resolve", + auth(), + feature(TeamPreference.Commenting), + validate(T.CommentsResolveSchema), + transaction(), + async (ctx: APIContext<T.CommentsResolveReq>) => { + const { id } = ctx.input.body; + const { user } = ctx.state.auth; + const { transaction } = ctx.state; -// router.post("comments.resolve", auth(), async (ctx) => { -// router.post("comments.unresolve", auth(), async (ctx) => { + const comment = await Comment.findByPk(id, { + transaction, + rejectOnEmpty: true, + lock: { + level: transaction.LOCK.UPDATE, + of: Comment, + }, + }); + const document = await Document.findByPk(comment.documentId, { + userId: user.id, + }); + authorize(user, "resolve", comment); + authorize(user, "update", document); + + comment.resolve(user); + const changes = comment.changeset; + await comment.save({ transaction }); + + await Event.createFromContext( + ctx, + { + name: "comments.update", + modelId: comment.id, + documentId: comment.documentId, + changes, + }, + { transaction } + ); + + ctx.body = { + data: presentComment(comment), + policies: presentPolicies(user, [comment]), + }; + } +); + +router.post( + "comments.unresolve", + auth(), + feature(TeamPreference.Commenting), + validate(T.CommentsUnresolveSchema), + transaction(), + async (ctx: APIContext<T.CommentsUnresolveReq>) => { + const { id } = ctx.input.body; + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + + const comment = await Comment.findByPk(id, { + transaction, + rejectOnEmpty: true, + lock: { + level: transaction.LOCK.UPDATE, + of: Comment, + }, + }); + const document = await Document.findByPk(comment.documentId, { + userId: user.id, + }); + authorize(user, "unresolve", comment); + authorize(user, "update", document); + + comment.unresolve(); + const changes = comment.changeset; + await comment.save({ transaction }); + + await Event.createFromContext( + ctx, + { + name: "comments.update", + modelId: comment.id, + documentId: comment.documentId, + changes, + }, + { transaction } + ); + + ctx.body = { + data: presentComment(comment), + policies: presentPolicies(user, [comment]), + }; + } +); export default router; diff --git a/server/routes/api/comments/schema.ts b/server/routes/api/comments/schema.ts index f5495766c..a9b16b36d 100644 --- a/server/routes/api/comments/schema.ts +++ b/server/routes/api/comments/schema.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { CommentStatusFilter } from "@shared/types"; import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema"; const BaseIdSchema = z.object({ @@ -57,7 +58,12 @@ export const CommentsListSchema = BaseSchema.extend({ body: CommentsSortParamsSchema.extend({ /** Id of a document to list comments for */ documentId: z.string().optional(), - collectionId: z.string().uuid().optional(), + /** Id of a collection to list comments for */ + collectionId: z.string().optional(), + /** Id of a parent comment to list comments for */ + parentCommentId: z.string().uuid().optional(), + /** Comment statuses to include in results */ + statusFilter: z.nativeEnum(CommentStatusFilter).array().optional(), }), }); @@ -68,3 +74,15 @@ export const CommentsInfoSchema = z.object({ }); export type CommentsInfoReq = z.infer<typeof CommentsInfoSchema>; + +export const CommentsResolveSchema = z.object({ + body: BaseIdSchema, +}); + +export type CommentsResolveReq = z.infer<typeof CommentsResolveSchema>; + +export const CommentsUnresolveSchema = z.object({ + body: BaseIdSchema, +}); + +export type CommentsUnresolveReq = z.infer<typeof CommentsUnresolveSchema>; diff --git a/server/test/factories.ts b/server/test/factories.ts index 929cb0672..1d9a46e95 100644 --- a/server/test/factories.ts +++ b/server/test/factories.ts @@ -403,8 +403,12 @@ export async function buildDocument( export async function buildComment(overrides: { userId: string; documentId: string; + parentCommentId?: string; + resolvedById?: string; }) { const comment = await Comment.create({ + resolvedById: overrides.resolvedById, + parentCommentId: overrides.parentCommentId, documentId: overrides.documentId, data: { type: "doc", @@ -427,6 +431,16 @@ export async function buildComment(overrides: { return comment; } +export async function buildResolvedComment( + user: User, + overrides: Parameters<typeof buildComment>[0] +) { + const comment = await buildComment(overrides); + comment.resolve(user); + await comment.save(); + return comment; +} + export async function buildFileOperation( overrides: Partial<FileOperation> = {} ) { diff --git a/shared/editor/commands/addMark.ts b/shared/editor/commands/addMark.ts new file mode 100644 index 000000000..ef879e333 --- /dev/null +++ b/shared/editor/commands/addMark.ts @@ -0,0 +1,20 @@ +import { Attrs, MarkType } from "prosemirror-model"; +import { Command } from "prosemirror-state"; + +/** + * A prosemirror command to create a mark at the current selection. + * + * @returns A prosemirror command. + */ +export const addMark = + (type: MarkType, attrs?: Attrs | null): Command => + (state, dispatch) => { + dispatch?.( + state.tr.addMark( + state.selection.from, + state.selection.to, + type.create(attrs) + ) + ); + return true; + }; diff --git a/shared/editor/commands/collapseSelection.ts b/shared/editor/commands/collapseSelection.ts index 8467c0046..aabb106c1 100644 --- a/shared/editor/commands/collapseSelection.ts +++ b/shared/editor/commands/collapseSelection.ts @@ -1,6 +1,11 @@ import { Command, TextSelection } from "prosemirror-state"; -const collapseSelection = (): Command => (state, dispatch) => { +/** + * A prosemirror command to collapse the current selection to a cursor at the start of the selection. + * + * @returns A prosemirror command. + */ +export const collapseSelection = (): Command => (state, dispatch) => { dispatch?.( state.tr.setSelection( TextSelection.create(state.doc, state.tr.selection.from) @@ -8,5 +13,3 @@ const collapseSelection = (): Command => (state, dispatch) => { ); return true; }; - -export default collapseSelection; diff --git a/shared/editor/commands/table.ts b/shared/editor/commands/table.ts index c83592a39..59e05260a 100644 --- a/shared/editor/commands/table.ts +++ b/shared/editor/commands/table.ts @@ -14,7 +14,7 @@ import { import { chainTransactions } from "../lib/chainTransactions"; import { getCellsInColumn, isHeaderEnabled } from "../queries/table"; import { TableLayout } from "../types"; -import collapseSelection from "./collapseSelection"; +import { collapseSelection } from "./collapseSelection"; export function createTable({ rowsCount, diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 4a4a4aa36..9567dab95 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -857,14 +857,16 @@ h6 { opacity: 1; } -.comment-marker { - border-bottom: 2px solid ${props.theme.commentMarkBackground}; - transition: background 100ms ease-in-out; - border-radius: 2px; +.${EditorStyleHelper.comment} { + &:not([data-resolved]) { + border-bottom: 2px solid ${props.theme.commentMarkBackground}; + transition: background 100ms ease-in-out; + border-radius: 2px; - &:hover { - ${props.readOnly ? "cursor: var(--pointer);" : ""} - background: ${props.theme.commentMarkBackground}; + &:hover { + ${props.readOnly ? "cursor: var(--pointer);" : ""} + background: ${props.theme.commentMarkBackground}; + } } } @@ -1768,7 +1770,7 @@ del[data-operation-index] { page-break-inside: avoid; } - .comment-marker { + .${EditorStyleHelper.comment} { border: 0; background: none; } diff --git a/shared/editor/marks/Comment.ts b/shared/editor/marks/Comment.ts index 84fac3518..d3a511e90 100644 --- a/shared/editor/marks/Comment.ts +++ b/shared/editor/marks/Comment.ts @@ -2,9 +2,11 @@ import { toggleMark } from "prosemirror-commands"; import { MarkSpec, MarkType, Schema, Mark as PMMark } from "prosemirror-model"; import { Command, Plugin } from "prosemirror-state"; import { v4 as uuidv4 } from "uuid"; -import collapseSelection from "../commands/collapseSelection"; +import { addMark } from "../commands/addMark"; +import { collapseSelection } from "../commands/collapseSelection"; import { chainTransactions } from "../lib/chainTransactions"; import { isMarkActive } from "../queries/isMarkActive"; +import { EditorStyleHelper } from "../styles/EditorStyleHelper"; import Mark from "./Mark"; export default class Comment extends Mark { @@ -17,11 +19,14 @@ export default class Comment extends Mark { attrs: { id: {}, userId: {}, + resolved: { + default: false, + }, }, inclusive: false, parseDOM: [ { - tag: "span.comment-marker", + tag: `.${EditorStyleHelper.comment}`, getAttrs: (dom: HTMLSpanElement) => { // Ignore comment markers from other documents const documentId = dom.getAttribute("data-document-id"); @@ -32,6 +37,7 @@ export default class Comment extends Mark { return { id: dom.getAttribute("id")?.replace("comment-", ""), userId: dom.getAttribute("data-user-id"), + resolved: !!dom.getAttribute("data-resolved"), }; }, }, @@ -39,8 +45,9 @@ export default class Comment extends Mark { toDOM: (node) => [ "span", { - class: "comment-marker", + class: EditorStyleHelper.comment, id: `comment-${node.attrs.id}`, + "data-resolved": node.attrs.resolved ? "true" : undefined, "data-user-id": node.attrs.userId, "data-document-id": this.editor?.props.id, }, @@ -56,7 +63,11 @@ export default class Comment extends Mark { return this.options.onCreateCommentMark ? { "Mod-Alt-m": (state, dispatch) => { - if (isMarkActive(state.schema.marks.comment)(state)) { + if ( + isMarkActive(state.schema.marks.comment, { resolved: false })( + state + ) + ) { return false; } @@ -77,12 +88,14 @@ export default class Comment extends Mark { commands({ type }: { type: MarkType; schema: Schema }) { return this.options.onCreateCommentMark ? (): Command => (state, dispatch) => { - if (isMarkActive(state.schema.marks.comment)(state)) { + if ( + isMarkActive(state.schema.marks.comment, { resolved: false })(state) + ) { return false; } chainTransactions( - toggleMark(type, { + addMark(type, { id: uuidv4(), userId: this.options.userId, }), @@ -152,13 +165,16 @@ export default class Comment extends Mark { return false; } - const comment = event.target.closest(".comment-marker"); + const comment = event.target.closest( + `.${EditorStyleHelper.comment}` + ); if (!comment) { return false; } const commentId = comment.id.replace("comment-", ""); - if (commentId) { + const resolved = comment.getAttribute("data-resolved"); + if (commentId && !resolved) { this.options?.onClickCommentMark?.(commentId); } diff --git a/shared/editor/styles/EditorStyleHelper.ts b/shared/editor/styles/EditorStyleHelper.ts index f4fabb091..237fe4f09 100644 --- a/shared/editor/styles/EditorStyleHelper.ts +++ b/shared/editor/styles/EditorStyleHelper.ts @@ -2,6 +2,10 @@ * Class names and values used by the editor. */ export class EditorStyleHelper { + // Comments + + static readonly comment = "comment-marker"; + // Tables /** Table wrapper */ @@ -34,6 +38,8 @@ export class EditorStyleHelper { /** Shadow on the left side of the table */ static readonly tableShadowLeft = "table-shadow-left"; + // Global + /** Minimum padding around editor */ static readonly padding = 32; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 1fea3f120..68810678c 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -14,6 +14,10 @@ "Delete": "Delete", "Delete collection": "Delete collection", "New template": "New template", + "Delete comment": "Delete comment", + "Mark as resolved": "Mark as resolved", + "Thread resolved": "Thread resolved", + "Mark as unresolved": "Mark as unresolved", "Copy ID": "Copy ID", "Clear IndexedDB cache": "Clear IndexedDB cache", "IndexedDB cache cleared": "IndexedDB cache cleared", @@ -469,7 +473,6 @@ "Sort in sidebar": "Sort in sidebar", "Alphabetical sort": "Alphabetical sort", "Manual sort": "Manual sort", - "Delete comment": "Delete comment", "Comment options": "Comment options", "Document restored": "Document restored", "Document options": "Document options", @@ -551,6 +554,10 @@ "Post": "Post", "Cancel": "Cancel", "Upload image": "Upload image", + "Resolved comments": "Resolved comments", + "View comments": "View comments", + "View resolved comments": "View resolved comments", + "No resolved comments": "No resolved comments", "No comments yet": "No comments yet", "Error updating comment": "Error updating comment", "Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?", @@ -700,6 +707,7 @@ "Publish document and exit": "Publish document and exit", "Save document": "Save document", "Cancel editing": "Cancel editing", + "Collaboration": "Collaboration", "Formatting": "Formatting", "Paragraph": "Paragraph", "Large header": "Large header", diff --git a/shared/types.ts b/shared/types.ts index 73f90e8d0..031bc79b9 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -13,6 +13,11 @@ export enum StatusFilter { Draft = "draft", } +export enum CommentStatusFilter { + Resolved = "resolved", + Unresolved = "unresolved", +} + export enum Client { Web = "web", Desktop = "desktop", diff --git a/yarn.lock b/yarn.lock index 6d2662df5..8e5586769 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8173,54 +8173,7 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -es-abstract@^1.22.1, es-abstract@^1.22.3: - version "1.22.5" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.5.tgz#1417df4e97cc55f09bf7e58d1e614bc61cb8df46" - integrity sha512-oW69R+4q2wG+Hc3KZePPZxOiisRIqfKBVo/HLx94QcJeWGU/8sZhCvc829rd1kS366vlJbzBfXf9yWwf0+Ko7w== - dependencies: - array-buffer-byte-length "^1.0.1" - arraybuffer.prototype.slice "^1.0.3" - available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - es-define-property "^1.0.0" - es-errors "^1.3.0" - es-set-tostringtag "^2.0.3" - es-to-primitive "^1.2.1" - function.prototype.name "^1.1.6" - get-intrinsic "^1.2.4" - get-symbol-description "^1.0.2" - globalthis "^1.0.3" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" - has-proto "^1.0.3" - has-symbols "^1.0.3" - hasown "^2.0.1" - internal-slot "^1.0.7" - is-array-buffer "^3.0.4" - is-callable "^1.2.7" - is-negative-zero "^2.0.3" - is-regex "^1.1.4" - is-shared-array-buffer "^1.0.3" - is-string "^1.0.7" - is-typed-array "^1.1.13" - is-weakref "^1.0.2" - object-inspect "^1.13.1" - object-keys "^1.1.1" - object.assign "^4.1.5" - regexp.prototype.flags "^1.5.2" - safe-array-concat "^1.1.0" - safe-regex-test "^1.0.3" - string.prototype.trim "^1.2.8" - string.prototype.trimend "^1.0.7" - string.prototype.trimstart "^1.0.7" - typed-array-buffer "^1.0.2" - typed-array-byte-length "^1.0.1" - typed-array-byte-offset "^1.0.2" - typed-array-length "^1.0.5" - unbox-primitive "^1.0.2" - which-typed-array "^1.1.14" - -es-abstract@^1.23.0, es-abstract@^1.23.2, es-abstract@^1.23.3: +es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2, es-abstract@^1.23.3: version "1.23.3" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== @@ -12406,10 +12359,10 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==" -outline-icons@^3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.4.1.tgz#2a7c17f7d2b132359a6cc00f449371fa0adb3450" - integrity sha512-H6FRWVLNammxqNpA1n5ktN4T6eAhuLyTI6A8d0mukkz7y/CDCWiffcLetlWhZf9m/jv/EU8ZCOwVSY3CmVeU6Q== +outline-icons@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.5.0.tgz#acc3896a3f0eae2ab70fe693d1d1a924cced6e0f" + integrity sha512-zZAbnR6gjXI4KLEmVj3EsdrlVG3YXBmZ1clY5O1zI5LfaLXQvUAThV/z5MxZpMwcNVYOZMRyXv/W1Sy0TNwCsA== oy-vey@^0.12.1: version "0.12.1" @@ -14128,16 +14081,7 @@ set-function-length@^1.2.1: gopd "^1.0.1" has-property-descriptors "^1.0.2" -set-function-name@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a" - integrity "sha1-Es44t5VDELn2H6oScBYgoMiCeTo= sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==" - dependencies: - define-data-property "^1.0.1" - functions-have-names "^1.2.3" - has-property-descriptors "^1.0.0" - -set-function-name@^2.0.2: +set-function-name@^2.0.1, set-function-name@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== @@ -14189,16 +14133,7 @@ shell-quote@^1.8.1: resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680" integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== -side-channel@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" - integrity "sha1-785cj9wQTudRslxY1CkAEfpeos8= sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==" - dependencies: - call-bind "^1.0.0" - get-intrinsic "^1.0.2" - object-inspect "^1.9.0" - -side-channel@^1.0.6: +side-channel@^1.0.4, side-channel@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== @@ -14549,16 +14484,7 @@ string.prototype.matchall@^4.0.11, string.prototype.matchall@^4.0.6: set-function-name "^2.0.2" side-channel "^1.0.6" -string.prototype.trim@^1.2.8: - version "1.2.8" - resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz#f9ac6f8af4bd55ddfa8895e6aea92a96395393bd" - integrity sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ== - dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - -string.prototype.trim@^1.2.9: +string.prototype.trim@^1.2.8, string.prototype.trim@^1.2.9: version "1.2.9" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw== @@ -14568,16 +14494,7 @@ string.prototype.trim@^1.2.9: es-abstract "^1.23.0" es-object-atoms "^1.0.0" -string.prototype.trimend@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz#1bb3afc5008661d73e2dc015cd4853732d6c471e" - integrity sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - -string.prototype.trimend@^1.0.8: +string.prototype.trimend@^1.0.7, string.prototype.trimend@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229" integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ== @@ -14586,16 +14503,7 @@ string.prototype.trimend@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -string.prototype.trimstart@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz#d4cdb44b83a4737ffbac2d406e405d43d0184298" - integrity sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg== - dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - -string.prototype.trimstart@^1.0.8: +string.prototype.trimstart@^1.0.7, string.prototype.trimstart@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== @@ -15190,19 +15098,7 @@ typed-array-byte-offset@^1.0.2: has-proto "^1.0.3" is-typed-array "^1.1.13" -typed-array-length@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.5.tgz#57d44da160296d8663fd63180a1802ebf25905d5" - integrity sha512-yMi0PlwuznKHxKmcpoOdeLwxBoVPkqZxd7q2FgMkmD3bNwvF5VW0+UlUQ1k1vmktTu4Yu13Q0RIxEP8+B+wloA== - dependencies: - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-proto "^1.0.3" - is-typed-array "^1.1.13" - possible-typed-array-names "^1.0.0" - -typed-array-length@^1.0.6: +typed-array-length@^1.0.5, typed-array-length@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3" integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==