From ca9558b246c6fce8cd130a9f51c1b9152cefa818 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 29 Jul 2025 21:36:48 +0100 Subject: [PATCH] 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 --- .../public/locales/en-US/translation.json | 11 + .../src/components/layouts/global/aside.tsx | 22 +- .../ee/comment/components/resolve-comment.tsx | 67 ++++ .../src/ee/comment/queries/comment-query.ts | 87 +++++ .../comment/components/comment-list-item.tsx | 79 ++++- .../components/comment-list-with-tabs.tsx | 318 ++++++++++++++++++ .../comment/components/comment-list.tsx | 162 --------- .../comment/components/comment-menu.tsx | 50 ++- .../comment/components/resolve-comment.tsx | 47 --- .../features/comment/queries/comment-query.ts | 34 +- .../features/comment/types/comment.types.ts | 2 + .../src/features/editor/page-editor.tsx | 13 +- .../src/features/editor/styles/core.css | 7 +- .../src/features/websocket/types/types.ts | 17 +- .../websocket/use-query-subscription.ts | 25 ++ apps/client/src/hooks/use-is-cloud-ee.tsx | 7 + .../src/core/comment/comment.controller.ts | 89 ++++- .../src/core/comment/comment.service.ts | 51 +-- ...0250725T052004-add-new-comments-columns.ts | 61 ++++ .../database/repos/comment/comment.repo.ts | 34 +- apps/server/src/database/types/db.d.ts | 4 + apps/server/src/ee | 2 +- .../editor-ext/src/lib/comment/comment.ts | 79 ++++- 23 files changed, 927 insertions(+), 341 deletions(-) create mode 100644 apps/client/src/ee/comment/components/resolve-comment.tsx create mode 100644 apps/client/src/ee/comment/queries/comment-query.ts create mode 100644 apps/client/src/features/comment/components/comment-list-with-tabs.tsx delete mode 100644 apps/client/src/features/comment/components/comment-list.tsx delete mode 100644 apps/client/src/features/comment/components/resolve-comment.tsx create mode 100644 apps/client/src/hooks/use-is-cloud-ee.tsx create mode 100644 apps/server/src/database/migrations/20250725T052004-add-new-comments-columns.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 7d6c55b2..efad41cc 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -213,7 +213,18 @@ "Comment deleted successfully": "Comment deleted successfully", "Failed to delete comment": "Failed to delete comment", "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", + "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": "Revoke", "Don't": "Don't", diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index a590aabc..1d6d3f41 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -1,5 +1,5 @@ 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 { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import React, { ReactNode } from "react"; @@ -18,7 +18,7 @@ export default function Aside() { switch (tab) { case "comments": - component = ; + component = ; title = "Comments"; break; case "toc": @@ -38,13 +38,17 @@ export default function Aside() { {t(title)} - -
{component}
-
+ {tab === "comments" ? ( + + ) : ( + +
{component}
+
+ )} )} diff --git a/apps/client/src/ee/comment/components/resolve-comment.tsx b/apps/client/src/ee/comment/components/resolve-comment.tsx new file mode 100644 index 00000000..4e22fb71 --- /dev/null +++ b/apps/client/src/ee/comment/components/resolve-comment.tsx @@ -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 ( + + + {isResolved ? ( + + ) : ( + + )} + + + ); +} + +export default ResolveComment; diff --git a/apps/client/src/ee/comment/queries/comment-query.ts b/apps/client/src/ee/comment/queries/comment-query.ts new file mode 100644 index 00000000..b09f4e79 --- /dev/null +++ b/apps/client/src/ee/comment/queries/comment-query.ts @@ -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) => { + 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; + 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") + }); + }, + }); +} \ No newline at end of file diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index cbf2a4e9..ebf27196 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -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 classes from "./comment.module.css"; 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 CommentActions from "@/features/comment/components/comment-actions"; 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 { useDeleteCommentMutation, useUpdateCommentMutation, } from "@/features/comment/queries/comment-query"; +import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query"; import { IComment } from "@/features/comment/types/comment.types"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit"; +import { useTranslation } from "react-i18next"; interface CommentListItemProps { comment: IComment; 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 [isEditing, setIsEditing] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -30,11 +42,13 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) { const [content, setContent] = useState(comment.content); const updateCommentMutation = useUpdateCommentMutation(); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); + const resolveCommentMutation = useResolveCommentMutation(); const [currentUser] = useAtom(currentUserAtom); const emit = useQueryEmit(); + const isCloudEE = useIsCloudEE(); useEffect(() => { - setContent(comment.content) + setContent(comment.content); }, [comment]); 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) { - const el = document.querySelector(`.comment-mark[data-comment-id="${comment.id}"]`); + const el = document.querySelector( + `.comment-mark[data-comment-id="${comment.id}"]`, + ); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); el.classList.add("comment-highlight"); @@ -106,28 +147,42 @@ function CommentListItem({ comment, pageId }: CommentListItemProps) {
- {/*!comment.parentCommentId && ( - - )*/} + {!comment.parentCommentId && canComment && isCloudEE && ( + + )} - {currentUser?.user?.id === comment.creatorId && ( + {(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && ( )}
- - {timeAgo(comment.createdAt)} - + + + {timeAgo(comment.createdAt)} + +
{!comment.parentCommentId && comment?.selection && ( - handleCommentClick(comment)}> + handleCommentClick(comment)} + > {comment?.selection} )} diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx new file mode 100644 index 00000000..792cac40 --- /dev/null +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -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) => ( + +
+ + +
+ + {!comment.resolvedAt && canComment && ( + <> + + + + )} +
+ ), + [comments, handleAddReply, isLoading, space?.membership?.role] + ); + + if (isCommentsLoading) { + return <>; + } + + if (isError) { + return
{t("Error loading comments.")}
; + } + + 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 ( + +
+ {comments?.items + .filter((comment: IComment) => comment.parentCommentId === null) + .map((comment) => ( + +
+ + +
+
+ ))} +
+
+ ); + } + + return ( +
+ + + + {activeComments.length} + + } + > + {t("Open")} + + + {resolvedComments.length} + + } + > + {t("Resolved")} + + + + +
+ + {activeComments.length === 0 ? ( + + {t("No open comments.")} + + ) : ( + activeComments.map(renderComments) + )} + + + + {resolvedComments.length === 0 ? ( + + {t("No resolved comments.")} + + ) : ( + resolvedComments.map(renderComments) + )} + +
+
+
+
+ ); +} + +interface ChildCommentsProps { + comments: IPagination; + 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 ( +
+ {getChildComments(parentId).map((childComment) => ( +
+ + +
+ ))} +
+ ); +}; + +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 ( +
+ + {focused && } +
+ ); +}; + +export default CommentListWithTabs; diff --git a/apps/client/src/features/comment/components/comment-list.tsx b/apps/client/src/features/comment/components/comment-list.tsx deleted file mode 100644 index 1aded495..00000000 --- a/apps/client/src/features/comment/components/comment-list.tsx +++ /dev/null @@ -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) => ( - -
- - -
- - - - -
- ), - [comments, handleAddReply, isLoading], - ); - - if (isCommentsLoading) { - return <>; - } - - if (isError) { - return
{t("Error loading comments.")}
; - } - - 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; - 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 ( -
- {getChildComments(parentId).map((childComment) => ( -
- - -
- ))} -
- ); -}; - -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 ( -
- - {focused && } -
- ); -}; - -export default CommentList; diff --git a/apps/client/src/features/comment/components/comment-menu.tsx b/apps/client/src/features/comment/components/comment-menu.tsx index 051c415a..0726c36f 100644 --- a/apps/client/src/features/comment/components/comment-menu.tsx +++ b/apps/client/src/features/comment/components/comment-menu.tsx @@ -1,15 +1,28 @@ -import { ActionIcon, Menu } from "@mantine/core"; -import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; +import { ActionIcon, Menu, Tooltip } from "@mantine/core"; +import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react"; import { modals } from "@mantine/modals"; import { useTranslation } from "react-i18next"; +import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; type CommentMenuProps = { onEditComment: () => 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 isCloudEE = useIsCloudEE(); //@ts-ignore const openDeleteModal = () => @@ -30,9 +43,34 @@ function CommentMenu({ onEditComment, onDeleteComment }: CommentMenuProps) { - }> - {t("Edit comment")} - + {canEdit && ( + }> + {t("Edit comment")} + + )} + {isParentComment && ( + isCloudEE ? ( + : + + } + > + {isResolved ? t("Re-open comment") : t("Resolve comment")} + + ) : ( + + } + > + {t("Resolve comment")} + + + ) + )} } onClick={openDeleteModal} diff --git a/apps/client/src/features/comment/components/resolve-comment.tsx b/apps/client/src/features/comment/components/resolve-comment.tsx deleted file mode 100644 index 9d7c4e7c..00000000 --- a/apps/client/src/features/comment/components/resolve-comment.tsx +++ /dev/null @@ -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 ( - - - - ); -} - -export default ResolveComment; diff --git a/apps/client/src/features/comment/queries/comment-query.ts b/apps/client/src/features/comment/queries/comment-query.ts index 85b2e549..c10ca418 100644 --- a/apps/client/src/features/comment/queries/comment-query.ts +++ b/apps/client/src/features/comment/queries/comment-query.ts @@ -8,13 +8,11 @@ import { createComment, deleteComment, getPageComments, - resolveComment, updateComment, } from "@/features/comment/services/comment-service"; import { ICommentParams, IComment, - IResolveComment, } from "@/features/comment/types/comment.types"; import { notifications } from "@mantine/notifications"; import { IPagination } from "@/lib/types.ts"; @@ -108,34 +106,4 @@ export function useDeleteCommentMutation(pageId?: string) { }); } -export function useResolveCommentMutation() { - 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", - }); - }, - }); -} +// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query diff --git a/apps/client/src/features/comment/types/comment.types.ts b/apps/client/src/features/comment/types/comment.types.ts index a29e770e..6c8cc909 100644 --- a/apps/client/src/features/comment/types/comment.types.ts +++ b/apps/client/src/features/comment/types/comment.types.ts @@ -16,6 +16,7 @@ export interface IComment { editedAt?: Date; deletedAt?: Date; creator: IUser; + resolvedBy?: IUser; } export interface ICommentData { @@ -28,6 +29,7 @@ export interface ICommentData { export interface IResolveComment { commentId: string; + pageId: string; resolved: boolean; } diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 01f6dc55..5d88b208 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -75,7 +75,7 @@ export default function PageEditor({ const [isLocalSynced, setLocalSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false); const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( - yjsConnectionStatusAtom, + yjsConnectionStatusAtom ); const menuContainerRef = useRef(null); const documentName = `page.${pageId}`; @@ -262,7 +262,7 @@ export default function PageEditor({ debouncedUpdateContent(editorJson); }, }, - [pageId, editable, remoteProvider], + [pageId, editable, remoteProvider] ); const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { @@ -278,7 +278,12 @@ export default function PageEditor({ }, 3000); const handleActiveCommentEvent = (event) => { - const { commentId } = event.detail; + const { commentId, resolved } = event.detail; + + if (resolved) { + return; + } + setActiveCommentId(commentId); setAsideState({ tab: "comments", isAsideOpen: true }); @@ -295,7 +300,7 @@ export default function PageEditor({ return () => { document.removeEventListener( "ACTIVE_COMMENT_EVENT", - handleActiveCommentEvent, + handleActiveCommentEvent ); }; }, []); diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index 38acb74b..de60d710 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -142,6 +142,11 @@ .comment-mark { background: rgba(255, 215, 0, 0.14); border-bottom: 2px solid rgb(166, 158, 12); + + &.resolved { + background: none; + border-bottom: none; + } } .comment-highlight { @@ -187,7 +192,7 @@ mask-size: 100% 100%; 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"); } diff --git a/apps/client/src/features/websocket/types/types.ts b/apps/client/src/features/websocket/types/types.ts index 25b57df9..21561038 100644 --- a/apps/client/src/features/websocket/types/types.ts +++ b/apps/client/src/features/websocket/types/types.ts @@ -63,6 +63,20 @@ export type RefetchRootTreeNodeEvent = { 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 = | InvalidateEvent | InvalidateCommentsEvent @@ -71,4 +85,5 @@ export type WebSocketEvent = | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent - | RefetchRootTreeNodeEvent; + | RefetchRootTreeNodeEvent + | ResolveCommentEvent; diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts index 29a815be..3aa95417 100644 --- a/apps/client/src/features/websocket/use-query-subscription.ts +++ b/apps/client/src/features/websocket/use-query-subscription.ts @@ -13,6 +13,7 @@ import { } from "../page/queries/page-query"; import { RQ_KEY } from "../comment/queries/comment-query"; import { queryClient } from "@/main.tsx"; +import { IComment } from "@/features/comment/types/comment.types"; export const useQuerySubscription = () => { const queryClient = useQueryClient(); @@ -96,6 +97,30 @@ export const useQuerySubscription = () => { }); break; } + case "resolveComment": { + const currentComments = queryClient.getQueryData( + RQ_KEY(data.pageId), + ) as IPagination; + + 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]); diff --git a/apps/client/src/hooks/use-is-cloud-ee.tsx b/apps/client/src/hooks/use-is-cloud-ee.tsx new file mode 100644 index 00000000..148fe395 --- /dev/null +++ b/apps/client/src/hooks/use-is-cloud-ee.tsx @@ -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; +}; \ No newline at end of file diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index fcb19723..ea9447b6 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -53,9 +53,11 @@ export class CommentController { } return this.commentService.create( - user.id, - page.id, - workspace.id, + { + userId: user.id, + page, + workspaceId: workspace.id, + }, createCommentDto, ); } @@ -67,7 +69,6 @@ export class CommentController { @Body() pagination: PaginationOptions, @AuthUser() user: User, - // @AuthWorkspace() workspace: Workspace, ) { const page = await this.pageRepo.findById(input.pageId); if (!page) { @@ -89,12 +90,7 @@ export class CommentController { throw new NotFoundException('Comment not found'); } - const page = await this.pageRepo.findById(comment.pageId); - if (!page) { - throw new NotFoundException('Page not found'); - } - - const ability = await this.spaceAbility.createForUser(user, page.spaceId); + const ability = await this.spaceAbility.createForUser(user, comment.spaceId); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } @@ -103,19 +99,76 @@ export class CommentController { @HttpCode(HttpStatus.OK) @Post('update') - update(@Body() updateCommentDto: UpdateCommentDto, @AuthUser() user: User) { - //TODO: only comment creators can update their comments - return this.commentService.update( - updateCommentDto.commentId, - updateCommentDto, + async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) { + const comment = await this.commentRepo.findById(dto.commentId); + 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( + 'You must have space edit permission to edit comments', + ); + } + + return this.commentService.update(comment, dto, user); } @HttpCode(HttpStatus.OK) @Post('delete') - remove(@Body() input: CommentIdDto, @AuthUser() user: User) { - // TODO: only comment creators and admins can delete their comments - return this.commentService.remove(input.commentId, user); + async delete(@Body() input: CommentIdDto, @AuthUser() user: User) { + const comment = await this.commentRepo.findById(input.commentId); + 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); } } diff --git a/apps/server/src/core/comment/comment.service.ts b/apps/server/src/core/comment/comment.service.ts index b1d4ae4c..61fb15c9 100644 --- a/apps/server/src/core/comment/comment.service.ts +++ b/apps/server/src/core/comment/comment.service.ts @@ -7,21 +7,24 @@ import { import { CreateCommentDto } from './dto/create-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto'; 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 { PaginationResult } from '@docmost/db/pagination/pagination'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; @Injectable() export class CommentService { constructor( private commentRepo: CommentRepo, private pageRepo: PageRepo, + private spaceMemberRepo: SpaceMemberRepo, ) {} async findById(commentId: string) { const comment = await this.commentRepo.findById(commentId, { includeCreator: true, + includeResolvedBy: true, }); if (!comment) { throw new NotFoundException('Comment not found'); @@ -30,11 +33,10 @@ export class CommentService { } async create( - userId: string, - pageId: string, - workspaceId: string, + opts: { userId: string; page: Page; workspaceId: string }, createCommentDto: CreateCommentDto, ) { + const { userId, page, workspaceId } = opts; const commentContent = JSON.parse(createCommentDto.content); if (createCommentDto.parentCommentId) { @@ -42,7 +44,7 @@ export class CommentService { createCommentDto.parentCommentId, ); - if (!parentComment || parentComment.pageId !== pageId) { + if (!parentComment || parentComment.pageId !== page.id) { throw new BadRequestException('Parent comment not found'); } @@ -51,17 +53,16 @@ export class CommentService { } } - const createdComment = await this.commentRepo.insertComment({ - pageId: pageId, + return await this.commentRepo.insertComment({ + pageId: page.id, content: commentContent, selection: createCommentDto?.selection?.substring(0, 250), type: 'inline', parentCommentId: createCommentDto?.parentCommentId, creatorId: userId, workspaceId: workspaceId, + spaceId: page.spaceId, }); - - return createdComment; } async findByPageId( @@ -74,26 +75,16 @@ export class CommentService { throw new BadRequestException('Page not found'); } - const pageComments = await this.commentRepo.findPageComments( - pageId, - pagination, - ); - - return pageComments; + return await this.commentRepo.findPageComments(pageId, pagination); } async update( - commentId: string, + comment: Comment, updateCommentDto: UpdateCommentDto, authUser: User, ): Promise { 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) { throw new ForbiddenException('You can only edit your own comments'); } @@ -104,26 +95,14 @@ export class CommentService { { content: commentContent, editedAt: editedAt, + updatedAt: editedAt, }, - commentId, + comment.id, ); comment.content = commentContent; comment.editedAt = editedAt; + comment.updatedAt = editedAt; return comment; } - - async remove(commentId: string, authUser: User): Promise { - 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); - } } diff --git a/apps/server/src/database/migrations/20250725T052004-add-new-comments-columns.ts b/apps/server/src/database/migrations/20250725T052004-add-new-comments-columns.ts new file mode 100644 index 00000000..74feb0e8 --- /dev/null +++ b/apps/server/src/database/migrations/20250725T052004-add-new-comments-columns.ts @@ -0,0 +1,61 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // 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): Promise { + 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(); +} diff --git a/apps/server/src/database/repos/comment/comment.repo.ts b/apps/server/src/database/repos/comment/comment.repo.ts index 8f957f42..965bd611 100644 --- a/apps/server/src/database/repos/comment/comment.repo.ts +++ b/apps/server/src/database/repos/comment/comment.repo.ts @@ -20,12 +20,13 @@ export class CommentRepo { // todo, add workspaceId async findById( commentId: string, - opts?: { includeCreator: boolean }, + opts?: { includeCreator: boolean; includeResolvedBy: boolean }, ): Promise { return await this.db .selectFrom('comments') .selectAll('comments') .$if(opts?.includeCreator, (qb) => qb.select(this.withCreator)) + .$if(opts?.includeResolvedBy, (qb) => qb.select(this.withResolvedBy)) .where('id', '=', commentId) .executeTakeFirst(); } @@ -35,6 +36,7 @@ export class CommentRepo { .selectFrom('comments') .selectAll('comments') .select((eb) => this.withCreator(eb)) + .select((eb) => this.withResolvedBy(eb)) .where('pageId', '=', pageId) .orderBy('createdAt', 'asc'); @@ -80,7 +82,37 @@ export class CommentRepo { ).as('creator'); } + withResolvedBy(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef('users.id', '=', 'comments.resolvedById'), + ).as('resolvedBy'); + } + async deleteComment(commentId: string): Promise { await this.db.deleteFrom('comments').where('id', '=', commentId).execute(); } + + async hasChildren(commentId: string): Promise { + 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 { + 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; + } } diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 1d2051d4..e8662649 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -119,11 +119,15 @@ export interface Comments { deletedAt: Timestamp | null; editedAt: Timestamp | null; id: Generated; + lastEditedById: string | null; pageId: string; parentCommentId: string | null; resolvedAt: Timestamp | null; + resolvedById: string | null; selection: string | null; + spaceId: string; type: string | null; + updatedAt: Generated; workspaceId: string; } diff --git a/apps/server/src/ee b/apps/server/src/ee index 64758945..35e3440f 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 647589454224fcba963f76f6d44539e33369ff39 +Subproject commit 35e3440f422ae1120147c6e290b19f54c74f1364 diff --git a/packages/editor-ext/src/lib/comment/comment.ts b/packages/editor-ext/src/lib/comment/comment.ts index 2c338ee8..ec896357 100644 --- a/packages/editor-ext/src/lib/comment/comment.ts +++ b/packages/editor-ext/src/lib/comment/comment.ts @@ -1,5 +1,6 @@ import { Mark, mergeAttributes } from "@tiptap/core"; import { commentDecoration } from "./comment-decoration"; +import { Plugin } from "@tiptap/pm/state"; export interface ICommentOptions { HTMLAttributes: Record; @@ -19,6 +20,7 @@ declare module "@tiptap/core" { unsetCommentDecoration: () => ReturnType; setComment: (commentId: string) => ReturnType; unsetComment: (commentId: string) => ReturnType; + setCommentResolved: (commentId: string, resolved: boolean) => ReturnType; }; } } @@ -53,6 +55,17 @@ export const Comment = Mark.create({ }; }, }, + 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({ return [ { tag: "span[data-comment-id]", - getAttrs: (el) => - !!(el as HTMLSpanElement).getAttribute("data-comment-id")?.trim() && - null, + getAttrs: (el) => { + const element = el as HTMLSpanElement; + 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({ (commentId) => ({ commands }) => { 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: (commentId) => @@ -101,7 +124,7 @@ export const Comment = Mark.create({ const commentMark = node.marks.find( (mark) => mark.type.name === this.name && - mark.attrs.commentId === commentId, + mark.attrs.commentId === commentId ); if (commentMark) { @@ -109,6 +132,37 @@ export const Comment = Mark.create({ } }); + 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); }, }; @@ -116,13 +170,15 @@ export const Comment = Mark.create({ renderHTML({ HTMLAttributes }) { const commentId = HTMLAttributes?.["data-comment-id"] || null; + const resolved = HTMLAttributes?.["data-resolved"] || false; if (typeof window === "undefined" || typeof document === "undefined") { return [ "span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { - class: 'comment-mark', + class: resolved ? "comment-mark resolved" : "comment-mark", "data-comment-id": commentId, + ...(resolved && { "data-resolved": "true" }), }), 0, ]; @@ -131,9 +187,14 @@ export const Comment = Mark.create({ const elem = document.createElement("span"); Object.entries( - mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes) ).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) => { const selection = document.getSelection(); if (selection.type === "Range") return; @@ -141,7 +202,7 @@ export const Comment = Mark.create({ this.storage.activeCommentId = commentId; const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", { bubbles: true, - detail: { commentId }, + detail: { commentId, resolved }, }); elem.dispatchEvent(commentEventClick); @@ -150,9 +211,7 @@ export const Comment = Mark.create({ return elem; }, - // @ts-ignore addProseMirrorPlugins(): Plugin[] { - // @ts-ignore return [commentDecoration()]; }, });