feat(EE): resolve comments (#1420)

* feat: resolve comment (EE)

* Add resolve to comment mark in editor (EE)

* comment ui permissions

* sticky comment state tabs (EE)

* cleanup

* feat: add space_id to comments and allow space admins to delete any comment

- Add space_id column to comments table with data migration from pages
- Add last_edited_by_id, resolved_by_id, and updated_at columns to comments
- Update comment deletion permissions to allow space admins to delete any comment
- Backfill space_id on old comments

* fix foreign keys
This commit is contained in:
Philip Okugbe
2025-07-29 21:36:48 +01:00
committed by GitHub
parent ec12e80423
commit ca9558b246
23 changed files with 927 additions and 341 deletions

View File

@ -213,7 +213,18 @@
"Comment deleted successfully": "Comment deleted successfully", "Comment deleted successfully": "Comment deleted successfully",
"Failed to delete comment": "Failed to delete comment", "Failed to delete comment": "Failed to delete comment",
"Comment resolved successfully": "Comment resolved successfully", "Comment resolved successfully": "Comment resolved successfully",
"Comment re-opened successfully": "Comment re-opened successfully",
"Comment unresolved successfully": "Comment unresolved successfully",
"Failed to resolve comment": "Failed to resolve comment", "Failed to resolve comment": "Failed to resolve comment",
"Resolve comment": "Resolve comment",
"Unresolve comment": "Unresolve comment",
"Resolve Comment Thread": "Resolve Comment Thread",
"Unresolve Comment Thread": "Unresolve Comment Thread",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Are you sure you want to resolve this comment thread? This will mark it as completed.",
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
"Resolved": "Resolved",
"No active comments.": "No active comments.",
"No resolved comments.": "No resolved comments.",
"Revoke invitation": "Revoke invitation", "Revoke invitation": "Revoke invitation",
"Revoke": "Revoke", "Revoke": "Revoke",
"Don't": "Don't", "Don't": "Don't",

View File

@ -1,5 +1,5 @@
import { Box, ScrollArea, Text } from "@mantine/core"; import { Box, ScrollArea, Text } from "@mantine/core";
import CommentList from "@/features/comment/components/comment-list.tsx"; import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
@ -18,7 +18,7 @@ export default function Aside() {
switch (tab) { switch (tab) {
case "comments": case "comments":
component = <CommentList />; component = <CommentListWithTabs />;
title = "Comments"; title = "Comments";
break; break;
case "toc": case "toc":
@ -38,6 +38,9 @@ export default function Aside() {
{t(title)} {t(title)}
</Text> </Text>
{tab === "comments" ? (
<CommentListWithTabs />
) : (
<ScrollArea <ScrollArea
style={{ height: "85vh" }} style={{ height: "85vh" }}
scrollbarSize={5} scrollbarSize={5}
@ -45,6 +48,7 @@ export default function Aside() {
> >
<div style={{ paddingBottom: "200px" }}>{component}</div> <div style={{ paddingBottom: "200px" }}>{component}</div>
</ScrollArea> </ScrollArea>
)}
</> </>
)} )}
</Box> </Box>

View File

@ -0,0 +1,67 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/react";
interface ResolveCommentProps {
editor: Editor;
commentId: string;
pageId: string;
resolvedAt?: Date;
}
function ResolveComment({
editor,
commentId,
pageId,
resolvedAt,
}: ResolveCommentProps) {
const { t } = useTranslation();
const resolveCommentMutation = useResolveCommentMutation();
const isResolved = resolvedAt != null;
const iconColor = isResolved ? "green" : "gray";
const handleResolveToggle = async () => {
try {
await resolveCommentMutation.mutateAsync({
commentId,
pageId,
resolved: !isResolved,
});
if (editor) {
editor.commands.setCommentResolved(commentId, !isResolved);
}
//
} catch (error) {
console.error("Failed to toggle resolved state:", error);
}
};
return (
<Tooltip
label={isResolved ? t("Re-Open comment") : t("Resolve comment")}
position="top"
>
<ActionIcon
onClick={handleResolveToggle}
variant="subtle"
color={isResolved ? "green" : "gray"}
size="sm"
loading={resolveCommentMutation.isPending}
disabled={resolveCommentMutation.isPending}
>
{isResolved ? (
<IconCircleCheckFilled size={18} />
) : (
<IconCircleCheck size={18} />
)}
</ActionIcon>
</Tooltip>
);
}
export default ResolveComment;

View File

@ -0,0 +1,87 @@
import {
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { resolveComment } from "@/features/comment/services/comment-service";
import {
IComment,
IResolveComment,
} from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { RQ_KEY } from "@/features/comment/queries/comment-query";
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const emit = useQueryEmit();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
if (!old || !old.items) return old;
const updatedItems = old.items.map((comment) =>
comment.id === variables.commentId
? {
...comment,
resolvedAt: variables.resolved ? new Date() : null,
resolvedById: variables.resolved ? 'optimistic-user' : null,
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
}
: comment,
);
return {
...old,
items: updatedItems,
};
});
return { previousComments };
},
onError: (err, variables, context) => {
if (context?.previousComments) {
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
}
notifications.show({
message: t("Failed to resolve comment"),
color: "red",
});
},
onSuccess: (data: IComment, variables) => {
const pageId = data.pageId;
const currentComments = queryClient.getQueryData(
RQ_KEY(pageId),
) as IPagination<IComment>;
if (currentComments && currentComments.items) {
const updatedComments = currentComments.items.map((comment) =>
comment.id === variables.commentId
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
: comment,
);
queryClient.setQueryData(RQ_KEY(pageId), {
...currentComments,
items: updatedComments,
});
}
emit({
operation: "resolveComment",
pageId: pageId,
commentId: variables.commentId,
resolved: variables.resolved,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
});
queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) });
notifications.show({
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully")
});
},
});
}

