mirror of
https://github.com/docmost/docmost.git
synced 2026-05-09 07:43:06 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d6e8fd287 | |||
| 237fd79971 | |||
| 15e5b05c7f | |||
| 7b3572a285 | |||
| 3940c259e8 | |||
| 32446d1320 | |||
| da7bb9a07f | |||
| 2648d7bea3 | |||
| c6d2f0c6cc |
@@ -442,9 +442,6 @@
|
|||||||
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
|
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
|
||||||
"Toggle public sharing": "Toggle public sharing",
|
"Toggle public sharing": "Toggle public sharing",
|
||||||
"Toggle space public sharing": "Toggle space 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",
|
"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.",
|
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
|
||||||
"Page permissions": "Page permissions",
|
"Page permissions": "Page permissions",
|
||||||
|
|||||||
@@ -16,5 +16,4 @@ export const Feature = {
|
|||||||
AUDIT_LOGS: 'audit:logs',
|
AUDIT_LOGS: 'audit:logs',
|
||||||
RETENTION: 'retention',
|
RETENTION: 'retention',
|
||||||
SHARING_CONTROLS: 'sharing:controls',
|
SHARING_CONTROLS: 'sharing:controls',
|
||||||
VIEWER_COMMENTS: 'comment:viewer',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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<HTMLInputElement>) => {
|
|
||||||
const value = event.currentTarget.checked;
|
|
||||||
try {
|
|
||||||
await updateSpaceMutation.mutateAsync({
|
|
||||||
spaceId: space.id,
|
|
||||||
allowViewerComments: value,
|
|
||||||
});
|
|
||||||
setChecked(value);
|
|
||||||
} catch {
|
|
||||||
// error handled by mutation
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
|
||||||
<div>
|
|
||||||
<Text size="md">{t("Allow viewers to comment")}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("Allow viewers to add comments on pages in this space.")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Tooltip
|
|
||||||
label={upgradeLabel}
|
|
||||||
disabled={!isDisabled}
|
|
||||||
refProp="rootRef"
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={checked}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={isDisabled}
|
|
||||||
aria-label={t("Toggle viewer comments")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,15 +3,3 @@ import { atom } from 'jotai';
|
|||||||
export const showCommentPopupAtom = atom<boolean>(false);
|
export const showCommentPopupAtom = atom<boolean>(false);
|
||||||
export const activeCommentIdAtom = atom<string>('');
|
export const activeCommentIdAtom = atom<string>('');
|
||||||
export const draftCommentIdAtom = atom<string>('');
|
export const draftCommentIdAtom = atom<string>('');
|
||||||
|
|
||||||
// Read-only comment state
|
|
||||||
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
|
|
||||||
export type YjsSelection = {
|
|
||||||
anchor: any;
|
|
||||||
head: any;
|
|
||||||
};
|
|
||||||
export type ReadOnlyCommentData = {
|
|
||||||
yjsSelection: YjsSelection;
|
|
||||||
selectedText: string;
|
|
||||||
};
|
|
||||||
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
draftCommentIdAtom,
|
draftCommentIdAtom,
|
||||||
showCommentPopupAtom,
|
showCommentPopupAtom,
|
||||||
showReadOnlyCommentPopupAtom,
|
|
||||||
readOnlyCommentDataAtom,
|
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
@@ -21,15 +19,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
interface CommentDialogProps {
|
interface CommentDialogProps {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
readOnly?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
|
||||||
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
|
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
@@ -39,17 +34,11 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
|||||||
handleDialogClose();
|
handleDialogClose();
|
||||||
});
|
});
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
const isPending = createCommentMutation.isPending;
|
const { isPending } = createCommentMutation;
|
||||||
|
|
||||||
const handleDialogClose = () => {
|
const handleDialogClose = () => {
|
||||||
if (readOnly) {
|
setShowCommentPopup(false);
|
||||||
setShowReadOnlyCommentPopup(false);
|
editor.chain().focus().unsetCommentDecoration().run();
|
||||||
// @ts-ignore
|
|
||||||
setReadOnlyCommentData(null);
|
|
||||||
} else {
|
|
||||||
setShowCommentPopup(false);
|
|
||||||
editor.chain().focus().unsetCommentDecoration().run();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedText = () => {
|
const getSelectedText = () => {
|
||||||
@@ -58,11 +47,6 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddComment = async () => {
|
const handleAddComment = async () => {
|
||||||
if (readOnly) {
|
|
||||||
await handleAddReadOnlyComment();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const selectedText = getSelectedText();
|
const selectedText = getSelectedText();
|
||||||
const commentData = {
|
const commentData = {
|
||||||
@@ -81,6 +65,7 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
|||||||
.run();
|
.run();
|
||||||
setActiveCommentId(createdComment.id);
|
setActiveCommentId(createdComment.id);
|
||||||
|
|
||||||
|
//unselect text to close bubble menu
|
||||||
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
||||||
|
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||||
@@ -100,33 +85,6 @@ function CommentDialog({ editor, pageId, readOnly }: 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) => {
|
const handleCommentEditorChange = (newContent: any) => {
|
||||||
setComment(newContent);
|
setComment(newContent);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ function CommentListWithTabs() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const canComment =
|
const canComment = page?.permissions?.canEdit ?? false;
|
||||||
(page?.permissions?.canEdit ?? false) ||
|
|
||||||
(space?.settings?.comments?.allowViewerComments === true);
|
|
||||||
|
|
||||||
// Separate active and resolved comments
|
// Separate active and resolved comments
|
||||||
const { activeComments, resolvedComments } = useMemo(() => {
|
const { activeComments, resolvedComments } = useMemo(() => {
|
||||||
@@ -155,7 +153,7 @@ function CommentListWithTabs() {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
|
[comments, handleAddReply, isLoading, space?.membership?.role],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isCommentsLoading) {
|
if (isCommentsLoading) {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function CommentMenu({
|
|||||||
{isResolved ? t("Re-open comment") : t("Resolve comment")}
|
{isResolved ? t("Re-open comment") : t("Resolve comment")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip label={upgradeLabel} position="left" withPortal={false}>
|
<Tooltip label={upgradeLabel} position="left">
|
||||||
<Menu.Item disabled leftSection={<IconCircleCheck size={14} />}>
|
<Menu.Item disabled leftSection={<IconCircleCheck size={14} />}>
|
||||||
{t("Resolve comment")}
|
{t("Resolve comment")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ export interface IComment {
|
|||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
creator: IUser;
|
creator: IUser;
|
||||||
resolvedBy?: IUser;
|
resolvedBy?: IUser;
|
||||||
yjsSelection?: {
|
|
||||||
anchor: any;
|
|
||||||
head: any;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICommentData {
|
export interface ICommentData {
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
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<ReadonlyBubbleMenuProps> = ({ editor }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
|
|
||||||
showReadOnlyCommentPopupAtom,
|
|
||||||
);
|
|
||||||
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
|
|
||||||
const menuRef = useRef<HTMLDivElement>(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 (
|
|
||||||
<div
|
|
||||||
ref={menuRef}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: position.top,
|
|
||||||
left: position.left,
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
zIndex: 199,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={classes.bubbleMenu}>
|
|
||||||
<Tooltip label={t("Comment")} withArrow withinPortal={false}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
radius="6px"
|
|
||||||
aria-label={t("Comment")}
|
|
||||||
style={{ border: "none" }}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
isInteractingRef.current = true;
|
|
||||||
handleCommentClick();
|
|
||||||
isInteractingRef.current = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconMessage size={16} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -16,7 +16,6 @@ export interface FullEditorProps {
|
|||||||
content: string;
|
content: string;
|
||||||
spaceSlug: string;
|
spaceSlug: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
canComment?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FullEditor({
|
export function FullEditor({
|
||||||
@@ -26,7 +25,6 @@ export function FullEditor({
|
|||||||
content,
|
content,
|
||||||
spaceSlug,
|
spaceSlug,
|
||||||
editable,
|
editable,
|
||||||
canComment,
|
|
||||||
}: FullEditorProps) {
|
}: FullEditorProps) {
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||||
@@ -48,7 +46,6 @@ export function FullEditor({
|
|||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
editable={editable}
|
editable={editable}
|
||||||
content={content}
|
content={content}
|
||||||
canComment={canComment}
|
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,11 +37,9 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
|||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
showCommentPopupAtom,
|
showCommentPopupAtom,
|
||||||
showReadOnlyCommentPopupAtom,
|
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
import CommentDialog from "@/features/comment/components/comment-dialog";
|
import CommentDialog from "@/features/comment/components/comment-dialog";
|
||||||
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
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 TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
||||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||||
@@ -75,14 +73,12 @@ interface PageEditorProps {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
content: any;
|
content: any;
|
||||||
canComment?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageEditor({
|
export default function PageEditor({
|
||||||
pageId,
|
pageId,
|
||||||
editable,
|
editable,
|
||||||
content,
|
content,
|
||||||
canComment,
|
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
const collaborationURL = useCollaborationUrl();
|
const collaborationURL = useCollaborationUrl();
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
@@ -97,7 +93,6 @@ export default function PageEditor({
|
|||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
|
||||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
@@ -426,13 +421,7 @@ export default function PageEditor({
|
|||||||
<ColumnsMenu editor={editor} />
|
<ColumnsMenu editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{editor && !editorIsEditable && (editable || canComment) && providersRef.current && (
|
|
||||||
<ReadonlyBubbleMenu editor={editor} />
|
|
||||||
)}
|
|
||||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||||
{showReadOnlyCommentPopup && (
|
|
||||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => editor.commands.focus("end")}
|
onClick={() => editor.commands.focus("end")}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import SpaceMembersList from "@/features/space/components/space-members.tsx";
|
|||||||
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import SpaceDetails from "@/features/space/components/space-details.tsx";
|
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 { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||||
import {
|
import {
|
||||||
@@ -60,14 +59,6 @@ export default function SpaceSettingsModal({
|
|||||||
<Tabs.Tab fw={500} value="members">
|
<Tabs.Tab fw={500} value="members">
|
||||||
{t("Members")}
|
{t("Members")}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
{spaceAbility.can(
|
|
||||||
SpaceCaslAction.Manage,
|
|
||||||
SpaceCaslSubject.Settings,
|
|
||||||
) && (
|
|
||||||
<Tabs.Tab fw={500} value="security">
|
|
||||||
{t("Security")}
|
|
||||||
</Tabs.Tab>
|
|
||||||
)}
|
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="general">
|
<Tabs.Panel value="general">
|
||||||
@@ -100,20 +91,6 @@ export default function SpaceSettingsModal({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="security">
|
|
||||||
<ScrollArea h={580} scrollbarSize={5} pr={8}>
|
|
||||||
<div style={{ paddingBottom: "100px" }}>
|
|
||||||
<SpaceSecuritySettings
|
|
||||||
space={space}
|
|
||||||
readOnly={spaceAbility.cannot(
|
|
||||||
SpaceCaslAction.Manage,
|
|
||||||
SpaceCaslSubject.Settings,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
ResponsiveSettingsControl,
|
ResponsiveSettingsControl,
|
||||||
ResponsiveSettingsRow,
|
ResponsiveSettingsRow,
|
||||||
} from "@/components/ui/responsive-settings-row.tsx";
|
} from "@/components/ui/responsive-settings-row.tsx";
|
||||||
|
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
|
||||||
|
|
||||||
interface SpaceDetailsProps {
|
interface SpaceDetailsProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -27,6 +27,7 @@ interface SpaceDetailsProps {
|
|||||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
|
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
|
||||||
|
const showSharingToggle = !readOnly;
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const [isIconUploading, setIsIconUploading] = useState(false);
|
const [isIconUploading, setIsIconUploading] = useState(false);
|
||||||
@@ -88,6 +89,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
|||||||
|
|
||||||
<EditSpaceForm space={space} readOnly={readOnly} />
|
<EditSpaceForm space={space} readOnly={readOnly} />
|
||||||
|
|
||||||
|
{showSharingToggle && (
|
||||||
|
<>
|
||||||
|
<Divider my="lg" />
|
||||||
|
<SpacePublicSharingToggle space={space} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
<Divider my="lg" />
|
<Divider my="lg" />
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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 (
|
|
||||||
<div>
|
|
||||||
<Text my="md" fw={600}>
|
|
||||||
{t("Security")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<SpacePublicSharingToggle space={space} />
|
|
||||||
|
|
||||||
<Divider my="lg" />
|
|
||||||
|
|
||||||
<SpaceViewerCommentsToggle space={space} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,13 +9,8 @@ export interface ISpaceSharingSettings {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISpaceCommentsSettings {
|
|
||||||
allowViewerComments?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ISpaceSettings {
|
export interface ISpaceSettings {
|
||||||
sharing?: ISpaceSharingSettings;
|
sharing?: ISpaceSharingSettings;
|
||||||
comments?: ISpaceCommentsSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISpace {
|
export interface ISpace {
|
||||||
@@ -34,7 +29,6 @@ export interface ISpace {
|
|||||||
settings?: ISpaceSettings;
|
settings?: ISpaceSettings;
|
||||||
// for updates
|
// for updates
|
||||||
disablePublicSharing?: boolean;
|
disablePublicSharing?: boolean;
|
||||||
allowViewerComments?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IMembership {
|
interface IMembership {
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const canEdit = page?.permissions?.canEdit ?? false;
|
const canEdit = page?.permissions?.canEdit ?? false;
|
||||||
const canComment =
|
|
||||||
canEdit ||
|
|
||||||
(space?.settings?.comments?.allowViewerComments === true);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <></>;
|
return <></>;
|
||||||
@@ -107,7 +104,6 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
slugId={page.slugId}
|
slugId={page.slugId}
|
||||||
spaceSlug={page?.space?.slug}
|
spaceSlug={page?.space?.slug}
|
||||||
editable={canEdit}
|
editable={canEdit}
|
||||||
canComment={canComment}
|
|
||||||
/>
|
/>
|
||||||
<MemoizedHistoryModal pageId={page.id} />
|
<MemoizedHistoryModal pageId={page.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
prosemirrorNodeToYElement,
|
prosemirrorNodeToYElement,
|
||||||
tiptapExtensions,
|
tiptapExtensions,
|
||||||
} from './collaboration.util';
|
} from './collaboration.util';
|
||||||
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
|
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
@@ -28,53 +27,6 @@ export class CollaborationHandler {
|
|||||||
// const fragment = doc.getXmlFragment('default');
|
// 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 (
|
updatePageContent: async (
|
||||||
documentName: string,
|
documentName: string,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -106,7 +58,8 @@ export class CollaborationHandler {
|
|||||||
} else {
|
} else {
|
||||||
const newContent = prosemirrorJson.content || [];
|
const newContent = prosemirrorJson.content || [];
|
||||||
const yElements = newContent.map(prosemirrorNodeToYElement);
|
const yElements = newContent.map(prosemirrorNodeToYElement);
|
||||||
const position = operation === 'prepend' ? 0 : fragment.length;
|
const position =
|
||||||
|
operation === 'prepend' ? 0 : fragment.length;
|
||||||
fragment.insert(position, yElements);
|
fragment.insert(position, yElements);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
initProseMirrorDoc,
|
initProseMirrorDoc,
|
||||||
relativePositionToAbsolutePosition,
|
relativePositionToAbsolutePosition,
|
||||||
} from '@tiptap/y-tiptap';
|
} from 'y-prosemirror';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { Document } from '@hocuspocus/server';
|
import { Document } from '@hocuspocus/server';
|
||||||
import { getSchema } from '@tiptap/core';
|
import { getSchema } from '@tiptap/core';
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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];
|
|
||||||
@@ -58,13 +58,13 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
|
||||||
const comment = await this.commentService.create(
|
const comment = await this.commentService.create(
|
||||||
{
|
{
|
||||||
|
userId: user.id,
|
||||||
page,
|
page,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
user,
|
|
||||||
},
|
},
|
||||||
createCommentDto,
|
createCommentDto,
|
||||||
);
|
);
|
||||||
@@ -120,7 +120,7 @@ export class CommentController {
|
|||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('update')
|
@Post('update')
|
||||||
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
|
||||||
const comment = await this.commentRepo.findById(dto.commentId, {
|
const comment = await this.commentRepo.findById(dto.commentId, {
|
||||||
includeCreator: true,
|
includeCreator: true,
|
||||||
includeResolvedBy: true,
|
includeResolvedBy: true,
|
||||||
@@ -134,14 +134,14 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
|
||||||
return this.commentService.update(comment, dto, user);
|
return this.commentService.update(comment, dto, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('delete')
|
@Post('delete')
|
||||||
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
async delete(@Body() input: CommentIdDto, @AuthUser() user: User) {
|
||||||
const comment = await this.commentRepo.findById(input.commentId);
|
const comment = await this.commentRepo.findById(input.commentId);
|
||||||
if (!comment) {
|
if (!comment) {
|
||||||
throw new NotFoundException('Comment not found');
|
throw new NotFoundException('Comment not found');
|
||||||
@@ -152,7 +152,8 @@ export class CommentController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
// Check page-level edit permission first
|
||||||
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
|
||||||
// Check if user is the comment owner
|
// Check if user is the comment owner
|
||||||
const isOwner = comment.creatorId === user.id;
|
const isOwner = comment.creatorId === user.id;
|
||||||
@@ -168,7 +169,7 @@ export class CommentController {
|
|||||||
// Space admin can delete any comment
|
// Space admin can delete any comment
|
||||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
'You can only delete your own comments',
|
'You can only delete your own comments or must be a space admin',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.commentRepo.deleteComment(comment.id);
|
await this.commentRepo.deleteComment(comment.id);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CommentService } from './comment.service';
|
import { CommentService } from './comment.service';
|
||||||
import { CommentController } from './comment.controller';
|
import { CommentController } from './comment.controller';
|
||||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CollaborationModule],
|
|
||||||
controllers: [CommentController],
|
controllers: [CommentController],
|
||||||
providers: [CommentService],
|
providers: [CommentService],
|
||||||
exports: [CommentService],
|
exports: [CommentService],
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { CreateCommentDto, yjsSelectionSchema } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
import { CollaborationGateway } from '../../collaboration/collaboration.gateway';
|
|
||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||||
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||||
@@ -28,7 +27,6 @@ export class CommentService {
|
|||||||
private commentRepo: CommentRepo,
|
private commentRepo: CommentRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private wsService: WsService,
|
private wsService: WsService,
|
||||||
private collaborationGateway: CollaborationGateway,
|
|
||||||
@InjectQueue(QueueName.GENERAL_QUEUE)
|
@InjectQueue(QueueName.GENERAL_QUEUE)
|
||||||
private generalQueue: Queue,
|
private generalQueue: Queue,
|
||||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
|
||||||
@@ -47,10 +45,10 @@ export class CommentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
opts: { page: Page; workspaceId: string; user: User },
|
opts: { userId: string; page: Page; workspaceId: string },
|
||||||
createCommentDto: CreateCommentDto,
|
createCommentDto: CreateCommentDto,
|
||||||
) {
|
) {
|
||||||
const { page, workspaceId, user } = opts;
|
const { userId, page, workspaceId } = opts;
|
||||||
const commentContent = JSON.parse(createCommentDto.content);
|
const commentContent = JSON.parse(createCommentDto.content);
|
||||||
|
|
||||||
if (createCommentDto.parentCommentId) {
|
if (createCommentDto.parentCommentId) {
|
||||||
@@ -73,39 +71,11 @@ export class CommentService {
|
|||||||
selection: createCommentDto?.selection?.substring(0, 250) ?? null,
|
selection: createCommentDto?.selection?.substring(0, 250) ?? null,
|
||||||
type: createCommentDto.type ?? 'page',
|
type: createCommentDto.type ?? 'page',
|
||||||
parentCommentId: createCommentDto?.parentCommentId,
|
parentCommentId: createCommentDto?.parentCommentId,
|
||||||
creatorId: user.id,
|
creatorId: userId,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
spaceId: page.spaceId,
|
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, {
|
const comment = await this.commentRepo.findById(inserted.id, {
|
||||||
includeCreator: true,
|
includeCreator: true,
|
||||||
includeResolvedBy: true,
|
includeResolvedBy: true,
|
||||||
@@ -113,7 +83,7 @@ export class CommentService {
|
|||||||
|
|
||||||
this.generalQueue
|
this.generalQueue
|
||||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||||
userIds: [user.id],
|
userIds: [userId],
|
||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -131,7 +101,7 @@ export class CommentService {
|
|||||||
page.id,
|
page.id,
|
||||||
page.spaceId,
|
page.spaceId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
user.id,
|
userId,
|
||||||
!isReply,
|
!isReply,
|
||||||
createCommentDto.parentCommentId,
|
createCommentDto.parentCommentId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,4 @@
|
|||||||
import { IsIn, IsJSON, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
import { IsIn, IsJSON, 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 {
|
export class CreateCommentDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -36,11 +18,4 @@ export class CreateCommentDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
parentCommentId: string;
|
parentCommentId: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
yjsSelection?: {
|
|
||||||
anchor: any;
|
|
||||||
head: any;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import {
|
|||||||
SpaceCaslAction,
|
SpaceCaslAction,
|
||||||
SpaceCaslSubject,
|
SpaceCaslSubject,
|
||||||
} from '../../casl/interfaces/space-ability.type';
|
} from '../../casl/interfaces/space-ability.type';
|
||||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageAccessService {
|
export class PageAccessService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
private readonly spaceRepo: SpaceRepo,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,25 +99,4 @@ export class PageAccessService {
|
|||||||
|
|
||||||
return { hasRestriction: hasAnyRestriction };
|
return { hasRestriction: hasAnyRestriction };
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateCanComment(
|
|
||||||
page: Page,
|
|
||||||
user: User,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
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<string, any> | null;
|
|
||||||
if (!settings?.comments?.allowViewerComments) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,4 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
disablePublicSharing: boolean;
|
disablePublicSharing: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
allowViewerComments: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { Space, User } from '@docmost/db/types/entity.types';
|
|||||||
import { UpdateSpaceDto } from '../dto/update-space.dto';
|
import { UpdateSpaceDto } from '../dto/update-space.dto';
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Feature } from '../../../common/features';
|
|
||||||
import { SpaceMemberService } from './space-member.service';
|
import { SpaceMemberService } from './space-member.service';
|
||||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||||
@@ -134,34 +133,17 @@ export class SpaceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
|
||||||
typeof updateSpaceDto.disablePublicSharing !== 'undefined' ||
|
|
||||||
typeof updateSpaceDto.allowViewerComments !== 'undefined'
|
|
||||||
) {
|
|
||||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||||
withLicenseKey: true,
|
withLicenseKey: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof updateSpaceDto.disablePublicSharing !== 'undefined' &&
|
!this.licenseCheckService.hasFeature(workspace.licenseKey, 'security:settings', workspace.plan)
|
||||||
!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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,22 +179,6 @@ 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(
|
updatedSpace = await this.spaceRepo.updateSpace(
|
||||||
{
|
{
|
||||||
name: updateSpaceDto.name,
|
name: updateSpaceDto.name,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
|||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
import { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Feature } from '../../../common/features';
|
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
@@ -353,7 +352,7 @@ export class WorkspaceService {
|
|||||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
||||||
) {
|
) {
|
||||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SECURITY_SETTINGS, ws.plan)) {
|
if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'security:settings', ws.plan)) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
'This feature requires a valid license',
|
'This feature requires a valid license',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -111,28 +111,6 @@ export class SpaceRepo {
|
|||||||
.executeTakeFirst();
|
.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(
|
async insertSpace(
|
||||||
insertableSpace: InsertableSpace,
|
insertableSpace: InsertableSpace,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: c70a29cb25...02911b3b46
Reference in New Issue
Block a user