feat: Comment resolving (#7115)

This commit is contained in:
Tom Moor
2024-07-02 06:55:16 -04:00
committed by GitHub
parent f34557337d
commit 117c4f5009
38 changed files with 1126 additions and 291 deletions

View File

@ -41,6 +41,7 @@
"@typescript-eslint/no-shadow": [
"warn",
{
"allow": ["transaction"],
"hoist": "all",
"ignoreTypeValueShadow": true
}
@ -139,4 +140,4 @@
"typescript": {}
}
}
}
}

View File

@ -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: <TrashIcon />,
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: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
});
},
});
export const resolveCommentFactory = ({
comment,
onResolve,
}: {
comment: Comment;
onResolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: DocumentSection,
icon: <DoneIcon outline />,
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: <DoneIcon outline />,
visible: () => stores.policies.abilities(comment.id).unresolve,
perform: async () => {
await comment.unresolve();
history.replace({
...history.location,
state: null,
});
onUnresolve();
},
});

View File

@ -30,6 +30,7 @@ type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
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 || <MenuIconWrapper aria-hidden />;
}
@ -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={item.title} icon={item.icon} />}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu}
/>
);

View File

@ -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};
}
`}

View File

@ -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,
},
{

View File

@ -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>
</>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
};
}

View File

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

View File

@ -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();
}
}

View File

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

View File

@ -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,
};

View File

@ -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"]),

View File

@ -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,
}
`;

View File

@ -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);
});
});

View File

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

View File

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

View File

@ -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> = {}
) {

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
}

View File

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

View File

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

View File

@ -13,6 +13,11 @@ export enum StatusFilter {
Draft = "draft",
}
export enum CommentStatusFilter {
Resolved = "resolved",
Unresolved = "unresolved",
}
export enum Client {
Web = "web",
Desktop = "desktop",

126
yarn.lock
View File

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