feat: Comment resolving (#7115)
This commit is contained in:
86
app/actions/definitions/comments.tsx
Normal file
86
app/actions/definitions/comments.tsx
Normal 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();
|
||||
},
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
`}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user