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={
+
+ }
{...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: ,
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: ,
- });
- }, [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}
/>
-
- {can.update && (
-
- )}
-
- {can.delete && (
- <>
-
-
- >
- )}
+ ,
+ onClick: onEdit,
+ visible: can.update,
+ },
+ actionToMenuItem(
+ resolveCommentFactory({
+ comment,
+ onResolve: () => onUpdate({ resolved: true }),
+ }),
+ context
+ ),
+ actionToMenuItem(
+ unresolveCommentFactory({
+ comment,
+ onUnresolve: () => onUpdate({ resolved: false }),
+ }),
+ context
+ ),
+ {
+ type: "button",
+ icon: ,
+ title: t("Copy link"),
+ onClick: handleCopyLink,
+ },
+ {
+ type: "separator",
+ },
+ actionToMenuItem(
+ deleteCommentFactory({ comment, onDelete }),
+ context
+ ),
+ ]}
+ />
>
);
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(null);
@@ -198,14 +204,17 @@ function CommentThreadItem({
)}