View File

@ -1,4 +1,4 @@
import { Group, Text, Box } from "@mantine/core"; import { Group, Text, Box, Badge } from "@mantine/core";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import classes from "./comment.module.css"; import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
@ -7,22 +7,34 @@ import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions"; import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu"; import CommentMenu from "@/features/comment/components/comment-menu";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import ResolveComment from "@/ee/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks"; import { useHover } from "@mantine/hooks";
import { import {
useDeleteCommentMutation, useDeleteCommentMutation,
useUpdateCommentMutation, useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query"; } from "@/features/comment/queries/comment-query";
import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types"; import { IComment } from "@/features/comment/types/comment.types";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit"; import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { useTranslation } from "react-i18next";
interface CommentListItemProps { interface CommentListItemProps {
comment: IComment; comment: IComment;
pageId: string; pageId: string;
canComment: boolean;
userSpaceRole?: string;
} }
function CommentListItem({ comment, pageId }: CommentListItemProps) { function CommentListItem({
comment,
pageId,
canComment,
userSpaceRole,
}: CommentListItemProps) {
const { t } = useTranslation();
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -30,11 +42,13 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
const [content, setContent] = useState<string>(comment.content); const [content, setContent] = useState<string>(comment.content);
const updateCommentMutation = useUpdateCommentMutation(); const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
const isCloudEE = useIsCloudEE();
useEffect(() => { useEffect(() => {
setContent(comment.content) setContent(comment.content);
}, [comment]); }, [comment]);
async function handleUpdateComment() { async function handleUpdateComment() {
@ -72,8 +86,35 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
} }
} }
async function handleResolveComment() {
if (!isCloudEE) return;
try {
const isResolved = comment.resolvedAt != null;
await resolveCommentMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
resolved: !isResolved,
});
if (editor) {
editor.commands.setCommentResolved(comment.id, !isResolved);
}
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) {
console.error("Failed to toggle resolved state:", error);
}
}
function handleCommentClick(comment: IComment) { function handleCommentClick(comment: IComment) {
const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`); const el = document.querySelector(
`.comment-mark[data-comment-id="${comment.id}"]`,
);
if (el) { if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" }); el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("comment-highlight"); el.classList.add("comment-highlight");
@ -106,28 +147,42 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
</Text> </Text>
<div style={{ visibility: hovered ? "visible" : "hidden" }}> <div style={{ visibility: hovered ? "visible" : "hidden" }}>
{/*!comment.parentCommentId && ( {!comment.parentCommentId && canComment && isCloudEE && (
<ResolveComment commentId={comment.id} pageId={comment.pageId} resolvedAt={comment.resolvedAt} /> <ResolveComment
)*/} editor={editor}
commentId={comment.id}
pageId={comment.pageId}
resolvedAt={comment.resolvedAt}
/>
)}
{currentUser?.user?.id === comment.creatorId && ( {(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
<CommentMenu <CommentMenu
onEditComment={handleEditToggle} onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment} onDeleteComment={handleDeleteComment}
onResolveComment={handleResolveComment}
canEdit={currentUser?.user?.id === comment.creatorId}
isResolved={comment.resolvedAt != null}
isParentComment={!comment.parentCommentId}
/> />
)} )}
</div> </div>
</Group> </Group>
<Group gap="xs">
<Text size="xs" fw={500} c="dimmed"> <Text size="xs" fw={500} c="dimmed">
{timeAgo(comment.createdAt)} {timeAgo(comment.createdAt)}
</Text> </Text>
</Group>
</div> </div>
</Group> </Group>
<div> <div>
{!comment.parentCommentId && comment?.selection && ( {!comment.parentCommentId && comment?.selection && (
<Box className={classes.textSelection} onClick={() => handleCommentClick(comment)}> <Box
className={classes.textSelection}
onClick={() => handleCommentClick(comment)}
>
<Text size="sm">{comment?.selection}</Text> <Text size="sm">{comment?.selection}</Text>
</Box> </Box>
)} )}

View File

@ -0,0 +1,318 @@
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
import { useParams } from "react-router-dom";
import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item";
import {
useCommentsQuery,
useCreateCommentMutation,
} from "@/features/comment/queries/comment-query";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks";
import { IComment } from "@/features/comment/types/comment.types.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
function CommentListWithTabs() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const {
data: comments,
isLoading: isCommentsLoading,
isError,
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
const createCommentMutation = useCreateCommentMutation();
const [isLoading, setIsLoading] = useState(false);
const emit = useQueryEmit();
const isCloudEE = useIsCloudEE();
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const canComment: boolean = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page
);
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
if (!comments?.items) {
return { activeComments: [], resolvedComments: [] };
}
const parentComments = comments.items.filter(
(comment: IComment) => comment.parentCommentId === null
);
const active = parentComments.filter(
(comment: IComment) => !comment.resolvedAt
);
const resolved = parentComments.filter(
(comment: IComment) => comment.resolvedAt
);
return { activeComments: active, resolvedComments: resolved };
}, [comments]);
const handleAddReply = useCallback(
async (commentId: string, content: string) => {
try {
setIsLoading(true);
const commentData = {
pageId: page?.id,
parentCommentId: commentId,
content: JSON.stringify(content),
};
await createCommentMutation.mutateAsync(commentData);
emit({
operation: "invalidateComment",
pageId: page?.id,
});
} catch (error) {
console.error("Failed to post comment:", error);
} finally {
setIsLoading(false);
}
},
[createCommentMutation, page?.id]
);
const renderComments = useCallback(
(comment: IComment) => (
<Paper
shadow="sm"
radius="md"
p="sm"
mb="sm"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem
comment={comment}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
<MemoizedChildComments
comments={comments}
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
</div>
{!comment.resolvedAt && canComment && (
<>
<Divider my={4} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
isLoading={isLoading}
/>
</>
)}
</Paper>
),
[comments, handleAddReply, isLoading, space?.membership?.role]
);
if (isCommentsLoading) {
return <></>;
}
if (isError) {
return <div>{t("Error loading comments.")}</div>;
}
const totalComments = activeComments.length + resolvedComments.length;
// If not cloud/enterprise, show simple list without tabs
if (!isCloudEE) {
if (totalComments === 0) {
return <>{t("No comments yet.")}</>;
}
return (
<ScrollArea style={{ height: "85vh" }} scrollbarSize={5} type="scroll">
<div style={{ paddingBottom: "200px" }}>
{comments?.items
.filter((comment: IComment) => comment.parentCommentId === null)
.map((comment) => (
<Paper
shadow="sm"
radius="md"
p="sm"
mb="sm"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem
comment={comment}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
<MemoizedChildComments
comments={comments}
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
</div>
</Paper>
))}
</div>
</ScrollArea>
);
}
return (
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
<Tabs.List justify="center">
<Tabs.Tab
value="open"
leftSection={
<Badge size="sm" variant="light" color="blue">
{activeComments.length}
</Badge>
}
>
{t("Open")}
</Tabs.Tab>
<Tabs.Tab
value="resolved"
leftSection={
<Badge size="sm" variant="light" color="green">
{resolvedComments.length}
</Badge>
}
>
{t("Resolved")}
</Tabs.Tab>
</Tabs.List>
<ScrollArea
style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
scrollbarSize={5}
type="scroll"
>
<div style={{ paddingBottom: "200px" }}>
<Tabs.Panel value="open" pt="xs">
{activeComments.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{t("No open comments.")}
</Text>
) : (
activeComments.map(renderComments)
)}
</Tabs.Panel>
<Tabs.Panel value="resolved" pt="xs">
{resolvedComments.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{t("No resolved comments.")}
</Text>
) : (
resolvedComments.map(renderComments)
)}
</Tabs.Panel>
</div>
</ScrollArea>
</Tabs>
</div>
);
}
interface ChildCommentsProps {
comments: IPagination<IComment>;
parentId: string;
pageId: string;
canComment: boolean;
userSpaceRole?: string;
}
const ChildComments = ({
comments,
parentId,
pageId,
canComment,
userSpaceRole,
}: ChildCommentsProps) => {
const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId
),
[comments.items]
);
return (
<div>
{getChildComments(parentId).map((childComment) => (
<div key={childComment.id}>
<CommentListItem
comment={childComment}
pageId={pageId}
canComment={canComment}
userSpaceRole={userSpaceRole}
/>
<MemoizedChildComments
comments={comments}
parentId={childComment.id}
pageId={pageId}
canComment={canComment}
userSpaceRole={userSpaceRole}
/>
</div>
))}
</div>
);
};
const MemoizedChildComments = memo(ChildComments);
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
const handleSave = useCallback(() => {
onSave(commentId, content);
setContent("");
commentEditorRef.current?.clearContent();
}, [commentId, content, onSave]);
return (
<div ref={ref}>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
/>
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
</div>
);
};
export default CommentListWithTabs;

View File

@ -1,162 +0,0 @@
import React, { useState, useRef, useCallback, memo } from "react";
import { useParams } from "react-router-dom";
import { Divider, Paper } from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item";
import {
useCommentsQuery,
useCreateCommentMutation,
} from "@/features/comment/queries/comment-query";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks";
import { IComment } from "@/features/comment/types/comment.types.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
function CommentList() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const {
data: comments,
isLoading: isCommentsLoading,
isError,
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
const createCommentMutation = useCreateCommentMutation();
const [isLoading, setIsLoading] = useState(false);
const emit = useQueryEmit();
const handleAddReply = useCallback(
async (commentId: string, content: string) => {
try {
setIsLoading(true);
const commentData = {
pageId: page?.id,
parentCommentId: commentId,
content: JSON.stringify(content),
};
await createCommentMutation.mutateAsync(commentData);
emit({
operation: "invalidateComment",
pageId: page?.id,
});
} catch (error) {
console.error("Failed to post comment:", error);
} finally {
setIsLoading(false);
}
},
[createCommentMutation, page?.id],
);
const renderComments = useCallback(
(comment: IComment) => (
<Paper
shadow="sm"
radius="md"
p="sm"
mb="sm"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem comment={comment} pageId={page?.id} />
<MemoizedChildComments comments={comments} parentId={comment.id} pageId={page?.id} />
</div>
<Divider my={4} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
isLoading={isLoading}
/>
</Paper>
),
[comments, handleAddReply, isLoading],
);
if (isCommentsLoading) {
return <></>;
}
if (isError) {
return <div>{t("Error loading comments.")}</div>;
}
if (!comments || comments.items.length === 0) {
return <>{t("No comments yet.")}</>;
}
return (
<>
{comments.items
.filter((comment) => comment.parentCommentId === null)
.map(renderComments)}
</>
);
}
interface ChildCommentsProps {
comments: IPagination<IComment>;
parentId: string;
pageId: string;
}
const ChildComments = ({ comments, parentId, pageId }: ChildCommentsProps) => {
const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId,
),
[comments.items],
);
return (
<div>
{getChildComments(parentId).map((childComment) => (
<div key={childComment.id}>
<CommentListItem comment={childComment} pageId={pageId} />
<MemoizedChildComments
comments={comments}
parentId={childComment.id}
pageId={pageId}
/>
</div>
))}
</div>
);
};
const MemoizedChildComments = memo(ChildComments);
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
const handleSave = useCallback(() => {
onSave(commentId, content);
setContent("");
commentEditorRef.current?.clearContent();
}, [commentId, content, onSave]);
return (
<div ref={ref}>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
/>
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
</div>
);
};
export default CommentList;

View File

@ -1,15 +1,28 @@
import { ActionIcon, Menu } from "@mantine/core"; import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
type CommentMenuProps = { type CommentMenuProps = {
onEditComment: () => void; onEditComment: () => void;
onDeleteComment: () => void; onDeleteComment: () => void;
onResolveComment?: () => void;
canEdit?: boolean;
isResolved?: boolean;
isParentComment?: boolean;
}; };
function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) { function CommentMenu({
onEditComment,
onDeleteComment,
onResolveComment,
canEdit = true,
isResolved = false,
isParentComment = false
}: CommentMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isCloudEE = useIsCloudEE();
//@ts-ignore //@ts-ignore
const openDeleteModal = () => const openDeleteModal = () =>
@ -30,9 +43,34 @@ function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) {
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{canEdit && (
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}> <Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
{t("Edit comment")} {t("Edit comment")}
</Menu.Item> </Menu.Item>
)}
{isParentComment && (
isCloudEE ? (
<Menu.Item
onClick={onResolveComment}
leftSection={
isResolved ?
<IconCircleCheckFilled size={14} /> :
<IconCircleCheck size={14} />
}
>
{isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item>
) : (
<Tooltip label={t("Available in enterprise edition")} position="left">
<Menu.Item
disabled
leftSection={<IconCircleCheck size={14} />}
>
{t("Resolve comment")}
</Menu.Item>
</Tooltip>
)
)}
<Menu.Item <Menu.Item
leftSection={<IconTrash size={14} />} leftSection={<IconTrash size={14} />}
onClick={openDeleteModal} onClick={openDeleteModal}

View File

@ -1,47 +0,0 @@
import { ActionIcon } from "@mantine/core";
import { IconCircleCheck } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useResolveCommentMutation } from "@/features/comment/queries/comment-query";
import { useTranslation } from "react-i18next";
function ResolveComment({ commentId, pageId, resolvedAt }) {
const { t } = useTranslation();
const resolveCommentMutation = useResolveCommentMutation();
const isResolved = resolvedAt != null;
const iconColor = isResolved ? "green" : "gray";
//@ts-ignore
const openConfirmModal = () =>
modals.openConfirmModal({
title: t("Are you sure you want to resolve this comment thread?"),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
onConfirm: handleResolveToggle,
});
const handleResolveToggle = async () => {
try {
await resolveCommentMutation.mutateAsync({
commentId,
resolved: !isResolved,
});
//TODO: remove comment mark
// Remove comment thread from state on resolve
} catch (error) {
console.error("Failed to toggle resolved state:", error);
}
};
return (
<ActionIcon
onClick={openConfirmModal}
variant="default"
style={{ border: "none" }}
>
<IconCircleCheck size={20} stroke={2} color={iconColor} />
</ActionIcon>
);
}
export default ResolveComment;

View File

@ -8,13 +8,11 @@ import {
createComment, createComment,
deleteComment, deleteComment,
getPageComments, getPageComments,
resolveComment,
updateComment, updateComment,
} from "@/features/comment/services/comment-service"; } from "@/features/comment/services/comment-service";
import { import {
ICommentParams, ICommentParams,
IComment, IComment,
IResolveComment,
} from "@/features/comment/types/comment.types"; } from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
@ -108,34 +106,4 @@ export function useDeleteCommentMutation(pageId?: string) {
}); });
} }
export function useResolveCommentMutation() { // EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onSuccess: (data: IComment, variables) => {
const currentComments = queryClient.getQueryData(
RQ_KEY(data.pageId),
) as IComment[];
/*
if (currentComments) {
const updatedComments = currentComments.map((comment) =>
comment.id === variables.commentId
? { ...comment, ...data }
: comment,
);
queryClient.setQueryData(RQ_KEY(data.pageId), updatedComments);
}*/
notifications.show({ message: t("Comment resolved successfully") });
},
onError: (error) => {
notifications.show({
message: t("Failed to resolve comment"),
color: "red",
});
},
});
}

View File

@ -16,6 +16,7 @@ export interface IComment {
editedAt?: Date; editedAt?: Date;
deletedAt?: Date; deletedAt?: Date;
creator: IUser; creator: IUser;
resolvedBy?: IUser;
} }
export interface ICommentData { export interface ICommentData {
@ -28,6 +29,7 @@ export interface ICommentData {
export interface IResolveComment { export interface IResolveComment {
commentId: string; commentId: string;
pageId: string;
resolved: boolean; resolved: boolean;
} }

View File

@ -75,7 +75,7 @@ export default function PageEditor({
const [isLocalSynced, setLocalSynced] = useState(false); const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom, yjsConnectionStatusAtom
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`; const documentName = `page.${pageId}`;
@ -262,7 +262,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, remoteProvider], [pageId, editable, remoteProvider]
); );
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
@ -278,7 +278,12 @@ export default function PageEditor({
}, 3000); }, 3000);
const handleActiveCommentEvent = (event) => { const handleActiveCommentEvent = (event) => {
const { commentId } = event.detail; const { commentId, resolved } = event.detail;
if (resolved) {
return;
}
setActiveCommentId(commentId); setActiveCommentId(commentId);
setAsideState({ tab: "comments", isAsideOpen: true }); setAsideState({ tab: "comments", isAsideOpen: true });
@ -295,7 +300,7 @@ export default function PageEditor({
return () => { return () => {
document.removeEventListener( document.removeEventListener(
"ACTIVE_COMMENT_EVENT", "ACTIVE_COMMENT_EVENT",
handleActiveCommentEvent, handleActiveCommentEvent
); );
}; };
}, []); }, []);

