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

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