From d6255a7e704a2b5bb27de97053b1a4f246fe6fad Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 10 Sep 2025 16:10:02 +0200 Subject: [PATCH 1/3] feat(comments): add ability to deny a user to view a comment #1994 --- .../07-collaboration/04-comments/src/App.tsx | 8 +++-- .../04-comments/src/userdata.ts | 8 ++++- .../threadstore/DefaultThreadStoreAuth.ts | 30 ++++++++++++------- .../comments/threadstore/ThreadStoreAuth.ts | 1 + .../src/extensions/Comments/CommentsPlugin.ts | 11 ++++++- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/examples/07-collaboration/04-comments/src/App.tsx b/examples/07-collaboration/04-comments/src/App.tsx index bd11b2539e..5275df7048 100644 --- a/examples/07-collaboration/04-comments/src/App.tsx +++ b/examples/07-collaboration/04-comments/src/App.tsx @@ -43,7 +43,7 @@ export default function App() { } function Document() { - const [activeUser, setActiveUser] = useState(HARDCODED_USERS[0]); + const [activeUser, setActiveUser] = useState(HARDCODED_USERS[4]); const provider = useYjsProvider(); @@ -100,7 +100,11 @@ function Document() { label={"User"} items={HARDCODED_USERS.map((user) => ({ text: `${user.username} (${ - user.role === "editor" ? "Editor" : "Commenter" + user.role === "editor" + ? "Editor" + : user.role === "comment" + ? "Commenter" + : "Viewer Only" })`, icon: null, onClick: () => setActiveUser(user), diff --git a/examples/07-collaboration/04-comments/src/userdata.ts b/examples/07-collaboration/04-comments/src/userdata.ts index c54eaf0f9a..c0a43f074a 100644 --- a/examples/07-collaboration/04-comments/src/userdata.ts +++ b/examples/07-collaboration/04-comments/src/userdata.ts @@ -16,7 +16,7 @@ const getRandomElement = (list: any[]) => export const getRandomColor = () => getRandomElement(colors); export type MyUserType = User & { - role: "editor" | "comment"; + role: "editor" | "comment" | "document-only"; }; export const HARDCODED_USERS: MyUserType[] = [ @@ -44,4 +44,10 @@ export const HARDCODED_USERS: MyUserType[] = [ avatarUrl: "https://placehold.co/100x100?text=Betty", role: "comment", }, + { + id: "5", + username: "Donna Document", + avatarUrl: "https://placehold.co/100x100?text=Donna", + role: "document-only", + }, ]; diff --git a/packages/core/src/comments/threadstore/DefaultThreadStoreAuth.ts b/packages/core/src/comments/threadstore/DefaultThreadStoreAuth.ts index 9b79a5aaf1..28c7cc35a7 100644 --- a/packages/core/src/comments/threadstore/DefaultThreadStoreAuth.ts +++ b/packages/core/src/comments/threadstore/DefaultThreadStoreAuth.ts @@ -18,58 +18,68 @@ import { ThreadStoreAuth } from "./ThreadStoreAuth.js"; export class DefaultThreadStoreAuth extends ThreadStoreAuth { constructor( private readonly userId: string, - private readonly role: "comment" | "editor", + private readonly role: "document-only" | "comment" | "editor", ) { super(); } + /** + * Auth: should be possible by anyone with comment access + */ + canViewComments(): boolean { + return this.role !== "document-only"; + } + /** * Auth: should be possible by anyone with comment access */ canCreateThread(): boolean { - return true; + return this.canViewComments(); } /** * Auth: should be possible by anyone with comment access */ canAddComment(_thread: ThreadData): boolean { - return true; + return this.canViewComments(); } /** * Auth: should only be possible by the comment author */ canUpdateComment(comment: CommentData): boolean { - return comment.userId === this.userId; + return this.canViewComments() && comment.userId === this.userId; } /** * Auth: should be possible by the comment author OR an editor of the document */ canDeleteComment(comment: CommentData): boolean { - return comment.userId === this.userId || this.role === "editor"; + return ( + this.canViewComments() && + (comment.userId === this.userId || this.role === "editor") + ); } /** * Auth: should only be possible by an editor of the document */ canDeleteThread(_thread: ThreadData): boolean { - return this.role === "editor"; + return this.canViewComments() && this.role === "editor"; } /** * Auth: should be possible by anyone with comment access */ canResolveThread(_thread: ThreadData): boolean { - return true; + return this.canViewComments(); } /** * Auth: should be possible by anyone with comment access */ canUnresolveThread(_thread: ThreadData): boolean { - return true; + return this.canViewComments(); } /** @@ -79,7 +89,7 @@ export class DefaultThreadStoreAuth extends ThreadStoreAuth { */ canAddReaction(comment: CommentData, emoji?: string): boolean { if (!emoji) { - return true; + return this.canViewComments(); } return !comment.reactions.some( @@ -95,7 +105,7 @@ export class DefaultThreadStoreAuth extends ThreadStoreAuth { */ canDeleteReaction(comment: CommentData, emoji?: string): boolean { if (!emoji) { - return true; + return this.canViewComments(); } return comment.reactions.some( diff --git a/packages/core/src/comments/threadstore/ThreadStoreAuth.ts b/packages/core/src/comments/threadstore/ThreadStoreAuth.ts index 452ad25b03..0f4cdb9f91 100644 --- a/packages/core/src/comments/threadstore/ThreadStoreAuth.ts +++ b/packages/core/src/comments/threadstore/ThreadStoreAuth.ts @@ -2,6 +2,7 @@ import { CommentData, ThreadData } from "../types.js"; export abstract class ThreadStoreAuth { abstract canCreateThread(): boolean; + abstract canViewComments(): boolean; abstract canAddComment(thread: ThreadData): boolean; abstract canUpdateComment(comment: CommentData): boolean; abstract canDeleteComment(comment: CommentData): boolean; diff --git a/packages/core/src/extensions/Comments/CommentsPlugin.ts b/packages/core/src/extensions/Comments/CommentsPlugin.ts index e5fb09d7d7..a97c9c7b32 100644 --- a/packages/core/src/extensions/Comments/CommentsPlugin.ts +++ b/packages/core/src/extensions/Comments/CommentsPlugin.ts @@ -177,6 +177,12 @@ export class CommentsPlugin extends BlockNoteExtension { if (!tr.docChanged && !action) { return state; } + if (!self.threadStore.auth.canViewComments()) { + // if the user doesn't have comment access, don't display the marks in the document + return { + decorations: DecorationSet.empty, + }; + } // only update threadPositions if the doc changed const threadPositions = tr.docChanged @@ -225,7 +231,10 @@ export class CommentsPlugin extends BlockNoteExtension { * Handle click on a thread mark and mark it as selected */ handleClick: (view, pos, event) => { - if (event.button !== 0) { + if ( + event.button !== 0 || + !self.threadStore.auth.canViewComments() + ) { return; } From 66b23d154b06c651687f7b2fcc767d03a9b144a7 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 10 Sep 2025 16:13:56 +0200 Subject: [PATCH 2/3] chore: deny comment creation --- .../FormattingToolbar/DefaultButtons/AddCommentButton.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx index 3e85b2994f..fbd6d0f026 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx @@ -24,7 +24,8 @@ export const AddCommentButton = () => { if ( // We manually check if a comment extension (like liveblocks) is installed // By adding default support for this, the user doesn't need to customize the formatting toolbar - !editor.comments + !editor.comments || + !editor.comments.threadStore.auth.canViewComments() ) { return null; } From 2aa0c156f99d207a4cdd27e684276e0a7c764122 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Tue, 7 Oct 2025 18:56:41 +0200 Subject: [PATCH 3/3] Made comment marks invisible for content-only viewers + small changes --- examples/07-collaboration/05-comments/src/App.tsx | 4 ++-- examples/07-collaboration/05-comments/src/userdata.ts | 8 +++++++- .../07-collaboration/06-comments-with-sidebar/src/App.tsx | 6 +++++- packages/core/src/editor/Block.css | 4 ++-- packages/core/src/editor/BlockNoteEditor.ts | 3 +++ 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/examples/07-collaboration/05-comments/src/App.tsx b/examples/07-collaboration/05-comments/src/App.tsx index 5275df7048..43a96eb3b1 100644 --- a/examples/07-collaboration/05-comments/src/App.tsx +++ b/examples/07-collaboration/05-comments/src/App.tsx @@ -43,7 +43,7 @@ export default function App() { } function Document() { - const [activeUser, setActiveUser] = useState(HARDCODED_USERS[4]); + const [activeUser, setActiveUser] = useState(HARDCODED_USERS[0]); const provider = useYjsProvider(); @@ -104,7 +104,7 @@ function Document() { ? "Editor" : user.role === "comment" ? "Commenter" - : "Viewer Only" + : "Content-Only Viewer" })`, icon: null, onClick: () => setActiveUser(user), diff --git a/examples/07-collaboration/05-comments/src/userdata.ts b/examples/07-collaboration/05-comments/src/userdata.ts index c54eaf0f9a..c0a43f074a 100644 --- a/examples/07-collaboration/05-comments/src/userdata.ts +++ b/examples/07-collaboration/05-comments/src/userdata.ts @@ -16,7 +16,7 @@ const getRandomElement = (list: any[]) => export const getRandomColor = () => getRandomElement(colors); export type MyUserType = User & { - role: "editor" | "comment"; + role: "editor" | "comment" | "document-only"; }; export const HARDCODED_USERS: MyUserType[] = [ @@ -44,4 +44,10 @@ export const HARDCODED_USERS: MyUserType[] = [ avatarUrl: "https://placehold.co/100x100?text=Betty", role: "comment", }, + { + id: "5", + username: "Donna Document", + avatarUrl: "https://placehold.co/100x100?text=Donna", + role: "document-only", + }, ]; diff --git a/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx b/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx index ed7bf38473..ecf8bfde05 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx +++ b/examples/07-collaboration/06-comments-with-sidebar/src/App.tsx @@ -125,7 +125,11 @@ function Document() { label={"User"} items={HARDCODED_USERS.map((user) => ({ text: `${user.username} (${ - user.role === "editor" ? "Editor" : "Commenter" + user.role === "editor" + ? "Editor" + : user.role === "comment" + ? "Commenter" + : "Content-Only Viewer" })`, icon: null, onClick: () => { diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index fdf4d31123..f523905077 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -717,10 +717,10 @@ NESTED BLOCKS padding-right: 0; } -.bn-thread-mark:not([data-orphan="true"]) { +.bn-editor:not([data-hide-comments]) .bn-thread-mark:not([data-orphan="true"]) { background: rgba(255, 200, 0, 0.15); } -.bn-thread-mark .bn-thread-mark-selected { +.bn-editor:not([data-hide-comments]) .bn-thread-mark .bn-thread-mark-selected { background: rgba(255, 200, 0, 0.25); } diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 0ef8ba10ba..3fb756e5d8 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -848,6 +848,9 @@ export class BlockNoteEditor< editorProps: { ...newOptions._tiptapOptions?.editorProps, attributes: { + ...(this.comments?.threadStore.auth.canViewComments() + ? {} + : { "data-hide-comments": "true" }), // As of TipTap v2.5.0 the tabIndex is removed when the editor is not // editable, so you can't focus it. We want to revert this as we have // UI behaviour that relies on it.