View File

@ -142,6 +142,11 @@
.comment-mark { .comment-mark {
background: rgba(255, 215, 0, 0.14); background: rgba(255, 215, 0, 0.14);
border-bottom: 2px solid rgb(166, 158, 12); border-bottom: 2px solid rgb(166, 158, 12);
&.resolved {
background: none;
border-bottom: none;
}
} }
.comment-highlight { .comment-highlight {
@ -187,7 +192,7 @@
mask-size: 100% 100%; mask-size: 100% 100%;
background-color: currentColor; background-color: currentColor;
& -open { &-open {
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 3v2H5v14h14v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7.586 2H13V3h8v8h-2V6.414l-7 7L10.586 12z'/%3E%3C/svg%3E"); --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 3v2H5v14h14v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7.586 2H13V3h8v8h-2V6.414l-7 7L10.586 12z'/%3E%3C/svg%3E");
} }

View File

@ -63,6 +63,20 @@ export type RefetchRootTreeNodeEvent = {
spaceId: string; spaceId: string;
}; };
export type ResolveCommentEvent = {
operation: "resolveComment";
pageId: string;
commentId: string;
resolved: boolean;
resolvedAt?: Date;
resolvedById?: string;
resolvedBy?: {
id: string;
name: string;
avatarUrl?: string | null;
};
};
export type WebSocketEvent = export type WebSocketEvent =
| InvalidateEvent | InvalidateEvent
| InvalidateCommentsEvent | InvalidateCommentsEvent
@ -71,4 +85,5 @@ export type WebSocketEvent =
| AddTreeNodeEvent | AddTreeNodeEvent
| MoveTreeNodeEvent | MoveTreeNodeEvent
| DeleteTreeNodeEvent | DeleteTreeNodeEvent
| RefetchRootTreeNodeEvent; | RefetchRootTreeNodeEvent
| ResolveCommentEvent;

View File

@ -13,6 +13,7 @@ import {
} from "../page/queries/page-query"; } from "../page/queries/page-query";
import { RQ_KEY } from "../comment/queries/comment-query"; import { RQ_KEY } from "../comment/queries/comment-query";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { IComment } from "@/features/comment/types/comment.types";
export const useQuerySubscription = () => { export const useQuerySubscription = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -96,6 +97,30 @@ export const useQuerySubscription = () => {
}); });
break; break;
} }
case "resolveComment": {
const currentComments = queryClient.getQueryData(
RQ_KEY(data.pageId),
) as IPagination<IComment>;
if (currentComments && currentComments.items) {
const updatedComments = currentComments.items.map((comment) =>
comment.id === data.commentId
? {
...comment,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy
}
: comment,
);
queryClient.setQueryData(RQ_KEY(data.pageId), {
...currentComments,
items: updatedComments,
});
}
break;
}
} }
}); });
}, [queryClient, socket]); }, [queryClient, socket]);

