From 3829b6cbefe8b7f547c7eb236c92288a67be3059 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:32:52 +0000 Subject: [PATCH] feat(ee): viewer comments (#2060) --- .../public/locales/en-US/translation.json | 3 + apps/client/src/ee/features.ts | 1 + .../space-viewer-comments-toggle.tsx | 61 +++++++ .../features/comment/atoms/comment-atom.ts | 12 ++ .../comment/components/comment-dialog.tsx | 52 +++++- .../components/comment-list-with-tabs.tsx | 6 +- .../comment/components/comment-menu.tsx | 2 +- .../features/comment/types/comment.types.ts | 4 + .../bubble-menu/readonly-bubble-menu.tsx | 159 ++++++++++++++++++ .../src/features/editor/full-editor.tsx | 3 + .../src/features/editor/page-editor.tsx | 11 ++ .../space/components/settings-modal.tsx | 23 +++ .../space/components/space-details.tsx | 10 +- .../components/space-security-settings.tsx | 34 ++++ .../src/features/space/types/space.types.ts | 6 + apps/client/src/pages/page/page.tsx | 4 + .../collaboration/collaboration.handler.ts | 51 +++++- apps/server/src/collaboration/yjs.util.ts | 2 +- apps/server/src/common/features.ts | 22 +++ .../src/core/comment/comment.controller.ts | 15 +- .../server/src/core/comment/comment.module.ts | 2 + .../src/core/comment/comment.service.ts | 42 ++++- .../core/comment/dto/create-comment.dto.ts | 27 ++- .../page/page-access/page-access.service.ts | 23 +++ .../src/core/space/dto/update-space.dto.ts | 4 + .../src/core/space/services/space.service.ts | 44 ++++- .../workspace/services/workspace.service.ts | 3 +- .../src/database/repos/space/space.repo.ts | 22 +++ apps/server/src/ee | 2 +- 29 files changed, 608 insertions(+), 42 deletions(-) create mode 100644 apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx create mode 100644 apps/client/src/features/editor/components/bubble-menu/readonly-bubble-menu.tsx create mode 100644 apps/client/src/features/space/components/space-security-settings.tsx create mode 100644 apps/server/src/common/features.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 68bef0f2..b0dd7d53 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -449,6 +449,9 @@ "Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.", "Toggle public sharing": "Toggle public sharing", "Toggle space public sharing": "Toggle space public sharing", + "Allow viewers to comment": "Allow viewers to comment", + "Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.", + "Toggle viewer comments": "Toggle viewer comments", "Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level", "Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.", "Page permissions": "Page permissions", diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index 70438cba..4cd802fa 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -16,4 +16,5 @@ export const Feature = { AUDIT_LOGS: 'audit:logs', RETENTION: 'retention', SHARING_CONTROLS: 'sharing:controls', + VIEWER_COMMENTS: 'comment:viewer', } as const; diff --git a/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx b/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx new file mode 100644 index 00000000..88fac67f --- /dev/null +++ b/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx @@ -0,0 +1,61 @@ +import { Group, Text, Switch, Tooltip } from "@mantine/core"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts"; +import { useHasFeature } from "@/ee/hooks/use-feature.ts"; +import { Feature } from "@/ee/features.ts"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts"; + +type SpaceViewerCommentsToggleProps = { + space: ISpace; +}; + +export default function SpaceViewerCommentsToggle({ + space, +}: SpaceViewerCommentsToggleProps) { + const { t } = useTranslation(); + const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS); + const upgradeLabel = useUpgradeLabel(); + const isDisabled = !hasViewerComments; + const [checked, setChecked] = useState( + space.settings?.comments?.allowViewerComments === true, + ); + const updateSpaceMutation = useUpdateSpaceMutation(); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + await updateSpaceMutation.mutateAsync({ + spaceId: space.id, + allowViewerComments: value, + }); + setChecked(value); + } catch { + // error handled by mutation + } + }; + + return ( + +
+ {t("Allow viewers to comment")} + + {t("Allow viewers to add comments on pages in this space.")} + +
+ + + +
+ ); +} diff --git a/apps/client/src/features/comment/atoms/comment-atom.ts b/apps/client/src/features/comment/atoms/comment-atom.ts index 384a2f3d..374e7f7f 100644 --- a/apps/client/src/features/comment/atoms/comment-atom.ts +++ b/apps/client/src/features/comment/atoms/comment-atom.ts @@ -3,3 +3,15 @@ import { atom } from 'jotai'; export const showCommentPopupAtom = atom(false); export const activeCommentIdAtom = atom(''); export const draftCommentIdAtom = atom(''); + +// Read-only comment state +export const showReadOnlyCommentPopupAtom = atom(false); +export type YjsSelection = { + anchor: any; + head: any; +}; +export type ReadOnlyCommentData = { + yjsSelection: YjsSelection; + selectedText: string; +}; +export const readOnlyCommentDataAtom = atom(null); diff --git a/apps/client/src/features/comment/components/comment-dialog.tsx b/apps/client/src/features/comment/components/comment-dialog.tsx index 6248e913..24781a94 100644 --- a/apps/client/src/features/comment/components/comment-dialog.tsx +++ b/apps/client/src/features/comment/components/comment-dialog.tsx @@ -6,6 +6,8 @@ import { activeCommentIdAtom, draftCommentIdAtom, showCommentPopupAtom, + showReadOnlyCommentPopupAtom, + readOnlyCommentDataAtom, } from "@/features/comment/atoms/comment-atom"; import CommentEditor from "@/features/comment/components/comment-editor"; import CommentActions from "@/features/comment/components/comment-actions"; @@ -19,12 +21,15 @@ import { useTranslation } from "react-i18next"; interface CommentDialogProps { editor: ReturnType; pageId: string; + readOnly?: boolean; } -function CommentDialog({ editor, pageId }: CommentDialogProps) { +function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) { const { t } = useTranslation(); const [comment, setComment] = useState(""); const [, setShowCommentPopup] = useAtom(showCommentPopupAtom); + const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom); + const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom); const [currentUser] = useAtom(currentUserAtom); @@ -34,11 +39,17 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { handleDialogClose(); }); const createCommentMutation = useCreateCommentMutation(); - const { isPending } = createCommentMutation; + const isPending = createCommentMutation.isPending; const handleDialogClose = () => { - setShowCommentPopup(false); - editor.chain().focus().unsetCommentDecoration().run(); + if (readOnly) { + setShowReadOnlyCommentPopup(false); + // @ts-ignore + setReadOnlyCommentData(null); + } else { + setShowCommentPopup(false); + editor.chain().focus().unsetCommentDecoration().run(); + } }; const getSelectedText = () => { @@ -47,6 +58,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { }; const handleAddComment = async () => { + if (readOnly) { + await handleAddReadOnlyComment(); + return; + } + try { const selectedText = getSelectedText(); const commentData = { @@ -65,7 +81,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { .run(); setActiveCommentId(createdComment.id); - //unselect text to close bubble menu editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from }); setAsideState({ tab: "comments", isAsideOpen: true }); @@ -85,6 +100,33 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { } }; + const handleAddReadOnlyComment = async () => { + if (!readOnlyCommentData) return; + + try { + const createdComment = await createCommentMutation.mutateAsync({ + pageId, + content: JSON.stringify(comment), + selection: readOnlyCommentData.selectedText, + type: "inline", + yjsSelection: readOnlyCommentData.yjsSelection, + }); + + setActiveCommentId(createdComment.id); + setAsideState({ tab: "comments", isAsideOpen: true }); + + setTimeout(() => { + const selector = `div[data-comment-id="${createdComment.id}"]`; + const commentElement = document.querySelector(selector); + commentElement?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 400); + } finally { + setShowReadOnlyCommentPopup(false); + // @ts-ignore + setReadOnlyCommentData(null); + } + }; + const handleCommentEditorChange = (newContent: any) => { setComment(newContent); }; 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 index 022ea2fa..94d9e020 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -44,7 +44,9 @@ function CommentListWithTabs() { const [isLoading, setIsLoading] = useState(false); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); - const canComment = page?.permissions?.canEdit ?? false; + const canComment = + (page?.permissions?.canEdit ?? false) || + (space?.settings?.comments?.allowViewerComments === true); // Separate active and resolved comments const { activeComments, resolvedComments } = useMemo(() => { @@ -153,7 +155,7 @@ function CommentListWithTabs() { )} ), - [comments, handleAddReply, isLoading, space?.membership?.role], + [comments, handleAddReply, isLoading, space?.membership?.role, canComment], ); if (isCommentsLoading) { diff --git a/apps/client/src/features/comment/components/comment-menu.tsx b/apps/client/src/features/comment/components/comment-menu.tsx index 201a0702..b9cd1e0e 100644 --- a/apps/client/src/features/comment/components/comment-menu.tsx +++ b/apps/client/src/features/comment/components/comment-menu.tsx @@ -75,7 +75,7 @@ function CommentMenu({ {isResolved ? t("Re-open comment") : t("Resolve comment")} ) : ( - + }> {t("Resolve comment")} diff --git a/apps/client/src/features/comment/types/comment.types.ts b/apps/client/src/features/comment/types/comment.types.ts index 6c8cc909..164e63dc 100644 --- a/apps/client/src/features/comment/types/comment.types.ts +++ b/apps/client/src/features/comment/types/comment.types.ts @@ -17,6 +17,10 @@ export interface IComment { deletedAt?: Date; creator: IUser; resolvedBy?: IUser; + yjsSelection?: { + anchor: any; + head: any; + }; } export interface ICommentData { diff --git a/apps/client/src/features/editor/components/bubble-menu/readonly-bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/readonly-bubble-menu.tsx new file mode 100644 index 00000000..7998becf --- /dev/null +++ b/apps/client/src/features/editor/components/bubble-menu/readonly-bubble-menu.tsx @@ -0,0 +1,159 @@ +import type { Editor } from "@tiptap/react"; +import { TextSelection } from "@tiptap/pm/state"; +import { FC, useCallback, useEffect, useRef, useState } from "react"; +import { IconMessage } from "@tabler/icons-react"; +import classes from "./bubble-menu.module.css"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { useAtom } from "jotai"; +import { + showReadOnlyCommentPopupAtom, + readOnlyCommentDataAtom, +} from "@/features/comment/atoms/comment-atom"; +import { useTranslation } from "react-i18next"; +import { getRelativeSelection, ySyncPluginKey } from "@tiptap/y-tiptap"; + +type ReadonlyBubbleMenuProps = { + editor: Editor; +}; + +export const ReadonlyBubbleMenu: FC = ({ editor }) => { + const { t } = useTranslation(); + const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom( + showReadOnlyCommentPopupAtom, + ); + const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom); + const menuRef = useRef(null); + const [visible, setVisible] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const isInteractingRef = useRef(false); + + const updateMenuPosition = useCallback(() => { + if (isInteractingRef.current) return; + + const pmSelection = editor.state.selection; + if (!(pmSelection instanceof TextSelection) || pmSelection.empty) { + setVisible(false); + return; + } + + const selection = window.getSelection(); + if ( + !selection || + selection.isCollapsed || + selection.rangeCount === 0 || + showReadOnlyCommentPopup + ) { + setVisible(false); + return; + } + + const editorDom = editor.view.dom; + if ( + !editorDom.contains(selection.anchorNode) || + !editorDom.contains(selection.focusNode) + ) { + setVisible(false); + return; + } + + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + if (rect.width === 0) { + setVisible(false); + return; + } + + const editorRect = editorDom + .closest(".editor-container") + ?.getBoundingClientRect(); + if (!editorRect) { + setVisible(false); + return; + } + + setPosition({ + top: rect.top - editorRect.top - 44, + left: rect.left - editorRect.left + rect.width / 2, + }); + setVisible(true); + }, [editor, showReadOnlyCommentPopup]); + + useEffect(() => { + const handleSelectionChange = () => { + updateMenuPosition(); + }; + + document.addEventListener("selectionchange", handleSelectionChange); + return () => { + document.removeEventListener("selectionchange", handleSelectionChange); + }; + }, [updateMenuPosition]); + + useEffect(() => { + if (showReadOnlyCommentPopup) { + setVisible(false); + } + }, [showReadOnlyCommentPopup]); + + const handleCommentClick = () => { + if (!editor) return; + + const view = editor.view; + const ystate = ySyncPluginKey.getState(view.state); + + if (ystate?.binding) { + const selection = getRelativeSelection(ystate.binding, view.state); + const { from, to } = editor.state.selection; + const selectedText = editor.state.doc.textBetween(from, to); + + // @ts-ignore + setReadOnlyCommentData({ + yjsSelection: { + anchor: selection.anchor, + head: selection.head, + }, + selectedText, + }); + + setShowReadOnlyCommentPopup(true); + setVisible(false); + } + }; + + if (!visible) return null; + + return ( +
+
+ + { + e.preventDefault(); + e.stopPropagation(); + isInteractingRef.current = true; + handleCommentClick(); + isInteractingRef.current = false; + }} + > + + + +
+
+ ); +}; diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index cbe16adb..89eb7ca8 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -16,6 +16,7 @@ export interface FullEditorProps { content: string; spaceSlug: string; editable: boolean; + canComment?: boolean; } export function FullEditor({ @@ -25,6 +26,7 @@ export function FullEditor({ content, spaceSlug, editable, + canComment, }: FullEditorProps) { const [user] = useAtom(userAtom); const fullPageWidth = user.settings?.preferences?.fullPageWidth; @@ -46,6 +48,7 @@ export function FullEditor({ pageId={pageId} editable={editable} content={content} + canComment={canComment} /> ); diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index d51a5a4c..7cc4723a 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -37,9 +37,11 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar- import { activeCommentIdAtom, showCommentPopupAtom, + showReadOnlyCommentPopupAtom, } from "@/features/comment/atoms/comment-atom"; import CommentDialog from "@/features/comment/components/comment-dialog"; import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu"; +import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu"; import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx"; import TableMenu from "@/features/editor/components/table/table-menu.tsx"; import ImageMenu from "@/features/editor/components/image/image-menu.tsx"; @@ -74,12 +76,14 @@ interface PageEditorProps { pageId: string; editable: boolean; content: any; + canComment?: boolean; } export default function PageEditor({ pageId, editable, content, + canComment, }: PageEditorProps) { const collaborationURL = useCollaborationUrl(); const isComponentMounted = useRef(false); @@ -94,6 +98,7 @@ export default function PageEditor({ const [, setAsideState] = useAtom(asideStateAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); + const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom); const [isLocalSynced, setIsLocalSynced] = useState(false); const [isRemoteSynced, setIsRemoteSynced] = useState(false); const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( @@ -423,7 +428,13 @@ export default function PageEditor({ )} + {editor && !editorIsEditable && (editable || canComment) && providersRef.current && ( + + )} {showCommentPopup && } + {showReadOnlyCommentPopup && ( + + )}
editor.commands.focus("end")} diff --git a/apps/client/src/features/space/components/settings-modal.tsx b/apps/client/src/features/space/components/settings-modal.tsx index 7fb07b39..fb8d24f6 100644 --- a/apps/client/src/features/space/components/settings-modal.tsx +++ b/apps/client/src/features/space/components/settings-modal.tsx @@ -3,6 +3,7 @@ import SpaceMembersList from "@/features/space/components/space-members.tsx"; import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx"; import React from "react"; import SpaceDetails from "@/features/space/components/space-details.tsx"; +import SpaceSecuritySettings from "@/features/space/components/space-security-settings.tsx"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { @@ -59,6 +60,14 @@ export default function SpaceSettingsModal({ {t("Members")} + {spaceAbility.can( + SpaceCaslAction.Manage, + SpaceCaslSubject.Settings, + ) && ( + + {t("Security")} + + )} @@ -91,6 +100,20 @@ export default function SpaceSettingsModal({ )} /> + + + +
+ +
+
+
diff --git a/apps/client/src/features/space/components/space-details.tsx b/apps/client/src/features/space/components/space-details.tsx index 5aea40aa..746d1bbf 100644 --- a/apps/client/src/features/space/components/space-details.tsx +++ b/apps/client/src/features/space/components/space-details.tsx @@ -18,7 +18,7 @@ import { ResponsiveSettingsControl, ResponsiveSettingsRow, } from "@/components/ui/responsive-settings-row.tsx"; -import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx"; + interface SpaceDetailsProps { spaceId: string; @@ -27,7 +27,6 @@ interface SpaceDetailsProps { export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { const { t } = useTranslation(); const { data: space, isLoading, refetch } = useSpaceQuery(spaceId); - const showSharingToggle = !readOnly; const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); const [isIconUploading, setIsIconUploading] = useState(false); @@ -89,13 +88,6 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { - {showSharingToggle && ( - <> - - - - )} - {!readOnly && ( <> diff --git a/apps/client/src/features/space/components/space-security-settings.tsx b/apps/client/src/features/space/components/space-security-settings.tsx new file mode 100644 index 00000000..c606cf68 --- /dev/null +++ b/apps/client/src/features/space/components/space-security-settings.tsx @@ -0,0 +1,34 @@ +import { Text, Divider } from "@mantine/core"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx"; +import SpaceViewerCommentsToggle from "@/ee/security/components/space-viewer-comments-toggle.tsx"; + +type SpaceSecuritySettingsProps = { + space: ISpace; + readOnly?: boolean; +}; + +export default function SpaceSecuritySettings({ + space, + readOnly, +}: SpaceSecuritySettingsProps) { + const { t } = useTranslation(); + + if (readOnly) return null; + + return ( +
+ + {t("Security")} + + + + + + + +
+ ); +} diff --git a/apps/client/src/features/space/types/space.types.ts b/apps/client/src/features/space/types/space.types.ts index f7dcc11a..c856d88a 100644 --- a/apps/client/src/features/space/types/space.types.ts +++ b/apps/client/src/features/space/types/space.types.ts @@ -9,8 +9,13 @@ export interface ISpaceSharingSettings { disabled?: boolean; } +export interface ISpaceCommentsSettings { + allowViewerComments?: boolean; +} + export interface ISpaceSettings { sharing?: ISpaceSharingSettings; + comments?: ISpaceCommentsSettings; } export interface ISpace { @@ -29,6 +34,7 @@ export interface ISpace { settings?: ISpaceSettings; // for updates disablePublicSharing?: boolean; + allowViewerComments?: boolean; } interface IMembership { diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index fc564b4b..f0fc93fa 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -53,6 +53,9 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) { const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); const canEdit = page?.permissions?.canEdit ?? false; + const canComment = + canEdit || + (space?.settings?.comments?.allowViewerComments === true); if (isLoading) { return <>; @@ -104,6 +107,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) { slugId={page.slugId} spaceSlug={page?.space?.slug} editable={canEdit} + canComment={canComment} /> diff --git a/apps/server/src/collaboration/collaboration.handler.ts b/apps/server/src/collaboration/collaboration.handler.ts index 87dc5010..992f9b74 100644 --- a/apps/server/src/collaboration/collaboration.handler.ts +++ b/apps/server/src/collaboration/collaboration.handler.ts @@ -5,6 +5,7 @@ import { prosemirrorNodeToYElement, tiptapExtensions, } from './collaboration.util'; +import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util'; import * as Y from 'yjs'; import { User } from '@docmost/db/types/entity.types'; @@ -27,6 +28,53 @@ export class CollaborationHandler { // const fragment = doc.getXmlFragment('default'); //}); }, + setCommentMark: async ( + documentName: string, + payload: { + yjsSelection: YjsSelection; + commentId: string; + resolved: boolean; + user: User; + }, + ) => { + const { yjsSelection, commentId, resolved, user } = payload; + await this.withYdocConnection( + hocuspocus, + documentName, + { user }, + (doc) => { + const fragment = doc.getXmlFragment('default'); + setYjsMark(doc, fragment, yjsSelection, 'comment', { + commentId, + resolved, + }); + }, + ); + }, + resolveCommentMark: async ( + documentName: string, + payload: { + commentId: string; + resolved: boolean; + user: User; + }, + ) => { + const { commentId, resolved, user } = payload; + await this.withYdocConnection( + hocuspocus, + documentName, + { user }, + (doc) => { + const fragment = doc.getXmlFragment('default'); + updateYjsMarkAttribute( + fragment, + 'comment', + { name: 'commentId', value: commentId }, + { resolved }, + ); + }, + ); + }, updatePageContent: async ( documentName: string, payload: { @@ -58,8 +106,7 @@ export class CollaborationHandler { } else { const newContent = prosemirrorJson.content || []; const yElements = newContent.map(prosemirrorNodeToYElement); - const position = - operation === 'prepend' ? 0 : fragment.length; + const position = operation === 'prepend' ? 0 : fragment.length; fragment.insert(position, yElements); } }, diff --git a/apps/server/src/collaboration/yjs.util.ts b/apps/server/src/collaboration/yjs.util.ts index 3e494bbc..863b149a 100644 --- a/apps/server/src/collaboration/yjs.util.ts +++ b/apps/server/src/collaboration/yjs.util.ts @@ -1,7 +1,7 @@ import { initProseMirrorDoc, relativePositionToAbsolutePosition, -} from 'y-prosemirror'; +} from '@tiptap/y-tiptap'; import * as Y from 'yjs'; import { Document } from '@hocuspocus/server'; import { getSchema } from '@tiptap/core'; diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts new file mode 100644 index 00000000..b893e0b9 --- /dev/null +++ b/apps/server/src/common/features.ts @@ -0,0 +1,22 @@ +export const Feature = { + SSO_CUSTOM: 'sso:custom', + SSO_GOOGLE: 'sso:google', + MFA: 'mfa', + API_KEYS: 'api:keys', + COMMENT_RESOLUTION: 'comment:resolution', + PAGE_PERMISSIONS: 'page:permissions', + AI: 'ai', + CONFLUENCE_IMPORT: 'import:confluence', + DOCX_IMPORT: 'import:docx', + ATTACHMENT_INDEXING: 'attachment:indexing', + SECURITY_SETTINGS: 'security:settings', + MCP: 'mcp', + SCIM: 'scim', + PAGE_VERIFICATION: 'page:verification', + AUDIT_LOGS: 'audit:logs', + RETENTION: 'retention', + SHARING_CONTROLS: 'sharing:controls', + VIEWER_COMMENTS: 'comment:viewer', +} as const; + +export type FeatureKey = (typeof Feature)[keyof typeof Feature]; diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index 99e51384..22458848 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -58,13 +58,13 @@ export class CommentController { throw new NotFoundException('Page not found'); } - await this.pageAccessService.validateCanEdit(page, user); + await this.pageAccessService.validateCanComment(page, user, workspace.id); const comment = await this.commentService.create( { - userId: user.id, page, workspaceId: workspace.id, + user, }, createCommentDto, ); @@ -120,7 +120,7 @@ export class CommentController { @HttpCode(HttpStatus.OK) @Post('update') - async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) { + async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) { const comment = await this.commentRepo.findById(dto.commentId, { includeCreator: true, includeResolvedBy: true, @@ -134,14 +134,14 @@ export class CommentController { throw new NotFoundException('Page not found'); } - await this.pageAccessService.validateCanEdit(page, user); + await this.pageAccessService.validateCanComment(page, user, workspace.id); return this.commentService.update(comment, dto, user); } @HttpCode(HttpStatus.OK) @Post('delete') - async delete(@Body() input: CommentIdDto, @AuthUser() user: User) { + async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) { const comment = await this.commentRepo.findById(input.commentId); if (!comment) { throw new NotFoundException('Comment not found'); @@ -152,8 +152,7 @@ export class CommentController { throw new NotFoundException('Page not found'); } - // Check page-level edit permission first - await this.pageAccessService.validateCanEdit(page, user); + await this.pageAccessService.validateCanComment(page, user, workspace.id); // Check if user is the comment owner const isOwner = comment.creatorId === user.id; @@ -169,7 +168,7 @@ export class CommentController { // 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', + 'You can only delete your own comments', ); } await this.commentRepo.deleteComment(comment.id); diff --git a/apps/server/src/core/comment/comment.module.ts b/apps/server/src/core/comment/comment.module.ts index 02cb6d81..e08f3610 100644 --- a/apps/server/src/core/comment/comment.module.ts +++ b/apps/server/src/core/comment/comment.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { CommentService } from './comment.service'; import { CommentController } from './comment.controller'; +import { CollaborationModule } from '../../collaboration/collaboration.module'; @Module({ + imports: [CollaborationModule], controllers: [CommentController], providers: [CommentService], exports: [CommentService], diff --git a/apps/server/src/core/comment/comment.service.ts b/apps/server/src/core/comment/comment.service.ts index 9fa5e24c..e888ef50 100644 --- a/apps/server/src/core/comment/comment.service.ts +++ b/apps/server/src/core/comment/comment.service.ts @@ -7,7 +7,8 @@ import { } from '@nestjs/common'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; -import { CreateCommentDto } from './dto/create-comment.dto'; +import { CreateCommentDto, yjsSelectionSchema } from './dto/create-comment.dto'; +import { CollaborationGateway } from '../../collaboration/collaboration.gateway'; import { UpdateCommentDto } from './dto/update-comment.dto'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; import { Comment, Page, User } from '@docmost/db/types/entity.types'; @@ -27,6 +28,7 @@ export class CommentService { private commentRepo: CommentRepo, private pageRepo: PageRepo, private wsService: WsService, + private collaborationGateway: CollaborationGateway, @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue, @InjectQueue(QueueName.NOTIFICATION_QUEUE) @@ -45,10 +47,10 @@ export class CommentService { } async create( - opts: { userId: string; page: Page; workspaceId: string }, + opts: { page: Page; workspaceId: string; user: User }, createCommentDto: CreateCommentDto, ) { - const { userId, page, workspaceId } = opts; + const { page, workspaceId, user } = opts; const commentContent = JSON.parse(createCommentDto.content); if (createCommentDto.parentCommentId) { @@ -71,11 +73,39 @@ export class CommentService { selection: createCommentDto?.selection?.substring(0, 250) ?? null, type: createCommentDto.type ?? 'page', parentCommentId: createCommentDto?.parentCommentId, - creatorId: userId, + creatorId: user.id, workspaceId: workspaceId, spaceId: page.spaceId, }); + if (createCommentDto.yjsSelection) { + const parsed = yjsSelectionSchema.safeParse(createCommentDto.yjsSelection); + if (!parsed.success) { + this.logger.warn( + `Invalid yjsSelection for comment ${inserted.id}: ${parsed.error.message}`, + ); + } else { + const documentName = `page.${page.id}`; + try { + await this.collaborationGateway.handleYjsEvent( + 'setCommentMark', + documentName, + { + yjsSelection: parsed.data, + commentId: inserted.id, + resolved: false, + user, + }, + ); + } catch (error) { + this.logger.warn( + `Failed to apply comment mark for comment ${inserted.id}, comment saved without inline highlight`, + error, + ); + } + } + } + const comment = await this.commentRepo.findById(inserted.id, { includeCreator: true, includeResolvedBy: true, @@ -83,7 +113,7 @@ export class CommentService { this.generalQueue .add(QueueJob.ADD_PAGE_WATCHERS, { - userIds: [userId], + userIds: [user.id], pageId: page.id, spaceId: page.spaceId, workspaceId, @@ -101,7 +131,7 @@ export class CommentService { page.id, page.spaceId, workspaceId, - userId, + user.id, !isReply, createCommentDto.parentCommentId, ); diff --git a/apps/server/src/core/comment/dto/create-comment.dto.ts b/apps/server/src/core/comment/dto/create-comment.dto.ts index ca21f47b..c82ae187 100644 --- a/apps/server/src/core/comment/dto/create-comment.dto.ts +++ b/apps/server/src/core/comment/dto/create-comment.dto.ts @@ -1,4 +1,22 @@ -import { IsIn, IsJSON, IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsIn, IsJSON, IsObject, IsOptional, IsString, IsUUID } from 'class-validator'; +import { z } from 'zod'; + +const yjsIdSchema = z.object({ + client: z.number().int().nonnegative(), + clock: z.number().int().nonnegative(), +}); + +const yjsRelativePositionSchema = z.object({ + type: yjsIdSchema, + tname: z.string().nullable(), + item: yjsIdSchema.nullable(), + assoc: z.number().int(), +}); + +export const yjsSelectionSchema = z.object({ + anchor: yjsRelativePositionSchema, + head: yjsRelativePositionSchema, +}); export class CreateCommentDto { @IsString() @@ -18,4 +36,11 @@ export class CreateCommentDto { @IsOptional() @IsUUID() parentCommentId: string; + + @IsOptional() + @IsObject() + yjsSelection?: { + anchor: any; + head: any; + }; } diff --git a/apps/server/src/core/page/page-access/page-access.service.ts b/apps/server/src/core/page/page-access/page-access.service.ts index 07395ed4..6d6db03f 100644 --- a/apps/server/src/core/page/page-access/page-access.service.ts +++ b/apps/server/src/core/page/page-access/page-access.service.ts @@ -6,12 +6,14 @@ import { SpaceCaslAction, SpaceCaslSubject, } from '../../casl/interfaces/space-ability.type'; +import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; @Injectable() export class PageAccessService { constructor( private readonly pagePermissionRepo: PagePermissionRepo, private readonly spaceAbility: SpaceAbilityFactory, + private readonly spaceRepo: SpaceRepo, ) {} /** @@ -99,4 +101,25 @@ export class PageAccessService { return { hasRestriction: hasAnyRestriction }; } + + async validateCanComment( + page: Page, + user: User, + workspaceId: string, + ): Promise { + try { + await this.validateCanEdit(page, user); + return; + } catch { + // User cannot edit — check if reader commenting is enabled + } + + await this.validateCanView(page, user); + + const space = await this.spaceRepo.findById(page.spaceId, workspaceId); + const settings = space?.settings as Record | null; + if (!settings?.comments?.allowViewerComments) { + throw new ForbiddenException(); + } + } } diff --git a/apps/server/src/core/space/dto/update-space.dto.ts b/apps/server/src/core/space/dto/update-space.dto.ts index 47f1529b..8b40e894 100644 --- a/apps/server/src/core/space/dto/update-space.dto.ts +++ b/apps/server/src/core/space/dto/update-space.dto.ts @@ -11,4 +11,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) { @IsOptional() @IsBoolean() disablePublicSharing: boolean; + + @IsOptional() + @IsBoolean() + allowViewerComments: boolean; } diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts index e512e644..2675a9e6 100644 --- a/apps/server/src/core/space/services/space.service.ts +++ b/apps/server/src/core/space/services/space.service.ts @@ -13,6 +13,7 @@ import { Space, User } from '@docmost/db/types/entity.types'; import { UpdateSpaceDto } from '../dto/update-space.dto'; import { executeTx } from '@docmost/db/utils'; import { InjectKysely } from 'nestjs-kysely'; +import { Feature } from '../../../common/features'; import { SpaceMemberService } from './space-member.service'; import { SpaceRole } from '../../../common/helpers/types/permission'; import { QueueJob, QueueName } from 'src/integrations/queue/constants'; @@ -133,17 +134,34 @@ export class SpaceService { } } - if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') { + if ( + typeof updateSpaceDto.disablePublicSharing !== 'undefined' || + typeof updateSpaceDto.allowViewerComments !== 'undefined' + ) { const workspace = await this.workspaceRepo.findById(workspaceId, { withLicenseKey: true, }); if ( - !this.licenseCheckService.hasFeature(workspace.licenseKey, 'security:settings', workspace.plan) + typeof updateSpaceDto.disablePublicSharing !== 'undefined' && + !this.licenseCheckService.hasFeature( + workspace.licenseKey, + Feature.SECURITY_SETTINGS, + workspace.plan, + ) ) { - throw new ForbiddenException( - 'This feature requires a valid license', - ); + throw new ForbiddenException('This feature requires a valid license'); + } + + if ( + typeof updateSpaceDto.allowViewerComments !== 'undefined' && + !this.licenseCheckService.hasFeature( + workspace.licenseKey, + Feature.VIEWER_COMMENTS, + workspace.plan, + ) + ) { + throw new ForbiddenException('This feature requires a valid license'); } } @@ -179,6 +197,22 @@ export class SpaceService { } } + if (typeof updateSpaceDto.allowViewerComments !== 'undefined') { + const prev = settingsBefore?.comments?.allowViewerComments ?? false; + if (prev !== updateSpaceDto.allowViewerComments) { + before.allowViewerComments = prev; + after.allowViewerComments = updateSpaceDto.allowViewerComments; + } + + await this.spaceRepo.updateCommentSettings( + updateSpaceDto.spaceId, + workspaceId, + 'allowViewerComments', + updateSpaceDto.allowViewerComments, + trx, + ); + } + updatedSpace = await this.spaceRepo.updateSpace( { name: updateSpaceDto.name, diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index c3b6550f..d0bd27c6 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -18,6 +18,7 @@ import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { executeTx } from '@docmost/db/utils'; import { InjectKysely } from 'nestjs-kysely'; +import { Feature } from '../../../common/features'; import { User } from '@docmost/db/types/entity.types'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo'; @@ -352,7 +353,7 @@ export class WorkspaceService { typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' || typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ) { - if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'security:settings', ws.plan)) { + if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SECURITY_SETTINGS, ws.plan)) { throw new ForbiddenException( 'This feature requires a valid license', ); diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index 8ec5904c..0b389665 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -111,6 +111,28 @@ export class SpaceRepo { .executeTakeFirst(); } + async updateCommentSettings( + spaceId: string, + workspaceId: string, + prefKey: string, + prefValue: string | boolean, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .updateTable('spaces') + .set({ + settings: sql`COALESCE(settings, '{}'::jsonb) + || jsonb_build_object('comments', COALESCE(settings->'comments', '{}'::jsonb) + || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, + updatedAt: new Date(), + }) + .where('id', '=', spaceId) + .where('workspaceId', '=', workspaceId) + .returningAll() + .executeTakeFirst(); + } + async insertSpace( insertableSpace: InsertableSpace, trx?: KyselyTransaction, diff --git a/apps/server/src/ee b/apps/server/src/ee index a258ca36..c70a29cb 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit a258ca366077d6e97b340c4faf56747ca6a3ca78 +Subproject commit c70a29cb2532364d235f00409588448f9d995e6b