feat: Comment resolving (#7115)
This commit is contained in:
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user