View File

@ -0,0 +1,7 @@
import { isCloud } from "@/lib/config";
import { useLicense } from "@/ee/hooks/use-license";
export const useIsCloudEE = () => {
const { hasLicenseKey } = useLicense();
return isCloud() || !!hasLicenseKey;
};

View File

@ -53,9 +53,11 @@ export class CommentController {
} }
return this.commentService.create( return this.commentService.create(
user.id, {
page.id, userId: user.id,
workspace.id, page,
workspaceId: workspace.id,
},
createCommentDto, createCommentDto,
); );
} }
@ -67,7 +69,6 @@ export class CommentController {
@Body() @Body()
pagination: PaginationOptions, pagination: PaginationOptions,
@AuthUser() user: User, @AuthUser() user: User,
// @AuthWorkspace() workspace: Workspace,
) { ) {
const page = await this.pageRepo.findById(input.pageId); const page = await this.pageRepo.findById(input.pageId);
if (!page) { if (!page) {
@ -89,12 +90,7 @@ export class CommentController {
throw new NotFoundException('Comment not found'); throw new NotFoundException('Comment not found');
} }
const page = await this.pageRepo.findById(comment.pageId); const ability = await this.spaceAbility.createForUser(user, comment.spaceId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
@ -103,19 +99,76 @@ export class CommentController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
update(@Body() updateCommentDto: UpdateCommentDto, @AuthUser() user: User) { async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
//TODO: only comment creators can update their comments const comment = await this.commentRepo.findById(dto.commentId);
return this.commentService.update( if (!comment) {
updateCommentDto.commentId, throw new NotFoundException('Comment not found');
updateCommentDto, }
const ability = await this.spaceAbility.createForUser(
user, user,
comment.spaceId,
); );
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException(
'You must have space edit permission to edit comments',
);
}
return this.commentService.update(comment, dto, user);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('delete') @Post('delete')
remove(@Body() input: CommentIdDto, @AuthUser() user: User) { async delete(@Body() input: CommentIdDto, @AuthUser() user: User) {
// TODO: only comment creators and admins can delete their comments const comment = await this.commentRepo.findById(input.commentId);
return this.commentService.remove(input.commentId, user); if (!comment) {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Check if user is the comment owner
const isOwner = comment.creatorId === user.id;
if (isOwner) {
/*
// Check if comment has children from other users
const hasChildrenFromOthers =
await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
// Owner can delete if no children from other users
if (!hasChildrenFromOthers) {
await this.commentRepo.deleteComment(comment.id);
return;
}
// If has children from others, only space admin can delete
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'Only space admins can delete comments with replies from other users',
);
}*/
await this.commentRepo.deleteComment(comment.id);
return;
}
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'You can only delete your own comments or must be a space admin',
);
}
await this.commentRepo.deleteComment(comment.id);
} }
} }

View File

@ -7,21 +7,24 @@ import {
import { CreateCommentDto } from './dto/create-comment.dto'; import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { Comment, User } from '@docmost/db/types/entity.types'; import { Comment, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination'; import { PaginationResult } from '@docmost/db/pagination/pagination';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable() @Injectable()
export class CommentService { export class CommentService {
constructor( constructor(
private commentRepo: CommentRepo, private commentRepo: CommentRepo,
private pageRepo: PageRepo, private pageRepo: PageRepo,
private spaceMemberRepo: SpaceMemberRepo,
) {} ) {}
async findById(commentId: string) { async findById(commentId: string) {
const comment = await this.commentRepo.findById(commentId, { const comment = await this.commentRepo.findById(commentId, {
includeCreator: true, includeCreator: true,
includeResolvedBy: true,
}); });
if (!comment) { if (!comment) {
throw new NotFoundException('Comment not found'); throw new NotFoundException('Comment not found');
@ -30,11 +33,10 @@ export class CommentService {
} }
async create( async create(
userId: string, opts: { userId: string; page: Page; workspaceId: string },
pageId: string,
workspaceId: string,
createCommentDto: CreateCommentDto, createCommentDto: CreateCommentDto,
) { ) {
const { userId, page, workspaceId } = opts;
const commentContent = JSON.parse(createCommentDto.content); const commentContent = JSON.parse(createCommentDto.content);
if (createCommentDto.parentCommentId) { if (createCommentDto.parentCommentId) {
@ -42,7 +44,7 @@ export class CommentService {
createCommentDto.parentCommentId, createCommentDto.parentCommentId,
); );
if (!parentComment || parentComment.pageId !== pageId) { if (!parentComment || parentComment.pageId !== page.id) {
throw new BadRequestException('Parent comment not found'); throw new BadRequestException('Parent comment not found');
} }
@ -51,17 +53,16 @@ export class CommentService {
} }
} }
const createdComment = await this.commentRepo.insertComment({ return await this.commentRepo.insertComment({
pageId: pageId, pageId: page.id,
content: commentContent, content: commentContent,
selection: createCommentDto?.selection?.substring(0, 250), selection: createCommentDto?.selection?.substring(0, 250),
type: 'inline', type: 'inline',
parentCommentId: createCommentDto?.parentCommentId, parentCommentId: createCommentDto?.parentCommentId,
creatorId: userId, creatorId: userId,
workspaceId: workspaceId, workspaceId: workspaceId,
spaceId: page.spaceId,
}); });
return createdComment;
} }
async findByPageId( async findByPageId(
@ -74,26 +75,16 @@ export class CommentService {
throw new BadRequestException('Page not found'); throw new BadRequestException('Page not found');
} }
const pageComments = await this.commentRepo.findPageComments( return await this.commentRepo.findPageComments(pageId, pagination);
pageId,
pagination,
);
return pageComments;
} }
async update( async update(
commentId: string, comment: Comment,
updateCommentDto: UpdateCommentDto, updateCommentDto: UpdateCommentDto,
authUser: User, authUser: User,
): Promise<Comment> { ): Promise<Comment> {
const commentContent = JSON.parse(updateCommentDto.content); const commentContent = JSON.parse(updateCommentDto.content);
const comment = await this.commentRepo.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
if (comment.creatorId !== authUser.id) { if (comment.creatorId !== authUser.id) {
throw new ForbiddenException('You can only edit your own comments'); throw new ForbiddenException('You can only edit your own comments');
} }
@ -104,26 +95,14 @@ export class CommentService {
{ {
content: commentContent, content: commentContent,
editedAt: editedAt, editedAt: editedAt,
updatedAt: editedAt,
}, },
commentId, comment.id,
); );
comment.content = commentContent; comment.content = commentContent;
comment.editedAt = editedAt; comment.editedAt = editedAt;
comment.updatedAt = editedAt;
return comment; return comment;
} }
async remove(commentId: string, authUser: User): Promise<void> {
const comment = await this.commentRepo.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
if (comment.creatorId !== authUser.id) {
throw new ForbiddenException('You can only delete your own comments');
}
await this.commentRepo.deleteComment(commentId);
}
} }

View File

@ -0,0 +1,61 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Add last_edited_by_id column to comments table
await db.schema
.alterTable('comments')
.addColumn('last_edited_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.execute();
// Add resolved_by_id column to comments table
await db.schema
.alterTable('comments')
.addColumn('resolved_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.execute();
// Add updated_at timestamp column to comments table
await db.schema
.alterTable('comments')
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
// Add space_id column to comments table
await db.schema
.alterTable('comments')
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade'),
)
.execute();
// Backfill space_id from the related pages
await db
.updateTable('comments as c')
.set((eb) => ({
space_id: eb.ref('p.space_id'),
}))
.from('pages as p')
.whereRef('c.page_id', '=', 'p.id')
.execute();
// Make space_id NOT NULL after populating data
await db.schema
.alterTable('comments')
.alterColumn('space_id', (col) => col.setNotNull())
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('comments')
.dropColumn('last_edited_by_id')
.execute();
await db.schema.alterTable('comments').dropColumn('resolved_by_id').execute();
await db.schema.alterTable('comments').dropColumn('updated_at').execute();
await db.schema.alterTable('comments').dropColumn('space_id').execute();
}

View File

@ -20,12 +20,13 @@ export class CommentRepo {
// todo, add workspaceId // todo, add workspaceId
async findById( async findById(
commentId: string, commentId: string,
opts?: { includeCreator: boolean }, opts?: { includeCreator: boolean; includeResolvedBy: boolean },
): Promise<Comment> { ): Promise<Comment> {
return await this.db return await this.db
.selectFrom('comments') .selectFrom('comments')
.selectAll('comments') .selectAll('comments')
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator)) .$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
.$if(opts?.includeResolvedBy, (qb) => qb.select(this.withResolvedBy))
.where('id', '=', commentId) .where('id', '=', commentId)
.executeTakeFirst(); .executeTakeFirst();
} }
@ -35,6 +36,7 @@ export class CommentRepo {
.selectFrom('comments') .selectFrom('comments')
.selectAll('comments') .selectAll('comments')
.select((eb) => this.withCreator(eb)) .select((eb) => this.withCreator(eb))
.select((eb) => this.withResolvedBy(eb))
.where('pageId', '=', pageId) .where('pageId', '=', pageId)
.orderBy('createdAt', 'asc'); .orderBy('createdAt', 'asc');
@ -80,7 +82,37 @@ export class CommentRepo {
).as('creator'); ).as('creator');
} }
withResolvedBy(eb: ExpressionBuilder<DB, 'comments'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'comments.resolvedById'),
).as('resolvedBy');
}
async deleteComment(commentId: string): Promise<void> { async deleteComment(commentId: string): Promise<void> {
await this.db.deleteFrom('comments').where('id', '=', commentId).execute(); await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
} }
async hasChildren(commentId: string): Promise<boolean> {
const result = await this.db
.selectFrom('comments')
.select((eb) => eb.fn.count('id').as('count'))
.where('parentCommentId', '=', commentId)
.executeTakeFirst();
return Number(result?.count) > 0;
}
async hasChildrenFromOtherUsers(commentId: string, userId: string): Promise<boolean> {
const result = await this.db
.selectFrom('comments')
.select((eb) => eb.fn.count('id').as('count'))
.where('parentCommentId', '=', commentId)
.where('creatorId', '!=', userId)
.executeTakeFirst();
return Number(result?.count) > 0;
}
} }

View File

@ -119,11 +119,15 @@ export interface Comments {
deletedAt: Timestamp | null; deletedAt: Timestamp | null;
editedAt: Timestamp | null; editedAt: Timestamp | null;
id: Generated<string>; id: Generated<string>;
lastEditedById: string | null;
pageId: string; pageId: string;
parentCommentId: string | null; parentCommentId: string | null;
resolvedAt: Timestamp | null; resolvedAt: Timestamp | null;
resolvedById: string | null;
selection: string | null; selection: string | null;
spaceId: string;
type: string | null; type: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string; workspaceId: string;
} }

View File

@ -1,5 +1,6 @@
import { Mark, mergeAttributes } from "@tiptap/core"; import { Mark, mergeAttributes } from "@tiptap/core";
import { commentDecoration } from "./comment-decoration"; import { commentDecoration } from "./comment-decoration";
import { Plugin } from "@tiptap/pm/state";
export interface ICommentOptions { export interface ICommentOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@ -19,6 +20,7 @@ declare module "@tiptap/core" {
unsetCommentDecoration: () => ReturnType; unsetCommentDecoration: () => ReturnType;
setComment: (commentId: string) => ReturnType; setComment: (commentId: string) => ReturnType;
unsetComment: (commentId: string) => ReturnType; unsetComment: (commentId: string) => ReturnType;
setCommentResolved: (commentId: string, resolved: boolean) => ReturnType;
}; };
} }
} }
@ -53,6 +55,17 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
}; };
}, },
}, },
resolved: {
default: false,
parseHTML: (element) => element.hasAttribute("data-resolved"),
renderHTML: (attributes) => {
if (!attributes.resolved) return {};
return {
"data-resolved": "true",
};
},
},
}; };
}, },
@ -60,9 +73,18 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
return [ return [
{ {
tag: "span[data-comment-id]", tag: "span[data-comment-id]",
getAttrs: (el) => getAttrs: (el) => {
!!(el as HTMLSpanElement).getAttribute("data-comment-id")?.trim() && const element = el as HTMLSpanElement;
null, const commentId = element.getAttribute("data-comment-id")?.trim();
const resolved = element.hasAttribute("data-resolved");
if (!commentId) return false;
return {
commentId,
resolved,
};
},
}, },
]; ];
}, },
@ -87,7 +109,8 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
(commentId) => (commentId) =>
({ commands }) => { ({ commands }) => {
if (!commentId) return false; if (!commentId) return false;
return commands.setMark(this.name, { commentId }); // Just add the new mark, do not remove existing ones
return commands.setMark(this.name, { commentId, resolved: false });
}, },
unsetComment: unsetComment:
(commentId) => (commentId) =>
@ -101,7 +124,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
const commentMark = node.marks.find( const commentMark = node.marks.find(
(mark) => (mark) =>
mark.type.name === this.name && mark.type.name === this.name &&
mark.attrs.commentId === commentId, mark.attrs.commentId === commentId
); );
if (commentMark) { if (commentMark) {
@ -109,6 +132,37 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
} }
}); });
return dispatch?.(tr);
},
setCommentResolved:
(commentId, resolved) =>
({ tr, dispatch }) => {
if (!commentId) return false;
tr.doc.descendants((node, pos) => {
const from = pos;
const to = pos + node.nodeSize;
const commentMark = node.marks.find(
(mark) =>
mark.type.name === this.name &&
mark.attrs.commentId === commentId
);
if (commentMark) {
// Remove the existing mark and add a new one with updated resolved state
tr = tr.removeMark(from, to, commentMark);
tr = tr.addMark(
from,
to,
this.type.create({
commentId: commentMark.attrs.commentId,
resolved: resolved,
})
);
}
});
return dispatch?.(tr); return dispatch?.(tr);
}, },
}; };
@ -116,13 +170,15 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
const commentId = HTMLAttributes?.["data-comment-id"] || null; const commentId = HTMLAttributes?.["data-comment-id"] || null;
const resolved = HTMLAttributes?.["data-resolved"] || false;
if (typeof window === "undefined" || typeof document === "undefined") { if (typeof window === "undefined" || typeof document === "undefined") {
return [ return [
"span", "span",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: 'comment-mark', class: resolved ? "comment-mark resolved" : "comment-mark",
"data-comment-id": commentId, "data-comment-id": commentId,
...(resolved && { "data-resolved": "true" }),
}), }),
0, 0,
]; ];
@ -131,9 +187,14 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
const elem = document.createElement("span"); const elem = document.createElement("span");
Object.entries( Object.entries(
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
).forEach(([attr, val]) => elem.setAttribute(attr, val)); ).forEach(([attr, val]) => elem.setAttribute(attr, val));
// Add resolved class if the comment is resolved
if (resolved) {
elem.classList.add("resolved");
}
elem.addEventListener("click", (e) => { elem.addEventListener("click", (e) => {
const selection = document.getSelection(); const selection = document.getSelection();
if (selection.type === "Range") return; if (selection.type === "Range") return;
@ -141,7 +202,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
this.storage.activeCommentId = commentId; this.storage.activeCommentId = commentId;
const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", { const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", {
bubbles: true, bubbles: true,
detail: { commentId }, detail: { commentId, resolved },
}); });
elem.dispatchEvent(commentEventClick); elem.dispatchEvent(commentEventClick);
@ -150,9 +211,7 @@ export const Comment = Mark.create<ICommentOptions, ICommentStorage>({
return elem; return elem;
}, },
// @ts-ignore
addProseMirrorPlugins(): Plugin[] { addProseMirrorPlugins(): Plugin[] {
// @ts-ignore
return [commentDecoration()]; return [commentDecoration()];
}, },
}); });